diff --git a/.editorconfig b/.editorconfig
index cf127de..ba0a886 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,52 +1,57 @@
-root = true
-
-[*]
-charset = utf-8
-end_of_line = crlf
-indent_style = space
-indent_size = 4
-insert_final_newline = true
-trim_trailing_whitespace = true
-
-[*.{xml,axaml,xaml,csproj,props,targets,json,yml,yaml}]
-indent_size = 2
-
-[*.md]
-trim_trailing_whitespace = false
-
-[*.cs]
-# Namespaces
-csharp_style_namespace_declarations = file_scoped:warning
-
-# var
-csharp_style_var_when_type_is_apparent = true:suggestion
-csharp_style_var_elsewhere = true:suggestion
-
-# Modern language features
-csharp_style_expression_bodied_methods = when_on_single_line:suggestion
-csharp_style_expression_bodied_properties = true:suggestion
-csharp_style_prefer_primary_constructors = true:suggestion
-csharp_style_prefer_pattern_matching = true:suggestion
-csharp_style_prefer_switch_expression = true:suggestion
-csharp_prefer_braces = true:warning
-csharp_prefer_simple_using_statement = true:suggestion
-dotnet_style_collection_initializer = true:suggestion
-dotnet_style_prefer_collection_expression = true:suggestion
-dotnet_style_readonly_field = true:warning
-dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
-
-# Usings
-dotnet_sort_system_directives_first = true
-csharp_using_directive_placement = outside_namespace:warning
-
-# Naming: private fields are _camelCase
-dotnet_naming_rule.private_fields_underscore.symbols = private_fields
-dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
-dotnet_naming_rule.private_fields_underscore.severity = warning
-
-dotnet_naming_symbols.private_fields.applicable_kinds = field
-dotnet_naming_symbols.private_fields.applicable_accessibilities = private
-dotnet_naming_symbols.private_fields.required_modifiers =
-
-dotnet_naming_style.underscore_prefix.capitalization = camel_case
-dotnet_naming_style.underscore_prefix.required_prefix = _
+root = true
+
+[*]
+charset = utf-8
+end_of_line = crlf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{xml,axaml,xaml,csproj,props,targets,json,yml,yaml}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+# EF writes these; style rules are not ours to enforce on them.
+[**/Migrations/*.cs]
+generated_code = true
+dotnet_analyzer_diagnostic.severity = none
+
+[*.cs]
+# Namespaces
+csharp_style_namespace_declarations = file_scoped:warning
+
+# var
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+
+# Modern language features
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_prefer_braces = true:warning
+csharp_prefer_simple_using_statement = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_prefer_collection_expression = true:suggestion
+dotnet_style_readonly_field = true:warning
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
+
+# Usings
+dotnet_sort_system_directives_first = true
+csharp_using_directive_placement = outside_namespace:warning
+
+# Naming: private fields are _camelCase
+dotnet_naming_rule.private_fields_underscore.symbols = private_fields
+dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
+dotnet_naming_rule.private_fields_underscore.severity = warning
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+dotnet_naming_symbols.private_fields.required_modifiers =
+
+dotnet_naming_style.underscore_prefix.capitalization = camel_case
+dotnet_naming_style.underscore_prefix.required_prefix = _
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 82d55ea..d85cb14 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -26,6 +26,7 @@
+
@@ -34,6 +35,7 @@
+
diff --git a/README.md b/README.md
index b9c73d7..0b9eb05 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@
## Что уже работает
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
+- Слежение за папками: новые файлы подхватываются сами, без кнопки.
- Метаданные (длительность, разрешение, кодек) через ffprobe.
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
@@ -94,6 +95,16 @@ dotnet test
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
+- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них
+ одинаковая, различается только назначение. Одна сущность — одна таблица связей, один
+ репозиторий и одно правило именования; разделить потом можно переименованием и миграцией,
+ а держать два почти одинаковых агрегата синхронными пришлось бы всегда. Уникальность —
+ по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся,
+ а тег и коллекция с одним именем сосуществуют.
+- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
+ событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
+ из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
+ разницу и так умеет считать сканирование.
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
@@ -110,5 +121,11 @@ dotnet test
перечитывается на лету;
- `logs/` — Serilog, ротация по дням.
-Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на
-миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть).
+Схема ведётся миграциями EF Core (`src/PLib.Infrastructure/Persistence/Migrations`) и
+применяется при старте. База, созданная сборками до появления миграций, распознаётся по
+отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно
+пересканирование, а превью привязаны к файлам и переживают это нетронутыми.
+
+```bash
+dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations
+```
diff --git a/src/PLib.Application/Abstractions/ILabelRepository.cs b/src/PLib.Application/Abstractions/ILabelRepository.cs
new file mode 100644
index 0000000..c98e62f
--- /dev/null
+++ b/src/PLib.Application/Abstractions/ILabelRepository.cs
@@ -0,0 +1,16 @@
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Abstractions;
+
+/// Persistence boundary for tags and collections.
+public interface ILabelRepository
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+
+ /// Finds a label by kind and name, ignoring case and surrounding space.
+ Task FindAsync(LabelKind kind, string name, CancellationToken cancellationToken = default);
+
+ Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default);
+
+ Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Abstractions/ILibraryWatcher.cs b/src/PLib.Application/Abstractions/ILibraryWatcher.cs
new file mode 100644
index 0000000..47be1ea
--- /dev/null
+++ b/src/PLib.Application/Abstractions/ILibraryWatcher.cs
@@ -0,0 +1,28 @@
+namespace PLib.Application.Abstractions;
+
+///
+/// Reports that something under the library folders changed and a rescan is warranted.
+///
+///
+/// Deliberately says nothing about what 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.
+///
+public interface ILibraryWatcher
+{
+ /// Fires after activity in the watched folders settles.
+ IObservable Changed { get; }
+
+ /// Starts watching the given roots, replacing whatever was watched before.
+ void Watch(IReadOnlyList folders);
+
+ void StopWatching();
+}
+
+/// A signal carrying nothing; the fact that it happened is the whole payload.
+public readonly record struct Unit
+{
+ public static Unit Default => default;
+}
diff --git a/src/PLib.Application/Abstractions/IVideoRepository.cs b/src/PLib.Application/Abstractions/IVideoRepository.cs
index 91e241b..fbc352e 100644
--- a/src/PLib.Application/Abstractions/IVideoRepository.cs
+++ b/src/PLib.Application/Abstractions/IVideoRepository.cs
@@ -1,21 +1,24 @@
-using PLib.Domain.Videos;
-
-namespace PLib.Application.Abstractions;
-
-///
-/// 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.
-///
-public interface IVideoRepository
-{
- Task> GetAllAsync(CancellationToken cancellationToken = default);
-
- Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
-
- Task AddAsync(VideoItem item, CancellationToken cancellationToken = default);
-
- Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default);
-
- /// Flushes every pending change made to tracked entities.
- Task SaveChangesAsync(CancellationToken cancellationToken = default);
-}
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Abstractions;
+
+///
+/// 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.
+///
+public interface IVideoRepository
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+
+ Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
+
+ /// Loads one video together with the labels attached to it.
+ Task FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default);
+
+ Task AddAsync(VideoItem item, CancellationToken cancellationToken = default);
+
+ Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default);
+
+ /// Flushes every pending change made to tracked entities.
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs
index 242b6b2..9d72b21 100644
--- a/src/PLib.Application/Library/ILibraryService.cs
+++ b/src/PLib.Application/Library/ILibraryService.cs
@@ -24,4 +24,25 @@ public interface ILibraryService
/// from scratch. Useful after changing the thumbnail width or capture position.
///
Task ResetThumbnailsAsync(CancellationToken cancellationToken = default);
+
+ /// Remembers where playback stopped so the video can be resumed later.
+ Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
+
+ /// One video with its labels loaded, or null if it is gone.
+ Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default);
+
+ /// Every tag and collection in the library, alphabetically.
+ Task> GetLabelsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// 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.
+ ///
+ Task AttachLabelAsync(
+ Guid videoId,
+ string name,
+ LabelKind kind,
+ CancellationToken cancellationToken = default);
+
+ Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
}
diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs
index 2a821d7..95d5579 100644
--- a/src/PLib.Application/Library/LibraryService.cs
+++ b/src/PLib.Application/Library/LibraryService.cs
@@ -1,235 +1,301 @@
-using System.Runtime.CompilerServices;
-using System.Threading.Channels;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using PLib.Application.Abstractions;
-using PLib.Domain.Videos;
-
-namespace PLib.Application.Library;
-
-///
-public sealed class LibraryService(
- IVideoRepository repository,
- IVideoFileScanner scanner,
- IMediaProbe mediaProbe,
- IThumbnailGenerator thumbnailGenerator,
- IOptions options,
- ILogger logger) : ILibraryService
-{
- /// How many indexed items to accumulate before flushing them to storage.
- private const int SaveBatchSize = 25;
-
- private readonly LibraryOptions _options = options.Value;
-
- public async Task> GetLibraryAsync(CancellationToken cancellationToken = default)
- {
- var items = await repository.GetAllAsync(cancellationToken);
- return [.. items.OrderByDescending(x => x.AddedAt)];
- }
-
- public Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) =>
- thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
-
- public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
- {
- var items = await repository.GetAllAsync(cancellationToken);
-
- foreach (var item in items)
- {
- item.DetachThumbnail();
- }
-
- // 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
- // a full scan notices. This order leaves at worst some orphans, which the purge eats.
- await repository.SaveChangesAsync(cancellationToken);
-
- var removed = await thumbnailGenerator.ClearAsync(cancellationToken);
- logger.LogInformation("Cleared {Count} cached poster frames on request", removed);
- }
-
- public async IAsyncEnumerable ScanAsync(
- IReadOnlyList folders,
- [EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- var known = (await repository.GetAllAsync(cancellationToken))
- .ToDictionary(x => x.FullPath, LibraryPathComparer.Instance);
-
- var discovered = await DiscoverAsync(folders, cancellationToken);
- yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count);
-
- var pending = new List();
-
- foreach (var file in discovered.Values)
- {
- if (known.TryGetValue(file.FullPath, out var existing))
- {
- existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
-
- // The cache directory is ordinary user storage: a poster frame we remember
- // may simply have been deleted. Trusting the stored path would leave the
- // card blank forever, because the item still looks indexed.
- if (existing.ThumbnailPath is not null &&
- !thumbnailGenerator.IsAvailable(existing.ThumbnailPath))
- {
- existing.DetachThumbnail();
- yield return new LibraryScanEvent.ItemUpdated(existing);
- }
- }
- else
- {
- existing = new VideoItem(
- file.FullPath,
- Path.GetFileNameWithoutExtension(file.FullPath),
- file.SizeInBytes,
- file.ModifiedAt);
-
- await repository.AddAsync(existing, cancellationToken);
- known.Add(existing.FullPath, existing);
- yield return new LibraryScanEvent.ItemAdded(existing);
- }
-
- if (!existing.IsIndexed)
- {
- pending.Add(existing);
- }
- }
-
- foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList())
- {
- await repository.RemoveAsync(orphan, cancellationToken);
- known.Remove(orphan.FullPath);
- yield return new LibraryScanEvent.ItemRemoved(orphan.Id);
- }
-
- await repository.SaveChangesAsync(cancellationToken);
-
- await foreach (var indexed in IndexAsync(pending, cancellationToken))
- {
- yield return indexed;
- }
-
- await repository.SaveChangesAsync(cancellationToken);
- await PurgeThumbnailCacheAsync(known.Values, cancellationToken);
-
- yield return new LibraryScanEvent.Completed(known.Count);
- }
-
- ///
- /// Drops cached frames nothing points at any more. Safe only here, at the end of a
- /// 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.
- ///
- private async Task PurgeThumbnailCacheAsync(
- IEnumerable library,
- CancellationToken cancellationToken)
- {
- var inUse = library.Select(x => x.ThumbnailPath).OfType().ToArray();
- var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken);
-
- if (removed > 0)
- {
- logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed);
- }
- }
-
- private async Task> DiscoverAsync(
- IReadOnlyList folders,
- CancellationToken cancellationToken)
- {
- var discovered = new Dictionary(LibraryPathComparer.Instance);
-
- foreach (var folder in folders)
- {
- await foreach (var file in scanner.ScanAsync(folder, cancellationToken))
- {
- if (file.SizeInBytes < _options.MinimumFileSizeInBytes)
- {
- continue;
- }
-
- // Overlapping roots are legal, so the first sighting of a path wins.
- discovered.TryAdd(file.FullPath, file);
- }
- }
-
- return discovered;
- }
-
- ///
- /// 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.
- ///
- private async IAsyncEnumerable IndexAsync(
- IReadOnlyList pending,
- [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- if (pending.Count == 0)
- {
- yield break;
- }
-
- var channel = Channel.CreateBounded(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);
-}
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using PLib.Application.Abstractions;
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Library;
+
+///
+public sealed class LibraryService(
+ IVideoRepository repository,
+ ILabelRepository labels,
+ IVideoFileScanner scanner,
+ IMediaProbe mediaProbe,
+ IThumbnailGenerator thumbnailGenerator,
+ IOptions options,
+ ILogger logger) : ILibraryService
+{
+ /// How many indexed items to accumulate before flushing them to storage.
+ private const int SaveBatchSize = 25;
+
+ private readonly LibraryOptions _options = options.Value;
+
+ public async Task> GetLibraryAsync(CancellationToken cancellationToken = default)
+ {
+ var items = await repository.GetAllAsync(cancellationToken);
+ return [.. items.OrderByDescending(x => x.AddedAt)];
+ }
+
+ public async Task SaveProgressAsync(
+ Guid videoId,
+ TimeSpan position,
+ CancellationToken cancellationToken = default)
+ {
+ var video = await repository.FindWithLabelsAsync(videoId, cancellationToken);
+
+ if (video is null)
+ {
+ return;
+ }
+
+ video.RememberProgress(position);
+ await repository.SaveChangesAsync(cancellationToken);
+ }
+
+ public Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) =>
+ repository.FindWithLabelsAsync(videoId, cancellationToken);
+
+ public async Task> GetLabelsAsync(CancellationToken cancellationToken = default)
+ {
+ var all = await labels.GetAllAsync(cancellationToken);
+ return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)];
+ }
+
+ public async Task AttachLabelAsync(
+ Guid videoId,
+ string name,
+ LabelKind kind,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ var video = await repository.FindWithLabelsAsync(videoId, cancellationToken)
+ ?? throw new InvalidOperationException($"Video {videoId} is not in the library");
+
+ // 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.
+ var label = await labels.FindAsync(kind, name, cancellationToken);
+
+ if (label is null)
+ {
+ label = new LibraryLabel(name, kind);
+ await labels.AddAsync(label, cancellationToken);
+ }
+
+ if (video.AddLabel(label))
+ {
+ await repository.SaveChangesAsync(cancellationToken);
+ logger.LogInformation("Attached {Kind} '{Name}' to {Video}", kind, label.Name, video.Title);
+ }
+
+ return label;
+ }
+
+ public async Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default)
+ {
+ var video = await repository.FindWithLabelsAsync(videoId, cancellationToken);
+
+ if (video?.RemoveLabel(labelId) == true)
+ {
+ await repository.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ public Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) =>
+ thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
+
+ public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
+ {
+ var items = await repository.GetAllAsync(cancellationToken);
+
+ foreach (var item in items)
+ {
+ item.DetachThumbnail();
+ }
+
+ // 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
+ // a full scan notices. This order leaves at worst some orphans, which the purge eats.
+ await repository.SaveChangesAsync(cancellationToken);
+
+ var removed = await thumbnailGenerator.ClearAsync(cancellationToken);
+ logger.LogInformation("Cleared {Count} cached poster frames on request", removed);
+ }
+
+ public async IAsyncEnumerable ScanAsync(
+ IReadOnlyList folders,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var known = (await repository.GetAllAsync(cancellationToken))
+ .ToDictionary(x => x.FullPath, LibraryPathComparer.Instance);
+
+ var discovered = await DiscoverAsync(folders, cancellationToken);
+ yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count);
+
+ var pending = new List();
+
+ foreach (var file in discovered.Values)
+ {
+ if (known.TryGetValue(file.FullPath, out var existing))
+ {
+ existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
+
+ // The cache directory is ordinary user storage: a poster frame we remember
+ // may simply have been deleted. Trusting the stored path would leave the
+ // card blank forever, because the item still looks indexed.
+ if (existing.ThumbnailPath is not null &&
+ !thumbnailGenerator.IsAvailable(existing.ThumbnailPath))
+ {
+ existing.DetachThumbnail();
+ yield return new LibraryScanEvent.ItemUpdated(existing);
+ }
+ }
+ else
+ {
+ existing = new VideoItem(
+ file.FullPath,
+ Path.GetFileNameWithoutExtension(file.FullPath),
+ file.SizeInBytes,
+ file.ModifiedAt);
+
+ await repository.AddAsync(existing, cancellationToken);
+ known.Add(existing.FullPath, existing);
+ yield return new LibraryScanEvent.ItemAdded(existing);
+ }
+
+ if (!existing.IsIndexed)
+ {
+ pending.Add(existing);
+ }
+ }
+
+ foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList())
+ {
+ await repository.RemoveAsync(orphan, cancellationToken);
+ known.Remove(orphan.FullPath);
+ yield return new LibraryScanEvent.ItemRemoved(orphan.Id);
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+
+ await foreach (var indexed in IndexAsync(pending, cancellationToken))
+ {
+ yield return indexed;
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+ await PurgeThumbnailCacheAsync(known.Values, cancellationToken);
+
+ yield return new LibraryScanEvent.Completed(known.Count);
+ }
+
+ ///
+ /// Drops cached frames nothing points at any more. Safe only here, at the end of a
+ /// 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.
+ ///
+ private async Task PurgeThumbnailCacheAsync(
+ IEnumerable library,
+ CancellationToken cancellationToken)
+ {
+ var inUse = library.Select(x => x.ThumbnailPath).OfType().ToArray();
+ var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken);
+
+ if (removed > 0)
+ {
+ logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed);
+ }
+ }
+
+ private async Task> DiscoverAsync(
+ IReadOnlyList folders,
+ CancellationToken cancellationToken)
+ {
+ var discovered = new Dictionary(LibraryPathComparer.Instance);
+
+ foreach (var folder in folders)
+ {
+ await foreach (var file in scanner.ScanAsync(folder, cancellationToken))
+ {
+ if (file.SizeInBytes < _options.MinimumFileSizeInBytes)
+ {
+ continue;
+ }
+
+ // Overlapping roots are legal, so the first sighting of a path wins.
+ discovered.TryAdd(file.FullPath, file);
+ }
+ }
+
+ return discovered;
+ }
+
+ ///
+ /// 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.
+ ///
+ private async IAsyncEnumerable IndexAsync(
+ IReadOnlyList pending,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (pending.Count == 0)
+ {
+ yield break;
+ }
+
+ var channel = Channel.CreateBounded(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);
+}
diff --git a/src/PLib.Application/PLib.Application.csproj b/src/PLib.Application/PLib.Application.csproj
index 1867bb1..987f954 100644
--- a/src/PLib.Application/PLib.Application.csproj
+++ b/src/PLib.Application/PLib.Application.csproj
@@ -1,16 +1,17 @@
-
-
-
- PLib.Application
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ PLib.Application
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/ViewModels/LabelViewModel.cs b/src/PLib.Desktop/ViewModels/LabelViewModel.cs
new file mode 100644
index 0000000..31c0bbd
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/LabelViewModel.cs
@@ -0,0 +1,28 @@
+using PLib.Domain.Videos;
+using ReactiveUI;
+using RxVoid = ReactiveUI.Primitives.RxVoid;
+
+namespace PLib.Desktop.ViewModels;
+
+///
+/// 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.
+///
+public sealed class LabelViewModel
+{
+ public LabelViewModel(LibraryLabel label, Action 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 RemoveCommand { get; }
+}
diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
index 5f0cfc1..763c229 100644
--- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
@@ -9,6 +9,7 @@ using DynamicData.Kernel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using PLib.Application.Abstractions;
using PLib.Application.Library;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
@@ -34,6 +35,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor _options;
private readonly IAppSettingsStore _settingsStore;
+ private readonly ILibraryWatcher _watcher;
private readonly IThemeService _theme;
private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell;
@@ -68,12 +70,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase
IAppSettingsStore settingsStore,
IFolderPicker folderPicker,
ISystemShell shell,
+ ILibraryWatcher watcher,
IThemeService theme,
ILogger logger)
{
_scopeFactory = scopeFactory;
_options = options;
_settingsStore = settingsStore;
+ _watcher = watcher;
_theme = theme;
_folderPicker = folderPicker;
_shell = shell;
@@ -129,6 +133,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
_isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning);
BuildLibraryView(out _videos, out _isEmpty);
+ ObserveFolderChanges();
ObserveCommandFailures();
}
@@ -240,9 +245,25 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenVideo(VideoCardViewModel card)
{
OpenedVideo?.Dispose();
- OpenedVideo = new VideoPlayerViewModel(card, _shell, _settingsStore, _logger, () => OpenedVideo = null);
+ OpenedVideo = new VideoPlayerViewModel(card, _shell, _scopeFactory, _settingsStore, _logger, () => OpenedVideo = null);
}
+ ///
+ /// 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.
+ ///
+ 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 BuildFilter(string? term)
{
if (string.IsNullOrWhiteSpace(term))
@@ -313,6 +334,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
ScanProgress = 0;
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
{
// Task.Run detaches the whole pipeline from the UI synchronisation context, so
diff --git a/src/PLib.Desktop/ViewModels/MetadataRow.cs b/src/PLib.Desktop/ViewModels/MetadataRow.cs
new file mode 100644
index 0000000..ddea2bd
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/MetadataRow.cs
@@ -0,0 +1,6 @@
+namespace PLib.Desktop.ViewModels;
+
+/// One label/value line in the media page's details block.
+/// What the value is, in the user's language.
+/// Already formatted; the view only prints it.
+public sealed record MetadataRow(string Label, string Value);
diff --git a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
index 8cb4659..b599525 100644
--- a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
@@ -66,8 +66,35 @@ public sealed partial class VideoCardViewModel : ReactiveObject
[Reactive]
public partial long RawSizeInBytes { get; set; }
+ /// How far through the video the viewer got, 0..1, for the bar across the poster.
+ [Reactive]
+ public partial double WatchedFraction { get; set; }
+
+ /// True once there is progress worth drawing.
+ [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; }
+ /// Where playback stopped last time, or null if there is nothing to resume.
+ 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; }
+
/// Copies the current state of the entity into the card.
public void Apply(VideoItem item)
{
@@ -77,9 +104,21 @@ public sealed partial class VideoCardViewModel : ReactiveObject
SizeText = DisplayText.FileSize(item.SizeInBytes);
QualityText = DisplayText.Quality(item.Width, item.Height);
AddedAt = item.AddedAt;
+ Width = item.Width;
+ Height = item.Height;
+ VideoCodec = item.VideoCodec;
+ LastPlayedAt = item.LastPlayedAt;
+ PlayCount = item.PlayCount;
RawDuration = item.Duration;
RawSizeInBytes = item.SizeInBytes;
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) =>
diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
index 1744407..962202f 100644
--- a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
@@ -1,126 +1,306 @@
-using System.Reactive.Concurrency;
-using System.Reactive.Linq;
-using Microsoft.Extensions.Logging;
-using PLib.Desktop.Services;
-using ReactiveUI;
-using ReactiveUI.SourceGenerators;
-using RxVoid = ReactiveUI.Primitives.RxVoid;
-
-namespace PLib.Desktop.ViewModels;
-
-///
-/// The media page: one video, opened from the grid. Most of the transport lives on the
-/// 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.
-///
-public sealed partial class VideoPlayerViewModel : ViewModelBase
-{
- ///
- /// How long the volume has to sit still before it is written. Dragging the slider
- /// produces a value per pixel, and each one would otherwise be a file write.
- ///
- private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
-
- private readonly IAppSettingsStore _settingsStore;
- private readonly ILogger _logger;
-
- public VideoPlayerViewModel(
- VideoCardViewModel card,
- ISystemShell shell,
- IAppSettingsStore settingsStore,
- ILogger logger,
- Action close)
- {
- _settingsStore = settingsStore;
- _logger = logger;
-
- Title = card.Title;
- FullPath = card.FullPath;
- Source = new Uri(card.FullPath);
-
- Subtitle = string.Join(
- " · ",
- new[] { card.QualityText, card.DurationText, card.SizeText }
- .Where(part => !string.IsNullOrWhiteSpace(part)));
-
- var settings = settingsStore.Current;
- Volume = settings.Volume;
- IsMuted = settings.IsMuted;
-
- CloseCommand = ReactiveCommand.Create(close);
- ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
- ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
- OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
- RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
-
- 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.
- .Skip(1)
- .Throttle(SaveDebounce, TaskPoolScheduler.Default)
- .DistinctUntilChanged()
- .Subscribe(state => Persist(state.volume, state.muted))
- .AddTo(Subscriptions);
-
- ObserveCommandFailures();
- }
-
- public string Title { get; }
-
- public string FullPath { get; }
-
- /// What the player plays; a file:// URI built from the path.
- public Uri Source { get; }
-
- /// Quality, duration and size on one line, for the page header.
- public string Subtitle { get; }
-
- public ReactiveCommand CloseCommand { get; }
-
- public ReactiveCommand ToggleFullScreenCommand { get; }
-
- public ReactiveCommand ToggleMuteCommand { get; }
-
- public ReactiveCommand OpenExternallyCommand { get; }
-
- public ReactiveCommand RevealCommand { get; }
-
- ///
- /// True while the window is given over to the video. The page hides its own header and
- /// the window hides its chrome.
- ///
- [Reactive]
- public partial bool IsFullScreen { get; set; }
-
- /// Volume as a fraction; restored on open and remembered across restarts.
- [Reactive]
- public partial double Volume { get; set; }
-
- [Reactive]
- public partial bool IsMuted { get; set; }
-
- 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,
- OpenExternallyCommand.ThrownExceptions,
- RevealCommand.ThrownExceptions)
- .Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
- .AddTo(Subscriptions);
-}
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using PLib.Application.Library;
+using PLib.Desktop.Services;
+using PLib.Domain.Videos;
+using ReactiveUI;
+using ReactiveUI.SourceGenerators;
+using RxVoid = ReactiveUI.Primitives.RxVoid;
+
+namespace PLib.Desktop.ViewModels;
+
+///
+/// The media page: one video with its player, its details and its labels.
+///
+///
+/// Transport state stays on the player control; what the page owns is the video's identity,
+/// everything shown around the picture, and the settings that outlive the page.
+///
+public sealed partial class VideoPlayerViewModel : ViewModelBase
+{
+ ///
+ /// How long the volume has to sit still before it is written. Dragging the slider
+ /// produces a value per pixel, and each one would otherwise be a file write.
+ ///
+ private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
+
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly IAppSettingsStore _settingsStore;
+ private readonly ILogger _logger;
+
+ public VideoPlayerViewModel(
+ VideoCardViewModel card,
+ ISystemShell shell,
+ IServiceScopeFactory scopeFactory,
+ IAppSettingsStore settingsStore,
+ ILogger logger,
+ Action close)
+ {
+ _scopeFactory = scopeFactory;
+ _settingsStore = settingsStore;
+ _logger = logger;
+
+ Card = card;
+ VideoId = card.Id;
+ Title = card.Title;
+ FullPath = card.FullPath;
+ Source = new Uri(card.FullPath);
+ ResumeFrom = card.ResumePosition;
+ Details = BuildDetails(card);
+
+ Subtitle = string.Join(
+ " · ",
+ new[] { card.QualityText, card.DurationText, card.SizeText }
+ .Where(part => !string.IsNullOrWhiteSpace(part)));
+
+ var settings = settingsStore.Current;
+ Volume = settings.Volume;
+ IsMuted = settings.IsMuted;
+
+ CloseCommand = ReactiveCommand.Create(close);
+ ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
+ ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
+ ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
+ OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
+ RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
+
+ AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
+ AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
+ LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
+
+ 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.
+ .Skip(1)
+ .Throttle(SaveDebounce, TaskPoolScheduler.Default)
+ .DistinctUntilChanged()
+ .Subscribe(state => Persist(state.volume, state.muted))
+ .AddTo(Subscriptions);
+
+ ObserveCommandFailures();
+ }
+
+ /// The card this page was opened from; refreshed in place as progress is saved.
+ public VideoCardViewModel Card { get; }
+
+ public Guid VideoId { get; }
+
+ public string Title { get; }
+
+ public string FullPath { get; }
+
+ /// What the player plays; a file:// URI built from the path.
+ public Uri Source { get; }
+
+ /// Quality, duration and size on one line, for the page header.
+ public string Subtitle { get; }
+
+ /// Where to start playback, or null to start from the beginning.
+ public TimeSpan? ResumeFrom { get; }
+
+ public IReadOnlyList Details { get; }
+
+ public ObservableCollection Tags { get; } = [];
+
+ public ObservableCollection Collections { get; } = [];
+
+ public ReactiveCommand CloseCommand { get; }
+
+ public ReactiveCommand ToggleFullScreenCommand { get; }
+
+ public ReactiveCommand ToggleMuteCommand { get; }
+
+ public ReactiveCommand ToggleDetailsCommand { get; }
+
+ public ReactiveCommand OpenExternallyCommand { get; }
+
+ public ReactiveCommand RevealCommand { get; }
+
+ public ReactiveCommand AddTagCommand { get; }
+
+ public ReactiveCommand AddCollectionCommand { get; }
+
+ public ReactiveCommand LoadLabelsCommand { get; }
+
+ ///
+ /// True while the window is given over to the video. The page hides its own header and
+ /// the window hides its chrome.
+ ///
+ [Reactive]
+ public partial bool IsFullScreen { get; set; }
+
+ /// The details and labels panel beside the video.
+ [Reactive]
+ public partial bool AreDetailsVisible { get; set; }
+
+ /// Volume as a fraction; restored on open and remembered across restarts.
+ [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;
+
+ ///
+ /// Records where playback stopped and refreshes the card behind the page, so the grid
+ /// shows the new progress without waiting for a rescan.
+ ///
+ public async Task SaveProgressAsync(TimeSpan position)
+ {
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var library = scope.ServiceProvider.GetRequiredService();
+
+ 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 BuildDetails(VideoCardViewModel card)
+ {
+ var rows = new List
+ {
+ 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();
+
+ 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();
+
+ 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();
+
+ 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 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);
+}
diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml
index a2dfd20..d9e1991 100644
--- a/src/PLib.Desktop/Views/MainWindow.axaml
+++ b/src/PLib.Desktop/Views/MainWindow.axaml
@@ -73,6 +73,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml
index 6a8c964..4d95e7c 100644
--- a/src/PLib.Desktop/Views/VideoPlayerView.axaml
+++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml
@@ -6,6 +6,28 @@
x:Class="PLib.Desktop.Views.VideoPlayerView"
x:DataType="vm:VideoPlayerViewModel">
+
+
+
+
+
+
+
+
+
+
+