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();
}
}