namespace PLib.Domain.Videos; /// /// A single video file that belongs to the library. /// /// /// 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. /// public sealed class VideoItem { /// /// How close to the end counts as finished. Credits and trailing black frames mean a /// video is done well before its last millisecond, and offering to resume there is worse /// than offering nothing. /// private static readonly TimeSpan EndOfPlaybackSlack = TimeSpan.FromSeconds(15); /// Below this, the viewer barely started; resuming would be noise. private static readonly TimeSpan ResumeThreshold = TimeSpan.FromSeconds(20); private readonly List _labels = []; /// Required by EF Core materialization; do not use from application code. 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; } /// Absolute path of the file on disk. Unique within the library. public string FullPath { get; private set; } /// Human readable name; defaults to the file name without extension. public string Title { get; private set; } /// /// Free text about the video. Never derived from the file — it only arrives from a /// metadata source or from the user, which is why refreshing the file leaves it alone. /// public string? Description { 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; } /// Absolute path of the generated poster frame, or null if none exists yet. public string? ThumbnailPath { get; private set; } /// /// Absolute path of the animated preview — frames sampled across the video and stacked /// into one image — or null if it has not been rendered yet. /// public string? PreviewPath { get; private set; } /// /// How many frames holds. Stored rather than assumed, because /// the setting that produced it can change while old previews stay on disk, and a strip /// sliced by the wrong count animates as a jumble. /// public int PreviewFrameCount { get; private set; } /// Last write time of the file when it was last indexed. public DateTimeOffset FileModifiedAt { get; private set; } public DateTimeOffset AddedAt { get; private set; } /// /// Where playback stopped last time, or null when there is nothing worth /// resuming — never watched, barely started, or watched to the end. /// public TimeSpan? ResumePosition { get; private set; } public DateTimeOffset? LastPlayedAt { get; private set; } /// How many times the video was watched through to the end. public int PlayCount { get; private set; } /// /// Perceptual hash of the video's visuals, or null if it has not been computed. /// Compared by Hamming distance rather than for equality. /// public ulong? PerceptualHash { get; private set; } /// Tags and collections this video belongs to. public IReadOnlyCollection Labels => _labels; /// /// True once the file has been probed and a poster frame produced — everything the grid /// needs. The perceptual hash is deliberately not part of this: it costs far more than /// the rest put together and is computed in a pass of its own, after the cards are /// already on screen. /// public bool IsIndexed => Duration is not null && ThumbnailPath is not null; /// /// True when the animated preview is still to be rendered. Like hashing, it samples the /// running time and therefore cannot start before the duration is known. /// public bool NeedsAnimatedPreview => Duration is not null && PreviewPath is null; /// /// True when the video is ready to be hashed but has not been. Hashing samples frames /// across the running time, so it cannot start before the duration is known. /// public bool NeedsPerceptualHash => Duration is not null && PerceptualHash is null; /// How far through the video the viewer got, as a fraction, for the card overlay. 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; } /// Replaces the description; blank clears it rather than storing whitespace. public void Describe(string? description) => Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(); public void ApplyTechnicalInfo(VideoTechnicalInfo info) { Duration = info.Duration; Width = info.Width; Height = info.Height; VideoCodec = info.VideoCodec; } /// /// 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 goes up. /// 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 ApplyPerceptualHash(ulong? hash) => PerceptualHash = hash; /// /// How many bits differ from another video's hash, or null when either side has /// no hash to compare. /// public int? DistanceTo(VideoItem other) { ArgumentNullException.ThrowIfNull(other); return PerceptualHash is { } mine && other.PerceptualHash is { } theirs ? System.Numerics.BitOperations.PopCount(mine ^ theirs) : null; } public void AttachThumbnail(string thumbnailPath) { ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath); ThumbnailPath = thumbnailPath; } public void DetachThumbnail() => ThumbnailPath = null; public void AttachPreview(string previewPath, int frameCount) { ArgumentException.ThrowIfNullOrWhiteSpace(previewPath); ArgumentOutOfRangeException.ThrowIfLessThan(frameCount, 2); PreviewPath = previewPath; PreviewFrameCount = frameCount; } public void DetachPreview() { PreviewPath = null; PreviewFrameCount = 0; } /// /// Refreshes the file system facts after the file changed on disk, and invalidates /// everything that was derived from the previous revision of the file. /// public void RefreshFileFacts(long sizeInBytes, DateTimeOffset fileModifiedAt) { ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes); if (SizeInBytes == sizeInBytes && FileModifiedAt == fileModifiedAt) { return; } SizeInBytes = sizeInBytes; FileModifiedAt = fileModifiedAt; ApplyTechnicalInfo(VideoTechnicalInfo.Unknown); ApplyPerceptualHash(null); DetachThumbnail(); DetachPreview(); } }