259 lines
9.3 KiB
C#
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();
|
|
}
|
|
}
|