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:
+180
-103
@@ -1,103 +1,180 @@
|
||||
namespace PLib.Domain.Videos;
|
||||
|
||||
/// <summary>
|
||||
/// A single video file that belongs to the library.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The absolute path is the natural identity of a video: the library is a view over the
|
||||
/// file system, so two entries pointing at the same path are the same video. Mutation goes
|
||||
/// through explicit methods so that the entity can never end up half-updated.
|
||||
/// </remarks>
|
||||
public sealed class VideoItem
|
||||
{
|
||||
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||
private VideoItem()
|
||||
{
|
||||
FullPath = null!;
|
||||
Title = null!;
|
||||
}
|
||||
|
||||
public VideoItem(string fullPath, string title, long sizeInBytes, DateTimeOffset fileModifiedAt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
||||
|
||||
Id = Guid.CreateVersion7();
|
||||
FullPath = fullPath;
|
||||
Title = title;
|
||||
SizeInBytes = sizeInBytes;
|
||||
FileModifiedAt = fileModifiedAt;
|
||||
AddedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
|
||||
/// <summary>Absolute path of the file on disk. Unique within the library.</summary>
|
||||
public string FullPath { get; private set; }
|
||||
|
||||
/// <summary>Human readable name; defaults to the file name without extension.</summary>
|
||||
public string Title { get; private set; }
|
||||
|
||||
public long SizeInBytes { get; private set; }
|
||||
|
||||
public TimeSpan? Duration { get; private set; }
|
||||
|
||||
public int? Width { get; private set; }
|
||||
|
||||
public int? Height { get; private set; }
|
||||
|
||||
public string? VideoCodec { get; private set; }
|
||||
|
||||
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
|
||||
public string? ThumbnailPath { get; private set; }
|
||||
|
||||
/// <summary>Last write time of the file when it was last indexed.</summary>
|
||||
public DateTimeOffset FileModifiedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
/// <summary>True once the file has been probed and a poster frame produced.</summary>
|
||||
public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
|
||||
|
||||
public void Rename(string title)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
Title = title;
|
||||
}
|
||||
|
||||
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
|
||||
{
|
||||
Duration = info.Duration;
|
||||
Width = info.Width;
|
||||
Height = info.Height;
|
||||
VideoCodec = info.VideoCodec;
|
||||
}
|
||||
|
||||
public void AttachThumbnail(string thumbnailPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
|
||||
ThumbnailPath = thumbnailPath;
|
||||
}
|
||||
|
||||
public void DetachThumbnail() => ThumbnailPath = null;
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the file system facts after the file changed on disk, and invalidates
|
||||
/// everything that was derived from the previous revision of the file.
|
||||
/// </summary>
|
||||
public void RefreshFileFacts(long sizeInBytes, DateTimeOffset fileModifiedAt)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
||||
|
||||
if (SizeInBytes == sizeInBytes && FileModifiedAt == fileModifiedAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SizeInBytes = sizeInBytes;
|
||||
FileModifiedAt = fileModifiedAt;
|
||||
ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
|
||||
DetachThumbnail();
|
||||
}
|
||||
}
|
||||
namespace PLib.Domain.Videos;
|
||||
|
||||
/// <summary>
|
||||
/// A single video file that belongs to the library.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The absolute path is the natural identity of a video: the library is a view over the
|
||||
/// file system, so two entries pointing at the same path are the same video. Mutation goes
|
||||
/// through explicit methods so that the entity can never end up half-updated.
|
||||
/// </remarks>
|
||||
public sealed class VideoItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 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()
|
||||
{
|
||||
FullPath = null!;
|
||||
Title = null!;
|
||||
}
|
||||
|
||||
public VideoItem(string fullPath, string title, long sizeInBytes, DateTimeOffset fileModifiedAt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
||||
|
||||
Id = Guid.CreateVersion7();
|
||||
FullPath = fullPath;
|
||||
Title = title;
|
||||
SizeInBytes = sizeInBytes;
|
||||
FileModifiedAt = fileModifiedAt;
|
||||
AddedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
|
||||
/// <summary>Absolute path of the file on disk. Unique within the library.</summary>
|
||||
public string FullPath { get; private set; }
|
||||
|
||||
/// <summary>Human readable name; defaults to the file name without extension.</summary>
|
||||
public string Title { get; private set; }
|
||||
|
||||
public long SizeInBytes { get; private set; }
|
||||
|
||||
public TimeSpan? Duration { get; private set; }
|
||||
|
||||
public int? Width { get; private set; }
|
||||
|
||||
public int? Height { get; private set; }
|
||||
|
||||
public string? VideoCodec { get; private set; }
|
||||
|
||||
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
|
||||
public string? ThumbnailPath { get; private set; }
|
||||
|
||||
/// <summary>Last write time of the file when it was last indexed.</summary>
|
||||
public DateTimeOffset FileModifiedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
Title = title;
|
||||
}
|
||||
|
||||
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
|
||||
{
|
||||
Duration = info.Duration;
|
||||
Width = info.Width;
|
||||
Height = info.Height;
|
||||
VideoCodec = info.VideoCodec;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records where playback stopped. A position at either extreme is stored as "nothing to
|
||||
/// resume": too early to matter, or close enough to the end that the video counts as
|
||||
/// watched — which is also the only place <see cref="PlayCount"/> goes up.
|
||||
/// </summary>
|
||||
public void RememberProgress(TimeSpan position)
|
||||
{
|
||||
LastPlayedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (position < ResumeThreshold)
|
||||
{
|
||||
ResumePosition = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Duration is { } duration && position >= duration - EndOfPlaybackSlack)
|
||||
{
|
||||
MarkWatched();
|
||||
return;
|
||||
}
|
||||
|
||||
ResumePosition = position;
|
||||
}
|
||||
|
||||
public void MarkWatched()
|
||||
{
|
||||
ResumePosition = null;
|
||||
LastPlayedAt = DateTimeOffset.UtcNow;
|
||||
PlayCount++;
|
||||
}
|
||||
|
||||
public bool AddLabel(LibraryLabel label)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(label);
|
||||
|
||||
if (_labels.Any(existing => existing.Id == label.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_labels.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveLabel(Guid labelId) => _labels.RemoveAll(label => label.Id == labelId) > 0;
|
||||
|
||||
public void AttachThumbnail(string thumbnailPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
|
||||
ThumbnailPath = thumbnailPath;
|
||||
}
|
||||
|
||||
public void DetachThumbnail() => ThumbnailPath = null;
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the file system facts after the file changed on disk, and invalidates
|
||||
/// everything that was derived from the previous revision of the file.
|
||||
/// </summary>
|
||||
public void RefreshFileFacts(long sizeInBytes, DateTimeOffset fileModifiedAt)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
||||
|
||||
if (SizeInBytes == sizeInBytes && FileModifiedAt == fileModifiedAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SizeInBytes = sizeInBytes;
|
||||
FileModifiedAt = fileModifiedAt;
|
||||
ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
|
||||
DetachThumbnail();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user