Enhance video library management in PLib by introducing folder change tracking and improving video item metadata handling. Update IVideoRepository to include a method for loading video items with labels. Revise VideoPlayerViewModel to manage playback progress and integrate new UI elements for displaying watched status and resume options. Update MainWindowViewModel to observe folder changes for automatic rescanning. Enhance README.md to document these new features and usage instructions.
This commit is contained in:
@@ -14,6 +14,11 @@ 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
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
|
||||
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
|
||||
<PackageVersion Include="DynamicData" Version="9.4.33" />
|
||||
<PackageVersion Include="System.Reactive" Version="7.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.10" />
|
||||
@@ -34,6 +35,7 @@
|
||||
|
||||
<ItemGroup Label="Persistence">
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||
<!-- Pinned above the version EF Core resolves: 2.1.11 carries GHSA-2m69-gcr7-jv3q. -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.5" />
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Application.Abstractions;
|
||||
|
||||
/// <summary>Persistence boundary for tags and collections.</summary>
|
||||
public interface ILabelRepository
|
||||
{
|
||||
Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Finds a label by kind and name, ignoring case and surrounding space.</summary>
|
||||
Task<LibraryLabel?> FindAsync(LabelKind kind, string name, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace PLib.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Reports that something under the library folders changed and a rescan is warranted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately says nothing about <em>what</em> changed. A file system watcher reports
|
||||
/// creates, renames and writes as separate events, several per file, and a partially copied
|
||||
/// file arrives as a stream of them — reconciling that into a precise delta is guesswork.
|
||||
/// The scan already knows how to work out the difference, so the watcher only has to say
|
||||
/// "look again", coalesced so a folder full of new files is one signal rather than hundreds.
|
||||
/// </remarks>
|
||||
public interface ILibraryWatcher
|
||||
{
|
||||
/// <summary>Fires after activity in the watched folders settles.</summary>
|
||||
IObservable<Unit> Changed { get; }
|
||||
|
||||
/// <summary>Starts watching the given roots, replacing whatever was watched before.</summary>
|
||||
void Watch(IReadOnlyList<string> folders);
|
||||
|
||||
void StopWatching();
|
||||
}
|
||||
|
||||
/// <summary>A signal carrying nothing; the fact that it happened is the whole payload.</summary>
|
||||
public readonly record struct Unit
|
||||
{
|
||||
public static Unit Default => default;
|
||||
}
|
||||
@@ -12,6 +12,9 @@ public interface IVideoRepository
|
||||
|
||||
Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Loads one video together with the labels attached to it.</summary>
|
||||
Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(VideoItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -24,4 +24,25 @@ public interface ILibraryService
|
||||
/// from scratch. Useful after changing the thumbnail width or capture position.
|
||||
/// </summary>
|
||||
Task ResetThumbnailsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Remembers where playback stopped so the video can be resumed later.</summary>
|
||||
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>One video with its labels loaded, or <c>null</c> if it is gone.</summary>
|
||||
Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Every tag and collection in the library, alphabetically.</summary>
|
||||
Task<IReadOnlyList<LibraryLabel>> GetLabelsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches a label to a video, creating it if this is the first time the name is used.
|
||||
/// Returns the label, whether it was new or not.
|
||||
/// </summary>
|
||||
Task<LibraryLabel> AttachLabelAsync(
|
||||
Guid videoId,
|
||||
string name,
|
||||
LabelKind kind,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace PLib.Application.Library;
|
||||
/// <inheritdoc cref="ILibraryService"/>
|
||||
public sealed class LibraryService(
|
||||
IVideoRepository repository,
|
||||
ILabelRepository labels,
|
||||
IVideoFileScanner scanner,
|
||||
IMediaProbe mediaProbe,
|
||||
IThumbnailGenerator thumbnailGenerator,
|
||||
@@ -27,6 +28,71 @@ public sealed class LibraryService(
|
||||
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<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) =>
|
||||
repository.FindWithLabelsAsync(videoId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<LibraryLabel>> GetLabelsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await labels.GetAllAsync(cancellationToken);
|
||||
return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)];
|
||||
}
|
||||
|
||||
public async Task<LibraryLabel> 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<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) =>
|
||||
thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="System.Reactive" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using PLib.Domain.Videos;
|
||||
using ReactiveUI;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// One tag or collection as shown on the media page. Carries its own remove command so the
|
||||
/// chip template never has to reach up the visual tree.
|
||||
/// </summary>
|
||||
public sealed class LabelViewModel
|
||||
{
|
||||
public LabelViewModel(LibraryLabel label, Action<LabelViewModel> remove)
|
||||
{
|
||||
Id = label.Id;
|
||||
Name = label.Name;
|
||||
Kind = label.Kind;
|
||||
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public LabelKind Kind { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using DynamicData.Kernel;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.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<LibraryOptions> _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<MainWindowViewModel> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rescans when the folders change on disk. The watcher has already waited for the
|
||||
/// activity to settle; this only has to make sure a scan is not started on top of one
|
||||
/// that is still running.
|
||||
/// </summary>
|
||||
private void ObserveFolderChanges() =>
|
||||
_watcher.Changed
|
||||
.ObserveOn(_uiScheduler)
|
||||
.Where(_ => !IsScanning && !IsSettingsOpen)
|
||||
.Subscribe(_ =>
|
||||
{
|
||||
_logger.LogInformation("Library folders changed on disk; rescanning");
|
||||
ScanCommand.Execute().Subscribe();
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
|
||||
{
|
||||
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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>One label/value line in the media page's details block.</summary>
|
||||
/// <param name="Label">What the value is, in the user's language.</param>
|
||||
/// <param name="Value">Already formatted; the view only prints it.</param>
|
||||
public sealed record MetadataRow(string Label, string Value);
|
||||
@@ -66,8 +66,35 @@ public sealed partial class VideoCardViewModel : ReactiveObject
|
||||
[Reactive]
|
||||
public partial long RawSizeInBytes { get; set; }
|
||||
|
||||
/// <summary>How far through the video the viewer got, 0..1, for the bar across the poster.</summary>
|
||||
[Reactive]
|
||||
public partial double WatchedFraction { get; set; }
|
||||
|
||||
/// <summary>True once there is progress worth drawing.</summary>
|
||||
[Reactive]
|
||||
public partial bool HasProgress { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsWatched { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string? ResumeText { get; set; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
/// <summary>Where playback stopped last time, or <c>null</c> if there is nothing to resume.</summary>
|
||||
public TimeSpan? ResumePosition { get; private set; }
|
||||
|
||||
public int? Width { get; private set; }
|
||||
|
||||
public int? Height { get; private set; }
|
||||
|
||||
public string? VideoCodec { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastPlayedAt { get; private set; }
|
||||
|
||||
public int PlayCount { get; private set; }
|
||||
|
||||
/// <summary>Copies the current state of the entity into the card.</summary>
|
||||
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) =>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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;
|
||||
@@ -9,10 +14,12 @@ using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// The media page: one video with its player, its details and its labels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>
|
||||
@@ -21,22 +28,29 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
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(
|
||||
" · ",
|
||||
@@ -50,9 +64,14 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
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)
|
||||
@@ -64,6 +83,11 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
|
||||
public VideoCardViewModel Card { get; }
|
||||
|
||||
public Guid VideoId { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string FullPath { get; }
|
||||
@@ -74,16 +98,33 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
||||
public string Subtitle { get; }
|
||||
|
||||
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
|
||||
public TimeSpan? ResumeFrom { get; }
|
||||
|
||||
public IReadOnlyList<MetadataRow> Details { get; }
|
||||
|
||||
public ObservableCollection<LabelViewModel> Tags { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True while the window is given over to the video. The page hides its own header and
|
||||
/// the window hides its chrome.
|
||||
@@ -91,6 +132,10 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
[Reactive]
|
||||
public partial bool IsFullScreen { get; set; }
|
||||
|
||||
/// <summary>The details and labels panel beside the video.</summary>
|
||||
[Reactive]
|
||||
public partial bool AreDetailsVisible { get; set; }
|
||||
|
||||
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
|
||||
[Reactive]
|
||||
public partial double Volume { get; set; }
|
||||
@@ -98,6 +143,137 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
[Reactive]
|
||||
public partial bool IsMuted { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string NewTag { get; set; } = string.Empty;
|
||||
|
||||
[Reactive]
|
||||
public partial string NewCollection { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Records where playback stopped and refreshes the card behind the page, so the grid
|
||||
/// shows the new progress without waiting for a rescan.
|
||||
/// </summary>
|
||||
public async Task SaveProgressAsync(TimeSpan position)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.SaveProgressAsync(VideoId, position);
|
||||
|
||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||
{
|
||||
Card.Apply(refreshed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A lost resume position is not worth surfacing to someone who just closed a video.
|
||||
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
|
||||
{
|
||||
var rows = new List<MetadataRow>
|
||||
{
|
||||
new("Длительность", card.DurationText),
|
||||
new("Размер", card.SizeText),
|
||||
};
|
||||
|
||||
if (card.Width is { } width && card.Height is { } height)
|
||||
{
|
||||
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
|
||||
{
|
||||
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
|
||||
if (card.LastPlayedAt is { } lastPlayed)
|
||||
{
|
||||
rows.Add(new MetadataRow(
|
||||
"Последний просмотр",
|
||||
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
if (card.PlayCount > 0)
|
||||
{
|
||||
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Файл", card.FullPath));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task LoadLabelsAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var video = await library.GetVideoWithLabelsAsync(VideoId);
|
||||
|
||||
Tags.Clear();
|
||||
Collections.Clear();
|
||||
|
||||
var labels = video is null
|
||||
? []
|
||||
: video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
|
||||
|
||||
foreach (var label in labels)
|
||||
{
|
||||
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AttachAsync(string name, LabelKind kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.AttachLabelAsync(VideoId, name, kind);
|
||||
|
||||
if (kind == LabelKind.Tag)
|
||||
{
|
||||
NewTag = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
NewCollection = string.Empty;
|
||||
}
|
||||
|
||||
await LoadLabelsAsync();
|
||||
}
|
||||
|
||||
private async Task DetachAsync(LabelViewModel label)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.DetachLabelAsync(VideoId, label.Id);
|
||||
Target(label.Kind).Remove(label);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) =>
|
||||
kind == LabelKind.Tag ? Tags : Collections;
|
||||
|
||||
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
||||
|
||||
private async Task PersistAsync(double volume, bool isMuted)
|
||||
@@ -119,8 +295,12 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
CloseCommand.ThrownExceptions,
|
||||
ToggleFullScreenCommand.ThrownExceptions,
|
||||
ToggleMuteCommand.ThrownExceptions,
|
||||
ToggleDetailsCommand.ThrownExceptions,
|
||||
OpenExternallyCommand.ThrownExceptions,
|
||||
RevealCommand.ThrownExceptions)
|
||||
RevealCommand.ThrownExceptions,
|
||||
AddTagCommand.ThrownExceptions,
|
||||
AddCollectionCommand.ThrownExceptions,
|
||||
LoadLabelsCommand.ThrownExceptions)
|
||||
.Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,29 @@
|
||||
<TextBlock Text="{Binding DurationText}" />
|
||||
</Border>
|
||||
|
||||
<!-- Watched marker, top-right, so it never collides with the quality badge. -->
|
||||
<Border Margin="8"
|
||||
Width="20"
|
||||
Height="20"
|
||||
CornerRadius="10"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Background="{DynamicResource AccentBrush}"
|
||||
IsVisible="{Binding IsWatched}"
|
||||
ToolTip.Tip="Просмотрено">
|
||||
<icons:MaterialIcon Kind="Check" Width="13" Height="13" Foreground="White" />
|
||||
</Border>
|
||||
|
||||
<!-- How far the viewer got, drawn across the bottom of the poster. -->
|
||||
<ProgressBar VerticalAlignment="Bottom"
|
||||
Height="3"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding WatchedFraction}"
|
||||
IsVisible="{Binding HasProgress}"
|
||||
Background="{DynamicResource BadgeBackgroundBrush}"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
|
||||
<Border Classes="playOverlay" Background="{DynamicResource OverlayBrush}">
|
||||
<Border Width="46"
|
||||
Height="46"
|
||||
@@ -91,6 +114,10 @@
|
||||
<TextBlock Classes="cardTitle" Text="{Binding Title}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="cardMeta" Text="{Binding SizeText}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
Text="{Binding ResumeText}"
|
||||
IsVisible="{Binding ResumeText, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -6,6 +6,28 @@
|
||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||
x:DataType="vm:VideoPlayerViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
|
||||
<Border Background="{DynamicResource AccentSoftBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="9,3"
|
||||
Margin="0,0,6,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
<Button Command="{Binding RemoveCommand}"
|
||||
Classes="transport"
|
||||
Padding="2"
|
||||
ToolTip.Tip="Убрать">
|
||||
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.transport">
|
||||
<Setter Property="Padding" Value="8" />
|
||||
@@ -45,6 +67,11 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="transport"
|
||||
Command="{Binding ToggleDetailsCommand}"
|
||||
ToolTip.Tip="Сведения и метки">
|
||||
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding OpenExternallyCommand}"
|
||||
ToolTip.Tip="Открыть во внешнем плеере">
|
||||
@@ -61,7 +88,9 @@
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Video ======================= -->
|
||||
<Panel Grid.Row="1" Name="VideoArea" Background="Black">
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||
|
||||
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
|
||||
<controls:VlcVideoView Name="Player"
|
||||
Source="{Binding Source}"
|
||||
AutoPlay="True"
|
||||
@@ -83,6 +112,73 @@
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ======================= Details and labels ======================= -->
|
||||
<Border Grid.Column="1"
|
||||
Width="320"
|
||||
IsVisible="{Binding AreDetailsVisible}"
|
||||
Background="{DynamicResource PanelBackgroundBrush}">
|
||||
<ScrollViewer Padding="16,14">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Сведения" />
|
||||
<ItemsControl ItemsSource="{Binding Details}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataRow">
|
||||
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Label}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Value}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Коллекции" />
|
||||
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ======================= Transport ======================= -->
|
||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
||||
|
||||
@@ -29,6 +29,11 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
/// <summary>The window state to come back to when full screen is switched off.</summary>
|
||||
private WindowState _stateBeforeFullScreen = WindowState.Normal;
|
||||
|
||||
private VideoPlayerViewModel? _viewModel;
|
||||
|
||||
/// <summary>Set once playback has been asked to jump to the remembered position.</summary>
|
||||
private bool _resumeApplied;
|
||||
|
||||
public VideoPlayerView()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -65,6 +70,9 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
_subscriptions.Add(viewModel
|
||||
.WhenAnyValue(x => x.Volume, x => x.IsMuted, VolumeIconFor)
|
||||
.Subscribe(kind => MuteIcon.Kind = kind));
|
||||
|
||||
_viewModel = viewModel;
|
||||
viewModel.LoadLabelsCommand.Execute().Subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +82,14 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
|
||||
_subscriptions.Clear();
|
||||
|
||||
// Where playback got to has to be captured before the player is torn down.
|
||||
if (_viewModel is { } viewModel && Player.Position > TimeSpan.Zero)
|
||||
{
|
||||
_ = viewModel.SaveProgressAsync(Player.Position);
|
||||
}
|
||||
|
||||
_viewModel = null;
|
||||
|
||||
// Closing the page while full screen would otherwise strand the window with no chrome.
|
||||
ApplyFullScreen(false);
|
||||
|
||||
@@ -149,6 +165,14 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
{
|
||||
DurationText.Text = DisplayText.Duration(duration);
|
||||
|
||||
// Seeking is only possible once the length is known, so resuming waits for it —
|
||||
// and happens exactly once, or every later duration report would rewind playback.
|
||||
if (!_resumeApplied && duration > TimeSpan.Zero && _viewModel?.ResumeFrom is { } resume)
|
||||
{
|
||||
_resumeApplied = true;
|
||||
Player.Seek(resume);
|
||||
}
|
||||
|
||||
// A zero maximum would pin the thumb to the left and swallow every seek.
|
||||
Seek.Maximum = duration > TimeSpan.Zero ? duration.TotalSeconds : 1;
|
||||
Seek.IsEnabled = duration > TimeSpan.Zero;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace PLib.Domain.Videos;
|
||||
|
||||
/// <summary>What a label is for; the relation to videos is identical either way.</summary>
|
||||
public enum LabelKind
|
||||
{
|
||||
/// <summary>A free-form word used to narrow the grid down.</summary>
|
||||
Tag,
|
||||
|
||||
/// <summary>A named group the user curates and browses as a whole.</summary>
|
||||
Collection,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named grouping of videos — a tag or a collection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tags and collections are the same relation: a name, many videos, a video in many of them.
|
||||
/// They differ only in intent, and that intent is <see cref="Kind"/>. One entity means one
|
||||
/// join table, one repository and one set of rules about naming; splitting them later is a
|
||||
/// rename and a migration, whereas keeping two near-identical aggregates in sync from the
|
||||
/// start is a permanent tax.
|
||||
/// </remarks>
|
||||
public sealed class LibraryLabel
|
||||
{
|
||||
private readonly List<VideoItem> _videos = [];
|
||||
|
||||
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||
private LibraryLabel()
|
||||
{
|
||||
Name = null!;
|
||||
NormalizedName = null!;
|
||||
}
|
||||
|
||||
public LibraryLabel(string name, LabelKind kind)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
Id = Guid.CreateVersion7();
|
||||
Kind = kind;
|
||||
CreatedAt = DateTimeOffset.UtcNow;
|
||||
Name = name.Trim();
|
||||
NormalizedName = Normalize(name);
|
||||
}
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
|
||||
public string Name { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upper-cased, trimmed name. Uniqueness is enforced on this rather than on
|
||||
/// <see cref="Name"/>, so "Комедия" and "комедия" cannot both exist.
|
||||
/// </summary>
|
||||
public string NormalizedName { get; private set; }
|
||||
|
||||
public LabelKind Kind { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
public IReadOnlyCollection<VideoItem> Videos => _videos;
|
||||
|
||||
public static string Normalize(string name) => name.Trim().ToUpperInvariant();
|
||||
|
||||
public void Rename(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
Name = name.Trim();
|
||||
NormalizedName = Normalize(name);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,18 @@ namespace PLib.Domain.Videos;
|
||||
/// </remarks>
|
||||
public sealed class VideoItem
|
||||
{
|
||||
/// <summary>
|
||||
/// How close to the end counts as finished. Credits and trailing black frames mean a
|
||||
/// video is done well before its last millisecond, and offering to resume there is worse
|
||||
/// than offering nothing.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan EndOfPlaybackSlack = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Below this, the viewer barely started; resuming would be noise.</summary>
|
||||
private static readonly TimeSpan ResumeThreshold = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly List<LibraryLabel> _labels = [];
|
||||
|
||||
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||
private VideoItem()
|
||||
{
|
||||
@@ -57,9 +69,28 @@ public sealed class VideoItem
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where playback stopped last time, or <c>null</c> when there is nothing worth
|
||||
/// resuming — never watched, barely started, or watched to the end.
|
||||
/// </summary>
|
||||
public TimeSpan? ResumePosition { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastPlayedAt { get; private set; }
|
||||
|
||||
/// <summary>How many times the video was watched through to the end.</summary>
|
||||
public int PlayCount { get; private set; }
|
||||
|
||||
/// <summary>Tags and collections this video belongs to.</summary>
|
||||
public IReadOnlyCollection<LibraryLabel> Labels => _labels;
|
||||
|
||||
/// <summary>True once the file has been probed and a poster frame produced.</summary>
|
||||
public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
|
||||
|
||||
/// <summary>How far through the video the viewer got, as a fraction, for the card overlay.</summary>
|
||||
public double WatchedFraction => Duration is { TotalSeconds: > 0 } total && ResumePosition is { } position
|
||||
? Math.Clamp(position / total, 0, 1)
|
||||
: 0;
|
||||
|
||||
public void Rename(string title)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
@@ -74,6 +105,52 @@ public sealed class VideoItem
|
||||
VideoCodec = info.VideoCodec;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records where playback stopped. A position at either extreme is stored as "nothing to
|
||||
/// resume": too early to matter, or close enough to the end that the video counts as
|
||||
/// watched — which is also the only place <see cref="PlayCount"/> goes up.
|
||||
/// </summary>
|
||||
public void RememberProgress(TimeSpan position)
|
||||
{
|
||||
LastPlayedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (position < ResumeThreshold)
|
||||
{
|
||||
ResumePosition = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Duration is { } duration && position >= duration - EndOfPlaybackSlack)
|
||||
{
|
||||
MarkWatched();
|
||||
return;
|
||||
}
|
||||
|
||||
ResumePosition = position;
|
||||
}
|
||||
|
||||
public void MarkWatched()
|
||||
{
|
||||
ResumePosition = null;
|
||||
LastPlayedAt = DateTimeOffset.UtcNow;
|
||||
PlayCount++;
|
||||
}
|
||||
|
||||
public bool AddLabel(LibraryLabel label)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(label);
|
||||
|
||||
if (_labels.Any(existing => existing.Id == label.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_labels.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveLabel(Guid labelId) => _labels.RemoveAll(label => label.Id == labelId) > 0;
|
||||
|
||||
public void AttachThumbnail(string thumbnailPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
|
||||
|
||||
@@ -36,7 +36,9 @@ public static class DependencyInjection
|
||||
});
|
||||
|
||||
services.AddScoped<IVideoRepository, EfVideoRepository>();
|
||||
services.AddScoped<ILabelRepository, EfLabelRepository>();
|
||||
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
|
||||
services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>();
|
||||
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
|
||||
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
||||
services.AddScoped<ILibraryService, LibraryService>();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using Unit = PLib.Application.Abstractions.Unit;
|
||||
|
||||
namespace PLib.Infrastructure.Media;
|
||||
|
||||
/// <inheritdoc cref="ILibraryWatcher"/>
|
||||
public sealed class FileSystemLibraryWatcher : ILibraryWatcher, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// How long the folders must be quiet before a rescan is worth starting. Copying a file
|
||||
/// produces events all the way through the copy, and scanning a half-written file only
|
||||
/// means scanning it again later.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan Quiet = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly List<FileSystemWatcher> _watchers = [];
|
||||
private readonly Subject<Unit> _raw = new();
|
||||
private readonly HashSet<string> _extensions;
|
||||
private readonly ILogger<FileSystemLibraryWatcher> _logger;
|
||||
|
||||
public FileSystemLibraryWatcher(
|
||||
IOptions<LibraryOptions> options,
|
||||
ILogger<FileSystemLibraryWatcher> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_extensions = new HashSet<string>(options.Value.VideoExtensions, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Changed = _raw.Throttle(Quiet).Publish().RefCount();
|
||||
}
|
||||
|
||||
public IObservable<Unit> Changed { get; }
|
||||
|
||||
public void Watch(IReadOnlyList<string> folders)
|
||||
{
|
||||
StopWatching();
|
||||
|
||||
foreach (var folder in folders.Where(Directory.Exists))
|
||||
{
|
||||
try
|
||||
{
|
||||
var watcher = new FileSystemWatcher(folder)
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.LastWrite,
|
||||
};
|
||||
|
||||
watcher.Created += OnChanged;
|
||||
watcher.Deleted += OnChanged;
|
||||
watcher.Renamed += OnChanged;
|
||||
watcher.Changed += OnChanged;
|
||||
|
||||
// A burst larger than the internal buffer is reported as one error rather
|
||||
// than lost events, and a rescan is exactly the right response to it.
|
||||
watcher.Error += OnError;
|
||||
|
||||
watcher.EnableRaisingEvents = true;
|
||||
_watchers.Add(watcher);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A folder on a disconnected share should not stop the others being watched.
|
||||
_logger.LogWarning(ex, "Could not watch {Folder}", folder);
|
||||
}
|
||||
}
|
||||
|
||||
if (_watchers.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Watching {Count} library folder(s) for changes", _watchers.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public void StopWatching()
|
||||
{
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
watcher.EnableRaisingEvents = false;
|
||||
watcher.Dispose();
|
||||
}
|
||||
|
||||
_watchers.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopWatching();
|
||||
_raw.Dispose();
|
||||
}
|
||||
|
||||
private void OnChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
// Directory events carry no extension and must still count: a folder dropped in is
|
||||
// the most common way a batch of videos appears.
|
||||
var extension = Path.GetExtension(e.Name);
|
||||
|
||||
if (!string.IsNullOrEmpty(extension) && !_extensions.Contains(extension))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_raw.OnNext(Unit.Default);
|
||||
}
|
||||
|
||||
private void OnError(object sender, ErrorEventArgs e)
|
||||
{
|
||||
_logger.LogWarning(e.GetException(), "The file system watcher overflowed; rescanning");
|
||||
_raw.OnNext(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFMpegCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -8,10 +8,6 @@ namespace PLib.Infrastructure.Persistence;
|
||||
/// <summary>
|
||||
/// Brings the local database up to date before the first window is shown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// While the schema is still moving we create it from the model. Once the shape settles this
|
||||
/// becomes <c>MigrateAsync</c> plus a checked-in migration history.
|
||||
/// </remarks>
|
||||
public sealed class DatabaseInitializer(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DatabaseInitializer> logger) : IHostedService
|
||||
@@ -21,9 +17,55 @@ public sealed class DatabaseInitializer(
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
|
||||
|
||||
var created = await dbContext.Database.EnsureCreatedAsync(cancellationToken);
|
||||
logger.LogInformation("Library database ready (created: {Created})", created);
|
||||
if (await IsPreMigrationDatabaseAsync(dbContext, cancellationToken))
|
||||
{
|
||||
// Earlier builds created the schema straight from the model, so there is no
|
||||
// migration history to continue from and no honest way to baseline one — the
|
||||
// columns a baseline would claim exist do not. The database is a cache over the
|
||||
// file system, so throwing it away costs one rescan; poster frames are keyed by
|
||||
// file and survive untouched.
|
||||
logger.LogWarning("Replacing a database created before migrations were introduced");
|
||||
await dbContext.Database.EnsureDeletedAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var pending = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||
var pendingCount = pending.Count();
|
||||
|
||||
if (pendingCount > 0)
|
||||
{
|
||||
logger.LogInformation("Applying {Count} database migration(s)", pendingCount);
|
||||
}
|
||||
|
||||
await dbContext.Database.MigrateAsync(cancellationToken);
|
||||
logger.LogInformation("Library database ready");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// True when the file holds a schema but no migration history — the shape earlier
|
||||
/// versions left behind.
|
||||
/// </summary>
|
||||
private static async Task<bool> IsPreMigrationDatabaseAsync(
|
||||
LibraryDbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await dbContext.Database.CanConnectAsync(cancellationToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var applied = await dbContext.Database.GetAppliedMigrationsAsync(cancellationToken);
|
||||
|
||||
if (applied.Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tables = await dbContext.Database
|
||||
.SqlQuery<string>($"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Videos'")
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return tables.Count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace PLib.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Used only by <c>dotnet ef</c> when it needs a context without running the application.
|
||||
/// The connection string is irrelevant for generating migrations — nothing connects.
|
||||
/// </summary>
|
||||
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<LibraryDbContext>
|
||||
{
|
||||
public LibraryDbContext CreateDbContext(string[] args) =>
|
||||
new(new DbContextOptionsBuilder<LibraryDbContext>()
|
||||
.UseSqlite("Data Source=design-time.db")
|
||||
.Options);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Infrastructure.Persistence;
|
||||
|
||||
/// <inheritdoc cref="ILabelRepository"/>
|
||||
public sealed class EfLabelRepository(LibraryDbContext dbContext) : ILabelRepository
|
||||
{
|
||||
public async Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||
await dbContext.Labels.ToListAsync(cancellationToken);
|
||||
|
||||
public Task<LibraryLabel?> FindAsync(
|
||||
LabelKind kind,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Matched on the normalized column so the comparison is an index seek rather than a
|
||||
// collation guess.
|
||||
var normalized = LibraryLabel.Normalize(name);
|
||||
|
||||
return dbContext.Labels
|
||||
.FirstOrDefaultAsync(label => label.Kind == kind && label.NormalizedName == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default) =>
|
||||
await dbContext.Labels.AddAsync(label, cancellationToken);
|
||||
|
||||
public Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.Labels.Remove(label);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ public sealed class EfVideoRepository(LibraryDbContext dbContext) : IVideoReposi
|
||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||
dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
|
||||
|
||||
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
dbContext.Videos
|
||||
.Include(x => x.Labels)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
public async Task AddAsync(VideoItem item, CancellationToken cancellationToken = default) =>
|
||||
await dbContext.Videos.AddAsync(item, cancellationToken);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ public sealed class LibraryDbContext(DbContextOptions<LibraryDbContext> options)
|
||||
{
|
||||
public DbSet<VideoItem> Videos => Set<VideoItem>();
|
||||
|
||||
public DbSet<LibraryLabel> Labels => Set<LibraryLabel>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LibraryDbContext).Assembly);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Infrastructure.Persistence;
|
||||
|
||||
internal sealed class LibraryLabelConfiguration : IEntityTypeConfiguration<LibraryLabel>
|
||||
{
|
||||
private static readonly ValueConverter<DateTimeOffset, long> UtcTicksConverter = new(
|
||||
value => value.UtcTicks,
|
||||
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
||||
|
||||
public void Configure(EntityTypeBuilder<LibraryLabel> builder)
|
||||
{
|
||||
builder.ToTable("Labels");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(128);
|
||||
|
||||
builder.Property(x => x.NormalizedName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(128);
|
||||
|
||||
builder.Property(x => x.Kind)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(16);
|
||||
|
||||
builder.Property(x => x.CreatedAt).HasConversion(UtcTicksConverter);
|
||||
|
||||
// A tag and a collection may share a name; two tags may not.
|
||||
builder.HasIndex(x => new { x.Kind, x.NormalizedName }).IsUnique();
|
||||
|
||||
// Both sides expose read-only collections over a backing field, which EF discovers
|
||||
// by naming convention — no access mode needs spelling out.
|
||||
builder
|
||||
.HasMany(x => x.Videos)
|
||||
.WithMany(x => x.Labels)
|
||||
.UsingEntity(join => join.ToTable("VideoLabels"));
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using PLib.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PLib.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibraryDbContext))]
|
||||
[Migration("20260809035132_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||
{
|
||||
b.Property<Guid>("LabelsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("VideosId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LabelsId", "VideosId");
|
||||
|
||||
b.HasIndex("VideosId");
|
||||
|
||||
b.ToTable("VideoLabels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Kind", "NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeSpan?>("Duration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FileModifiedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FullPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("LastPlayedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlayCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeSpan?>("ResumePosition")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SizeInBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("VideoCodec")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AddedAt");
|
||||
|
||||
b.HasIndex("FullPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("LastPlayedAt");
|
||||
|
||||
b.ToTable("Videos", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||
{
|
||||
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("LabelsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PLib.Domain.Videos.VideoItem", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("VideosId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PLib.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Labels",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Kind = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Labels", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Videos",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
FullPath = table.Column<string>(type: "TEXT", maxLength: 1024, nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 512, nullable: false),
|
||||
SizeInBytes = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Duration = table.Column<TimeSpan>(type: "TEXT", nullable: true),
|
||||
Width = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Height = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
VideoCodec = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||
ThumbnailPath = table.Column<string>(type: "TEXT", maxLength: 1024, nullable: true),
|
||||
FileModifiedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
AddedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
ResumePosition = table.Column<TimeSpan>(type: "TEXT", nullable: true),
|
||||
LastPlayedAt = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
PlayCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Videos", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VideoLabels",
|
||||
columns: table => new
|
||||
{
|
||||
LabelsId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
VideosId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VideoLabels", x => new { x.LabelsId, x.VideosId });
|
||||
table.ForeignKey(
|
||||
name: "FK_VideoLabels_Labels_LabelsId",
|
||||
column: x => x.LabelsId,
|
||||
principalTable: "Labels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VideoLabels_Videos_VideosId",
|
||||
column: x => x.VideosId,
|
||||
principalTable: "Videos",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Labels_Kind_NormalizedName",
|
||||
table: "Labels",
|
||||
columns: new[] { "Kind", "NormalizedName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VideoLabels_VideosId",
|
||||
table: "VideoLabels",
|
||||
column: "VideosId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Videos_AddedAt",
|
||||
table: "Videos",
|
||||
column: "AddedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Videos_FullPath",
|
||||
table: "Videos",
|
||||
column: "FullPath",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Videos_LastPlayedAt",
|
||||
table: "Videos",
|
||||
column: "LastPlayedAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "VideoLabels");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Labels");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Videos");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using PLib.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PLib.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibraryDbContext))]
|
||||
partial class LibraryDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||
{
|
||||
b.Property<Guid>("LabelsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("VideosId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LabelsId", "VideosId");
|
||||
|
||||
b.HasIndex("VideosId");
|
||||
|
||||
b.ToTable("VideoLabels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Kind", "NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeSpan?>("Duration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FileModifiedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FullPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("LastPlayedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlayCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeSpan?>("ResumePosition")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SizeInBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("VideoCodec")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AddedAt");
|
||||
|
||||
b.HasIndex("FullPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("LastPlayedAt");
|
||||
|
||||
b.ToTable("Videos", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||
{
|
||||
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("LabelsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PLib.Domain.Videos.VideoItem", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("VideosId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoIte
|
||||
value => value.UtcTicks,
|
||||
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
||||
|
||||
private static readonly ValueConverter<DateTimeOffset?, long?> NullableUtcTicksConverter = new(
|
||||
value => value == null ? null : value.Value.UtcTicks,
|
||||
ticks => ticks == null ? null : new DateTimeOffset(ticks.Value, TimeSpan.Zero));
|
||||
|
||||
public void Configure(EntityTypeBuilder<VideoItem> builder)
|
||||
{
|
||||
builder.ToTable("Videos");
|
||||
@@ -24,6 +28,7 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoIte
|
||||
|
||||
builder.Property(x => x.AddedAt).HasConversion(UtcTicksConverter);
|
||||
builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
|
||||
builder.Property(x => x.LastPlayedAt).HasConversion(NullableUtcTicksConverter);
|
||||
|
||||
builder.Property(x => x.FullPath)
|
||||
.IsRequired()
|
||||
@@ -45,7 +50,11 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoIte
|
||||
// Sorting the grid by "recently added" is the default view, so it gets an index.
|
||||
builder.HasIndex(x => x.AddedAt);
|
||||
|
||||
// IsIndexed is derived from other columns and must not become a table column.
|
||||
// Both are computed from other columns and must not become table columns.
|
||||
builder.Ignore(x => x.IsIndexed);
|
||||
builder.Ignore(x => x.WatchedFraction);
|
||||
|
||||
// "Continue watching" and "recently played" are both ordered by this.
|
||||
builder.HasIndex(x => x.LastPlayedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using PLib.Domain.Videos;
|
||||
using Shouldly;
|
||||
|
||||
namespace PLib.Tests.Domain;
|
||||
|
||||
public sealed class WatchProgressTests
|
||||
{
|
||||
private static VideoItem CreateHourLongVideo()
|
||||
{
|
||||
var item = new VideoItem(@"C:\videos\film.mp4", "film", 1_000, DateTimeOffset.UnixEpoch);
|
||||
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromHours(1), 1920, 1080, "h264"));
|
||||
return item;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_position_worth_returning_to_is_remembered()
|
||||
{
|
||||
var item = CreateHourLongVideo();
|
||||
|
||||
item.RememberProgress(TimeSpan.FromMinutes(20));
|
||||
|
||||
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
item.WatchedFraction.ShouldBe(1.0 / 3, 0.01);
|
||||
item.PlayCount.ShouldBe(0);
|
||||
item.LastPlayedAt.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_in_the_first_seconds_leaves_nothing_to_resume()
|
||||
{
|
||||
var item = CreateHourLongVideo();
|
||||
|
||||
item.RememberProgress(TimeSpan.FromSeconds(5));
|
||||
|
||||
item.ResumePosition.ShouldBeNull();
|
||||
item.PlayCount.ShouldBe(0);
|
||||
|
||||
// It still counts as opened, which is what recently-played ordering uses.
|
||||
item.LastPlayedAt.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reaching_the_credits_counts_as_watched_rather_than_as_a_resume_point()
|
||||
{
|
||||
var item = CreateHourLongVideo();
|
||||
|
||||
// Inside the end-of-playback slack: the viewer is done, not paused.
|
||||
item.RememberProgress(TimeSpan.FromMinutes(60) - TimeSpan.FromSeconds(5));
|
||||
|
||||
item.ResumePosition.ShouldBeNull();
|
||||
item.PlayCount.ShouldBe(1);
|
||||
item.WatchedFraction.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watching_again_after_finishing_starts_a_fresh_resume_point()
|
||||
{
|
||||
var item = CreateHourLongVideo();
|
||||
item.RememberProgress(TimeSpan.FromMinutes(60));
|
||||
item.RememberProgress(TimeSpan.FromMinutes(3));
|
||||
|
||||
item.PlayCount.ShouldBe(1);
|
||||
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_video_of_unknown_length_still_remembers_where_it_stopped()
|
||||
{
|
||||
var item = new VideoItem(@"C:\videos\odd.mkv", "odd", 1_000, DateTimeOffset.UnixEpoch);
|
||||
|
||||
item.RememberProgress(TimeSpan.FromMinutes(5));
|
||||
|
||||
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Without a duration there is no fraction to draw.
|
||||
item.WatchedFraction.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
/// <summary>Hand-written double mirroring the EF repository's lookup-by-normalized-name rule.</summary>
|
||||
internal sealed class InMemoryLabelRepository : ILabelRepository
|
||||
{
|
||||
private readonly List<LibraryLabel> _labels = [];
|
||||
|
||||
public Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<LibraryLabel>>([.. _labels]);
|
||||
|
||||
public Task<LibraryLabel?> FindAsync(
|
||||
LabelKind kind,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = LibraryLabel.Normalize(name);
|
||||
|
||||
return Task.FromResult(_labels.FirstOrDefault(
|
||||
label => label.Kind == kind && label.NormalizedName == normalized));
|
||||
}
|
||||
|
||||
public Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_labels.Add(label);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_labels.Remove(label);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ internal sealed class InMemoryVideoRepository : IVideoRepository
|
||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
||||
|
||||
// Labels are held on the entity itself here, so there is nothing extra to load.
|
||||
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_items.Values.FirstOrDefault(item => item.Id == id));
|
||||
|
||||
public Task AddAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items[item.FullPath] = item;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Domain.Videos;
|
||||
using Shouldly;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
public sealed class LabelTests
|
||||
{
|
||||
private readonly InMemoryVideoRepository _videos = new();
|
||||
private readonly InMemoryLabelRepository _labels = new();
|
||||
private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch);
|
||||
|
||||
public LabelTests() => _videos.Seed(_video);
|
||||
|
||||
[Fact]
|
||||
public async Task A_name_used_for_the_first_time_creates_the_label()
|
||||
{
|
||||
var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
|
||||
label.Name.ShouldBe("Комедия");
|
||||
_video.Labels.ShouldHaveSingleItem();
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token);
|
||||
|
||||
second.Id.ShouldBe(first.Id);
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
|
||||
// And attaching it twice must not double it up on the video.
|
||||
_video.Labels.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tag_and_a_collection_may_share_a_name()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token);
|
||||
var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token);
|
||||
|
||||
collection.Id.ShouldNotBe(tag.Id);
|
||||
_video.Labels.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detaching_leaves_the_label_itself_in_the_library()
|
||||
{
|
||||
var service = CreateService();
|
||||
var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
|
||||
await service.DetachLabelAsync(_video.Id, label.Id, Token);
|
||||
|
||||
_video.Labels.ShouldBeEmpty();
|
||||
|
||||
// Other videos may still use it, and re-adding must not make a second one.
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
private LibraryService CreateService() => new(
|
||||
_videos,
|
||||
_labels,
|
||||
Substitute.For<IVideoFileScanner>(),
|
||||
Substitute.For<IMediaProbe>(),
|
||||
Substitute.For<IThumbnailGenerator>(),
|
||||
Options.Create(new LibraryOptions()),
|
||||
NullLogger<LibraryService>.Instance);
|
||||
}
|
||||
@@ -147,6 +147,7 @@ public sealed class LibraryServiceTests
|
||||
|
||||
private LibraryService CreateService(LibraryOptions? options = null) => new(
|
||||
_repository,
|
||||
new InMemoryLabelRepository(),
|
||||
_scanner,
|
||||
_probe,
|
||||
_thumbnails,
|
||||
|
||||
Reference in New Issue
Block a user