From 10c66baea8eb9700b961fdbe5585ad2e0a4d812f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 9 Aug 2026 07:06:57 +0300 Subject: [PATCH] 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. --- .editorconfig | 109 ++-- Directory.Packages.props | 2 + README.md | 21 +- .../Abstractions/ILabelRepository.cs | 16 + .../Abstractions/ILibraryWatcher.cs | 28 + .../Abstractions/IVideoRepository.cs | 45 +- .../Library/ILibraryService.cs | 21 + .../Library/LibraryService.cs | 536 ++++++++++-------- src/PLib.Application/PLib.Application.csproj | 33 +- src/PLib.Desktop/ViewModels/LabelViewModel.cs | 28 + .../ViewModels/MainWindowViewModel.cs | 27 +- src/PLib.Desktop/ViewModels/MetadataRow.cs | 6 + .../ViewModels/VideoCardViewModel.cs | 39 ++ .../ViewModels/VideoPlayerViewModel.cs | 432 ++++++++++---- src/PLib.Desktop/Views/MainWindow.axaml | 27 + src/PLib.Desktop/Views/VideoPlayerView.axaml | 98 +++- .../Views/VideoPlayerView.axaml.cs | 24 + src/PLib.Domain/Videos/LibraryLabel.cs | 70 +++ src/PLib.Domain/Videos/VideoItem.cs | 283 +++++---- .../DependencyInjection.cs | 98 ++-- .../Media/FileSystemLibraryWatcher.cs | 113 ++++ .../PLib.Infrastructure.csproj | 40 +- .../Persistence/DatabaseInitializer.cs | 54 +- .../Persistence/DesignTimeDbContextFactory.cs | 16 + .../Persistence/EfLabelRepository.cs | 34 ++ .../Persistence/EfVideoRepository.cs | 63 +- .../Persistence/LibraryDbContext.cs | 32 +- .../Persistence/LibraryLabelConfiguration.cs | 44 ++ .../20260809035132_InitialSchema.Designer.cs | 150 +++++ .../20260809035132_InitialSchema.cs | 118 ++++ .../LibraryDbContextModelSnapshot.cs | 147 +++++ .../Persistence/VideoItemConfiguration.cs | 111 ++-- tests/PLib.Tests/Domain/WatchProgressTests.cs | 78 +++ .../Library/InMemoryLabelRepository.cs | 36 ++ .../Library/InMemoryVideoRepository.cs | 104 ++-- tests/PLib.Tests/Library/LabelTests.cs | 80 +++ .../PLib.Tests/Library/LibraryServiceTests.cs | 365 ++++++------ 37 files changed, 2572 insertions(+), 956 deletions(-) create mode 100644 src/PLib.Application/Abstractions/ILabelRepository.cs create mode 100644 src/PLib.Application/Abstractions/ILibraryWatcher.cs create mode 100644 src/PLib.Desktop/ViewModels/LabelViewModel.cs create mode 100644 src/PLib.Desktop/ViewModels/MetadataRow.cs create mode 100644 src/PLib.Domain/Videos/LibraryLabel.cs create mode 100644 src/PLib.Infrastructure/Media/FileSystemLibraryWatcher.cs create mode 100644 src/PLib.Infrastructure/Persistence/DesignTimeDbContextFactory.cs create mode 100644 src/PLib.Infrastructure/Persistence/EfLabelRepository.cs create mode 100644 src/PLib.Infrastructure/Persistence/LibraryLabelConfiguration.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809035132_InitialSchema.Designer.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809035132_InitialSchema.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs create mode 100644 tests/PLib.Tests/Domain/WatchProgressTests.cs create mode 100644 tests/PLib.Tests/Library/InMemoryLabelRepository.cs create mode 100644 tests/PLib.Tests/Library/LabelTests.cs 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"> + + + + + + + + + + +