Enhance video library management in PLib by introducing folder change tracking and improving video item metadata handling. Update IVideoRepository to include a method for loading video items with labels. Revise VideoPlayerViewModel to manage playback progress and integrate new UI elements for displaying watched status and resume options. Update MainWindowViewModel to observe folder changes for automatic rescanning. Enhance README.md to document these new features and usage instructions.
This commit is contained in:
+57
-52
@@ -1,52 +1,57 @@
|
|||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = crlf
|
end_of_line = crlf
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
[*.{xml,axaml,xaml,csproj,props,targets,json,yml,yaml}]
|
[*.{xml,axaml,xaml,csproj,props,targets,json,yml,yaml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
[*.cs]
|
# EF writes these; style rules are not ours to enforce on them.
|
||||||
# Namespaces
|
[**/Migrations/*.cs]
|
||||||
csharp_style_namespace_declarations = file_scoped:warning
|
generated_code = true
|
||||||
|
dotnet_analyzer_diagnostic.severity = none
|
||||||
# var
|
|
||||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
[*.cs]
|
||||||
csharp_style_var_elsewhere = true:suggestion
|
# Namespaces
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
# Modern language features
|
|
||||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
# var
|
||||||
csharp_style_expression_bodied_properties = true:suggestion
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
csharp_style_prefer_primary_constructors = true:suggestion
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
csharp_style_prefer_pattern_matching = true:suggestion
|
|
||||||
csharp_style_prefer_switch_expression = true:suggestion
|
# Modern language features
|
||||||
csharp_prefer_braces = true:warning
|
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||||
csharp_prefer_simple_using_statement = true:suggestion
|
csharp_style_expression_bodied_properties = true:suggestion
|
||||||
dotnet_style_collection_initializer = true:suggestion
|
csharp_style_prefer_primary_constructors = true:suggestion
|
||||||
dotnet_style_prefer_collection_expression = true:suggestion
|
csharp_style_prefer_pattern_matching = true:suggestion
|
||||||
dotnet_style_readonly_field = true:warning
|
csharp_style_prefer_switch_expression = true:suggestion
|
||||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
csharp_prefer_braces = true:warning
|
||||||
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
# Usings
|
dotnet_style_collection_initializer = true:suggestion
|
||||||
dotnet_sort_system_directives_first = true
|
dotnet_style_prefer_collection_expression = true:suggestion
|
||||||
csharp_using_directive_placement = outside_namespace:warning
|
dotnet_style_readonly_field = true:warning
|
||||||
|
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||||
# Naming: private fields are _camelCase
|
|
||||||
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
|
# Usings
|
||||||
dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
|
dotnet_sort_system_directives_first = true
|
||||||
dotnet_naming_rule.private_fields_underscore.severity = warning
|
csharp_using_directive_placement = outside_namespace:warning
|
||||||
|
|
||||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
# Naming: private fields are _camelCase
|
||||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
|
||||||
dotnet_naming_symbols.private_fields.required_modifiers =
|
dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
|
||||||
|
dotnet_naming_rule.private_fields_underscore.severity = warning
|
||||||
dotnet_naming_style.underscore_prefix.capitalization = camel_case
|
|
||||||
dotnet_naming_style.underscore_prefix.required_prefix = _
|
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 = _
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
|
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
|
||||||
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
|
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
|
||||||
<PackageVersion Include="DynamicData" Version="9.4.33" />
|
<PackageVersion Include="DynamicData" Version="9.4.33" />
|
||||||
|
<PackageVersion Include="System.Reactive" Version="7.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" 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.Options.DataAnnotations" Version="10.0.10" />
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
|
|
||||||
<ItemGroup Label="Persistence">
|
<ItemGroup Label="Persistence">
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||||
<!-- Pinned above the version EF Core resolves: 2.1.11 carries GHSA-2m69-gcr7-jv3q. -->
|
<!-- 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.bundle_e_sqlite3" Version="3.0.5" />
|
||||||
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.5" />
|
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.5" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
## Что уже работает
|
## Что уже работает
|
||||||
|
|
||||||
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
|
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
|
||||||
|
- Слежение за папками: новые файлы подхватываются сами, без кнопки.
|
||||||
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
||||||
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
||||||
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
||||||
@@ -94,6 +95,16 @@ dotnet test
|
|||||||
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
|
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
|
||||||
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
|
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
|
||||||
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
|
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
|
||||||
|
- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них
|
||||||
|
одинаковая, различается только назначение. Одна сущность — одна таблица связей, один
|
||||||
|
репозиторий и одно правило именования; разделить потом можно переименованием и миграцией,
|
||||||
|
а держать два почти одинаковых агрегата синхронными пришлось бы всегда. Уникальность —
|
||||||
|
по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся,
|
||||||
|
а тег и коллекция с одним именем сосуществуют.
|
||||||
|
- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
|
||||||
|
событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
|
||||||
|
из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
|
||||||
|
разницу и так умеет считать сканирование.
|
||||||
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
||||||
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
||||||
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
|
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
|
||||||
@@ -110,5 +121,11 @@ dotnet test
|
|||||||
перечитывается на лету;
|
перечитывается на лету;
|
||||||
- `logs/` — Serilog, ротация по дням.
|
- `logs/` — Serilog, ротация по дням.
|
||||||
|
|
||||||
Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на
|
Схема ведётся миграциями EF Core (`src/PLib.Infrastructure/Persistence/Migrations`) и
|
||||||
миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть).
|
применяется при старте. База, созданная сборками до появления миграций, распознаётся по
|
||||||
|
отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно
|
||||||
|
пересканирование, а превью привязаны к файлам и переживают это нетронутыми.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
|
namespace PLib.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Persistence boundary for tags and collections.</summary>
|
||||||
|
public interface ILabelRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Finds a label by kind and name, ignoring case and surrounding space.</summary>
|
||||||
|
Task<LibraryLabel?> FindAsync(LabelKind kind, string name, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace PLib.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports that something under the library folders changed and a rescan is warranted.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately says nothing about <em>what</em> changed. A file system watcher reports
|
||||||
|
/// creates, renames and writes as separate events, several per file, and a partially copied
|
||||||
|
/// file arrives as a stream of them — reconciling that into a precise delta is guesswork.
|
||||||
|
/// The scan already knows how to work out the difference, so the watcher only has to say
|
||||||
|
/// "look again", coalesced so a folder full of new files is one signal rather than hundreds.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ILibraryWatcher
|
||||||
|
{
|
||||||
|
/// <summary>Fires after activity in the watched folders settles.</summary>
|
||||||
|
IObservable<Unit> Changed { get; }
|
||||||
|
|
||||||
|
/// <summary>Starts watching the given roots, replacing whatever was watched before.</summary>
|
||||||
|
void Watch(IReadOnlyList<string> folders);
|
||||||
|
|
||||||
|
void StopWatching();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A signal carrying nothing; the fact that it happened is the whole payload.</summary>
|
||||||
|
public readonly record struct Unit
|
||||||
|
{
|
||||||
|
public static Unit Default => default;
|
||||||
|
}
|
||||||
@@ -1,21 +1,24 @@
|
|||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Application.Abstractions;
|
namespace PLib.Application.Abstractions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persistence boundary for the library. The application layer only ever talks to this
|
/// 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.
|
/// interface, which keeps EF Core (and SQLite) an implementation detail of the outer ring.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IVideoRepository
|
public interface IVideoRepository
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
|
Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task AddAsync(VideoItem item, CancellationToken cancellationToken = default);
|
/// <summary>Loads one video together with the labels attached to it.</summary>
|
||||||
|
Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default);
|
|
||||||
|
Task AddAsync(VideoItem item, CancellationToken cancellationToken = default);
|
||||||
/// <summary>Flushes every pending change made to tracked entities.</summary>
|
|
||||||
Task SaveChangesAsync(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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,4 +24,25 @@ public interface ILibraryService
|
|||||||
/// from scratch. Useful after changing the thumbnail width or capture position.
|
/// from scratch. Useful after changing the thumbnail width or capture position.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task ResetThumbnailsAsync(CancellationToken cancellationToken = default);
|
Task ResetThumbnailsAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Remembers where playback stopped so the video can be resumed later.</summary>
|
||||||
|
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>One video with its labels loaded, or <c>null</c> if it is gone.</summary>
|
||||||
|
Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Every tag and collection in the library, alphabetically.</summary>
|
||||||
|
Task<IReadOnlyList<LibraryLabel>> GetLabelsAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches a label to a video, creating it if this is the first time the name is used.
|
||||||
|
/// Returns the label, whether it was new or not.
|
||||||
|
/// </summary>
|
||||||
|
Task<LibraryLabel> AttachLabelAsync(
|
||||||
|
Guid videoId,
|
||||||
|
string name,
|
||||||
|
LabelKind kind,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +1,301 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Application.Library;
|
namespace PLib.Application.Library;
|
||||||
|
|
||||||
/// <inheritdoc cref="ILibraryService"/>
|
/// <inheritdoc cref="ILibraryService"/>
|
||||||
public sealed class LibraryService(
|
public sealed class LibraryService(
|
||||||
IVideoRepository repository,
|
IVideoRepository repository,
|
||||||
IVideoFileScanner scanner,
|
ILabelRepository labels,
|
||||||
IMediaProbe mediaProbe,
|
IVideoFileScanner scanner,
|
||||||
IThumbnailGenerator thumbnailGenerator,
|
IMediaProbe mediaProbe,
|
||||||
IOptions<LibraryOptions> options,
|
IThumbnailGenerator thumbnailGenerator,
|
||||||
ILogger<LibraryService> logger) : ILibraryService
|
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;
|
/// <summary>How many indexed items to accumulate before flushing them to storage.</summary>
|
||||||
|
private const int SaveBatchSize = 25;
|
||||||
private readonly LibraryOptions _options = options.Value;
|
|
||||||
|
private readonly LibraryOptions _options = options.Value;
|
||||||
public async Task<IReadOnlyList<VideoItem>> GetLibraryAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
public async Task<IReadOnlyList<VideoItem>> GetLibraryAsync(CancellationToken cancellationToken = default)
|
||||||
var items = await repository.GetAllAsync(cancellationToken);
|
{
|
||||||
return [.. items.OrderByDescending(x => x.AddedAt)];
|
var items = await repository.GetAllAsync(cancellationToken);
|
||||||
}
|
return [.. items.OrderByDescending(x => x.AddedAt)];
|
||||||
|
}
|
||||||
public Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) =>
|
|
||||||
thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
|
public async Task SaveProgressAsync(
|
||||||
|
Guid videoId,
|
||||||
public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
|
TimeSpan position,
|
||||||
{
|
CancellationToken cancellationToken = default)
|
||||||
var items = await repository.GetAllAsync(cancellationToken);
|
{
|
||||||
|
var video = await repository.FindWithLabelsAsync(videoId, cancellationToken);
|
||||||
foreach (var item in items)
|
|
||||||
{
|
if (video is null)
|
||||||
item.DetachThumbnail();
|
{
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
// Forget the paths before deleting the files. Interrupted the other way round, the
|
|
||||||
// library would point at frames that no longer exist — recoverable, but only after
|
video.RememberProgress(position);
|
||||||
// a full scan notices. This order leaves at worst some orphans, which the purge eats.
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
await repository.SaveChangesAsync(cancellationToken);
|
}
|
||||||
|
|
||||||
var removed = await thumbnailGenerator.ClearAsync(cancellationToken);
|
public Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) =>
|
||||||
logger.LogInformation("Cleared {Count} cached poster frames on request", removed);
|
repository.FindWithLabelsAsync(videoId, cancellationToken);
|
||||||
}
|
|
||||||
|
public async Task<IReadOnlyList<LibraryLabel>> GetLabelsAsync(CancellationToken cancellationToken = default)
|
||||||
public async IAsyncEnumerable<LibraryScanEvent> ScanAsync(
|
{
|
||||||
IReadOnlyList<string> folders,
|
var all = await labels.GetAllAsync(cancellationToken);
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)];
|
||||||
{
|
}
|
||||||
var known = (await repository.GetAllAsync(cancellationToken))
|
|
||||||
.ToDictionary(x => x.FullPath, LibraryPathComparer.Instance);
|
public async Task<LibraryLabel> AttachLabelAsync(
|
||||||
|
Guid videoId,
|
||||||
var discovered = await DiscoverAsync(folders, cancellationToken);
|
string name,
|
||||||
yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count);
|
LabelKind kind,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
var pending = new List<VideoItem>();
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
foreach (var file in discovered.Values)
|
|
||||||
{
|
var video = await repository.FindWithLabelsAsync(videoId, cancellationToken)
|
||||||
if (known.TryGetValue(file.FullPath, out var existing))
|
?? throw new InvalidOperationException($"Video {videoId} is not in the library");
|
||||||
{
|
|
||||||
existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
|
// Reuse before create: the name is what the user thinks of as the identity of a tag,
|
||||||
|
// and two labels differing only in case would read as a duplicate.
|
||||||
// The cache directory is ordinary user storage: a poster frame we remember
|
var label = await labels.FindAsync(kind, name, cancellationToken);
|
||||||
// may simply have been deleted. Trusting the stored path would leave the
|
|
||||||
// card blank forever, because the item still looks indexed.
|
if (label is null)
|
||||||
if (existing.ThumbnailPath is not null &&
|
{
|
||||||
!thumbnailGenerator.IsAvailable(existing.ThumbnailPath))
|
label = new LibraryLabel(name, kind);
|
||||||
{
|
await labels.AddAsync(label, cancellationToken);
|
||||||
existing.DetachThumbnail();
|
}
|
||||||
yield return new LibraryScanEvent.ItemUpdated(existing);
|
|
||||||
}
|
if (video.AddLabel(label))
|
||||||
}
|
{
|
||||||
else
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
{
|
logger.LogInformation("Attached {Kind} '{Name}' to {Video}", kind, label.Name, video.Title);
|
||||||
existing = new VideoItem(
|
}
|
||||||
file.FullPath,
|
|
||||||
Path.GetFileNameWithoutExtension(file.FullPath),
|
return label;
|
||||||
file.SizeInBytes,
|
}
|
||||||
file.ModifiedAt);
|
|
||||||
|
public async Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default)
|
||||||
await repository.AddAsync(existing, cancellationToken);
|
{
|
||||||
known.Add(existing.FullPath, existing);
|
var video = await repository.FindWithLabelsAsync(videoId, cancellationToken);
|
||||||
yield return new LibraryScanEvent.ItemAdded(existing);
|
|
||||||
}
|
if (video?.RemoveLabel(labelId) == true)
|
||||||
|
{
|
||||||
if (!existing.IsIndexed)
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
{
|
}
|
||||||
pending.Add(existing);
|
}
|
||||||
}
|
|
||||||
}
|
public Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
|
||||||
foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList())
|
|
||||||
{
|
public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
|
||||||
await repository.RemoveAsync(orphan, cancellationToken);
|
{
|
||||||
known.Remove(orphan.FullPath);
|
var items = await repository.GetAllAsync(cancellationToken);
|
||||||
yield return new LibraryScanEvent.ItemRemoved(orphan.Id);
|
|
||||||
}
|
foreach (var item in items)
|
||||||
|
{
|
||||||
await repository.SaveChangesAsync(cancellationToken);
|
item.DetachThumbnail();
|
||||||
|
}
|
||||||
await foreach (var indexed in IndexAsync(pending, cancellationToken))
|
|
||||||
{
|
// Forget the paths before deleting the files. Interrupted the other way round, the
|
||||||
yield return indexed;
|
// library would point at frames that no longer exist — recoverable, but only after
|
||||||
}
|
// a full scan notices. This order leaves at worst some orphans, which the purge eats.
|
||||||
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
await repository.SaveChangesAsync(cancellationToken);
|
|
||||||
await PurgeThumbnailCacheAsync(known.Values, cancellationToken);
|
var removed = await thumbnailGenerator.ClearAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Cleared {Count} cached poster frames on request", removed);
|
||||||
yield return new LibraryScanEvent.Completed(known.Count);
|
}
|
||||||
}
|
|
||||||
|
public async IAsyncEnumerable<LibraryScanEvent> ScanAsync(
|
||||||
/// <summary>
|
IReadOnlyList<string> folders,
|
||||||
/// Drops cached frames nothing points at any more. Safe only here, at the end of a
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
/// completed scan, because that is the only moment the library is known to be whole —
|
{
|
||||||
/// running it mid-scan would delete frames of items not reconciled yet.
|
var known = (await repository.GetAllAsync(cancellationToken))
|
||||||
/// </summary>
|
.ToDictionary(x => x.FullPath, LibraryPathComparer.Instance);
|
||||||
private async Task PurgeThumbnailCacheAsync(
|
|
||||||
IEnumerable<VideoItem> library,
|
var discovered = await DiscoverAsync(folders, cancellationToken);
|
||||||
CancellationToken cancellationToken)
|
yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count);
|
||||||
{
|
|
||||||
var inUse = library.Select(x => x.ThumbnailPath).OfType<string>().ToArray();
|
var pending = new List<VideoItem>();
|
||||||
var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken);
|
|
||||||
|
foreach (var file in discovered.Values)
|
||||||
if (removed > 0)
|
{
|
||||||
{
|
if (known.TryGetValue(file.FullPath, out var existing))
|
||||||
logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed);
|
{
|
||||||
}
|
existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
|
||||||
}
|
|
||||||
|
// The cache directory is ordinary user storage: a poster frame we remember
|
||||||
private async Task<Dictionary<string, DiscoveredVideoFile>> DiscoverAsync(
|
// may simply have been deleted. Trusting the stored path would leave the
|
||||||
IReadOnlyList<string> folders,
|
// card blank forever, because the item still looks indexed.
|
||||||
CancellationToken cancellationToken)
|
if (existing.ThumbnailPath is not null &&
|
||||||
{
|
!thumbnailGenerator.IsAvailable(existing.ThumbnailPath))
|
||||||
var discovered = new Dictionary<string, DiscoveredVideoFile>(LibraryPathComparer.Instance);
|
{
|
||||||
|
existing.DetachThumbnail();
|
||||||
foreach (var folder in folders)
|
yield return new LibraryScanEvent.ItemUpdated(existing);
|
||||||
{
|
}
|
||||||
await foreach (var file in scanner.ScanAsync(folder, cancellationToken))
|
}
|
||||||
{
|
else
|
||||||
if (file.SizeInBytes < _options.MinimumFileSizeInBytes)
|
{
|
||||||
{
|
existing = new VideoItem(
|
||||||
continue;
|
file.FullPath,
|
||||||
}
|
Path.GetFileNameWithoutExtension(file.FullPath),
|
||||||
|
file.SizeInBytes,
|
||||||
// Overlapping roots are legal, so the first sighting of a path wins.
|
file.ModifiedAt);
|
||||||
discovered.TryAdd(file.FullPath, file);
|
|
||||||
}
|
await repository.AddAsync(existing, cancellationToken);
|
||||||
}
|
known.Add(existing.FullPath, existing);
|
||||||
|
yield return new LibraryScanEvent.ItemAdded(existing);
|
||||||
return discovered;
|
}
|
||||||
}
|
|
||||||
|
if (!existing.IsIndexed)
|
||||||
/// <summary>
|
{
|
||||||
/// Probes and renders poster frames with bounded concurrency. The expensive work runs in
|
pending.Add(existing);
|
||||||
/// 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(
|
foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList())
|
||||||
IReadOnlyList<VideoItem> pending,
|
{
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
await repository.RemoveAsync(orphan, cancellationToken);
|
||||||
{
|
known.Remove(orphan.FullPath);
|
||||||
if (pending.Count == 0)
|
yield return new LibraryScanEvent.ItemRemoved(orphan.Id);
|
||||||
{
|
}
|
||||||
yield break;
|
|
||||||
}
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
var channel = Channel.CreateBounded<IndexResult>(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4)
|
await foreach (var indexed in IndexAsync(pending, cancellationToken))
|
||||||
{
|
{
|
||||||
SingleReader = true,
|
yield return indexed;
|
||||||
});
|
}
|
||||||
|
|
||||||
var producer = Task.Run(
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
async () =>
|
await PurgeThumbnailCacheAsync(known.Values, cancellationToken);
|
||||||
{
|
|
||||||
try
|
yield return new LibraryScanEvent.Completed(known.Count);
|
||||||
{
|
}
|
||||||
var parallelOptions = new ParallelOptions
|
|
||||||
{
|
/// <summary>
|
||||||
MaxDegreeOfParallelism = _options.MaxIndexingConcurrency,
|
/// Drops cached frames nothing points at any more. Safe only here, at the end of a
|
||||||
CancellationToken = cancellationToken,
|
/// completed scan, because that is the only moment the library is known to be whole —
|
||||||
};
|
/// running it mid-scan would delete frames of items not reconciled yet.
|
||||||
|
/// </summary>
|
||||||
await Parallel.ForEachAsync(
|
private async Task PurgeThumbnailCacheAsync(
|
||||||
pending,
|
IEnumerable<VideoItem> library,
|
||||||
parallelOptions,
|
CancellationToken cancellationToken)
|
||||||
async (item, token) =>
|
{
|
||||||
{
|
var inUse = library.Select(x => x.ThumbnailPath).OfType<string>().ToArray();
|
||||||
var info = await mediaProbe.ProbeAsync(item.FullPath, token);
|
var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken);
|
||||||
var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token);
|
|
||||||
await channel.Writer.WriteAsync(new IndexResult(item, info, thumbnail), token);
|
if (removed > 0)
|
||||||
});
|
{
|
||||||
|
logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed);
|
||||||
channel.Writer.Complete();
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
private async Task<Dictionary<string, DiscoveredVideoFile>> DiscoverAsync(
|
||||||
channel.Writer.Complete(ex);
|
IReadOnlyList<string> folders,
|
||||||
}
|
CancellationToken cancellationToken)
|
||||||
},
|
{
|
||||||
cancellationToken);
|
var discovered = new Dictionary<string, DiscoveredVideoFile>(LibraryPathComparer.Instance);
|
||||||
|
|
||||||
var processed = 0;
|
foreach (var folder in folders)
|
||||||
|
{
|
||||||
await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken))
|
await foreach (var file in scanner.ScanAsync(folder, cancellationToken))
|
||||||
{
|
{
|
||||||
result.Item.ApplyTechnicalInfo(result.Info);
|
if (file.SizeInBytes < _options.MinimumFileSizeInBytes)
|
||||||
|
{
|
||||||
if (result.ThumbnailPath is not null)
|
continue;
|
||||||
{
|
}
|
||||||
result.Item.AttachThumbnail(result.ThumbnailPath);
|
|
||||||
}
|
// Overlapping roots are legal, so the first sighting of a path wins.
|
||||||
|
discovered.TryAdd(file.FullPath, file);
|
||||||
processed++;
|
}
|
||||||
|
}
|
||||||
yield return new LibraryScanEvent.ItemUpdated(result.Item);
|
|
||||||
yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count);
|
return discovered;
|
||||||
|
}
|
||||||
if (processed % SaveBatchSize == 0)
|
|
||||||
{
|
/// <summary>
|
||||||
await repository.SaveChangesAsync(cancellationToken);
|
/// 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>
|
||||||
await producer;
|
private async IAsyncEnumerable<LibraryScanEvent> IndexAsync(
|
||||||
logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count);
|
IReadOnlyList<VideoItem> pending,
|
||||||
}
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
private readonly record struct IndexResult(VideoItem Item, VideoTechnicalInfo Info, string? ThumbnailPath);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RootNamespace>PLib.Application</RootNamespace>
|
<RootNamespace>PLib.Application</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
</ItemGroup>
|
<PackageReference Include="System.Reactive" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\PLib.Domain\PLib.Domain.csproj" />
|
<ItemGroup>
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\PLib.Domain\PLib.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
|
||||||
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using PLib.Domain.Videos;
|
||||||
|
using ReactiveUI;
|
||||||
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One tag or collection as shown on the media page. Carries its own remove command so the
|
||||||
|
/// chip template never has to reach up the visual tree.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LabelViewModel
|
||||||
|
{
|
||||||
|
public LabelViewModel(LibraryLabel label, Action<LabelViewModel> remove)
|
||||||
|
{
|
||||||
|
Id = label.Id;
|
||||||
|
Name = label.Name;
|
||||||
|
Kind = label.Kind;
|
||||||
|
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public LabelKind Kind { get; }
|
||||||
|
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ using DynamicData.Kernel;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
@@ -34,6 +35,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||||||
private readonly IAppSettingsStore _settingsStore;
|
private readonly IAppSettingsStore _settingsStore;
|
||||||
|
private readonly ILibraryWatcher _watcher;
|
||||||
private readonly IThemeService _theme;
|
private readonly IThemeService _theme;
|
||||||
private readonly IFolderPicker _folderPicker;
|
private readonly IFolderPicker _folderPicker;
|
||||||
private readonly ISystemShell _shell;
|
private readonly ISystemShell _shell;
|
||||||
@@ -68,12 +70,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
IAppSettingsStore settingsStore,
|
IAppSettingsStore settingsStore,
|
||||||
IFolderPicker folderPicker,
|
IFolderPicker folderPicker,
|
||||||
ISystemShell shell,
|
ISystemShell shell,
|
||||||
|
ILibraryWatcher watcher,
|
||||||
IThemeService theme,
|
IThemeService theme,
|
||||||
ILogger<MainWindowViewModel> logger)
|
ILogger<MainWindowViewModel> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_options = options;
|
_options = options;
|
||||||
_settingsStore = settingsStore;
|
_settingsStore = settingsStore;
|
||||||
|
_watcher = watcher;
|
||||||
_theme = theme;
|
_theme = theme;
|
||||||
_folderPicker = folderPicker;
|
_folderPicker = folderPicker;
|
||||||
_shell = shell;
|
_shell = shell;
|
||||||
@@ -129,6 +133,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
_isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning);
|
_isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning);
|
||||||
|
|
||||||
BuildLibraryView(out _videos, out _isEmpty);
|
BuildLibraryView(out _videos, out _isEmpty);
|
||||||
|
ObserveFolderChanges();
|
||||||
ObserveCommandFailures();
|
ObserveCommandFailures();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,9 +245,25 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
private void OpenVideo(VideoCardViewModel card)
|
private void OpenVideo(VideoCardViewModel card)
|
||||||
{
|
{
|
||||||
OpenedVideo?.Dispose();
|
OpenedVideo?.Dispose();
|
||||||
OpenedVideo = new VideoPlayerViewModel(card, _shell, _settingsStore, _logger, () => OpenedVideo = null);
|
OpenedVideo = new VideoPlayerViewModel(card, _shell, _scopeFactory, _settingsStore, _logger, () => OpenedVideo = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rescans when the folders change on disk. The watcher has already waited for the
|
||||||
|
/// activity to settle; this only has to make sure a scan is not started on top of one
|
||||||
|
/// that is still running.
|
||||||
|
/// </summary>
|
||||||
|
private void ObserveFolderChanges() =>
|
||||||
|
_watcher.Changed
|
||||||
|
.ObserveOn(_uiScheduler)
|
||||||
|
.Where(_ => !IsScanning && !IsSettingsOpen)
|
||||||
|
.Subscribe(_ =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Library folders changed on disk; rescanning");
|
||||||
|
ScanCommand.Execute().Subscribe();
|
||||||
|
})
|
||||||
|
.AddTo(Subscriptions);
|
||||||
|
|
||||||
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
|
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(term))
|
if (string.IsNullOrWhiteSpace(term))
|
||||||
@@ -313,6 +334,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
ScanProgress = 0;
|
ScanProgress = 0;
|
||||||
StatusText = "Поиск файлов…";
|
StatusText = "Поиск файлов…";
|
||||||
|
|
||||||
|
// Re-armed on every scan so a folder added or removed in settings is picked up
|
||||||
|
// without any separate plumbing.
|
||||||
|
_watcher.Watch(folders);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Task.Run detaches the whole pipeline from the UI synchronisation context, so
|
// Task.Run detaches the whole pipeline from the UI synchronisation context, so
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>One label/value line in the media page's details block.</summary>
|
||||||
|
/// <param name="Label">What the value is, in the user's language.</param>
|
||||||
|
/// <param name="Value">Already formatted; the view only prints it.</param>
|
||||||
|
public sealed record MetadataRow(string Label, string Value);
|
||||||
@@ -66,8 +66,35 @@ public sealed partial class VideoCardViewModel : ReactiveObject
|
|||||||
[Reactive]
|
[Reactive]
|
||||||
public partial long RawSizeInBytes { get; set; }
|
public partial long RawSizeInBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How far through the video the viewer got, 0..1, for the bar across the poster.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial double WatchedFraction { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True once there is progress worth drawing.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial bool HasProgress { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial bool IsWatched { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string? ResumeText { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; private set; }
|
public DateTimeOffset AddedAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Where playback stopped last time, or <c>null</c> if there is nothing to resume.</summary>
|
||||||
|
public TimeSpan? ResumePosition { get; private set; }
|
||||||
|
|
||||||
|
public int? Width { get; private set; }
|
||||||
|
|
||||||
|
public int? Height { get; private set; }
|
||||||
|
|
||||||
|
public string? VideoCodec { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastPlayedAt { get; private set; }
|
||||||
|
|
||||||
|
public int PlayCount { get; private set; }
|
||||||
|
|
||||||
/// <summary>Copies the current state of the entity into the card.</summary>
|
/// <summary>Copies the current state of the entity into the card.</summary>
|
||||||
public void Apply(VideoItem item)
|
public void Apply(VideoItem item)
|
||||||
{
|
{
|
||||||
@@ -77,9 +104,21 @@ public sealed partial class VideoCardViewModel : ReactiveObject
|
|||||||
SizeText = DisplayText.FileSize(item.SizeInBytes);
|
SizeText = DisplayText.FileSize(item.SizeInBytes);
|
||||||
QualityText = DisplayText.Quality(item.Width, item.Height);
|
QualityText = DisplayText.Quality(item.Width, item.Height);
|
||||||
AddedAt = item.AddedAt;
|
AddedAt = item.AddedAt;
|
||||||
|
Width = item.Width;
|
||||||
|
Height = item.Height;
|
||||||
|
VideoCodec = item.VideoCodec;
|
||||||
|
LastPlayedAt = item.LastPlayedAt;
|
||||||
|
PlayCount = item.PlayCount;
|
||||||
RawDuration = item.Duration;
|
RawDuration = item.Duration;
|
||||||
RawSizeInBytes = item.SizeInBytes;
|
RawSizeInBytes = item.SizeInBytes;
|
||||||
IsPending = item.ThumbnailPath is null;
|
IsPending = item.ThumbnailPath is null;
|
||||||
|
WatchedFraction = item.WatchedFraction;
|
||||||
|
HasProgress = item.WatchedFraction > 0;
|
||||||
|
IsWatched = item.PlayCount > 0 && item.ResumePosition is null;
|
||||||
|
ResumePosition = item.ResumePosition;
|
||||||
|
ResumeText = item.ResumePosition is { } resume
|
||||||
|
? $"Продолжить с {DisplayText.Duration(resume)}"
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Matches(string term) =>
|
public bool Matches(string term) =>
|
||||||
|
|||||||
@@ -1,126 +1,306 @@
|
|||||||
using System.Reactive.Concurrency;
|
using System.Collections.ObjectModel;
|
||||||
using System.Reactive.Linq;
|
using System.Globalization;
|
||||||
using Microsoft.Extensions.Logging;
|
using System.Reactive.Concurrency;
|
||||||
using PLib.Desktop.Services;
|
using System.Reactive.Linq;
|
||||||
using ReactiveUI;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ReactiveUI.SourceGenerators;
|
using Microsoft.Extensions.Logging;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Desktop.Services;
|
||||||
namespace PLib.Desktop.ViewModels;
|
using PLib.Domain.Videos;
|
||||||
|
using ReactiveUI;
|
||||||
/// <summary>
|
using ReactiveUI.SourceGenerators;
|
||||||
/// The media page: one video, opened from the grid. Most of the transport lives on the
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
/// player control itself; what the page owns is the video's identity, the commands around
|
|
||||||
/// it, and the settings that have to outlive the page.
|
namespace PLib.Desktop.ViewModels;
|
||||||
/// </summary>
|
|
||||||
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
/// <summary>
|
||||||
{
|
/// The media page: one video with its player, its details and its labels.
|
||||||
/// <summary>
|
/// </summary>
|
||||||
/// How long the volume has to sit still before it is written. Dragging the slider
|
/// <remarks>
|
||||||
/// produces a value per pixel, and each one would otherwise be a file write.
|
/// Transport state stays on the player control; what the page owns is the video's identity,
|
||||||
/// </summary>
|
/// everything shown around the picture, and the settings that outlive the page.
|
||||||
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
|
/// </remarks>
|
||||||
|
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||||
private readonly IAppSettingsStore _settingsStore;
|
{
|
||||||
private readonly ILogger _logger;
|
/// <summary>
|
||||||
|
/// How long the volume has to sit still before it is written. Dragging the slider
|
||||||
public VideoPlayerViewModel(
|
/// produces a value per pixel, and each one would otherwise be a file write.
|
||||||
VideoCardViewModel card,
|
/// </summary>
|
||||||
ISystemShell shell,
|
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
|
||||||
IAppSettingsStore settingsStore,
|
|
||||||
ILogger logger,
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
Action close)
|
private readonly IAppSettingsStore _settingsStore;
|
||||||
{
|
private readonly ILogger _logger;
|
||||||
_settingsStore = settingsStore;
|
|
||||||
_logger = logger;
|
public VideoPlayerViewModel(
|
||||||
|
VideoCardViewModel card,
|
||||||
Title = card.Title;
|
ISystemShell shell,
|
||||||
FullPath = card.FullPath;
|
IServiceScopeFactory scopeFactory,
|
||||||
Source = new Uri(card.FullPath);
|
IAppSettingsStore settingsStore,
|
||||||
|
ILogger logger,
|
||||||
Subtitle = string.Join(
|
Action close)
|
||||||
" · ",
|
{
|
||||||
new[] { card.QualityText, card.DurationText, card.SizeText }
|
_scopeFactory = scopeFactory;
|
||||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
_settingsStore = settingsStore;
|
||||||
|
_logger = logger;
|
||||||
var settings = settingsStore.Current;
|
|
||||||
Volume = settings.Volume;
|
Card = card;
|
||||||
IsMuted = settings.IsMuted;
|
VideoId = card.Id;
|
||||||
|
Title = card.Title;
|
||||||
CloseCommand = ReactiveCommand.Create(close);
|
FullPath = card.FullPath;
|
||||||
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
Source = new Uri(card.FullPath);
|
||||||
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
|
ResumeFrom = card.ResumePosition;
|
||||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
Details = BuildDetails(card);
|
||||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
|
||||||
|
Subtitle = string.Join(
|
||||||
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
" · ",
|
||||||
// Skip the values we just restored: they are already what is on disk.
|
new[] { card.QualityText, card.DurationText, card.SizeText }
|
||||||
.Skip(1)
|
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||||
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
|
|
||||||
.DistinctUntilChanged()
|
var settings = settingsStore.Current;
|
||||||
.Subscribe(state => Persist(state.volume, state.muted))
|
Volume = settings.Volume;
|
||||||
.AddTo(Subscriptions);
|
IsMuted = settings.IsMuted;
|
||||||
|
|
||||||
ObserveCommandFailures();
|
CloseCommand = ReactiveCommand.Create(close);
|
||||||
}
|
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
||||||
|
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
|
||||||
public string Title { get; }
|
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
|
||||||
|
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||||
public string FullPath { get; }
|
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
||||||
|
|
||||||
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
|
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
|
||||||
public Uri Source { get; }
|
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
||||||
|
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
||||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
|
||||||
public string Subtitle { get; }
|
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
||||||
|
// Skip the values we just restored: they are already what is on disk.
|
||||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
.Skip(1)
|
||||||
|
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
.DistinctUntilChanged()
|
||||||
|
.Subscribe(state => Persist(state.volume, state.muted))
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
.AddTo(Subscriptions);
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
ObserveCommandFailures();
|
||||||
|
}
|
||||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
|
||||||
|
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
|
||||||
/// <summary>
|
public VideoCardViewModel Card { get; }
|
||||||
/// True while the window is given over to the video. The page hides its own header and
|
|
||||||
/// the window hides its chrome.
|
public Guid VideoId { get; }
|
||||||
/// </summary>
|
|
||||||
[Reactive]
|
public string Title { get; }
|
||||||
public partial bool IsFullScreen { get; set; }
|
|
||||||
|
public string FullPath { get; }
|
||||||
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
|
|
||||||
[Reactive]
|
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
|
||||||
public partial double Volume { get; set; }
|
public Uri Source { get; }
|
||||||
|
|
||||||
[Reactive]
|
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
||||||
public partial bool IsMuted { get; set; }
|
public string Subtitle { get; }
|
||||||
|
|
||||||
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
|
||||||
|
public TimeSpan? ResumeFrom { get; }
|
||||||
private async Task PersistAsync(double volume, bool isMuted)
|
|
||||||
{
|
public IReadOnlyList<MetadataRow> Details { get; }
|
||||||
try
|
|
||||||
{
|
public ObservableCollection<LabelViewModel> Tags { get; } = [];
|
||||||
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
|
|
||||||
}
|
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||||
catch (Exception ex)
|
|
||||||
{
|
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||||
// Losing a volume level is not worth interrupting playback over.
|
|
||||||
_logger.LogWarning(ex, "Could not save the playback volume");
|
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||||
}
|
|
||||||
}
|
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
||||||
|
|
||||||
private void ObserveCommandFailures() =>
|
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
|
||||||
Observable
|
|
||||||
.Merge(
|
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||||
CloseCommand.ThrownExceptions,
|
|
||||||
ToggleFullScreenCommand.ThrownExceptions,
|
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||||
ToggleMuteCommand.ThrownExceptions,
|
|
||||||
OpenExternallyCommand.ThrownExceptions,
|
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
|
||||||
RevealCommand.ThrownExceptions)
|
|
||||||
.Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
|
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
|
||||||
.AddTo(Subscriptions);
|
|
||||||
}
|
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True while the window is given over to the video. The page hides its own header and
|
||||||
|
/// the window hides its chrome.
|
||||||
|
/// </summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial bool IsFullScreen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The details and labels panel beside the video.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial bool AreDetailsVisible { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial double Volume { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial bool IsMuted { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string NewTag { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string NewCollection { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records where playback stopped and refreshes the card behind the page, so the grid
|
||||||
|
/// shows the new progress without waiting for a rescan.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SaveProgressAsync(TimeSpan position)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
|
await library.SaveProgressAsync(VideoId, position);
|
||||||
|
|
||||||
|
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||||
|
{
|
||||||
|
Card.Apply(refreshed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A lost resume position is not worth surfacing to someone who just closed a video.
|
||||||
|
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
|
||||||
|
{
|
||||||
|
var rows = new List<MetadataRow>
|
||||||
|
{
|
||||||
|
new("Длительность", card.DurationText),
|
||||||
|
new("Размер", card.SizeText),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (card.Width is { } width && card.Height is { } height)
|
||||||
|
{
|
||||||
|
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
|
||||||
|
{
|
||||||
|
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||||
|
|
||||||
|
if (card.LastPlayedAt is { } lastPlayed)
|
||||||
|
{
|
||||||
|
rows.Add(new MetadataRow(
|
||||||
|
"Последний просмотр",
|
||||||
|
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.PlayCount > 0)
|
||||||
|
{
|
||||||
|
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new MetadataRow("Файл", card.FullPath));
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadLabelsAsync()
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
|
var video = await library.GetVideoWithLabelsAsync(VideoId);
|
||||||
|
|
||||||
|
Tags.Clear();
|
||||||
|
Collections.Clear();
|
||||||
|
|
||||||
|
var labels = video is null
|
||||||
|
? []
|
||||||
|
: video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
|
||||||
|
|
||||||
|
foreach (var label in labels)
|
||||||
|
{
|
||||||
|
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AttachAsync(string name, LabelKind kind)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
|
await library.AttachLabelAsync(VideoId, name, kind);
|
||||||
|
|
||||||
|
if (kind == LabelKind.Tag)
|
||||||
|
{
|
||||||
|
NewTag = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NewCollection = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadLabelsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DetachAsync(LabelViewModel label)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
|
await library.DetachLabelAsync(VideoId, label.Id);
|
||||||
|
Target(label.Kind).Remove(label);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObservableCollection<LabelViewModel> Target(LabelKind kind) =>
|
||||||
|
kind == LabelKind.Tag ? Tags : Collections;
|
||||||
|
|
||||||
|
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
||||||
|
|
||||||
|
private async Task PersistAsync(double volume, bool isMuted)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Losing a volume level is not worth interrupting playback over.
|
||||||
|
_logger.LogWarning(ex, "Could not save the playback volume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ObserveCommandFailures() =>
|
||||||
|
Observable
|
||||||
|
.Merge(
|
||||||
|
CloseCommand.ThrownExceptions,
|
||||||
|
ToggleFullScreenCommand.ThrownExceptions,
|
||||||
|
ToggleMuteCommand.ThrownExceptions,
|
||||||
|
ToggleDetailsCommand.ThrownExceptions,
|
||||||
|
OpenExternallyCommand.ThrownExceptions,
|
||||||
|
RevealCommand.ThrownExceptions,
|
||||||
|
AddTagCommand.ThrownExceptions,
|
||||||
|
AddCollectionCommand.ThrownExceptions,
|
||||||
|
LoadLabelsCommand.ThrownExceptions)
|
||||||
|
.Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
|
||||||
|
.AddTo(Subscriptions);
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,29 @@
|
|||||||
<TextBlock Text="{Binding DurationText}" />
|
<TextBlock Text="{Binding DurationText}" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Watched marker, top-right, so it never collides with the quality badge. -->
|
||||||
|
<Border Margin="8"
|
||||||
|
Width="20"
|
||||||
|
Height="20"
|
||||||
|
CornerRadius="10"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Background="{DynamicResource AccentBrush}"
|
||||||
|
IsVisible="{Binding IsWatched}"
|
||||||
|
ToolTip.Tip="Просмотрено">
|
||||||
|
<icons:MaterialIcon Kind="Check" Width="13" Height="13" Foreground="White" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- How far the viewer got, drawn across the bottom of the poster. -->
|
||||||
|
<ProgressBar VerticalAlignment="Bottom"
|
||||||
|
Height="3"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="1"
|
||||||
|
Value="{Binding WatchedFraction}"
|
||||||
|
IsVisible="{Binding HasProgress}"
|
||||||
|
Background="{DynamicResource BadgeBackgroundBrush}"
|
||||||
|
Foreground="{DynamicResource AccentBrush}" />
|
||||||
|
|
||||||
<Border Classes="playOverlay" Background="{DynamicResource OverlayBrush}">
|
<Border Classes="playOverlay" Background="{DynamicResource OverlayBrush}">
|
||||||
<Border Width="46"
|
<Border Width="46"
|
||||||
Height="46"
|
Height="46"
|
||||||
@@ -91,6 +114,10 @@
|
|||||||
<TextBlock Classes="cardTitle" Text="{Binding Title}" />
|
<TextBlock Classes="cardTitle" Text="{Binding Title}" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Classes="cardMeta" Text="{Binding SizeText}" />
|
<TextBlock Classes="cardMeta" Text="{Binding SizeText}" />
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
|
Foreground="{DynamicResource AccentBrush}"
|
||||||
|
Text="{Binding ResumeText}"
|
||||||
|
IsVisible="{Binding ResumeText, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,28 @@
|
|||||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||||
x:DataType="vm:VideoPlayerViewModel">
|
x:DataType="vm:VideoPlayerViewModel">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
|
||||||
|
<Border Background="{DynamicResource AccentSoftBrush}"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="9,3"
|
||||||
|
Margin="0,0,6,6">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBlock Text="{Binding Name}"
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource AccentBrush}" />
|
||||||
|
<Button Command="{Binding RemoveCommand}"
|
||||||
|
Classes="transport"
|
||||||
|
Padding="2"
|
||||||
|
ToolTip.Tip="Убрать">
|
||||||
|
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="Button.transport">
|
<Style Selector="Button.transport">
|
||||||
<Setter Property="Padding" Value="8" />
|
<Setter Property="Padding" Value="8" />
|
||||||
@@ -45,6 +67,11 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="transport"
|
||||||
|
Command="{Binding ToggleDetailsCommand}"
|
||||||
|
ToolTip.Tip="Сведения и метки">
|
||||||
|
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||||
|
</Button>
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding OpenExternallyCommand}"
|
Command="{Binding OpenExternallyCommand}"
|
||||||
ToolTip.Tip="Открыть во внешнем плеере">
|
ToolTip.Tip="Открыть во внешнем плеере">
|
||||||
@@ -61,7 +88,9 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ======================= Video ======================= -->
|
<!-- ======================= Video ======================= -->
|
||||||
<Panel Grid.Row="1" Name="VideoArea" Background="Black">
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||||
|
|
||||||
|
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
|
||||||
<controls:VlcVideoView Name="Player"
|
<controls:VlcVideoView Name="Player"
|
||||||
Source="{Binding Source}"
|
Source="{Binding Source}"
|
||||||
AutoPlay="True"
|
AutoPlay="True"
|
||||||
@@ -83,6 +112,73 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ======================= Details and labels ======================= -->
|
||||||
|
<Border Grid.Column="1"
|
||||||
|
Width="320"
|
||||||
|
IsVisible="{Binding AreDetailsVisible}"
|
||||||
|
Background="{DynamicResource PanelBackgroundBrush}">
|
||||||
|
<ScrollViewer Padding="16,14">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Сведения" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Details}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MetadataRow">
|
||||||
|
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Label}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding Value}"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Коллекции" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- ======================= Transport ======================= -->
|
<!-- ======================= Transport ======================= -->
|
||||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ public sealed partial class VideoPlayerView : UserControl
|
|||||||
/// <summary>The window state to come back to when full screen is switched off.</summary>
|
/// <summary>The window state to come back to when full screen is switched off.</summary>
|
||||||
private WindowState _stateBeforeFullScreen = WindowState.Normal;
|
private WindowState _stateBeforeFullScreen = WindowState.Normal;
|
||||||
|
|
||||||
|
private VideoPlayerViewModel? _viewModel;
|
||||||
|
|
||||||
|
/// <summary>Set once playback has been asked to jump to the remembered position.</summary>
|
||||||
|
private bool _resumeApplied;
|
||||||
|
|
||||||
public VideoPlayerView()
|
public VideoPlayerView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -65,6 +70,9 @@ public sealed partial class VideoPlayerView : UserControl
|
|||||||
_subscriptions.Add(viewModel
|
_subscriptions.Add(viewModel
|
||||||
.WhenAnyValue(x => x.Volume, x => x.IsMuted, VolumeIconFor)
|
.WhenAnyValue(x => x.Volume, x => x.IsMuted, VolumeIconFor)
|
||||||
.Subscribe(kind => MuteIcon.Kind = kind));
|
.Subscribe(kind => MuteIcon.Kind = kind));
|
||||||
|
|
||||||
|
_viewModel = viewModel;
|
||||||
|
viewModel.LoadLabelsCommand.Execute().Subscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +82,14 @@ public sealed partial class VideoPlayerView : UserControl
|
|||||||
|
|
||||||
_subscriptions.Clear();
|
_subscriptions.Clear();
|
||||||
|
|
||||||
|
// Where playback got to has to be captured before the player is torn down.
|
||||||
|
if (_viewModel is { } viewModel && Player.Position > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
_ = viewModel.SaveProgressAsync(Player.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
_viewModel = null;
|
||||||
|
|
||||||
// Closing the page while full screen would otherwise strand the window with no chrome.
|
// Closing the page while full screen would otherwise strand the window with no chrome.
|
||||||
ApplyFullScreen(false);
|
ApplyFullScreen(false);
|
||||||
|
|
||||||
@@ -149,6 +165,14 @@ public sealed partial class VideoPlayerView : UserControl
|
|||||||
{
|
{
|
||||||
DurationText.Text = DisplayText.Duration(duration);
|
DurationText.Text = DisplayText.Duration(duration);
|
||||||
|
|
||||||
|
// Seeking is only possible once the length is known, so resuming waits for it —
|
||||||
|
// and happens exactly once, or every later duration report would rewind playback.
|
||||||
|
if (!_resumeApplied && duration > TimeSpan.Zero && _viewModel?.ResumeFrom is { } resume)
|
||||||
|
{
|
||||||
|
_resumeApplied = true;
|
||||||
|
Player.Seek(resume);
|
||||||
|
}
|
||||||
|
|
||||||
// A zero maximum would pin the thumb to the left and swallow every seek.
|
// A zero maximum would pin the thumb to the left and swallow every seek.
|
||||||
Seek.Maximum = duration > TimeSpan.Zero ? duration.TotalSeconds : 1;
|
Seek.Maximum = duration > TimeSpan.Zero ? duration.TotalSeconds : 1;
|
||||||
Seek.IsEnabled = duration > TimeSpan.Zero;
|
Seek.IsEnabled = duration > TimeSpan.Zero;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace PLib.Domain.Videos;
|
||||||
|
|
||||||
|
/// <summary>What a label is for; the relation to videos is identical either way.</summary>
|
||||||
|
public enum LabelKind
|
||||||
|
{
|
||||||
|
/// <summary>A free-form word used to narrow the grid down.</summary>
|
||||||
|
Tag,
|
||||||
|
|
||||||
|
/// <summary>A named group the user curates and browses as a whole.</summary>
|
||||||
|
Collection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A named grouping of videos — a tag or a collection.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Tags and collections are the same relation: a name, many videos, a video in many of them.
|
||||||
|
/// They differ only in intent, and that intent is <see cref="Kind"/>. One entity means one
|
||||||
|
/// join table, one repository and one set of rules about naming; splitting them later is a
|
||||||
|
/// rename and a migration, whereas keeping two near-identical aggregates in sync from the
|
||||||
|
/// start is a permanent tax.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LibraryLabel
|
||||||
|
{
|
||||||
|
private readonly List<VideoItem> _videos = [];
|
||||||
|
|
||||||
|
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||||
|
private LibraryLabel()
|
||||||
|
{
|
||||||
|
Name = null!;
|
||||||
|
NormalizedName = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LibraryLabel(string name, LabelKind kind)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
Id = Guid.CreateVersion7();
|
||||||
|
Kind = kind;
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow;
|
||||||
|
Name = name.Trim();
|
||||||
|
NormalizedName = Normalize(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
public string Name { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upper-cased, trimmed name. Uniqueness is enforced on this rather than on
|
||||||
|
/// <see cref="Name"/>, so "Комедия" and "комедия" cannot both exist.
|
||||||
|
/// </summary>
|
||||||
|
public string NormalizedName { get; private set; }
|
||||||
|
|
||||||
|
public LabelKind Kind { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<VideoItem> Videos => _videos;
|
||||||
|
|
||||||
|
public static string Normalize(string name) => name.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
public void Rename(string name)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
Name = name.Trim();
|
||||||
|
NormalizedName = Normalize(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
-103
@@ -1,103 +1,180 @@
|
|||||||
namespace PLib.Domain.Videos;
|
namespace PLib.Domain.Videos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single video file that belongs to the library.
|
/// A single video file that belongs to the library.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The absolute path is the natural identity of a video: the library is a view over the
|
/// 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
|
/// 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.
|
/// through explicit methods so that the entity can never end up half-updated.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class VideoItem
|
public sealed class VideoItem
|
||||||
{
|
{
|
||||||
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
/// <summary>
|
||||||
private VideoItem()
|
/// How close to the end counts as finished. Credits and trailing black frames mean a
|
||||||
{
|
/// video is done well before its last millisecond, and offering to resume there is worse
|
||||||
FullPath = null!;
|
/// than offering nothing.
|
||||||
Title = null!;
|
/// </summary>
|
||||||
}
|
private static readonly TimeSpan EndOfPlaybackSlack = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
public VideoItem(string fullPath, string title, long sizeInBytes, DateTimeOffset fileModifiedAt)
|
/// <summary>Below this, the viewer barely started; resuming would be noise.</summary>
|
||||||
{
|
private static readonly TimeSpan ResumeThreshold = TimeSpan.FromSeconds(20);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
private readonly List<LibraryLabel> _labels = [];
|
||||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
|
||||||
|
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||||
Id = Guid.CreateVersion7();
|
private VideoItem()
|
||||||
FullPath = fullPath;
|
{
|
||||||
Title = title;
|
FullPath = null!;
|
||||||
SizeInBytes = sizeInBytes;
|
Title = null!;
|
||||||
FileModifiedAt = fileModifiedAt;
|
}
|
||||||
AddedAt = DateTimeOffset.UtcNow;
|
|
||||||
}
|
public VideoItem(string fullPath, string title, long sizeInBytes, DateTimeOffset fileModifiedAt)
|
||||||
|
{
|
||||||
public Guid Id { get; private set; }
|
ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||||
/// <summary>Absolute path of the file on disk. Unique within the library.</summary>
|
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
||||||
public string FullPath { get; private set; }
|
|
||||||
|
Id = Guid.CreateVersion7();
|
||||||
/// <summary>Human readable name; defaults to the file name without extension.</summary>
|
FullPath = fullPath;
|
||||||
public string Title { get; private set; }
|
Title = title;
|
||||||
|
SizeInBytes = sizeInBytes;
|
||||||
public long SizeInBytes { get; private set; }
|
FileModifiedAt = fileModifiedAt;
|
||||||
|
AddedAt = DateTimeOffset.UtcNow;
|
||||||
public TimeSpan? Duration { get; private set; }
|
}
|
||||||
|
|
||||||
public int? Width { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
public int? Height { get; private set; }
|
/// <summary>Absolute path of the file on disk. Unique within the library.</summary>
|
||||||
|
public string FullPath { get; private set; }
|
||||||
public string? VideoCodec { get; private set; }
|
|
||||||
|
/// <summary>Human readable name; defaults to the file name without extension.</summary>
|
||||||
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
|
public string Title { get; private set; }
|
||||||
public string? ThumbnailPath { get; private set; }
|
|
||||||
|
public long SizeInBytes { get; private set; }
|
||||||
/// <summary>Last write time of the file when it was last indexed.</summary>
|
|
||||||
public DateTimeOffset FileModifiedAt { get; private set; }
|
public TimeSpan? Duration { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; private set; }
|
public int? Width { get; private set; }
|
||||||
|
|
||||||
/// <summary>True once the file has been probed and a poster frame produced.</summary>
|
public int? Height { get; private set; }
|
||||||
public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
|
|
||||||
|
public string? VideoCodec { get; private set; }
|
||||||
public void Rename(string title)
|
|
||||||
{
|
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
public string? ThumbnailPath { get; private set; }
|
||||||
Title = title;
|
|
||||||
}
|
/// <summary>Last write time of the file when it was last indexed.</summary>
|
||||||
|
public DateTimeOffset FileModifiedAt { get; private set; }
|
||||||
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
|
|
||||||
{
|
public DateTimeOffset AddedAt { get; private set; }
|
||||||
Duration = info.Duration;
|
|
||||||
Width = info.Width;
|
/// <summary>
|
||||||
Height = info.Height;
|
/// Where playback stopped last time, or <c>null</c> when there is nothing worth
|
||||||
VideoCodec = info.VideoCodec;
|
/// resuming — never watched, barely started, or watched to the end.
|
||||||
}
|
/// </summary>
|
||||||
|
public TimeSpan? ResumePosition { get; private set; }
|
||||||
public void AttachThumbnail(string thumbnailPath)
|
|
||||||
{
|
public DateTimeOffset? LastPlayedAt { get; private set; }
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
|
|
||||||
ThumbnailPath = thumbnailPath;
|
/// <summary>How many times the video was watched through to the end.</summary>
|
||||||
}
|
public int PlayCount { get; private set; }
|
||||||
|
|
||||||
public void DetachThumbnail() => ThumbnailPath = null;
|
/// <summary>Tags and collections this video belongs to.</summary>
|
||||||
|
public IReadOnlyCollection<LibraryLabel> Labels => _labels;
|
||||||
/// <summary>
|
|
||||||
/// Refreshes the file system facts after the file changed on disk, and invalidates
|
/// <summary>True once the file has been probed and a poster frame produced.</summary>
|
||||||
/// everything that was derived from the previous revision of the file.
|
public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
|
||||||
/// </summary>
|
|
||||||
public void RefreshFileFacts(long sizeInBytes, DateTimeOffset fileModifiedAt)
|
/// <summary>How far through the video the viewer got, as a fraction, for the card overlay.</summary>
|
||||||
{
|
public double WatchedFraction => Duration is { TotalSeconds: > 0 } total && ResumePosition is { } position
|
||||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
? Math.Clamp(position / total, 0, 1)
|
||||||
|
: 0;
|
||||||
if (SizeInBytes == sizeInBytes && FileModifiedAt == fileModifiedAt)
|
|
||||||
{
|
public void Rename(string title)
|
||||||
return;
|
{
|
||||||
}
|
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||||
|
Title = title;
|
||||||
SizeInBytes = sizeInBytes;
|
}
|
||||||
FileModifiedAt = fileModifiedAt;
|
|
||||||
ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
|
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
|
||||||
DetachThumbnail();
|
{
|
||||||
}
|
Duration = info.Duration;
|
||||||
}
|
Width = info.Width;
|
||||||
|
Height = info.Height;
|
||||||
|
VideoCodec = info.VideoCodec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records where playback stopped. A position at either extreme is stored as "nothing to
|
||||||
|
/// resume": too early to matter, or close enough to the end that the video counts as
|
||||||
|
/// watched — which is also the only place <see cref="PlayCount"/> goes up.
|
||||||
|
/// </summary>
|
||||||
|
public void RememberProgress(TimeSpan position)
|
||||||
|
{
|
||||||
|
LastPlayedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
if (position < ResumeThreshold)
|
||||||
|
{
|
||||||
|
ResumePosition = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Duration is { } duration && position >= duration - EndOfPlaybackSlack)
|
||||||
|
{
|
||||||
|
MarkWatched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResumePosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkWatched()
|
||||||
|
{
|
||||||
|
ResumePosition = null;
|
||||||
|
LastPlayedAt = DateTimeOffset.UtcNow;
|
||||||
|
PlayCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AddLabel(LibraryLabel label)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(label);
|
||||||
|
|
||||||
|
if (_labels.Any(existing => existing.Id == label.Id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_labels.Add(label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool RemoveLabel(Guid labelId) => _labels.RemoveAll(label => label.Id == labelId) > 0;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,48 +1,50 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Infrastructure.Media;
|
using PLib.Infrastructure.Media;
|
||||||
using PLib.Infrastructure.Persistence;
|
using PLib.Infrastructure.Persistence;
|
||||||
using PLib.Infrastructure.Storage;
|
using PLib.Infrastructure.Storage;
|
||||||
|
|
||||||
namespace PLib.Infrastructure;
|
namespace PLib.Infrastructure;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers everything the application layer declares as an abstraction. The composition
|
/// Registers everything the application layer declares as an abstraction. The composition
|
||||||
/// root (the UI project) never sees EF Core or ffmpeg types directly.
|
/// root (the UI project) never sees EF Core or ffmpeg types directly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddPLibInfrastructure(
|
public static IServiceCollection AddPLibInfrastructure(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddOptions<LibraryOptions>()
|
services.AddOptions<LibraryOptions>()
|
||||||
.Bind(configuration.GetSection(LibraryOptions.SectionName))
|
.Bind(configuration.GetSection(LibraryOptions.SectionName))
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
// TryAdd so a composition root that already needed the paths (to locate the user
|
// 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.
|
// settings file before the container exists) can share its own instance.
|
||||||
services.TryAddSingleton<IAppPaths, AppPaths>();
|
services.TryAddSingleton<IAppPaths, AppPaths>();
|
||||||
|
|
||||||
services.AddDbContext<LibraryDbContext>((provider, builder) =>
|
services.AddDbContext<LibraryDbContext>((provider, builder) =>
|
||||||
{
|
{
|
||||||
var paths = provider.GetRequiredService<IAppPaths>();
|
var paths = provider.GetRequiredService<IAppPaths>();
|
||||||
builder.UseSqlite($"Data Source={paths.DatabaseFile}");
|
builder.UseSqlite($"Data Source={paths.DatabaseFile}");
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddScoped<IVideoRepository, EfVideoRepository>();
|
services.AddScoped<IVideoRepository, EfVideoRepository>();
|
||||||
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
|
services.AddScoped<ILabelRepository, EfLabelRepository>();
|
||||||
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
|
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
|
||||||
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>();
|
||||||
services.AddScoped<ILibraryService, LibraryService>();
|
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
|
||||||
|
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
||||||
services.AddHostedService<DatabaseInitializer>();
|
services.AddScoped<ILibraryService, LibraryService>();
|
||||||
|
|
||||||
return services;
|
services.AddHostedService<DatabaseInitializer>();
|
||||||
}
|
|
||||||
}
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Reactive.Subjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PLib.Application.Abstractions;
|
||||||
|
using PLib.Application.Library;
|
||||||
|
using Unit = PLib.Application.Abstractions.Unit;
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Media;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ILibraryWatcher"/>
|
||||||
|
public sealed class FileSystemLibraryWatcher : ILibraryWatcher, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How long the folders must be quiet before a rescan is worth starting. Copying a file
|
||||||
|
/// produces events all the way through the copy, and scanning a half-written file only
|
||||||
|
/// means scanning it again later.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan Quiet = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
private readonly List<FileSystemWatcher> _watchers = [];
|
||||||
|
private readonly Subject<Unit> _raw = new();
|
||||||
|
private readonly HashSet<string> _extensions;
|
||||||
|
private readonly ILogger<FileSystemLibraryWatcher> _logger;
|
||||||
|
|
||||||
|
public FileSystemLibraryWatcher(
|
||||||
|
IOptions<LibraryOptions> options,
|
||||||
|
ILogger<FileSystemLibraryWatcher> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_extensions = new HashSet<string>(options.Value.VideoExtensions, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
Changed = _raw.Throttle(Quiet).Publish().RefCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IObservable<Unit> Changed { get; }
|
||||||
|
|
||||||
|
public void Watch(IReadOnlyList<string> folders)
|
||||||
|
{
|
||||||
|
StopWatching();
|
||||||
|
|
||||||
|
foreach (var folder in folders.Where(Directory.Exists))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var watcher = new FileSystemWatcher(folder)
|
||||||
|
{
|
||||||
|
IncludeSubdirectories = true,
|
||||||
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.LastWrite,
|
||||||
|
};
|
||||||
|
|
||||||
|
watcher.Created += OnChanged;
|
||||||
|
watcher.Deleted += OnChanged;
|
||||||
|
watcher.Renamed += OnChanged;
|
||||||
|
watcher.Changed += OnChanged;
|
||||||
|
|
||||||
|
// A burst larger than the internal buffer is reported as one error rather
|
||||||
|
// than lost events, and a rescan is exactly the right response to it.
|
||||||
|
watcher.Error += OnError;
|
||||||
|
|
||||||
|
watcher.EnableRaisingEvents = true;
|
||||||
|
_watchers.Add(watcher);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A folder on a disconnected share should not stop the others being watched.
|
||||||
|
_logger.LogWarning(ex, "Could not watch {Folder}", folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_watchers.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Watching {Count} library folder(s) for changes", _watchers.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopWatching()
|
||||||
|
{
|
||||||
|
foreach (var watcher in _watchers)
|
||||||
|
{
|
||||||
|
watcher.EnableRaisingEvents = false;
|
||||||
|
watcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchers.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
StopWatching();
|
||||||
|
_raw.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChanged(object sender, FileSystemEventArgs e)
|
||||||
|
{
|
||||||
|
// Directory events carry no extension and must still count: a folder dropped in is
|
||||||
|
// the most common way a batch of videos appears.
|
||||||
|
var extension = Path.GetExtension(e.Name);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(extension) && !_extensions.Contains(extension))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_raw.OnNext(Unit.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnError(object sender, ErrorEventArgs e)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(e.GetException(), "The file system watcher overflowed; rescanning");
|
||||||
|
_raw.OnNext(Unit.Default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RootNamespace>PLib.Infrastructure</RootNamespace>
|
<RootNamespace>PLib.Infrastructure</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FFMpegCore" />
|
<PackageReference Include="FFMpegCore" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</ItemGroup>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
<ItemGroup>
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||||
<ProjectReference Include="..\PLib.Application\PLib.Application.csproj" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\PLib.Application\PLib.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ namespace PLib.Infrastructure.Persistence;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Brings the local database up to date before the first window is shown.
|
/// Brings the local database up to date before the first window is shown.
|
||||||
/// </summary>
|
/// </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(
|
public sealed class DatabaseInitializer(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<DatabaseInitializer> logger) : IHostedService
|
ILogger<DatabaseInitializer> logger) : IHostedService
|
||||||
@@ -21,9 +17,55 @@ public sealed class DatabaseInitializer(
|
|||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
|
||||||
|
|
||||||
var created = await dbContext.Database.EnsureCreatedAsync(cancellationToken);
|
if (await IsPreMigrationDatabaseAsync(dbContext, cancellationToken))
|
||||||
logger.LogInformation("Library database ready (created: {Created})", created);
|
{
|
||||||
|
// Earlier builds created the schema straight from the model, so there is no
|
||||||
|
// migration history to continue from and no honest way to baseline one — the
|
||||||
|
// columns a baseline would claim exist do not. The database is a cache over the
|
||||||
|
// file system, so throwing it away costs one rescan; poster frames are keyed by
|
||||||
|
// file and survive untouched.
|
||||||
|
logger.LogWarning("Replacing a database created before migrations were introduced");
|
||||||
|
await dbContext.Database.EnsureDeletedAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||||
|
var pendingCount = pending.Count();
|
||||||
|
|
||||||
|
if (pendingCount > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Applying {Count} database migration(s)", pendingCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.Database.MigrateAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Library database ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the file holds a schema but no migration history — the shape earlier
|
||||||
|
/// versions left behind.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> IsPreMigrationDatabaseAsync(
|
||||||
|
LibraryDbContext dbContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await dbContext.Database.CanConnectAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var applied = await dbContext.Database.GetAppliedMigrationsAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (applied.Any())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables = await dbContext.Database
|
||||||
|
.SqlQuery<string>($"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Videos'")
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return tables.Count > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used only by <c>dotnet ef</c> when it needs a context without running the application.
|
||||||
|
/// The connection string is irrelevant for generating migrations — nothing connects.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<LibraryDbContext>
|
||||||
|
{
|
||||||
|
public LibraryDbContext CreateDbContext(string[] args) =>
|
||||||
|
new(new DbContextOptionsBuilder<LibraryDbContext>()
|
||||||
|
.UseSqlite("Data Source=design-time.db")
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PLib.Application.Abstractions;
|
||||||
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ILabelRepository"/>
|
||||||
|
public sealed class EfLabelRepository(LibraryDbContext dbContext) : ILabelRepository
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await dbContext.Labels.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
public Task<LibraryLabel?> FindAsync(
|
||||||
|
LabelKind kind,
|
||||||
|
string name,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Matched on the normalized column so the comparison is an index seek rather than a
|
||||||
|
// collation guess.
|
||||||
|
var normalized = LibraryLabel.Normalize(name);
|
||||||
|
|
||||||
|
return dbContext.Labels
|
||||||
|
.FirstOrDefaultAsync(label => label.Kind == kind && label.NormalizedName == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default) =>
|
||||||
|
await dbContext.Labels.AddAsync(label, cancellationToken);
|
||||||
|
|
||||||
|
public Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
dbContext.Labels.Remove(label);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Infrastructure.Persistence;
|
namespace PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
/// <inheritdoc cref="IVideoRepository"/>
|
/// <inheritdoc cref="IVideoRepository"/>
|
||||||
public sealed class EfVideoRepository(LibraryDbContext dbContext) : IVideoRepository
|
public sealed class EfVideoRepository(LibraryDbContext dbContext) : IVideoRepository
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
public async Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||||
await dbContext.Videos
|
await dbContext.Videos
|
||||||
.OrderByDescending(x => x.AddedAt)
|
.OrderByDescending(x => x.AddedAt)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||||
dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
|
dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
|
||||||
|
|
||||||
public async Task AddAsync(VideoItem item, CancellationToken cancellationToken = default) =>
|
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
await dbContext.Videos.AddAsync(item, cancellationToken);
|
dbContext.Videos
|
||||||
|
.Include(x => x.Labels)
|
||||||
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
{
|
|
||||||
dbContext.Videos.Remove(item);
|
public async Task AddAsync(VideoItem item, CancellationToken cancellationToken = default) =>
|
||||||
return Task.CompletedTask;
|
await dbContext.Videos.AddAsync(item, cancellationToken);
|
||||||
}
|
|
||||||
|
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
|
{
|
||||||
dbContext.SaveChangesAsync(cancellationToken);
|
dbContext.Videos.Remove(item);
|
||||||
}
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Infrastructure.Persistence;
|
namespace PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
public sealed class LibraryDbContext(DbContextOptions<LibraryDbContext> options) : DbContext(options)
|
public sealed class LibraryDbContext(DbContextOptions<LibraryDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<VideoItem> Videos => Set<VideoItem>();
|
public DbSet<VideoItem> Videos => Set<VideoItem>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
public DbSet<LibraryLabel> Labels => Set<LibraryLabel>();
|
||||||
{
|
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LibraryDbContext).Assembly);
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
base.OnModelCreating(modelBuilder);
|
{
|
||||||
}
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LibraryDbContext).Assembly);
|
||||||
}
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
internal sealed class LibraryLabelConfiguration : IEntityTypeConfiguration<LibraryLabel>
|
||||||
|
{
|
||||||
|
private static readonly ValueConverter<DateTimeOffset, long> UtcTicksConverter = new(
|
||||||
|
value => value.UtcTicks,
|
||||||
|
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
||||||
|
|
||||||
|
public void Configure(EntityTypeBuilder<LibraryLabel> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Labels");
|
||||||
|
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
builder.Property(x => x.Name)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128);
|
||||||
|
|
||||||
|
builder.Property(x => x.NormalizedName)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128);
|
||||||
|
|
||||||
|
builder.Property(x => x.Kind)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(16);
|
||||||
|
|
||||||
|
builder.Property(x => x.CreatedAt).HasConversion(UtcTicksConverter);
|
||||||
|
|
||||||
|
// A tag and a collection may share a name; two tags may not.
|
||||||
|
builder.HasIndex(x => new { x.Kind, x.NormalizedName }).IsUnique();
|
||||||
|
|
||||||
|
// Both sides expose read-only collections over a backing field, which EF discovers
|
||||||
|
// by naming convention — no access mode needs spelling out.
|
||||||
|
builder
|
||||||
|
.HasMany(x => x.Videos)
|
||||||
|
.WithMany(x => x.Labels)
|
||||||
|
.UsingEntity(join => join.ToTable("VideoLabels"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibraryDbContext))]
|
||||||
|
[Migration("20260809035132_InitialSchema")]
|
||||||
|
partial class InitialSchema
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("LabelsId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("VideosId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("LabelsId", "VideosId");
|
||||||
|
|
||||||
|
b.HasIndex("VideosId");
|
||||||
|
|
||||||
|
b.ToTable("VideoLabels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Kind", "NormalizedName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Labels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("Duration")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("FullPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("ResumePosition")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeInBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("VideoCodec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AddedAt");
|
||||||
|
|
||||||
|
b.HasIndex("FullPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("LastPlayedAt");
|
||||||
|
|
||||||
|
b.ToTable("Videos", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LabelsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PLib.Domain.Videos.VideoItem", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VideosId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialSchema : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Labels",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||||
|
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||||
|
Kind = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
|
||||||
|
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Labels", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Videos",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
FullPath = table.Column<string>(type: "TEXT", maxLength: 1024, nullable: false),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 512, nullable: false),
|
||||||
|
SizeInBytes = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
Duration = table.Column<TimeSpan>(type: "TEXT", nullable: true),
|
||||||
|
Width = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
Height = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
VideoCodec = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||||
|
ThumbnailPath = table.Column<string>(type: "TEXT", maxLength: 1024, nullable: true),
|
||||||
|
FileModifiedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
AddedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
ResumePosition = table.Column<TimeSpan>(type: "TEXT", nullable: true),
|
||||||
|
LastPlayedAt = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
|
PlayCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Videos", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VideoLabels",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LabelsId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
VideosId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VideoLabels", x => new { x.LabelsId, x.VideosId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VideoLabels_Labels_LabelsId",
|
||||||
|
column: x => x.LabelsId,
|
||||||
|
principalTable: "Labels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VideoLabels_Videos_VideosId",
|
||||||
|
column: x => x.VideosId,
|
||||||
|
principalTable: "Videos",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Labels_Kind_NormalizedName",
|
||||||
|
table: "Labels",
|
||||||
|
columns: new[] { "Kind", "NormalizedName" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VideoLabels_VideosId",
|
||||||
|
table: "VideoLabels",
|
||||||
|
column: "VideosId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Videos_AddedAt",
|
||||||
|
table: "Videos",
|
||||||
|
column: "AddedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Videos_FullPath",
|
||||||
|
table: "Videos",
|
||||||
|
column: "FullPath",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Videos_LastPlayedAt",
|
||||||
|
table: "Videos",
|
||||||
|
column: "LastPlayedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VideoLabels");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Labels");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Videos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibraryDbContext))]
|
||||||
|
partial class LibraryDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("LabelsId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("VideosId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("LabelsId", "VideosId");
|
||||||
|
|
||||||
|
b.HasIndex("VideosId");
|
||||||
|
|
||||||
|
b.ToTable("VideoLabels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Kind", "NormalizedName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Labels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("Duration")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("FullPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("ResumePosition")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeInBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("VideoCodec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AddedAt");
|
||||||
|
|
||||||
|
b.HasIndex("FullPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("LastPlayedAt");
|
||||||
|
|
||||||
|
b.ToTable("Videos", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LabelsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PLib.Domain.Videos.VideoItem", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VideosId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +1,60 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Infrastructure.Persistence;
|
namespace PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoItem>
|
internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoItem>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SQLite refuses to ORDER BY a DateTimeOffset because its default TEXT representation
|
/// 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
|
/// carries an offset and therefore does not sort chronologically. Storing UTC ticks keeps
|
||||||
/// the column both sortable and indexable.
|
/// the column both sortable and indexable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly ValueConverter<DateTimeOffset, long> UtcTicksConverter = new(
|
private static readonly ValueConverter<DateTimeOffset, long> UtcTicksConverter = new(
|
||||||
value => value.UtcTicks,
|
value => value.UtcTicks,
|
||||||
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
||||||
|
|
||||||
public void Configure(EntityTypeBuilder<VideoItem> builder)
|
private static readonly ValueConverter<DateTimeOffset?, long?> NullableUtcTicksConverter = new(
|
||||||
{
|
value => value == null ? null : value.Value.UtcTicks,
|
||||||
builder.ToTable("Videos");
|
ticks => ticks == null ? null : new DateTimeOffset(ticks.Value, TimeSpan.Zero));
|
||||||
|
|
||||||
builder.HasKey(x => x.Id);
|
public void Configure(EntityTypeBuilder<VideoItem> builder)
|
||||||
|
{
|
||||||
builder.Property(x => x.AddedAt).HasConversion(UtcTicksConverter);
|
builder.ToTable("Videos");
|
||||||
builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
|
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
builder.Property(x => x.FullPath)
|
|
||||||
.IsRequired()
|
builder.Property(x => x.AddedAt).HasConversion(UtcTicksConverter);
|
||||||
.HasMaxLength(1024);
|
builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
|
||||||
|
builder.Property(x => x.LastPlayedAt).HasConversion(NullableUtcTicksConverter);
|
||||||
builder.HasIndex(x => x.FullPath)
|
|
||||||
.IsUnique();
|
builder.Property(x => x.FullPath)
|
||||||
|
.IsRequired()
|
||||||
builder.Property(x => x.Title)
|
.HasMaxLength(1024);
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(512);
|
builder.HasIndex(x => x.FullPath)
|
||||||
|
.IsUnique();
|
||||||
builder.Property(x => x.VideoCodec)
|
|
||||||
.HasMaxLength(64);
|
builder.Property(x => x.Title)
|
||||||
|
.IsRequired()
|
||||||
builder.Property(x => x.ThumbnailPath)
|
.HasMaxLength(512);
|
||||||
.HasMaxLength(1024);
|
|
||||||
|
builder.Property(x => x.VideoCodec)
|
||||||
// Sorting the grid by "recently added" is the default view, so it gets an index.
|
.HasMaxLength(64);
|
||||||
builder.HasIndex(x => x.AddedAt);
|
|
||||||
|
builder.Property(x => x.ThumbnailPath)
|
||||||
// IsIndexed is derived from other columns and must not become a table column.
|
.HasMaxLength(1024);
|
||||||
builder.Ignore(x => x.IsIndexed);
|
|
||||||
}
|
// Sorting the grid by "recently added" is the default view, so it gets an index.
|
||||||
}
|
builder.HasIndex(x => x.AddedAt);
|
||||||
|
|
||||||
|
// Both are computed from other columns and must not become table columns.
|
||||||
|
builder.Ignore(x => x.IsIndexed);
|
||||||
|
builder.Ignore(x => x.WatchedFraction);
|
||||||
|
|
||||||
|
// "Continue watching" and "recently played" are both ordered by this.
|
||||||
|
builder.HasIndex(x => x.LastPlayedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using PLib.Domain.Videos;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace PLib.Tests.Domain;
|
||||||
|
|
||||||
|
public sealed class WatchProgressTests
|
||||||
|
{
|
||||||
|
private static VideoItem CreateHourLongVideo()
|
||||||
|
{
|
||||||
|
var item = new VideoItem(@"C:\videos\film.mp4", "film", 1_000, DateTimeOffset.UnixEpoch);
|
||||||
|
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromHours(1), 1920, 1080, "h264"));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_position_worth_returning_to_is_remembered()
|
||||||
|
{
|
||||||
|
var item = CreateHourLongVideo();
|
||||||
|
|
||||||
|
item.RememberProgress(TimeSpan.FromMinutes(20));
|
||||||
|
|
||||||
|
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(20));
|
||||||
|
item.WatchedFraction.ShouldBe(1.0 / 3, 0.01);
|
||||||
|
item.PlayCount.ShouldBe(0);
|
||||||
|
item.LastPlayedAt.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stopping_in_the_first_seconds_leaves_nothing_to_resume()
|
||||||
|
{
|
||||||
|
var item = CreateHourLongVideo();
|
||||||
|
|
||||||
|
item.RememberProgress(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
item.ResumePosition.ShouldBeNull();
|
||||||
|
item.PlayCount.ShouldBe(0);
|
||||||
|
|
||||||
|
// It still counts as opened, which is what recently-played ordering uses.
|
||||||
|
item.LastPlayedAt.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reaching_the_credits_counts_as_watched_rather_than_as_a_resume_point()
|
||||||
|
{
|
||||||
|
var item = CreateHourLongVideo();
|
||||||
|
|
||||||
|
// Inside the end-of-playback slack: the viewer is done, not paused.
|
||||||
|
item.RememberProgress(TimeSpan.FromMinutes(60) - TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
item.ResumePosition.ShouldBeNull();
|
||||||
|
item.PlayCount.ShouldBe(1);
|
||||||
|
item.WatchedFraction.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Watching_again_after_finishing_starts_a_fresh_resume_point()
|
||||||
|
{
|
||||||
|
var item = CreateHourLongVideo();
|
||||||
|
item.RememberProgress(TimeSpan.FromMinutes(60));
|
||||||
|
item.RememberProgress(TimeSpan.FromMinutes(3));
|
||||||
|
|
||||||
|
item.PlayCount.ShouldBe(1);
|
||||||
|
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_video_of_unknown_length_still_remembers_where_it_stopped()
|
||||||
|
{
|
||||||
|
var item = new VideoItem(@"C:\videos\odd.mkv", "odd", 1_000, DateTimeOffset.UnixEpoch);
|
||||||
|
|
||||||
|
item.RememberProgress(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
// Without a duration there is no fraction to draw.
|
||||||
|
item.WatchedFraction.ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using PLib.Application.Abstractions;
|
||||||
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
|
namespace PLib.Tests.Library;
|
||||||
|
|
||||||
|
/// <summary>Hand-written double mirroring the EF repository's lookup-by-normalized-name rule.</summary>
|
||||||
|
internal sealed class InMemoryLabelRepository : ILabelRepository
|
||||||
|
{
|
||||||
|
private readonly List<LibraryLabel> _labels = [];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<IReadOnlyList<LibraryLabel>>([.. _labels]);
|
||||||
|
|
||||||
|
public Task<LibraryLabel?> FindAsync(
|
||||||
|
LabelKind kind,
|
||||||
|
string name,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalized = LibraryLabel.Normalize(name);
|
||||||
|
|
||||||
|
return Task.FromResult(_labels.FirstOrDefault(
|
||||||
|
label => label.Kind == kind && label.NormalizedName == normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_labels.Add(label);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_labels.Remove(label);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,54 @@
|
|||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Tests.Library;
|
namespace PLib.Tests.Library;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A hand-written double rather than a mock: the scan logic is all about what ends up in the
|
/// 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.
|
/// repository, so the tests read better when they can just look at the resulting list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class InMemoryVideoRepository : IVideoRepository
|
internal sealed class InMemoryVideoRepository : IVideoRepository
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, VideoItem> _items = new(LibraryPathComparer.Instance);
|
private readonly Dictionary<string, VideoItem> _items = new(LibraryPathComparer.Instance);
|
||||||
|
|
||||||
public int SaveCount { get; private set; }
|
public int SaveCount { get; private set; }
|
||||||
|
|
||||||
public IReadOnlyCollection<VideoItem> Items => _items.Values;
|
public IReadOnlyCollection<VideoItem> Items => _items.Values;
|
||||||
|
|
||||||
public void Seed(params VideoItem[] items)
|
public void Seed(params VideoItem[] items)
|
||||||
{
|
{
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
_items[item.FullPath] = item;
|
_items[item.FullPath] = item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
public Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult<IReadOnlyList<VideoItem>>([.. _items.Values]);
|
Task.FromResult<IReadOnlyList<VideoItem>>([.. _items.Values]);
|
||||||
|
|
||||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
||||||
|
|
||||||
public Task AddAsync(VideoItem item, CancellationToken cancellationToken = default)
|
// Labels are held on the entity itself here, so there is nothing extra to load.
|
||||||
{
|
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
_items[item.FullPath] = item;
|
Task.FromResult(_items.Values.FirstOrDefault(item => item.Id == id));
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
public Task AddAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
_items[item.FullPath] = item;
|
||||||
{
|
return Task.CompletedTask;
|
||||||
_items.Remove(item.FullPath);
|
}
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
_items.Remove(item.FullPath);
|
||||||
{
|
return Task.CompletedTask;
|
||||||
SaveCount++;
|
}
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
}
|
{
|
||||||
|
SaveCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 LabelTests
|
||||||
|
{
|
||||||
|
private readonly InMemoryVideoRepository _videos = new();
|
||||||
|
private readonly InMemoryLabelRepository _labels = new();
|
||||||
|
private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch);
|
||||||
|
|
||||||
|
public LabelTests() => _videos.Seed(_video);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_name_used_for_the_first_time_creates_the_label()
|
||||||
|
{
|
||||||
|
var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||||
|
|
||||||
|
label.Name.ShouldBe("Комедия");
|
||||||
|
_video.Labels.ShouldHaveSingleItem();
|
||||||
|
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||||
|
var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token);
|
||||||
|
|
||||||
|
second.Id.ShouldBe(first.Id);
|
||||||
|
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// And attaching it twice must not double it up on the video.
|
||||||
|
_video.Labels.ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_tag_and_a_collection_may_share_a_name()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token);
|
||||||
|
var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token);
|
||||||
|
|
||||||
|
collection.Id.ShouldNotBe(tag.Id);
|
||||||
|
_video.Labels.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Detaching_leaves_the_label_itself_in_the_library()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||||
|
|
||||||
|
await service.DetachLabelAsync(_video.Id, label.Id, Token);
|
||||||
|
|
||||||
|
_video.Labels.ShouldBeEmpty();
|
||||||
|
|
||||||
|
// Other videos may still use it, and re-adding must not make a second one.
|
||||||
|
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private LibraryService CreateService() => new(
|
||||||
|
_videos,
|
||||||
|
_labels,
|
||||||
|
Substitute.For<IVideoFileScanner>(),
|
||||||
|
Substitute.For<IMediaProbe>(),
|
||||||
|
Substitute.For<IThumbnailGenerator>(),
|
||||||
|
Options.Create(new LibraryOptions()),
|
||||||
|
NullLogger<LibraryService>.Instance);
|
||||||
|
}
|
||||||
@@ -1,182 +1,183 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
|
||||||
namespace PLib.Tests.Library;
|
namespace PLib.Tests.Library;
|
||||||
|
|
||||||
public sealed class LibraryServiceTests
|
public sealed class LibraryServiceTests
|
||||||
{
|
{
|
||||||
private const string Root = @"C:\videos";
|
private const string Root = @"C:\videos";
|
||||||
|
|
||||||
private readonly InMemoryVideoRepository _repository = new();
|
private readonly InMemoryVideoRepository _repository = new();
|
||||||
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
|
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
|
||||||
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
||||||
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
||||||
|
|
||||||
public LibraryServiceTests()
|
public LibraryServiceTests()
|
||||||
{
|
{
|
||||||
_probe.ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
_probe.ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new VideoTechnicalInfo(TimeSpan.FromMinutes(2), 1920, 1080, "h264"));
|
.Returns(new VideoTechnicalInfo(TimeSpan.FromMinutes(2), 1920, 1080, "h264"));
|
||||||
|
|
||||||
_thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
_thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.jpg");
|
.Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.jpg");
|
||||||
|
|
||||||
// By default every remembered poster frame is still on disk.
|
// By default every remembered poster frame is still on disk.
|
||||||
_thumbnails.IsAvailable(Arg.Any<string?>()).Returns(true);
|
_thumbnails.IsAvailable(Arg.Any<string?>()).Returns(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Files_that_are_new_on_disk_are_added_probed_and_given_a_thumbnail()
|
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"));
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"), File(@"C:\videos\b.mkv"));
|
||||||
|
|
||||||
var events = await CollectAsync(CreateService());
|
var events = await CollectAsync(CreateService());
|
||||||
|
|
||||||
_repository.Items.Count.ShouldBe(2);
|
_repository.Items.Count.ShouldBe(2);
|
||||||
_repository.Items.ShouldAllBe(x => x.IsIndexed);
|
_repository.Items.ShouldAllBe(x => x.IsIndexed);
|
||||||
|
|
||||||
events.OfType<LibraryScanEvent.ItemAdded>().Count().ShouldBe(2);
|
events.OfType<LibraryScanEvent.ItemAdded>().Count().ShouldBe(2);
|
||||||
events.OfType<LibraryScanEvent.ItemUpdated>().Count().ShouldBe(2);
|
events.OfType<LibraryScanEvent.ItemUpdated>().Count().ShouldBe(2);
|
||||||
events.OfType<LibraryScanEvent.Completed>().Single().LibrarySize.ShouldBe(2);
|
events.OfType<LibraryScanEvent.Completed>().Single().LibrarySize.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Entries_whose_file_is_gone_are_dropped_from_the_library()
|
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));
|
_repository.Seed(new VideoItem(@"C:\videos\stale.mp4", "stale", 5_000, DateTimeOffset.UnixEpoch));
|
||||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
||||||
|
|
||||||
var events = await CollectAsync(CreateService());
|
var events = await CollectAsync(CreateService());
|
||||||
|
|
||||||
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\a.mp4"]);
|
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\a.mp4"]);
|
||||||
events.OfType<LibraryScanEvent.ItemRemoved>().Count().ShouldBe(1);
|
events.OfType<LibraryScanEvent.ItemRemoved>().Count().ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Files_below_the_minimum_size_are_not_part_of_the_library()
|
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"));
|
GivenFilesOnDisk(File(@"C:\videos\tiny.mp4", sizeInBytes: 128), File(@"C:\videos\real.mp4"));
|
||||||
|
|
||||||
await CollectAsync(CreateService(new LibraryOptions { MinimumFileSizeInBytes = 1_024 }));
|
await CollectAsync(CreateService(new LibraryOptions { MinimumFileSizeInBytes = 1_024 }));
|
||||||
|
|
||||||
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\real.mp4"]);
|
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\real.mp4"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task An_item_that_is_already_indexed_is_not_probed_again()
|
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);
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
||||||
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
||||||
indexed.AttachThumbnail(@"C:\cache\a.jpg");
|
indexed.AttachThumbnail(@"C:\cache\a.jpg");
|
||||||
_repository.Seed(indexed);
|
_repository.Seed(indexed);
|
||||||
|
|
||||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
||||||
|
|
||||||
await CollectAsync(CreateService());
|
await CollectAsync(CreateService());
|
||||||
|
|
||||||
await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task An_item_whose_file_changed_on_disk_is_indexed_again()
|
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);
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
||||||
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
||||||
indexed.AttachThumbnail(@"C:\cache\old.jpg");
|
indexed.AttachThumbnail(@"C:\cache\old.jpg");
|
||||||
_repository.Seed(indexed);
|
_repository.Seed(indexed);
|
||||||
|
|
||||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 9_999));
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 9_999));
|
||||||
|
|
||||||
await CollectAsync(CreateService());
|
await CollectAsync(CreateService());
|
||||||
|
|
||||||
await _probe.Received(1).ProbeAsync(@"C:\videos\a.mp4", Arg.Any<CancellationToken>());
|
await _probe.Received(1).ProbeAsync(@"C:\videos\a.mp4", Arg.Any<CancellationToken>());
|
||||||
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg");
|
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_poster_frame_that_disappeared_from_the_cache_is_generated_again()
|
public async Task A_poster_frame_that_disappeared_from_the_cache_is_generated_again()
|
||||||
{
|
{
|
||||||
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
||||||
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
||||||
indexed.AttachThumbnail(@"C:\cache\deleted.jpg");
|
indexed.AttachThumbnail(@"C:\cache\deleted.jpg");
|
||||||
_repository.Seed(indexed);
|
_repository.Seed(indexed);
|
||||||
|
|
||||||
_thumbnails.IsAvailable(@"C:\cache\deleted.jpg").Returns(false);
|
_thumbnails.IsAvailable(@"C:\cache\deleted.jpg").Returns(false);
|
||||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
||||||
|
|
||||||
await CollectAsync(CreateService());
|
await CollectAsync(CreateService());
|
||||||
|
|
||||||
await _thumbnails.Received(1)
|
await _thumbnails.Received(1)
|
||||||
.GetOrCreateAsync(@"C:\videos\a.mp4", Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
|
.GetOrCreateAsync(@"C:\videos\a.mp4", Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
|
||||||
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg");
|
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Cached_frames_nothing_points_at_are_purged_once_the_scan_is_whole()
|
public async Task Cached_frames_nothing_points_at_are_purged_once_the_scan_is_whole()
|
||||||
{
|
{
|
||||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
||||||
|
|
||||||
await CollectAsync(CreateService());
|
await CollectAsync(CreateService());
|
||||||
|
|
||||||
await _thumbnails.Received(1).PurgeUnusedAsync(
|
await _thumbnails.Received(1).PurgeUnusedAsync(
|
||||||
Arg.Is<IReadOnlyCollection<string>>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.jpg" })),
|
Arg.Is<IReadOnlyCollection<string>>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.jpg" })),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task The_same_file_reached_through_two_overlapping_roots_is_only_added_once()
|
public async Task The_same_file_reached_through_two_overlapping_roots_is_only_added_once()
|
||||||
{
|
{
|
||||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
||||||
|
|
||||||
var events = await CollectAsync(CreateService(), Root, Root);
|
var events = await CollectAsync(CreateService(), Root, Root);
|
||||||
|
|
||||||
_repository.Items.Count.ShouldBe(1);
|
_repository.Items.Count.ShouldBe(1);
|
||||||
events.OfType<LibraryScanEvent.DiscoveryCompleted>().Single().FilesFound.ShouldBe(1);
|
events.OfType<LibraryScanEvent.DiscoveryCompleted>().Single().FilesFound.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DiscoveredVideoFile File(string path, long sizeInBytes = 10_000) =>
|
private static DiscoveredVideoFile File(string path, long sizeInBytes = 10_000) =>
|
||||||
new(path, sizeInBytes, DateTimeOffset.UnixEpoch);
|
new(path, sizeInBytes, DateTimeOffset.UnixEpoch);
|
||||||
|
|
||||||
private void GivenFilesOnDisk(params DiscoveredVideoFile[] files) =>
|
private void GivenFilesOnDisk(params DiscoveredVideoFile[] files) =>
|
||||||
_scanner.ScanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
_scanner.ScanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(_ => files.ToAsyncEnumerable());
|
.Returns(_ => files.ToAsyncEnumerable());
|
||||||
|
|
||||||
private LibraryService CreateService(LibraryOptions? options = null) => new(
|
private LibraryService CreateService(LibraryOptions? options = null) => new(
|
||||||
_repository,
|
_repository,
|
||||||
_scanner,
|
new InMemoryLabelRepository(),
|
||||||
_probe,
|
_scanner,
|
||||||
_thumbnails,
|
_probe,
|
||||||
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
_thumbnails,
|
||||||
NullLogger<LibraryService>.Instance);
|
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
||||||
|
NullLogger<LibraryService>.Instance);
|
||||||
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
|
||||||
LibraryService service,
|
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
||||||
params string[] folders)
|
LibraryService service,
|
||||||
{
|
params string[] folders)
|
||||||
var events = new List<LibraryScanEvent>();
|
{
|
||||||
|
var events = new List<LibraryScanEvent>();
|
||||||
await foreach (var scanEvent in service.ScanAsync(folders.Length == 0 ? [Root] : folders))
|
|
||||||
{
|
await foreach (var scanEvent in service.ScanAsync(folders.Length == 0 ? [Root] : folders))
|
||||||
events.Add(scanEvent);
|
{
|
||||||
}
|
events.Add(scanEvent);
|
||||||
|
}
|
||||||
return events;
|
|
||||||
}
|
return events;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
internal static class AsyncEnumerableExtensions
|
|
||||||
{
|
internal static class AsyncEnumerableExtensions
|
||||||
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
|
{
|
||||||
{
|
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
|
||||||
foreach (var item in source)
|
{
|
||||||
{
|
foreach (var item in source)
|
||||||
yield return item;
|
{
|
||||||
}
|
yield return item;
|
||||||
|
}
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user