Files
PLib/src/PLib.Domain/Videos/VideoItem.cs
T

259 lines
9.3 KiB
C#

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; }
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
public string? ThumbnailPath { get; private set; }
/// <summary>
/// Absolute path of the animated preview — frames sampled across the video and stacked
/// into one image — or <c>null</c> if it has not been rendered yet.
/// </summary>
public string? PreviewPath { get; private set; }
/// <summary>
/// How many frames <see cref="PreviewPath"/> 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.
/// </summary>
public int PreviewFrameCount { 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>
/// Perceptual hash of the video's visuals, or <c>null</c> if it has not been computed.
/// Compared by Hamming distance rather than for equality.
/// </summary>
public ulong? PerceptualHash { 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 — 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.
/// </summary>
public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
/// <summary>
/// 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.
/// </summary>
public bool NeedsAnimatedPreview => Duration is not null && PreviewPath is null;
/// <summary>
/// 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.
/// </summary>
public bool NeedsPerceptualHash => Duration is not null && PerceptualHash is 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;
}
/// <summary>Replaces the description; blank clears it rather than storing whitespace.</summary>
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;
}
/// <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 ApplyPerceptualHash(ulong? hash) => PerceptualHash = hash;
/// <summary>
/// How many bits differ from another video's hash, or <c>null</c> when either side has
/// no hash to compare.
/// </summary>
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;
}
/// <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);
ApplyPerceptualHash(null);
DetachThumbnail();
DetachPreview();
}
}