Implement animated previews and perceptual hashing in PLib video library manager. Introduce IAnimatedPreviewGenerator and IVideoPerceptualHasher interfaces, enhancing video item metadata with animated preview paths and perceptual hashes. Update LibraryService to manage indexing in three passes: metadata, animated previews, and perceptual hashes. Revise UI components to display animated previews and manage duplicate video detection. Enhance README.md to document these new features and usage instructions.

This commit is contained in:
Leonid Pershin
2026-08-09 07:48:45 +03:00
parent 10c66baea8
commit 3c77baced7
36 changed files with 2711 additions and 471 deletions
+37 -2
View File
@@ -9,6 +9,8 @@
- Слежение за папками: новые файлы подхватываются сами, без кнопки. - Слежение за папками: новые файлы подхватываются сами, без кнопки.
- Метаданные (длительность, разрешение, кодек) через ffprobe. - Метаданные (длительность, разрешение, кодек) через ffprobe.
- Постеры кадром из видео через ffmpeg, с кэшем на диске. - Постеры кадром из видео через ffmpeg, с кэшем на диске.
- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются
кадры, снятые по всей длительности.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки - Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется
@@ -95,6 +97,35 @@ dotnet test
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей. появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
- **Индексация в три прохода.** Сначала метаданные и постеры для всех файлов, затем
анимированные превью, и только потом отпечатки. Каждый следующий проход берёт больше кадров
на файл; вперемешку они задерживали бы каждую следующую карточку на всю цепочку, и сетка
наполнялась бы в разы медленнее. Порядок — по видимости: постер нужен, чтобы карточка
вообще появилась, анимация — чтобы она ожила под курсором, хеш всплывает только при поиске
дублей. Поэтому `IsIndexed` намеренно не включает ни анимацию, ни хеш: это готовность к
показу, а не завершённость всей обработки. Поздние проходы умеют стартовать только после
первого — кадры распределяются по длительности, а её устанавливает probe.
- **Анимированное превью — кадры стопкой в одном JPEG, не GIF.** Гифку никто ниже по течению
не проиграет: Avalonia декодирует только первый кадр анимированного изображения, так что
за GIF пришлось бы тащить отдельный декодер. Стопка кадров обходится тем же декодером, что
и постеры — `FilmstripImage` просто рисует каждый тик другой срез, — и весит долю от
256-цветной гифки тех же кадров, а это цена за каждое видео в библиотеке. Рендерит один
процесс ffmpeg: по входу на таймкод (seek до входа, то есть прыжок на ключевой кадр,
а не декодирование до него) и один `vstack`; процесс на кадр умножил бы проход на их число.
Число кадров зашито в имя файла, поэтому смена настройки не режет старую полосу неверным
шагом — она просто промахивается мимо кэша, а лишнее подберёт очистка.
Полоса живёт только пока играет: она весит как все её кадры вместе, а под курсором всегда
одна карточка, так что держать её в общем кэше значило бы менять ограниченную память на
растущую с тем, сколько библиотеки пролистали.
- **pHash по рецепту stash.** 25 кадров сеткой 5×5, по 5% времени отброшено с каждого
конца, кадр по ширине 160 — то есть хеш описывает те же кадры, что и у stash. Декодирует
и масштабирует ffmpeg, отдавая сырой 8-битный серый, поэтому графическая библиотека не
нужна вовсе: монтаж, уменьшение до 64×64 и DCT — арифметика над массивом байт. Бит
ставится сравнением коэффициента с медианой блока 8×8.
Сравнивается хеш расстоянием Хэмминга, а не на равенство; группы дублей собираются
системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу.
**Побитовая совместимость со stash не проверена** — разные реализации ресайза способны
перевернуть биты у коэффициентов рядом с медианой.
- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них - **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них
одинаковая, различается только назначение. Одна сущность — одна таблица связей, один одинаковая, различается только назначение. Одна сущность — одна таблица связей, один
репозиторий и одно правило именования; разделить потом можно переименованием и миграцией, репозиторий и одно правило именования; разделить потом можно переименованием и миграцией,
@@ -107,16 +138,20 @@ dotnet test
разницу и так умеет считать сканирование. разницу и так умеет считать сканирование.
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU - **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного на месте (`IMediaArtifactCache.IsAvailable`), и перерисовывает удалённые; после полного
прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются
только если им больше часа — иначе можно снести рендер второго запущенного экземпляра. только если им больше часа — иначе можно снести рендер второго запущенного экземпляра.
Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и
описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда
появился второй вид файлов.
## Данные ## Данные
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`: Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
- `library.db` — SQLite с метаданными; - `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash;
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); - `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени;
- `settings.json` — папки, параметры превью и сканирования, тема, громкость; - `settings.json` — папки, параметры превью и сканирования, тема, громкость;
перечитывается на лету; перечитывается на лету;
- `logs/` — Serilog, ротация по дням. - `logs/` — Serilog, ротация по дням.
@@ -0,0 +1,26 @@
namespace PLib.Application.Abstractions;
/// <summary>
/// An animated preview: frames sampled across the video and stacked into a single image,
/// which the card cycles through while the pointer rests on it.
/// </summary>
/// <param name="Path">Absolute path of the stacked image.</param>
/// <param name="FrameCount">
/// How many frames are stacked in it. Without this the image is just a very tall picture —
/// it is what lets the player slice it back into frames.
/// </param>
public sealed record AnimatedPreview(string Path, int FrameCount);
/// <summary>Produces (and caches on disk) the animated preview of a video file.</summary>
public interface IAnimatedPreviewGenerator : IMediaArtifactCache
{
/// <summary>
/// Returns the preview for <paramref name="videoPath"/>, rendering it if it is not
/// cached yet, or <c>null</c> when one could not be produced — an unreadable file or an
/// unknown duration, both of which are ordinary outcomes rather than errors.
/// </summary>
Task<AnimatedPreview?> GetOrCreateAsync(
string videoPath,
TimeSpan? duration,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,36 @@
namespace PLib.Application.Abstractions;
/// <summary>
/// A cache of files derived from the videos themselves — poster frames, animated previews.
/// </summary>
/// <remarks>
/// Derived artefacts share one lifecycle regardless of what they are: they can vanish from
/// disk behind the library's back, they go stale when nothing points at them any more, and
/// the user is entitled to see what they cost and to throw them away. Saying that once keeps
/// the settings screen honest as artefact kinds are added — a size that only counted poster
/// frames would understate the cache the moment previews appeared.
/// </remarks>
public interface IMediaArtifactCache
{
/// <summary>
/// True when a previously generated file is still present. The cache directory is
/// ordinary user-writable storage, so a path the library remembers is not proof that the
/// file behind it still exists.
/// </summary>
bool IsAvailable(string? path);
/// <summary>
/// Deletes cached files that no library item points at any more, and returns how many
/// were removed. Call only after a complete scan: anything not in
/// <paramref name="inUsePaths"/> is treated as garbage.
/// </summary>
Task<int> PurgeUnusedAsync(
IReadOnlyCollection<string> inUsePaths,
CancellationToken cancellationToken = default);
/// <summary>How much disk this cache currently occupies, in bytes.</summary>
Task<long> GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default);
/// <summary>Empties the cache completely. Returns how many files were removed.</summary>
Task<int> ClearAsync(CancellationToken cancellationToken = default);
}
@@ -1,7 +1,7 @@
namespace PLib.Application.Abstractions; namespace PLib.Application.Abstractions;
/// <summary>Produces (and caches on disk) a poster frame for a video file.</summary> /// <summary>Produces (and caches on disk) a poster frame for a video file.</summary>
public interface IThumbnailGenerator public interface IThumbnailGenerator : IMediaArtifactCache
{ {
/// <summary> /// <summary>
/// Returns the absolute path of the poster frame for <paramref name="videoPath"/>, /// Returns the absolute path of the poster frame for <paramref name="videoPath"/>,
@@ -12,26 +12,4 @@ public interface IThumbnailGenerator
string videoPath, string videoPath,
TimeSpan? duration, TimeSpan? duration,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// True when a previously generated poster frame is still present in the cache. The
/// cache directory is ordinary user-writable storage, so a path the library remembers
/// is not proof that the file behind it still exists.
/// </summary>
bool IsAvailable(string? thumbnailPath);
/// <summary>
/// Deletes cached frames that no library item points at any more, and returns how many
/// files were removed. Call only after a complete scan: anything not in
/// <paramref name="inUsePaths"/> is treated as garbage.
/// </summary>
Task<int> PurgeUnusedAsync(
IReadOnlyCollection<string> inUsePaths,
CancellationToken cancellationToken = default);
/// <summary>How much disk the cache currently occupies, in bytes.</summary>
Task<long> GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default);
/// <summary>Empties the cache completely. Returns how many files were removed.</summary>
Task<int> ClearAsync(CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,19 @@
namespace PLib.Application.Abstractions;
/// <summary>
/// Computes a perceptual hash of a video's visual content.
/// </summary>
/// <remarks>
/// The hash describes what the video looks like, not what its bytes are: the same film
/// re-encoded, resized or slightly re-cut lands within a few bits of the original, while an
/// unrelated video lands far away. That is what makes it useful for finding duplicates a
/// checksum would miss.
/// </remarks>
public interface IVideoPerceptualHasher
{
/// <summary>
/// Returns the hash, or <c>null</c> when the file could not be sampled — an unreadable
/// or zero-length video is a normal outcome here, not an error.
/// </summary>
Task<ulong?> ComputeAsync(string videoPath, TimeSpan? duration, CancellationToken cancellationToken = default);
}
@@ -28,6 +28,18 @@ public interface ILibraryService
/// <summary>Remembers where playback stopped so the video can be resumed later.</summary> /// <summary>Remembers where playback stopped so the video can be resumed later.</summary>
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default); Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
/// <summary>
/// Groups of videos that look alike, by perceptual hash. Videos without a hash, and
/// groups of one, are left out.
/// </summary>
/// <param name="maxDistance">
/// How many differing bits still count as the same video. Zero means visually identical;
/// the useful range for re-encodes is a handful of bits.
/// </param>
Task<IReadOnlyList<IReadOnlyList<VideoItem>>> FindDuplicatesAsync(
int maxDistance,
CancellationToken cancellationToken = default);
/// <summary>One video with its labels loaded, or <c>null</c> if it is gone.</summary> /// <summary>One video with its labels loaded, or <c>null</c> if it is gone.</summary>
Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default); Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default);
@@ -27,6 +27,20 @@ public sealed class LibraryOptions
[Range(0.0, 0.9)] [Range(0.0, 0.9)]
public double ThumbnailPositionRatio { get; init; } = 0.15; public double ThumbnailPositionRatio { get; init; } = 0.15;
/// <summary>
/// How many frames the animated preview is made of. Each one costs a seek and a decode,
/// so this is the main lever on how long the preview pass takes.
/// </summary>
[Range(2, 60)]
public int PreviewFrameCount { get; init; } = 12;
/// <summary>
/// Width of each preview frame in pixels. Deliberately smaller than the poster frame:
/// the preview is a dozen images in one file and is only ever seen card-sized.
/// </summary>
[Range(120, 960)]
public int PreviewWidth { get; init; } = 240;
/// <summary>How many files may be probed / rendered concurrently.</summary> /// <summary>How many files may be probed / rendered concurrently.</summary>
[Range(1, 32)] [Range(1, 32)]
public int MaxIndexingConcurrency { get; init; } = 4; public int MaxIndexingConcurrency { get; init; } = 4;
@@ -24,9 +24,18 @@ public abstract record LibraryScanEvent
/// <summary>A video disappeared from disk and was dropped from the library.</summary> /// <summary>A video disappeared from disk and was dropped from the library.</summary>
public sealed record ItemRemoved(Guid Id) : LibraryScanEvent; public sealed record ItemRemoved(Guid Id) : LibraryScanEvent;
/// <summary>Indexing progress, reported after every processed file.</summary> /// <summary>Progress of the first pass — metadata and poster frames.</summary>
public sealed record IndexingProgress(int Processed, int Total) : LibraryScanEvent; public sealed record IndexingProgress(int Processed, int Total) : LibraryScanEvent;
/// <summary>Progress of the second pass — animated previews.</summary>
public sealed record PreviewProgress(int Processed, int Total) : LibraryScanEvent;
/// <summary>
/// Progress of the last pass — perceptual hashes. Reported separately because it runs
/// long after the grid is usable, and saying "indexing" again would misdescribe it.
/// </summary>
public sealed record HashingProgress(int Processed, int Total) : LibraryScanEvent;
/// <summary>The scan finished successfully.</summary> /// <summary>The scan finished successfully.</summary>
public sealed record Completed(int LibrarySize) : LibraryScanEvent; public sealed record Completed(int LibrarySize) : LibraryScanEvent;
} }
+247 -46
View File
@@ -14,6 +14,8 @@ public sealed class LibraryService(
IVideoFileScanner scanner, IVideoFileScanner scanner,
IMediaProbe mediaProbe, IMediaProbe mediaProbe,
IThumbnailGenerator thumbnailGenerator, IThumbnailGenerator thumbnailGenerator,
IAnimatedPreviewGenerator previewGenerator,
IVideoPerceptualHasher perceptualHasher,
IOptions<LibraryOptions> options, IOptions<LibraryOptions> options,
ILogger<LibraryService> logger) : ILibraryService ILogger<LibraryService> logger) : ILibraryService
{ {
@@ -44,6 +46,58 @@ public sealed class LibraryService(
await repository.SaveChangesAsync(cancellationToken); await repository.SaveChangesAsync(cancellationToken);
} }
public async Task<IReadOnlyList<IReadOnlyList<VideoItem>>> FindDuplicatesAsync(
int maxDistance,
CancellationToken cancellationToken = default)
{
var hashed = (await repository.GetAllAsync(cancellationToken))
.Where(item => item.PerceptualHash is not null)
.ToList();
// Union-find over the pairwise comparison: two videos land in the same group if a
// chain of near-matches connects them, which is what "these are the same film"
// means when one copy sits between two others.
var groupOf = new int[hashed.Count];
for (var i = 0; i < groupOf.Length; i++)
{
groupOf[i] = i;
}
int Root(int index)
{
while (groupOf[index] != index)
{
groupOf[index] = groupOf[groupOf[index]];
index = groupOf[index];
}
return index;
}
for (var i = 0; i < hashed.Count; i++)
{
for (var j = i + 1; j < hashed.Count; j++)
{
cancellationToken.ThrowIfCancellationRequested();
if (hashed[i].DistanceTo(hashed[j]) <= maxDistance)
{
groupOf[Root(i)] = Root(j);
}
}
}
return
[
.. hashed
.Select((item, index) => (item, group: Root(index)))
.GroupBy(pair => pair.group)
.Where(group => group.Count() > 1)
.Select(IReadOnlyList<VideoItem> (group) => [.. group.Select(pair => pair.item)])
];
}
public Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) => public Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) =>
repository.FindWithLabelsAsync(videoId, cancellationToken); repository.FindWithLabelsAsync(videoId, cancellationToken);
@@ -93,8 +147,16 @@ public sealed class LibraryService(
} }
} }
public Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) => /// <summary>Every cache of files derived from the videos, so none is ever forgotten.</summary>
thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken); private IEnumerable<IMediaArtifactCache> ArtifactCaches => [thumbnailGenerator, previewGenerator];
public async Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default)
{
var sizes = await Task.WhenAll(
ArtifactCaches.Select(cache => cache.GetCacheSizeInBytesAsync(cancellationToken)));
return sizes.Sum();
}
public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default) public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
{ {
@@ -103,6 +165,7 @@ public sealed class LibraryService(
foreach (var item in items) foreach (var item in items)
{ {
item.DetachThumbnail(); item.DetachThumbnail();
item.DetachPreview();
} }
// Forget the paths before deleting the files. Interrupted the other way round, the // Forget the paths before deleting the files. Interrupted the other way round, the
@@ -110,8 +173,8 @@ public sealed class LibraryService(
// a full scan notices. This order leaves at worst some orphans, which the purge eats. // a full scan notices. This order leaves at worst some orphans, which the purge eats.
await repository.SaveChangesAsync(cancellationToken); await repository.SaveChangesAsync(cancellationToken);
var removed = await thumbnailGenerator.ClearAsync(cancellationToken); var removed = await Task.WhenAll(ArtifactCaches.Select(cache => cache.ClearAsync(cancellationToken)));
logger.LogInformation("Cleared {Count} cached poster frames on request", removed); logger.LogInformation("Cleared {Count} cached images on request", removed.Sum());
} }
public async IAsyncEnumerable<LibraryScanEvent> ScanAsync( public async IAsyncEnumerable<LibraryScanEvent> ScanAsync(
@@ -132,13 +195,27 @@ public sealed class LibraryService(
{ {
existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt); existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
// The cache directory is ordinary user storage: a poster frame we remember // The cache directory is ordinary user storage: an image we remember may
// may simply have been deleted. Trusting the stored path would leave the // simply have been deleted. Trusting the stored path would leave the card
// card blank forever, because the item still looks indexed. // blank forever, because the item still looks indexed.
var forgotten = false;
if (existing.ThumbnailPath is not null && if (existing.ThumbnailPath is not null &&
!thumbnailGenerator.IsAvailable(existing.ThumbnailPath)) !thumbnailGenerator.IsAvailable(existing.ThumbnailPath))
{ {
existing.DetachThumbnail(); existing.DetachThumbnail();
forgotten = true;
}
if (existing.PreviewPath is not null &&
!previewGenerator.IsAvailable(existing.PreviewPath))
{
existing.DetachPreview();
forgotten = true;
}
if (forgotten)
{
yield return new LibraryScanEvent.ItemUpdated(existing); yield return new LibraryScanEvent.ItemUpdated(existing);
} }
} }
@@ -170,32 +247,64 @@ public sealed class LibraryService(
await repository.SaveChangesAsync(cancellationToken); await repository.SaveChangesAsync(cancellationToken);
// First pass: metadata and poster frames, so the grid fills in as fast as the files
// allow.
await foreach (var indexed in IndexAsync(pending, cancellationToken)) await foreach (var indexed in IndexAsync(pending, cancellationToken))
{ {
yield return indexed; yield return indexed;
} }
await repository.SaveChangesAsync(cancellationToken); await repository.SaveChangesAsync(cancellationToken);
await PurgeThumbnailCacheAsync(known.Values, cancellationToken);
// Second pass: animated previews. Ordered ahead of hashing because it is the one the
// user can see — a hash only ever surfaces when duplicates are searched for.
var withoutPreview = known.Values.Where(item => item.NeedsAnimatedPreview).ToList();
await foreach (var previewed in PreviewAsync(withoutPreview, cancellationToken))
{
yield return previewed;
}
await repository.SaveChangesAsync(cancellationToken);
// Last pass: perceptual hashes. Two dozen frame grabs per file makes this the
// expensive one, and it can only start once the first pass has established the
// duration to spread those frames across.
var unhashed = known.Values.Where(item => item.NeedsPerceptualHash).ToList();
await foreach (var hashed in HashAsync(unhashed, cancellationToken))
{
yield return hashed;
}
await repository.SaveChangesAsync(cancellationToken);
await PurgeArtifactCachesAsync(known.Values, cancellationToken);
yield return new LibraryScanEvent.Completed(known.Count); yield return new LibraryScanEvent.Completed(known.Count);
} }
/// <summary> /// <summary>
/// Drops cached frames nothing points at any more. Safe only here, at the end of a /// Drops cached images nothing points at any more. Safe only here, at the end of a
/// completed scan, because that is the only moment the library is known to be whole — /// completed scan, because that is the only moment the library is known to be whole —
/// running it mid-scan would delete frames of items not reconciled yet. /// running it mid-scan would delete the frames of items not reconciled yet.
/// </summary> /// </summary>
private async Task PurgeThumbnailCacheAsync( private async Task PurgeArtifactCachesAsync(
IEnumerable<VideoItem> library, IEnumerable<VideoItem> library,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var inUse = library.Select(x => x.ThumbnailPath).OfType<string>().ToArray(); var items = library as IReadOnlyCollection<VideoItem> ?? [.. library];
var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken);
var removed = await thumbnailGenerator.PurgeUnusedAsync(
[.. items.Select(x => x.ThumbnailPath).OfType<string>()],
cancellationToken);
removed += await previewGenerator.PurgeUnusedAsync(
[.. items.Select(x => x.PreviewPath).OfType<string>()],
cancellationToken);
if (removed > 0) if (removed > 0)
{ {
logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed); logger.LogInformation("Removed {Count} orphaned images from the cache", removed);
} }
} }
@@ -223,20 +332,136 @@ public sealed class LibraryService(
} }
/// <summary> /// <summary>
/// Probes and renders poster frames with bounded concurrency. The expensive work runs in /// First pass: probes each file and renders its poster frame, with bounded concurrency.
/// parallel, but the results are applied to the entities one at a time by the consumer /// The expensive work runs in parallel, but results are applied to the entities one at a
/// because change tracking is not thread safe. /// time by the consumer because change tracking is not thread safe.
/// </summary> /// </summary>
private async IAsyncEnumerable<LibraryScanEvent> IndexAsync( private async IAsyncEnumerable<LibraryScanEvent> IndexAsync(
IReadOnlyList<VideoItem> pending, IReadOnlyList<VideoItem> pending,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
if (pending.Count == 0) var processed = 0;
var results = InParallelAsync(
pending,
async (item, token) =>
{
var info = await mediaProbe.ProbeAsync(item.FullPath, token);
var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token);
return (Item: item, Info: info, Thumbnail: thumbnail);
},
cancellationToken);
await foreach (var result in results)
{
result.Item.ApplyTechnicalInfo(result.Info);
if (result.Thumbnail is not null)
{
result.Item.AttachThumbnail(result.Thumbnail);
}
processed++;
yield return new LibraryScanEvent.ItemUpdated(result.Item);
yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count);
if (processed % SaveBatchSize == 0)
{
await repository.SaveChangesAsync(cancellationToken);
}
}
if (processed > 0)
{
logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count);
}
}
/// <summary>Second pass: the animated preview of each video that does not have one yet.</summary>
private async IAsyncEnumerable<LibraryScanEvent> PreviewAsync(
IReadOnlyList<VideoItem> pending,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var processed = 0;
var results = InParallelAsync(
pending,
async (item, token) => (Item: item, Preview: await previewGenerator.GetOrCreateAsync(item.FullPath, item.Duration, token)),
cancellationToken);
await foreach (var result in results)
{
if (result.Preview is { } preview)
{
result.Item.AttachPreview(preview.Path, preview.FrameCount);
}
processed++;
yield return new LibraryScanEvent.ItemUpdated(result.Item);
yield return new LibraryScanEvent.PreviewProgress(processed, pending.Count);
if (processed % SaveBatchSize == 0)
{
await repository.SaveChangesAsync(cancellationToken);
}
}
if (processed > 0)
{
logger.LogInformation("Rendered previews for {Processed} of {Total} video files", processed, pending.Count);
}
}
/// <summary>Last pass: the perceptual hash of each video that does not have one yet.</summary>
private async IAsyncEnumerable<LibraryScanEvent> HashAsync(
IReadOnlyList<VideoItem> pending,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var processed = 0;
var results = InParallelAsync(
pending,
async (item, token) => (Item: item, Hash: await perceptualHasher.ComputeAsync(item.FullPath, item.Duration, token)),
cancellationToken);
await foreach (var result in results)
{
result.Item.ApplyPerceptualHash(result.Hash);
processed++;
yield return new LibraryScanEvent.ItemUpdated(result.Item);
yield return new LibraryScanEvent.HashingProgress(processed, pending.Count);
if (processed % SaveBatchSize == 0)
{
await repository.SaveChangesAsync(cancellationToken);
}
}
if (processed > 0)
{
logger.LogInformation("Hashed {Processed} of {Total} video files", processed, pending.Count);
}
}
/// <summary>
/// Runs <paramref name="work"/> over the items with bounded concurrency and streams the
/// results back as they finish. A bounded channel is what keeps the producers from
/// running ahead of a consumer that has to apply each result one at a time.
/// </summary>
private async IAsyncEnumerable<TResult> InParallelAsync<TResult>(
IReadOnlyList<VideoItem> items,
Func<VideoItem, CancellationToken, Task<TResult>> work,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (items.Count == 0)
{ {
yield break; yield break;
} }
var channel = Channel.CreateBounded<IndexResult>(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4) var channel = Channel.CreateBounded<TResult>(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4)
{ {
SingleReader = true, SingleReader = true,
}); });
@@ -253,14 +478,9 @@ public sealed class LibraryService(
}; };
await Parallel.ForEachAsync( await Parallel.ForEachAsync(
pending, items,
parallelOptions, parallelOptions,
async (item, token) => async (item, token) => await channel.Writer.WriteAsync(await work(item, token), token));
{
var info = await mediaProbe.ProbeAsync(item.FullPath, token);
var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token);
await channel.Writer.WriteAsync(new IndexResult(item, info, thumbnail), token);
});
channel.Writer.Complete(); channel.Writer.Complete();
} }
@@ -271,31 +491,12 @@ public sealed class LibraryService(
}, },
cancellationToken); cancellationToken);
var processed = 0;
await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken)) await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken))
{ {
result.Item.ApplyTechnicalInfo(result.Info); yield return result;
if (result.ThumbnailPath is not null)
{
result.Item.AttachThumbnail(result.ThumbnailPath);
}
processed++;
yield return new LibraryScanEvent.ItemUpdated(result.Item);
yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count);
if (processed % SaveBatchSize == 0)
{
await repository.SaveChangesAsync(cancellationToken);
}
} }
await producer; await producer;
logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count);
} }
private readonly record struct IndexResult(VideoItem Item, VideoTechnicalInfo Info, string? ThumbnailPath);
} }
+261
View File
@@ -0,0 +1,261 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
namespace PLib.Desktop.Controls;
/// <summary>
/// Plays an animated preview: one image holding several frames stacked on top of each other,
/// drawn a slice at a time.
/// </summary>
/// <remarks>
/// The strip is loaded when playback starts and disposed when it stops, rather than kept in
/// the shared thumbnail cache. A strip weighs as much as its frame count put together, and
/// only the card under the pointer is ever playing — caching them the way poster frames are
/// cached would trade a bounded cost for one that grows with how much of the library the
/// user has swept across.
/// </remarks>
public sealed class FilmstripImage : Control
{
public static readonly StyledProperty<string?> SourceProperty =
AvaloniaProperty.Register<FilmstripImage, string?>(nameof(Source));
public static readonly StyledProperty<int> FrameCountProperty =
AvaloniaProperty.Register<FilmstripImage, int>(nameof(FrameCount));
public static readonly StyledProperty<bool> IsPlayingProperty =
AvaloniaProperty.Register<FilmstripImage, bool>(nameof(IsPlaying));
/// <summary>Slow enough to read as a preview rather than a flicker, and cheap to draw.</summary>
private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(125);
/// <summary>
/// How long the pointer has to rest before anything is decoded. Without it, dragging the
/// pointer across the grid would start a decode for every card it crossed.
/// </summary>
private static readonly TimeSpan StartDelay = TimeSpan.FromMilliseconds(250);
private readonly DispatcherTimer _timer = new() { Interval = FrameInterval };
private CancellationTokenSource? _pending;
private Bitmap? _strip;
private int _frame;
private bool _isAttached;
static FilmstripImage()
{
AffectsRender<FilmstripImage>(SourceProperty, FrameCountProperty);
}
public FilmstripImage()
{
Opacity = 0;
Transitions =
[
new DoubleTransition
{
Property = OpacityProperty,
Duration = TimeSpan.FromMilliseconds(180),
Easing = new CubicEaseOut(),
},
];
_timer.Tick += OnTick;
}
/// <summary>Absolute path of the stacked image.</summary>
public string? Source
{
get => GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
/// <summary>How many frames the image is made of; below two there is nothing to animate.</summary>
public int FrameCount
{
get => GetValue(FrameCountProperty);
set => SetValue(FrameCountProperty, value);
}
/// <summary>Set by a style while the pointer is over the card.</summary>
public bool IsPlaying
{
get => GetValue(IsPlayingProperty);
set => SetValue(IsPlayingProperty, value);
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_isAttached = true;
Restart();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_isAttached = false;
Stop();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == IsPlayingProperty ||
change.Property == SourceProperty ||
change.Property == FrameCountProperty)
{
Restart();
}
}
public override void Render(DrawingContext context)
{
var bounds = new Rect(Bounds.Size);
if (_strip is null || bounds.Width <= 0 || bounds.Height <= 0)
{
return;
}
var frames = FrameCount;
var frameHeight = frames < 2 ? 0 : _strip.PixelSize.Height / frames;
if (frameHeight <= 0)
{
return;
}
var slice = new Rect(
0,
Math.Min(_frame, frames - 1) * frameHeight,
_strip.PixelSize.Width,
frameHeight);
context.DrawImage(_strip, Cover(slice, bounds), bounds);
}
/// <summary>
/// The largest centred part of <paramref name="source"/> that has the same aspect ratio as
/// the destination — CSS <c>object-fit: cover</c>, matching how the poster frame is drawn
/// so that the preview does not jump when it fades in over it.
/// </summary>
private static Rect Cover(Rect source, Rect destination)
{
var scale = Math.Max(destination.Width / source.Width, destination.Height / source.Height);
var width = destination.Width / scale;
var height = destination.Height / scale;
return new Rect(
source.X + ((source.Width - width) / 2),
source.Y + ((source.Height - height) / 2),
width,
height);
}
private void OnTick(object? sender, EventArgs e)
{
var frames = FrameCount;
if (frames < 2)
{
return;
}
_frame = (_frame + 1) % frames;
InvalidateVisual();
}
private void Restart()
{
Stop();
if (!_isAttached || !IsPlaying || FrameCount < 2 || string.IsNullOrEmpty(Source))
{
return;
}
var cts = new CancellationTokenSource();
_pending = cts;
_ = LoadAsync(Source, cts);
}
private async Task LoadAsync(string path, CancellationTokenSource cts)
{
try
{
await Task.Delay(StartDelay, cts.Token);
var strip = await Task.Run(() => Decode(path), cts.Token);
if (strip is null)
{
return;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
// The card may have been left, or its container recycled onto another video,
// while the strip was being decoded.
if (cts.IsCancellationRequested || !ReferenceEquals(_pending, cts))
{
strip.Dispose();
return;
}
_strip = strip;
_frame = 0;
Opacity = 1;
_timer.Start();
InvalidateVisual();
});
}
catch (OperationCanceledException)
{
// The pointer left before the preview was ready; nothing to show, nothing to report.
}
}
private static Bitmap? Decode(string path)
{
try
{
using var stream = File.OpenRead(path);
// Decoded at its natural size: the strip is already rendered small, and scaling it
// would only blur frames that are about to be drawn card-sized anyway.
return new Bitmap(stream);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
return null;
}
}
private void Stop()
{
_timer.Stop();
if (_pending is { } cts)
{
_pending = null;
cts.Cancel();
cts.Dispose();
}
// Owned here, unlike the poster frames, so it is disposed rather than left to the
// garbage collector — otherwise a browse through the library would pile up strips.
_strip?.Dispose();
_strip = null;
_frame = 0;
Opacity = 0;
InvalidateVisual();
}
}
+49 -1
View File
@@ -1,5 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui" <Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:PLib.Desktop.Controls">
<!-- ============================ Card ============================ --> <!-- ============================ Card ============================ -->
@@ -76,6 +77,15 @@
<Setter Property="Opacity" Value="1" /> <Setter Property="Opacity" Value="1" />
</Style> </Style>
<!--
The animated preview follows the same hover state as the play affordance, rather than
watching the pointer itself: the pointer is over the card even while it is over the
caption, and a preview that stopped when the pointer crossed the title would flicker.
-->
<Style Selector="Button.card:pointerover controls|FilmstripImage">
<Setter Property="IsPlaying" Value="True" />
</Style>
<!-- ============================ Text ============================ --> <!-- ============================ Text ============================ -->
<Style Selector="TextBlock.cardTitle"> <Style Selector="TextBlock.cardTitle">
@@ -188,6 +198,44 @@
<Setter Property="Fill" Value="{DynamicResource PanelDividerBrush}" /> <Setter Property="Fill" Value="{DynamicResource PanelDividerBrush}" />
</Style> </Style>
<!-- ========================== Task panel ========================== -->
<!--
Anchored to the bottom-right corner above the status bar. Kept in the tree and hidden by
opacity so the slide has something to animate from, the same trick the settings panel uses.
-->
<Style Selector="Border.taskPanel">
<Setter Property="Width" Value="330" />
<Setter Property="Margin" Value="0,0,16,8" />
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Bottom" />
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource PanelDividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="RenderTransform" Value="translateY(10px)" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.18" Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.taskPanel.open">
<Setter Property="Opacity" Value="1" />
<Setter Property="IsHitTestVisible" Value="True" />
<Setter Property="RenderTransform" Value="none" />
</Style>
<Style Selector="TextBlock.taskTitle">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="FontSize" Value="12.5" />
</Style>
<!-- ============================ Badge ============================ --> <!-- ============================ Badge ============================ -->
<Style Selector="Border.badge"> <Style Selector="Border.badge">
@@ -0,0 +1,74 @@
using ReactiveUI;
using ReactiveUI.SourceGenerators;
namespace PLib.Desktop.ViewModels;
public enum BackgroundTaskState
{
/// <summary>Known to be coming, not started.</summary>
Queued,
Running,
Completed,
Cancelled,
Failed,
}
/// <summary>
/// One unit of background work as the user thinks of it — "poster frames", "fingerprints" —
/// rather than one per file.
/// </summary>
/// <remarks>
/// The status bar can only ever describe the newest event, which during a scan means it
/// flickers between phases and hides what is still queued. A task list says what the whole
/// run consists of, where it has got to, and what is still to come.
/// </remarks>
public sealed partial class BackgroundTaskViewModel : ReactiveObject
{
public BackgroundTaskViewModel(string title, bool indeterminate = false)
{
Title = title;
IsIndeterminate = indeterminate;
}
public string Title { get; }
[Reactive]
public partial BackgroundTaskState State { get; set; }
/// <summary>Progress in percent; meaningless while <see cref="IsIndeterminate"/> is set.</summary>
[Reactive]
public partial double Progress { get; set; }
[Reactive]
public partial bool IsIndeterminate { get; set; }
/// <summary>Short line under the title: counts, results, or why it stopped.</summary>
[Reactive]
public partial string? Detail { get; set; }
public bool IsFinished => State is not (BackgroundTaskState.Queued or BackgroundTaskState.Running);
public void Advance(int processed, int total)
{
State = BackgroundTaskState.Running;
IsIndeterminate = false;
Progress = total == 0 ? 100 : processed * 100.0 / total;
Detail = $"{processed} из {total}";
}
public void Finish(string? detail = null)
{
// Leave a completed bar full rather than wherever the last report left it.
State = BackgroundTaskState.Completed;
IsIndeterminate = false;
Progress = 100;
Detail = detail ?? Detail;
}
public void Stop(BackgroundTaskState state, string? detail = null)
{
State = state;
IsIndeterminate = false;
Detail = detail ?? Detail;
}
}
@@ -23,6 +23,12 @@ namespace PLib.Desktop.ViewModels;
public sealed partial class MainWindowViewModel : ViewModelBase public sealed partial class MainWindowViewModel : ViewModelBase
{ {
/// <summary>
/// How many differing bits still count as the same video. Re-encodes of the same source
/// land within a few bits; unrelated videos are typically far past twenty.
/// </summary>
private const int DuplicateDistance = 6;
/// <summary>How long typing has to pause before the grid is re-filtered.</summary> /// <summary>How long typing has to pause before the grid is re-filtered.</summary>
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(200); private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(200);
@@ -88,6 +94,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync); InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync); AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync); ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync);
ToggleDuplicatesCommand = ReactiveCommand.CreateFromTask(ToggleDuplicatesAsync);
ToggleTaskPanelCommand = ReactiveCommand.Create(() => { IsTaskPanelOpen = !IsTaskPanelOpen; });
_isSettingsOpen = this _isSettingsOpen = this
.WhenAnyValue(x => x.SettingsPanel) .WhenAnyValue(x => x.SettingsPanel)
@@ -159,6 +167,24 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public ReactiveCommand<RxVoid, RxVoid> ToggleThemeCommand { get; } public ReactiveCommand<RxVoid, RxVoid> ToggleThemeCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleDuplicatesCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleTaskPanelCommand { get; }
/// <summary>What the application is busy with, as whole jobs rather than per file.</summary>
public ObservableCollection<BackgroundTaskViewModel> Tasks { get; } = [];
[Reactive]
public partial bool IsTaskPanelOpen { get; set; }
/// <summary>The job to show on the collapsed pill, or <c>null</c> when nothing is running.</summary>
[Reactive]
public partial BackgroundTaskViewModel? ActiveTask { get; set; }
/// <summary>True while the grid is narrowed down to videos that look like each other.</summary>
[Reactive]
public partial bool ShowingDuplicatesOnly { get; set; }
public ReactiveCommand<RxVoid, RxVoid> OpenSettingsCommand { get; } public ReactiveCommand<RxVoid, RxVoid> OpenSettingsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> CloseSettingsCommand { get; } public ReactiveCommand<RxVoid, RxVoid> CloseSettingsCommand { get; }
@@ -215,6 +241,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
// Throttle swallows the initial value, and the grid must not start out blank. // Throttle swallows the initial value, and the grid must not start out blank.
.StartWith(SearchText) .StartWith(SearchText)
.DistinctUntilChanged() .DistinctUntilChanged()
// The duplicates toggle is a second input to the same predicate, so it has to
// re-emit it — the search term alone would leave the grid on the old filter.
.CombineLatest(this.WhenAnyValue(x => x.ShowingDuplicatesOnly), (term, _) => term)
.Select(BuildFilter); .Select(BuildFilter);
var comparerChanged = this var comparerChanged = this
@@ -264,15 +293,50 @@ public sealed partial class MainWindowViewModel : ViewModelBase
}) })
.AddTo(Subscriptions); .AddTo(Subscriptions);
private static Func<VideoCardViewModel, bool> BuildFilter(string? term) /// <summary>
/// Identifiers of the videos that have a look-alike. Empty means the duplicates filter
/// is off; the filter reads it rather than recomputing distances per card.
/// </summary>
private readonly HashSet<Guid> _duplicates = [];
private async Task ToggleDuplicatesAsync()
{ {
if (string.IsNullOrWhiteSpace(term)) if (ShowingDuplicatesOnly)
{ {
return _ => true; _duplicates.Clear();
ShowingDuplicatesOnly = false;
StatusText = $"В библиотеке {_library.Count} видео";
return;
} }
var trimmed = term.Trim(); await using var scope = _scopeFactory.CreateAsyncScope();
return card => card.Matches(trimmed); var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var groups = await library.FindDuplicatesAsync(DuplicateDistance);
_duplicates.Clear();
foreach (var id in groups.SelectMany(group => group).Select(item => item.Id))
{
_duplicates.Add(id);
}
ShowingDuplicatesOnly = true;
StatusText = groups.Count == 0
? "Похожих видео не найдено"
: $"Групп похожих видео: {groups.Count}, файлов: {_duplicates.Count}";
}
private Func<VideoCardViewModel, bool> BuildFilter(string? term)
{
var trimmed = string.IsNullOrWhiteSpace(term) ? null : term.Trim();
var duplicatesOnly = ShowingDuplicatesOnly;
var duplicates = duplicatesOnly ? _duplicates.ToHashSet() : [];
return card =>
(!duplicatesOnly || duplicates.Contains(card.Id)) &&
(trimmed is null || card.Matches(trimmed));
} }
/// <summary> /// <summary>
@@ -287,6 +351,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
CancelScanCommand.ThrownExceptions, CancelScanCommand.ThrownExceptions,
AddFolderCommand.ThrownExceptions, AddFolderCommand.ThrownExceptions,
ToggleThemeCommand.ThrownExceptions, ToggleThemeCommand.ThrownExceptions,
ToggleDuplicatesCommand.ThrownExceptions,
ToggleTaskPanelCommand.ThrownExceptions,
OpenSettingsCommand.ThrownExceptions, OpenSettingsCommand.ThrownExceptions,
CloseSettingsCommand.ThrownExceptions, CloseSettingsCommand.ThrownExceptions,
ClosePlayerCommand.ThrownExceptions) ClosePlayerCommand.ThrownExceptions)
@@ -334,6 +400,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
ScanProgress = 0; ScanProgress = 0;
StatusText = "Поиск файлов…"; StatusText = "Поиск файлов…";
BeginTaskRun();
// Re-armed on every scan so a folder added or removed in settings is picked up // Re-armed on every scan so a folder added or removed in settings is picked up
// without any separate plumbing. // without any separate plumbing.
_watcher.Watch(folders); _watcher.Watch(folders);
@@ -359,10 +427,17 @@ public sealed partial class MainWindowViewModel : ViewModelBase
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
StatusText = "Сканирование отменено"; StatusText = "Сканирование отменено";
StopUnfinishedTasks(BackgroundTaskState.Cancelled, "отменено");
}
catch
{
StopUnfinishedTasks(BackgroundTaskState.Failed, "не удалось");
throw;
} }
finally finally
{ {
IsProgressIndeterminate = false; IsProgressIndeterminate = false;
ActiveTask = Tasks.FirstOrDefault(task => !task.IsFinished);
if (folders.Length == 0) if (folders.Length == 0)
{ {
@@ -472,12 +547,55 @@ public sealed partial class MainWindowViewModel : ViewModelBase
_logger.LogWarning("Configuration did not reload in time after saving settings"); _logger.LogWarning("Configuration did not reload in time after saving settings");
} }
private static double Percent(int processed, int total) =>
total == 0 ? 100 : processed * 100.0 / total;
private BackgroundTaskViewModel _discovery = new("Поиск файлов", indeterminate: true);
private BackgroundTaskViewModel _thumbnails = new("Превью");
private BackgroundTaskViewModel _animations = new("Анимированные превью");
private BackgroundTaskViewModel _fingerprints = new("Отпечатки для поиска дублей");
/// <summary>
/// Starts a fresh set of jobs for one scan. The list is rebuilt rather than appended to,
/// so it always describes the run in progress instead of the history of every run.
/// </summary>
private void BeginTaskRun()
{
Tasks.Clear();
_discovery = new BackgroundTaskViewModel("Поиск файлов", indeterminate: true)
{
State = BackgroundTaskState.Running,
};
_thumbnails = new BackgroundTaskViewModel("Превью");
_animations = new BackgroundTaskViewModel("Анимированные превью");
_fingerprints = new BackgroundTaskViewModel("Отпечатки для поиска дублей");
Tasks.Add(_discovery);
Tasks.Add(_thumbnails);
Tasks.Add(_animations);
Tasks.Add(_fingerprints);
ActiveTask = _discovery;
}
private void StopUnfinishedTasks(BackgroundTaskState state, string detail)
{
foreach (var task in Tasks.Where(task => !task.IsFinished))
{
task.Stop(state, detail);
}
}
private void Handle(LibraryScanEvent scanEvent) private void Handle(LibraryScanEvent scanEvent)
{ {
switch (scanEvent) switch (scanEvent)
{ {
case LibraryScanEvent.DiscoveryCompleted discovery: case LibraryScanEvent.DiscoveryCompleted discovery:
StatusText = $"Найдено файлов: {discovery.FilesFound}"; StatusText = $"Найдено файлов: {discovery.FilesFound}";
_discovery.Finish($"найдено {discovery.FilesFound}");
ActiveTask = _thumbnails;
break; break;
case LibraryScanEvent.ItemAdded added: case LibraryScanEvent.ItemAdded added:
@@ -496,12 +614,42 @@ public sealed partial class MainWindowViewModel : ViewModelBase
case LibraryScanEvent.IndexingProgress progress: case LibraryScanEvent.IndexingProgress progress:
IsProgressIndeterminate = false; IsProgressIndeterminate = false;
ScanProgress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total; ScanProgress = Percent(progress.Processed, progress.Total);
StatusText = $"Обработка превью: {progress.Processed} из {progress.Total}"; StatusText = $"Превью: {progress.Processed} из {progress.Total}";
_thumbnails.Advance(progress.Processed, progress.Total);
ActiveTask = _thumbnails;
break;
case LibraryScanEvent.PreviewProgress progress:
IsProgressIndeterminate = false;
ScanProgress = Percent(progress.Processed, progress.Total);
StatusText = $"Анимированные превью: {progress.Processed} из {progress.Total}";
// Reaching a later pass is the only reliable signal that the earlier one is
// over: a pass with nothing to do emits no progress at all.
_thumbnails.Finish();
_animations.Advance(progress.Processed, progress.Total);
ActiveTask = _animations;
break;
case LibraryScanEvent.HashingProgress progress:
IsProgressIndeterminate = false;
ScanProgress = Percent(progress.Processed, progress.Total);
StatusText = $"Отпечатки: {progress.Processed} из {progress.Total}";
_thumbnails.Finish();
_animations.Finish();
_fingerprints.Advance(progress.Processed, progress.Total);
ActiveTask = _fingerprints;
break; break;
case LibraryScanEvent.Completed completed: case LibraryScanEvent.Completed completed:
ScanProgress = 100; ScanProgress = 100;
_discovery.Finish();
_thumbnails.Finish(_thumbnails.Detail ?? "нечего обновлять");
_animations.Finish(_animations.Detail ?? "нечего собирать");
_fingerprints.Finish(_fingerprints.Detail ?? "нечего считать");
ActiveTask = null;
StatusText = completed.LibrarySize == 0 StatusText = completed.LibrarySize == 0
? "В выбранных папках не нашлось видео" ? "В выбранных папках не нашлось видео"
: $"В библиотеке {completed.LibrarySize} видео"; : $"В библиотеке {completed.LibrarySize} видео";
@@ -45,6 +45,13 @@ public sealed partial class VideoCardViewModel : ReactiveObject
[Reactive] [Reactive]
public partial string? ThumbnailPath { get; set; } public partial string? ThumbnailPath { get; set; }
/// <summary>The stacked frames played while the pointer rests on the card.</summary>
[Reactive]
public partial string? PreviewPath { get; set; }
[Reactive]
public partial int PreviewFrameCount { get; set; }
[Reactive] [Reactive]
public partial string DurationText { get; set; } public partial string DurationText { get; set; }
@@ -95,11 +102,15 @@ public sealed partial class VideoCardViewModel : ReactiveObject
public int PlayCount { get; private set; } public int PlayCount { get; private set; }
public ulong? PerceptualHash { get; private set; }
/// <summary>Copies the current state of the entity into the card.</summary> /// <summary>Copies the current state of the entity into the card.</summary>
public void Apply(VideoItem item) public void Apply(VideoItem item)
{ {
Title = item.Title; Title = item.Title;
ThumbnailPath = item.ThumbnailPath; ThumbnailPath = item.ThumbnailPath;
PreviewPath = item.PreviewPath;
PreviewFrameCount = item.PreviewFrameCount;
DurationText = DisplayText.Duration(item.Duration); DurationText = DisplayText.Duration(item.Duration);
SizeText = DisplayText.FileSize(item.SizeInBytes); SizeText = DisplayText.FileSize(item.SizeInBytes);
QualityText = DisplayText.Quality(item.Width, item.Height); QualityText = DisplayText.Quality(item.Width, item.Height);
@@ -109,6 +120,7 @@ public sealed partial class VideoCardViewModel : ReactiveObject
VideoCodec = item.VideoCodec; VideoCodec = item.VideoCodec;
LastPlayedAt = item.LastPlayedAt; LastPlayedAt = item.LastPlayedAt;
PlayCount = item.PlayCount; PlayCount = item.PlayCount;
PerceptualHash = item.PerceptualHash;
RawDuration = item.Duration; RawDuration = item.Duration;
RawSizeInBytes = item.SizeInBytes; RawSizeInBytes = item.SizeInBytes;
IsPending = item.ThumbnailPath is null; IsPending = item.ThumbnailPath is null;
@@ -206,6 +206,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture))); rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
} }
if (card.PerceptualHash is { } hash)
{
// Printed as hex: it is a bit pattern compared by Hamming distance, and the
// decimal form of a 64-bit value tells nobody anything.
rows.Add(new MetadataRow("pHash", hash.ToString("x16", CultureInfo.InvariantCulture)));
}
rows.Add(new MetadataRow("Файл", card.FullPath)); rows.Add(new MetadataRow("Файл", card.FullPath));
return rows; return rows;
+64 -8
View File
@@ -48,6 +48,11 @@
DecodeWidth="480" DecodeWidth="480"
PlaceholderBrush="{DynamicResource ThumbnailPlaceholderBrush}" /> PlaceholderBrush="{DynamicResource ThumbnailPlaceholderBrush}" />
<!-- Fades in over the poster frame while the pointer rests on the card. Placed
directly above it, so the badges and the play affordance stay on top. -->
<controls:FilmstripImage Source="{Binding PreviewPath}"
FrameCount="{Binding PreviewFrameCount}" />
<!-- Shown until ffmpeg has produced a frame for this file. --> <!-- Shown until ffmpeg has produced a frame for this file. -->
<icons:MaterialIcon Kind="FilmstripBoxMultiple" <icons:MaterialIcon Kind="FilmstripBoxMultiple"
Width="30" Width="30"
@@ -175,6 +180,12 @@
<icons:MaterialIcon Kind="Refresh" Width="17" Height="17" /> <icons:MaterialIcon Kind="Refresh" Width="17" Height="17" />
</Button> </Button>
<ToggleButton IsChecked="{Binding ShowingDuplicatesOnly, Mode=OneWay}"
Command="{Binding ToggleDuplicatesCommand}"
ToolTip.Tip="Показать похожие видео">
<icons:MaterialIcon Kind="ContentDuplicate" Width="17" Height="17" />
</ToggleButton>
<Button Command="{Binding ToggleThemeCommand}" ToolTip.Tip="Сменить тему"> <Button Command="{Binding ToggleThemeCommand}" ToolTip.Tip="Сменить тему">
<icons:MaterialIcon Kind="ThemeLightDark" Width="17" Height="17" /> <icons:MaterialIcon Kind="ThemeLightDark" Width="17" Height="17" />
</Button> </Button>
@@ -260,20 +271,65 @@
<Rectangle Grid.Column="1" Classes="panelDivider" IsVisible="{Binding IsSettingsOpen}" /> <Rectangle Grid.Column="1" Classes="panelDivider" IsVisible="{Binding IsSettingsOpen}" />
<!-- ======================= Tasks ======================= -->
<Border Grid.Column="0"
Classes="taskPanel"
Classes.open="{Binding IsTaskPanelOpen}">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="panelTitle" Text="Фоновые задачи" />
<Button Grid.Column="1"
Classes="transport"
Padding="4"
Command="{Binding ToggleTaskPanelCommand}">
<icons:MaterialIcon Kind="Close" Width="13" Height="13" />
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding Tasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:BackgroundTaskViewModel">
<StackPanel Spacing="4" Margin="0,0,0,10">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Classes="taskTitle" Text="{Binding Title}" />
<TextBlock Grid.Column="1" Classes="cardMeta" Text="{Binding Detail}" />
</Grid>
<ProgressBar Height="3"
Minimum="0"
Maximum="100"
Value="{Binding Progress}"
IsIndeterminate="{Binding IsIndeterminate}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</Grid> </Grid>
<!-- ======================= Status bar ======================= --> <!-- ======================= Status bar ======================= -->
<Border Grid.Row="2" Classes="statusBar" IsVisible="{Binding !IsVideoFullScreen}"> <Border Grid.Row="2" Classes="statusBar" IsVisible="{Binding !IsVideoFullScreen}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14"> <Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14">
<ProgressBar Grid.Column="0" <Button Grid.Column="0"
Width="160" Classes="transport"
VerticalAlignment="Center" Padding="8,4"
Minimum="0" Command="{Binding ToggleTaskPanelCommand}"
Maximum="100" ToolTip.Tip="Фоновые задачи">
Value="{Binding ScanProgress}" <StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
IsIndeterminate="{Binding IsProgressIndeterminate}" <icons:MaterialIcon Kind="FormatListChecks" Width="15" Height="15" />
IsVisible="{Binding IsScanning}" /> <ProgressBar Width="120"
VerticalAlignment="Center"
Minimum="0"
Maximum="100"
Value="{Binding ScanProgress}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
IsVisible="{Binding IsScanning}" />
</StackPanel>
</Button>
<TextBlock Grid.Column="1" <TextBlock Grid.Column="1"
Classes="subtle" Classes="subtle"
+2
View File
@@ -3,6 +3,8 @@
"Folders": [], "Folders": [],
"ThumbnailWidth": 480, "ThumbnailWidth": 480,
"ThumbnailPositionRatio": 0.15, "ThumbnailPositionRatio": 0.15,
"PreviewFrameCount": 12,
"PreviewWidth": 240,
"MaxIndexingConcurrency": 4, "MaxIndexingConcurrency": 4,
"MinimumFileSizeInBytes": 65536 "MinimumFileSizeInBytes": 65536
}, },
+69 -1
View File
@@ -64,6 +64,19 @@ public sealed class VideoItem
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary> /// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
public string? ThumbnailPath { get; private set; } 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> /// <summary>Last write time of the file when it was last indexed.</summary>
public DateTimeOffset FileModifiedAt { get; private set; } public DateTimeOffset FileModifiedAt { get; private set; }
@@ -80,12 +93,35 @@ public sealed class VideoItem
/// <summary>How many times the video was watched through to the end.</summary> /// <summary>How many times the video was watched through to the end.</summary>
public int PlayCount { get; private set; } 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> /// <summary>Tags and collections this video belongs to.</summary>
public IReadOnlyCollection<LibraryLabel> Labels => _labels; public IReadOnlyCollection<LibraryLabel> Labels => _labels;
/// <summary>True once the file has been probed and a poster frame produced.</summary> /// <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; 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> /// <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 public double WatchedFraction => Duration is { TotalSeconds: > 0 } total && ResumePosition is { } position
? Math.Clamp(position / total, 0, 1) ? Math.Clamp(position / total, 0, 1)
@@ -151,6 +187,21 @@ public sealed class VideoItem
public bool RemoveLabel(Guid labelId) => _labels.RemoveAll(label => label.Id == labelId) > 0; 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) public void AttachThumbnail(string thumbnailPath)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath); ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
@@ -159,6 +210,21 @@ public sealed class VideoItem
public void DetachThumbnail() => ThumbnailPath = null; 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> /// <summary>
/// Refreshes the file system facts after the file changed on disk, and invalidates /// Refreshes the file system facts after the file changed on disk, and invalidates
/// everything that was derived from the previous revision of the file. /// everything that was derived from the previous revision of the file.
@@ -175,6 +241,8 @@ public sealed class VideoItem
SizeInBytes = sizeInBytes; SizeInBytes = sizeInBytes;
FileModifiedAt = fileModifiedAt; FileModifiedAt = fileModifiedAt;
ApplyTechnicalInfo(VideoTechnicalInfo.Unknown); ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
ApplyPerceptualHash(null);
DetachThumbnail(); DetachThumbnail();
DetachPreview();
} }
} }
@@ -41,6 +41,8 @@ public static class DependencyInjection
services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>(); services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>();
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>(); services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>(); services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
services.AddSingleton<IAnimatedPreviewGenerator, FfmpegAnimatedPreviewGenerator>();
services.AddSingleton<IVideoPerceptualHasher, FfmpegVideoPerceptualHasher>();
services.AddScoped<ILibraryService, LibraryService>(); services.AddScoped<ILibraryService, LibraryService>();
services.AddHostedService<DatabaseInitializer>(); services.AddHostedService<DatabaseInitializer>();
@@ -0,0 +1,245 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PLib.Application.Abstractions;
using PLib.Application.Library;
using PLib.Infrastructure.Storage;
namespace PLib.Infrastructure.Media;
/// <summary>
/// Renders the animated preview as a single JPEG holding every frame stacked vertically.
/// </summary>
/// <remarks>
/// The obvious format would be a GIF, but nothing downstream can play one: Avalonia decodes
/// only the first frame of an animated image, so showing it would mean carrying a GIF decoder.
/// A stack of frames in one JPEG needs no decoder beyond the one already used for poster
/// frames — the card simply draws a different slice of it each tick — and costs a fraction of
/// what a 256-colour GIF of the same frames would, which matters when it is per video.
///
/// One ffmpeg process does the whole job: an input per timestamp, each seeking on the input
/// so ffmpeg jumps to the nearest keyframe rather than decoding up to it, then a single
/// <c>vstack</c>. Spawning a process per frame instead would multiply the pass by the frame
/// count for nothing.
/// </remarks>
public sealed class FfmpegAnimatedPreviewGenerator(
IAppPaths paths,
IOptions<LibraryOptions> options,
ILogger<FfmpegAnimatedPreviewGenerator> logger)
: MediaArtifactCache(paths.PreviewDirectory, logger), IAnimatedPreviewGenerator
{
/// <summary>Fraction of the running time ignored at each end, to skip intros and credits.</summary>
private const double EdgeSkip = 0.05;
/// <summary>A broken seek or a pathological file must not hold up the whole pass.</summary>
private static readonly TimeSpan RenderTimeout = TimeSpan.FromSeconds(90);
private readonly LibraryOptions _options = options.Value;
public async Task<AnimatedPreview?> GetOrCreateAsync(
string videoPath,
TimeSpan? duration,
CancellationToken cancellationToken = default)
{
// Without a length there is nothing to spread the frames across, and a preview of a
// video's first second is worse than no preview at all.
if (duration is not { TotalSeconds: > 0 } length)
{
return null;
}
var file = new FileInfo(videoPath);
if (!file.Exists)
{
return null;
}
var frames = _options.PreviewFrameCount;
// The frame count is part of the name, so a strip is never sliced by the wrong one:
// changing the setting simply misses the cache and renders a new file, and the old
// one is collected by the next purge once nothing points at it.
var target = Path.Combine(CacheDirectory, $"{BuildCacheKey(file)}_{frames}.jpg");
if (File.Exists(target))
{
return new AnimatedPreview(target, frames);
}
var staging = CreateStagingPath();
try
{
if (!await RenderAsync(videoPath, staging, length, frames, cancellationToken))
{
return null;
}
File.Move(staging, target, overwrite: true);
return new AnimatedPreview(target, frames);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not render an animated preview for {Path}", videoPath);
return null;
}
finally
{
if (File.Exists(staging))
{
File.Delete(staging);
}
}
}
/// <summary>
/// Positions of the sampled frames: evenly spaced across the middle of the video and
/// taken from the centre of each slot, so neither the first nor the last sits on an edge.
/// </summary>
private static IEnumerable<TimeSpan> TimestampsFor(TimeSpan duration, int frames)
{
var start = duration * EdgeSkip;
var span = duration * (1 - (2 * EdgeSkip));
for (var index = 0; index < frames; index++)
{
yield return start + (span * ((index + 0.5) / frames));
}
}
private async Task<bool> RenderAsync(
string videoPath,
string outputPath,
TimeSpan duration,
int frames,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo("ffmpeg")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-y");
foreach (var position in TimestampsFor(duration, frames))
{
startInfo.ArgumentList.Add("-ss");
startInfo.ArgumentList.Add(position.TotalSeconds.ToString("F3", CultureInfo.InvariantCulture));
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(videoPath);
}
startInfo.ArgumentList.Add("-filter_complex");
startInfo.ArgumentList.Add(BuildFilterGraph(frames));
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("[strip]");
startInfo.ArgumentList.Add("-frames:v");
startInfo.ArgumentList.Add("1");
startInfo.ArgumentList.Add("-c:v");
startInfo.ArgumentList.Add("mjpeg");
startInfo.ArgumentList.Add("-q:v");
startInfo.ArgumentList.Add("5");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("image2");
startInfo.ArgumentList.Add(outputPath);
return await RunAsync(startInfo, videoPath, cancellationToken);
}
/// <summary>
/// Scales every input to the preview width and stacks them into one tall image.
/// <c>setsar</c> is what keeps <c>vstack</c> from refusing the join: inputs whose pixel
/// aspect ratios are merely undefined rather than equal count as mismatched.
/// </summary>
private string BuildFilterGraph(int frames)
{
var graph = new System.Text.StringBuilder();
for (var index = 0; index < frames; index++)
{
graph.Append(CultureInfo.InvariantCulture, $"[{index}:v]scale={_options.PreviewWidth}:-2,setsar=1[f{index}];");
}
for (var index = 0; index < frames; index++)
{
graph.Append(CultureInfo.InvariantCulture, $"[f{index}]");
}
graph.Append(CultureInfo.InvariantCulture, $"vstack=inputs={frames}[strip]");
return graph.ToString();
}
private async Task<bool> RunAsync(
ProcessStartInfo startInfo,
string videoPath,
CancellationToken cancellationToken)
{
using var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(RenderTimeout);
string diagnostics;
try
{
// Both pipes are drained while the process runs: a full one would deadlock it.
var errors = process.StandardError.ReadToEndAsync(timeout.Token);
await process.StandardOutput.ReadToEndAsync(timeout.Token);
diagnostics = await errors;
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Rendering an animated preview for {Path} timed out", videoPath);
TryKill(process);
return false;
}
catch (OperationCanceledException)
{
TryKill(process);
throw;
}
if (process.ExitCode == 0)
{
return true;
}
logger.LogWarning(
"ffmpeg could not render an animated preview for {Path}: {Error}",
videoPath,
diagnostics.Trim());
return false;
}
private static void TryKill(Process process)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Already gone between the timeout and here.
}
}
}
@@ -1,5 +1,3 @@
using System.Security.Cryptography;
using System.Text;
using FFMpegCore; using FFMpegCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -13,20 +11,12 @@ namespace PLib.Infrastructure.Media;
public sealed class FfmpegThumbnailGenerator( public sealed class FfmpegThumbnailGenerator(
IAppPaths paths, IAppPaths paths,
IOptions<LibraryOptions> options, IOptions<LibraryOptions> options,
ILogger<FfmpegThumbnailGenerator> logger) : IThumbnailGenerator ILogger<FfmpegThumbnailGenerator> logger)
: MediaArtifactCache(paths.ThumbnailDirectory, logger), IThumbnailGenerator
{ {
/// <summary>Fallback capture position for files whose duration we could not read.</summary> /// <summary>Fallback capture position for files whose duration we could not read.</summary>
private static readonly TimeSpan BlindCapturePosition = TimeSpan.FromSeconds(5); private static readonly TimeSpan BlindCapturePosition = TimeSpan.FromSeconds(5);
/// <summary>
/// How long an unfinished render is left alone before it counts as abandoned. A second
/// instance of the application could be mid-render right now, and deleting its staging
/// file would silently cost it the frame.
/// </summary>
private static readonly TimeSpan AbandonedRenderAge = TimeSpan.FromHours(1);
private const string StagingExtension = ".tmp";
private readonly LibraryOptions _options = options.Value; private readonly LibraryOptions _options = options.Value;
public async Task<string?> GetOrCreateAsync( public async Task<string?> GetOrCreateAsync(
@@ -41,7 +31,7 @@ public sealed class FfmpegThumbnailGenerator(
return null; return null;
} }
var target = Path.Combine(paths.ThumbnailDirectory, $"{BuildCacheKey(file)}.jpg"); var target = Path.Combine(CacheDirectory, $"{BuildCacheKey(file)}.jpg");
if (File.Exists(target)) if (File.Exists(target))
{ {
@@ -50,7 +40,7 @@ public sealed class FfmpegThumbnailGenerator(
// Render to a private temp file first so a crash or cancellation can never leave a // Render to a private temp file first so a crash or cancellation can never leave a
// truncated JPEG behind that later runs would happily treat as a valid cache hit. // truncated JPEG behind that later runs would happily treat as a valid cache hit.
var staging = Path.Combine(paths.ThumbnailDirectory, $"{Guid.CreateVersion7()}{StagingExtension}"); var staging = CreateStagingPath();
try try
{ {
@@ -82,70 +72,6 @@ public sealed class FfmpegThumbnailGenerator(
} }
} }
public bool IsAvailable(string? thumbnailPath) =>
!string.IsNullOrEmpty(thumbnailPath) && File.Exists(thumbnailPath);
public Task<int> PurgeUnusedAsync(
IReadOnlyCollection<string> inUsePaths,
CancellationToken cancellationToken = default) =>
Task.Run(() => Purge(inUsePaths, cancellationToken), cancellationToken);
public Task<long> GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default) =>
Task.Run(
() => Directory.Exists(paths.ThumbnailDirectory)
? new DirectoryInfo(paths.ThumbnailDirectory).EnumerateFiles().Sum(file => file.Length)
: 0L,
cancellationToken);
public Task<int> ClearAsync(CancellationToken cancellationToken = default) =>
// Nothing is in use, so every frame is garbage — the purge already knows how to do
// this safely, including leaving another instance's in-flight renders alone.
PurgeUnusedAsync([], cancellationToken);
private int Purge(IReadOnlyCollection<string> inUsePaths, CancellationToken cancellationToken)
{
if (!Directory.Exists(paths.ThumbnailDirectory))
{
return 0;
}
var inUse = new HashSet<string>(inUsePaths, LibraryPathComparer.Instance);
var removed = 0;
foreach (var file in Directory.EnumerateFiles(paths.ThumbnailDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
if (!ShouldRemove(file, inUse))
{
continue;
}
try
{
File.Delete(file);
removed++;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Somebody else is holding the file; the next scan will try again.
logger.LogDebug(ex, "Could not remove the cached frame {Path}", file);
}
}
return removed;
}
private bool ShouldRemove(string file, HashSet<string> inUse)
{
if (file.EndsWith(StagingExtension, StringComparison.OrdinalIgnoreCase))
{
return File.GetLastWriteTimeUtc(file) < DateTime.UtcNow - AbandonedRenderAge;
}
return !inUse.Contains(file);
}
private async Task<bool> RenderAsync( private async Task<bool> RenderAsync(
string videoPath, string videoPath,
string outputPath, string outputPath,
@@ -170,15 +96,4 @@ public sealed class FfmpegThumbnailGenerator(
duration is { } value && value > TimeSpan.Zero duration is { } value && value > TimeSpan.Zero
? value * _options.ThumbnailPositionRatio ? value * _options.ThumbnailPositionRatio
: BlindCapturePosition; : BlindCapturePosition;
/// <summary>
/// Keys the cache by path plus size plus timestamp, so replacing a file on disk
/// naturally produces a different key rather than a stale poster frame.
/// </summary>
private static string BuildCacheKey(FileInfo file)
{
var seed = $"{file.FullName}|{file.Length}|{file.LastWriteTimeUtc.Ticks}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return Convert.ToHexStringLower(hash)[..32];
}
} }
@@ -0,0 +1,298 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using PLib.Application.Abstractions;
namespace PLib.Infrastructure.Media;
/// <summary>
/// Perceptual hash over a montage of evenly spaced frames, following the recipe stash uses
/// so that hashes describe the same thing: 25 frames in a 5×5 grid, the outer 5% of the
/// running time skipped, each frame 160 pixels wide.
/// </summary>
/// <remarks>
/// ffmpeg does the decoding and scaling and hands back raw 8-bit grey, so no image library is
/// involved and no format guessing can go wrong. What is left — montage, downscale, DCT — is
/// arithmetic over a byte array.
/// </remarks>
public sealed class FfmpegVideoPerceptualHasher(ILogger<FfmpegVideoPerceptualHasher> logger)
: IVideoPerceptualHasher
{
private const int Columns = 5;
private const int Rows = 5;
private const int FrameCount = Columns * Rows;
/// <summary>Width each sampled frame is scaled to before the montage is assembled.</summary>
private const int FrameWidth = 160;
/// <summary>Fraction of the running time ignored at each end, to skip intros and credits.</summary>
private const double EdgeSkip = 0.05;
/// <summary>Side of the square the montage is reduced to before the transform.</summary>
private const int HashInputSize = 64;
/// <summary>Side of the low-frequency block the hash bits are taken from.</summary>
private const int HashBlockSize = 8;
/// <summary>A still frame or a broken seek should not hold up a scan.</summary>
private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(20);
public async Task<ulong?> ComputeAsync(
string videoPath,
TimeSpan? duration,
CancellationToken cancellationToken = default)
{
if (duration is not { TotalSeconds: > 0 } length)
{
// Without a length there is nothing to spread samples across.
return null;
}
try
{
var frameHeight = 0;
byte[]? montage = null;
foreach (var (position, index) in TimestampsFor(length).Select((time, i) => (time, i)))
{
var frame = await GrabGrayFrameAsync(videoPath, position, cancellationToken);
if (frame is null)
{
continue;
}
// The first frame that comes back defines the cell size; ffmpeg keeps the
// aspect ratio, so every later frame has the same shape.
if (montage is null)
{
frameHeight = frame.Length / FrameWidth;
if (frameHeight <= 0)
{
return null;
}
montage = new byte[FrameWidth * Columns * frameHeight * Rows];
}
if (frame.Length == FrameWidth * frameHeight)
{
Paste(frame, montage, index, frameHeight);
}
}
if (montage is null)
{
return null;
}
var reduced = Downscale(montage, FrameWidth * Columns, frameHeight * Rows, HashInputSize);
return Fingerprint(reduced, HashInputSize);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not compute a perceptual hash for {Path}", videoPath);
return null;
}
}
/// <summary>
/// Evenly spaced positions across the middle of the video, matching the sampling stash
/// uses so the two produce hashes of the same frames.
/// </summary>
private static IEnumerable<TimeSpan> TimestampsFor(TimeSpan duration)
{
var start = duration * EdgeSkip;
var span = duration * (1 - (2 * EdgeSkip));
for (var index = 0; index < FrameCount; index++)
{
yield return start + (span * index / FrameCount);
}
}
/// <summary>
/// One frame as raw 8-bit grey, 160 pixels wide. Seeking before the input makes ffmpeg
/// jump to the nearest keyframe instead of decoding everything up to that point.
/// </summary>
private static async Task<byte[]?> GrabGrayFrameAsync(
string videoPath,
TimeSpan position,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo("ffmpeg")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-ss");
startInfo.ArgumentList.Add(position.TotalSeconds.ToString("F3", CultureInfo.InvariantCulture));
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(videoPath);
startInfo.ArgumentList.Add("-frames:v");
startInfo.ArgumentList.Add("1");
startInfo.ArgumentList.Add("-vf");
startInfo.ArgumentList.Add($"scale={FrameWidth}:-2");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("gray");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("-");
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(FrameTimeout);
using var buffer = new MemoryStream();
try
{
// stderr is drained in parallel: a full pipe would deadlock the child.
var drain = process.StandardError.ReadToEndAsync(timeout.Token);
await process.StandardOutput.BaseStream.CopyToAsync(buffer, timeout.Token);
await drain;
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKill(process);
return null;
}
return buffer.Length > 0 ? buffer.ToArray() : null;
}
private static void TryKill(Process process)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Already gone between the timeout and here.
}
}
private static void Paste(byte[] frame, byte[] montage, int index, int frameHeight)
{
var montageWidth = FrameWidth * Columns;
var left = FrameWidth * (index % Columns);
var top = frameHeight * (index / Columns);
for (var row = 0; row < frameHeight; row++)
{
Array.Copy(
frame,
row * FrameWidth,
montage,
((top + row) * montageWidth) + left,
FrameWidth);
}
}
/// <summary>Bilinear reduction of a grey plane to a square of <paramref name="size"/>.</summary>
private static double[] Downscale(byte[] source, int width, int height, int size)
{
var target = new double[size * size];
var scaleX = (double)width / size;
var scaleY = (double)height / size;
for (var y = 0; y < size; y++)
{
// Sample from pixel centres, otherwise the result drifts half a pixel up and left.
var sourceY = Math.Clamp(((y + 0.5) * scaleY) - 0.5, 0, height - 1);
var y0 = (int)sourceY;
var y1 = Math.Min(y0 + 1, height - 1);
var weightY = sourceY - y0;
for (var x = 0; x < size; x++)
{
var sourceX = Math.Clamp(((x + 0.5) * scaleX) - 0.5, 0, width - 1);
var x0 = (int)sourceX;
var x1 = Math.Min(x0 + 1, width - 1);
var weightX = sourceX - x0;
var top = (source[(y0 * width) + x0] * (1 - weightX)) + (source[(y0 * width) + x1] * weightX);
var bottom = (source[(y1 * width) + x0] * (1 - weightX)) + (source[(y1 * width) + x1] * weightX);
target[(y * size) + x] = (top * (1 - weightY)) + (bottom * weightY);
}
}
return target;
}
/// <summary>
/// The hash itself: the low-frequency corner of the discrete cosine transform, turned
/// into bits by comparing each coefficient with the median of the block.
/// </summary>
private static ulong Fingerprint(double[] pixels, int size)
{
var coefficients = new double[HashBlockSize * HashBlockSize];
// Only the top-left block is needed, so the transform is evaluated directly for those
// coefficients rather than computed in full and thrown away.
for (var u = 0; u < HashBlockSize; u++)
{
for (var v = 0; v < HashBlockSize; v++)
{
var sum = 0.0;
for (var y = 0; y < size; y++)
{
var cosY = Math.Cos((2 * y + 1) * u * Math.PI / (2 * size));
for (var x = 0; x < size; x++)
{
sum += pixels[(y * size) + x]
* cosY
* Math.Cos((2 * x + 1) * v * Math.PI / (2 * size));
}
}
coefficients[(u * HashBlockSize) + v] = sum;
}
}
var median = Median(coefficients);
var hash = 0UL;
for (var index = 0; index < coefficients.Length; index++)
{
if (coefficients[index] > median)
{
// Highest bit first, so the printed hash reads in coefficient order.
hash |= 1UL << (coefficients.Length - index - 1);
}
}
return hash;
}
private static double Median(double[] values)
{
var sorted = (double[])values.Clone();
Array.Sort(sorted);
var middle = sorted.Length / 2;
return sorted.Length % 2 == 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
}
}
@@ -0,0 +1,110 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using PLib.Application.Abstractions;
using PLib.Application.Library;
namespace PLib.Infrastructure.Media;
/// <summary>
/// A directory of images derived from the video files, keyed so that replacing a file on
/// disk produces a different key rather than a stale picture.
/// </summary>
/// <remarks>
/// Poster frames and animated previews differ only in what ffmpeg is asked to render. Their
/// housekeeping — staging a render, spotting a file that vanished, purging what nothing
/// points at, reporting and clearing the directory — is identical, and duplicating it once
/// per artefact kind is how the two would drift apart.
/// </remarks>
public abstract class MediaArtifactCache(string directory, ILogger logger) : IMediaArtifactCache
{
/// <summary>
/// How long an unfinished render is left alone before it counts as abandoned. A second
/// instance of the application could be mid-render right now, and deleting its staging
/// file would silently cost it the image.
/// </summary>
private static readonly TimeSpan AbandonedRenderAge = TimeSpan.FromHours(1);
protected const string StagingExtension = ".tmp";
/// <summary>Where this cache keeps its files. One directory per artefact kind.</summary>
protected string CacheDirectory { get; } = directory;
public bool IsAvailable(string? path) => !string.IsNullOrEmpty(path) && File.Exists(path);
public Task<int> PurgeUnusedAsync(
IReadOnlyCollection<string> inUsePaths,
CancellationToken cancellationToken = default) =>
Task.Run(() => Purge(inUsePaths, cancellationToken), cancellationToken);
public Task<long> GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default) =>
Task.Run(
() => Directory.Exists(CacheDirectory)
? new DirectoryInfo(CacheDirectory).EnumerateFiles().Sum(file => file.Length)
: 0L,
cancellationToken);
public Task<int> ClearAsync(CancellationToken cancellationToken = default) =>
// Nothing is in use, so every file is garbage — the purge already knows how to do
// this safely, including leaving another instance's in-flight renders alone.
PurgeUnusedAsync([], cancellationToken);
/// <summary>A private path to render into, in the same directory so the move is atomic.</summary>
protected string CreateStagingPath() =>
Path.Combine(CacheDirectory, $"{Guid.CreateVersion7()}{StagingExtension}");
/// <summary>
/// Keys the cache by path plus size plus timestamp, so replacing a file on disk
/// naturally produces a different key rather than a stale image.
/// </summary>
protected static string BuildCacheKey(FileInfo file)
{
var seed = $"{file.FullName}|{file.Length}|{file.LastWriteTimeUtc.Ticks}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return Convert.ToHexStringLower(hash)[..32];
}
private int Purge(IReadOnlyCollection<string> inUsePaths, CancellationToken cancellationToken)
{
if (!Directory.Exists(CacheDirectory))
{
return 0;
}
var inUse = new HashSet<string>(inUsePaths, LibraryPathComparer.Instance);
var removed = 0;
foreach (var file in Directory.EnumerateFiles(CacheDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
if (!ShouldRemove(file, inUse))
{
continue;
}
try
{
File.Delete(file);
removed++;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Somebody else is holding the file; the next scan will try again.
logger.LogDebug(ex, "Could not remove the cached image {Path}", file);
}
}
return removed;
}
private static bool ShouldRemove(string file, HashSet<string> inUse)
{
if (file.EndsWith(StagingExtension, StringComparison.OrdinalIgnoreCase))
{
return File.GetLastWriteTimeUtc(file) < DateTime.UtcNow - AbandonedRenderAge;
}
return !inUse.Contains(file);
}
}
@@ -0,0 +1,155 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PLib.Infrastructure.Persistence;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LibraryDbContext))]
[Migration("20260809041829_PerceptualHash")]
partial class PerceptualHash
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.Property<Guid>("LabelsId")
.HasColumnType("TEXT");
b.Property<Guid>("VideosId")
.HasColumnType("TEXT");
b.HasKey("LabelsId", "VideosId");
b.HasIndex("VideosId");
b.ToTable("VideoLabels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Kind", "NormalizedName")
.IsUnique();
b.ToTable("Labels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("Duration")
.HasColumnType("TEXT");
b.Property<long>("FileModifiedAt")
.HasColumnType("INTEGER");
b.Property<string>("FullPath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<long?>("PerceptualHash")
.HasColumnType("INTEGER");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("ResumePosition")
.HasColumnType("TEXT");
b.Property<long>("SizeInBytes")
.HasColumnType("INTEGER");
b.Property<string>("ThumbnailPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("VideoCodec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AddedAt");
b.HasIndex("FullPath")
.IsUnique();
b.HasIndex("LastPlayedAt");
b.HasIndex("PerceptualHash");
b.ToTable("Videos", (string)null);
});
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
.WithMany()
.HasForeignKey("LabelsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PLib.Domain.Videos.VideoItem", null)
.WithMany()
.HasForeignKey("VideosId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class PerceptualHash : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "PerceptualHash",
table: "Videos",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Videos_PerceptualHash",
table: "Videos",
column: "PerceptualHash");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Videos_PerceptualHash",
table: "Videos");
migrationBuilder.DropColumn(
name: "PerceptualHash",
table: "Videos");
}
}
}
@@ -0,0 +1,162 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PLib.Infrastructure.Persistence;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LibraryDbContext))]
[Migration("20260809044036_AnimatedPreview")]
partial class AnimatedPreview
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.Property<Guid>("LabelsId")
.HasColumnType("TEXT");
b.Property<Guid>("VideosId")
.HasColumnType("TEXT");
b.HasKey("LabelsId", "VideosId");
b.HasIndex("VideosId");
b.ToTable("VideoLabels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Kind", "NormalizedName")
.IsUnique();
b.ToTable("Labels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("Duration")
.HasColumnType("TEXT");
b.Property<long>("FileModifiedAt")
.HasColumnType("INTEGER");
b.Property<string>("FullPath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<long?>("PerceptualHash")
.HasColumnType("INTEGER");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<int>("PreviewFrameCount")
.HasColumnType("INTEGER");
b.Property<string>("PreviewPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<TimeSpan?>("ResumePosition")
.HasColumnType("TEXT");
b.Property<long>("SizeInBytes")
.HasColumnType("INTEGER");
b.Property<string>("ThumbnailPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("VideoCodec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AddedAt");
b.HasIndex("FullPath")
.IsUnique();
b.HasIndex("LastPlayedAt");
b.HasIndex("PerceptualHash");
b.ToTable("Videos", (string)null);
});
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
.WithMany()
.HasForeignKey("LabelsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PLib.Domain.Videos.VideoItem", null)
.WithMany()
.HasForeignKey("VideosId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AnimatedPreview : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PreviewFrameCount",
table: "Videos",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "PreviewPath",
table: "Videos",
type: "TEXT",
maxLength: 1024,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PreviewFrameCount",
table: "Videos");
migrationBuilder.DropColumn(
name: "PreviewPath",
table: "Videos");
}
}
}
@@ -90,9 +90,19 @@ namespace PLib.Infrastructure.Persistence.Migrations
b.Property<long?>("LastPlayedAt") b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("PerceptualHash")
.HasColumnType("INTEGER");
b.Property<int>("PlayCount") b.Property<int>("PlayCount")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("PreviewFrameCount")
.HasColumnType("INTEGER");
b.Property<string>("PreviewPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<TimeSpan?>("ResumePosition") b.Property<TimeSpan?>("ResumePosition")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -124,6 +134,8 @@ namespace PLib.Infrastructure.Persistence.Migrations
b.HasIndex("LastPlayedAt"); b.HasIndex("LastPlayedAt");
b.HasIndex("PerceptualHash");
b.ToTable("Videos", (string)null); b.ToTable("Videos", (string)null);
}); });
@@ -30,6 +30,15 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoIte
builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter); builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
builder.Property(x => x.LastPlayedAt).HasConversion(NullableUtcTicksConverter); builder.Property(x => x.LastPlayedAt).HasConversion(NullableUtcTicksConverter);
// SQLite integers are signed, so the hash is stored as its bit pattern rather than
// its value — reinterpreted, never converted, so no bits are lost either way.
builder.Property(x => x.PerceptualHash).HasConversion(
hash => hash == null ? (long?)null : unchecked((long)hash.Value),
stored => stored == null ? (ulong?)null : unchecked((ulong)stored.Value));
// Exact matches are the cheap half of duplicate detection and worth an index.
builder.HasIndex(x => x.PerceptualHash);
builder.Property(x => x.FullPath) builder.Property(x => x.FullPath)
.IsRequired() .IsRequired()
.HasMaxLength(1024); .HasMaxLength(1024);
@@ -47,11 +56,16 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoIte
builder.Property(x => x.ThumbnailPath) builder.Property(x => x.ThumbnailPath)
.HasMaxLength(1024); .HasMaxLength(1024);
builder.Property(x => x.PreviewPath)
.HasMaxLength(1024);
// Sorting the grid by "recently added" is the default view, so it gets an index. // Sorting the grid by "recently added" is the default view, so it gets an index.
builder.HasIndex(x => x.AddedAt); builder.HasIndex(x => x.AddedAt);
// Both are computed from other columns and must not become table columns. // All computed from other columns, and none of them may become table columns.
builder.Ignore(x => x.IsIndexed); builder.Ignore(x => x.IsIndexed);
builder.Ignore(x => x.NeedsAnimatedPreview);
builder.Ignore(x => x.NeedsPerceptualHash);
builder.Ignore(x => x.WatchedFraction); builder.Ignore(x => x.WatchedFraction);
// "Continue watching" and "recently played" are both ordered by this. // "Continue watching" and "recently played" are both ordered by this.
@@ -11,15 +11,19 @@ public sealed class AppPaths : IAppPaths
DataDirectory = Path.Combine(localAppData, "PLib"); DataDirectory = Path.Combine(localAppData, "PLib");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails"); ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews");
DatabaseFile = Path.Combine(DataDirectory, "library.db"); DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(DataDirectory); Directory.CreateDirectory(DataDirectory);
Directory.CreateDirectory(ThumbnailDirectory); Directory.CreateDirectory(ThumbnailDirectory);
Directory.CreateDirectory(PreviewDirectory);
} }
public string DataDirectory { get; } public string DataDirectory { get; }
public string ThumbnailDirectory { get; } public string ThumbnailDirectory { get; }
public string PreviewDirectory { get; }
public string DatabaseFile { get; } public string DatabaseFile { get; }
} }
@@ -9,6 +9,12 @@ public interface IAppPaths
/// <summary>Directory holding cached poster frames.</summary> /// <summary>Directory holding cached poster frames.</summary>
string ThumbnailDirectory { get; } string ThumbnailDirectory { get; }
/// <summary>
/// Directory holding cached animated previews. Kept apart from the poster frames so that
/// each cache can purge its own directory without having to tell the two kinds apart.
/// </summary>
string PreviewDirectory { get; }
/// <summary>Full path of the SQLite database file.</summary> /// <summary>Full path of the SQLite database file.</summary>
string DatabaseFile { get; } string DatabaseFile { get; }
} }
+38 -1
View File
@@ -10,11 +10,13 @@ public sealed class VideoItemTests
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch); var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(3), 1920, 1080, "h264")); item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(3), 1920, 1080, "h264"));
item.AttachThumbnail(@"C:\cache\clip.jpg"); item.AttachThumbnail(@"C:\cache\clip.jpg");
item.AttachPreview(@"C:\cache\clip.strip.jpg", 12);
item.ApplyPerceptualHash(0xDEADBEEF);
return item; return item;
} }
[Fact] [Fact]
public void An_item_is_indexed_only_once_it_has_both_a_duration_and_a_thumbnail() public void An_item_is_indexed_once_it_has_a_duration_and_a_poster_frame()
{ {
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch); var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
item.IsIndexed.ShouldBeFalse(); item.IsIndexed.ShouldBeFalse();
@@ -23,7 +25,36 @@ public sealed class VideoItemTests
item.IsIndexed.ShouldBeFalse(); item.IsIndexed.ShouldBeFalse();
item.AttachThumbnail(@"C:\cache\clip.jpg"); item.AttachThumbnail(@"C:\cache\clip.jpg");
// Neither the animated preview nor the hash is part of this: both belong to later
// passes, and the grid is usable long before either exists.
item.IsIndexed.ShouldBeTrue(); item.IsIndexed.ShouldBeTrue();
item.NeedsAnimatedPreview.ShouldBeTrue();
item.NeedsPerceptualHash.ShouldBeTrue();
item.AttachPreview(@"C:\cache\clip.strip.jpg", 12);
item.NeedsAnimatedPreview.ShouldBeFalse();
item.ApplyPerceptualHash(1);
item.NeedsPerceptualHash.ShouldBeFalse();
}
[Fact]
public void The_later_passes_cannot_be_asked_for_before_the_duration_is_known()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
// Both sample frames across the running time, so there is nothing to sample yet.
item.NeedsAnimatedPreview.ShouldBeFalse();
item.NeedsPerceptualHash.ShouldBeFalse();
}
[Fact]
public void A_preview_of_a_single_frame_is_not_an_animation()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
Should.Throw<ArgumentOutOfRangeException>(() => item.AttachPreview(@"C:\cache\clip.jpg", 1));
} }
[Fact] [Fact]
@@ -35,6 +66,7 @@ public sealed class VideoItemTests
item.IsIndexed.ShouldBeTrue(); item.IsIndexed.ShouldBeTrue();
item.ThumbnailPath.ShouldNotBeNull(); item.ThumbnailPath.ShouldNotBeNull();
item.PreviewPath.ShouldNotBeNull();
} }
[Fact] [Fact]
@@ -47,6 +79,11 @@ public sealed class VideoItemTests
item.SizeInBytes.ShouldBe(2_000); item.SizeInBytes.ShouldBe(2_000);
item.Duration.ShouldBeNull(); item.Duration.ShouldBeNull();
item.ThumbnailPath.ShouldBeNull(); item.ThumbnailPath.ShouldBeNull();
item.PreviewPath.ShouldBeNull();
item.PreviewFrameCount.ShouldBe(0);
// The hash describes the picture, so a different file means a different hash.
item.PerceptualHash.ShouldBeNull();
item.IsIndexed.ShouldBeFalse(); item.IsIndexed.ShouldBeFalse();
} }
@@ -0,0 +1,96 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using PLib.Application.Abstractions;
using PLib.Application.Library;
using PLib.Domain.Videos;
using Shouldly;
namespace PLib.Tests.Library;
public sealed class DuplicateDetectionTests
{
private readonly InMemoryVideoRepository _videos = new();
[Fact]
public void Distance_counts_the_bits_that_differ()
{
var left = Video("a", 0b1111);
var right = Video("b", 0b1010);
left.DistanceTo(right).ShouldBe(2);
}
[Fact]
public void A_video_without_a_hash_has_no_distance_to_anything()
{
var hashed = Video("a", 1);
var bare = new VideoItem(@"C:\videos\b.mp4", "b", 1, DateTimeOffset.UnixEpoch);
hashed.DistanceTo(bare).ShouldBeNull();
bare.DistanceTo(hashed).ShouldBeNull();
}
[Fact]
public async Task Near_identical_videos_are_grouped_and_unrelated_ones_are_not()
{
_videos.Seed(
Video("original", 0b0000_0000),
Video("re-encode", 0b0000_0011),
Video("unrelated", 0b1111_1111));
var groups = await CreateService().FindDuplicatesAsync(maxDistance: 4, Token);
groups.ShouldHaveSingleItem();
groups[0].Select(x => x.Title).OrderBy(x => x).ShouldBe(["original", "re-encode"]);
}
[Fact]
public async Task A_chain_of_near_matches_ends_up_in_one_group()
{
// Ends are 4 bits apart — further than the threshold — but the middle copy links
// them, and all three are the same film.
_videos.Seed(
Video("a", 0b0000_0000),
Video("b", 0b0000_0011),
Video("c", 0b0000_1111));
var groups = await CreateService().FindDuplicatesAsync(maxDistance: 2, Token);
groups.ShouldHaveSingleItem();
groups[0].Count.ShouldBe(3);
}
[Fact]
public async Task Videos_that_were_never_hashed_are_left_out_entirely()
{
_videos.Seed(
new VideoItem(@"C:\videos\x.mp4", "x", 1, DateTimeOffset.UnixEpoch),
new VideoItem(@"C:\videos\y.mp4", "y", 1, DateTimeOffset.UnixEpoch));
var groups = await CreateService().FindDuplicatesAsync(maxDistance: 64, Token);
// Two hashless videos are not evidence of anything, however wide the threshold.
groups.ShouldBeEmpty();
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static VideoItem Video(string title, ulong hash)
{
var item = new VideoItem($@"C:\videos\{title}.mp4", title, 1, DateTimeOffset.UnixEpoch);
item.ApplyPerceptualHash(hash);
return item;
}
private LibraryService CreateService() => new(
_videos,
new InMemoryLabelRepository(),
Substitute.For<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Options.Create(new LibraryOptions()),
NullLogger<LibraryService>.Instance);
}
+2
View File
@@ -75,6 +75,8 @@ public sealed class LabelTests
Substitute.For<IVideoFileScanner>(), Substitute.For<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(), Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(), Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Options.Create(new LibraryOptions()), Options.Create(new LibraryOptions()),
NullLogger<LibraryService>.Instance); NullLogger<LibraryService>.Instance);
} }
@@ -16,6 +16,8 @@ public sealed class LibraryServiceTests
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>(); private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>(); private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>(); private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
public LibraryServiceTests() public LibraryServiceTests()
{ {
@@ -25,8 +27,17 @@ public sealed class LibraryServiceTests
_thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>()) _thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
.Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.jpg"); .Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.jpg");
// By default every remembered poster frame is still on disk. _previews.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
.Returns(callInfo => new AnimatedPreview(
$@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.strip.jpg",
12));
// By default every remembered image is still on disk.
_thumbnails.IsAvailable(Arg.Any<string?>()).Returns(true); _thumbnails.IsAvailable(Arg.Any<string?>()).Returns(true);
_previews.IsAvailable(Arg.Any<string?>()).Returns(true);
_hasher.ComputeAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
.Returns(0xDEADBEEFUL);
} }
[Fact] [Fact]
@@ -40,10 +51,83 @@ public sealed class LibraryServiceTests
_repository.Items.ShouldAllBe(x => x.IsIndexed); _repository.Items.ShouldAllBe(x => x.IsIndexed);
events.OfType<LibraryScanEvent.ItemAdded>().Count().ShouldBe(2); events.OfType<LibraryScanEvent.ItemAdded>().Count().ShouldBe(2);
events.OfType<LibraryScanEvent.ItemUpdated>().Count().ShouldBe(2);
// Three passes touch every file: the poster frame, the animated preview, the hash.
events.OfType<LibraryScanEvent.ItemUpdated>().Count().ShouldBe(6);
events.OfType<LibraryScanEvent.Completed>().Single().LibrarySize.ShouldBe(2); events.OfType<LibraryScanEvent.Completed>().Single().LibrarySize.ShouldBe(2);
} }
[Fact]
public async Task Each_pass_finishes_for_every_file_before_the_next_one_starts()
{
GivenFilesOnDisk(File(@"C:\videos\a.mp4"), File(@"C:\videos\b.mp4"), File(@"C:\videos\c.mp4"));
var events = await CollectAsync(CreateService());
// Cheapest and most visible first: interleaving would hold the poster frame of every
// file behind the frame grabs of the one before it.
var lastThumbnail = events.FindLastIndex(e => e is LibraryScanEvent.IndexingProgress);
var firstPreview = events.FindIndex(e => e is LibraryScanEvent.PreviewProgress);
var lastPreview = events.FindLastIndex(e => e is LibraryScanEvent.PreviewProgress);
var firstHash = events.FindIndex(e => e is LibraryScanEvent.HashingProgress);
firstPreview.ShouldBeGreaterThan(lastThumbnail);
firstHash.ShouldBeGreaterThan(lastPreview);
}
[Fact]
public async Task A_file_that_already_has_a_poster_frame_still_gets_a_preview_and_a_hash()
{
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
indexed.AttachThumbnail(@"C:\cache\a.jpg");
_repository.Seed(indexed);
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
await CollectAsync(CreateService());
// Nothing to re-probe, but the later passes have their own work left to do.
await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
_repository.Items.Single().PreviewPath.ShouldNotBeNull();
_repository.Items.Single().PerceptualHash.ShouldNotBeNull();
}
[Fact]
public async Task An_animated_preview_that_disappeared_from_the_cache_is_rendered_again()
{
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
indexed.AttachThumbnail(@"C:\cache\a.jpg");
indexed.AttachPreview(@"C:\cache\deleted.strip.jpg", 12);
indexed.ApplyPerceptualHash(1);
_repository.Seed(indexed);
_previews.IsAvailable(@"C:\cache\deleted.strip.jpg").Returns(false);
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
await CollectAsync(CreateService());
_repository.Items.Single().PreviewPath.ShouldBe(@"C:\cache\a.strip.jpg");
}
[Fact]
public async Task A_file_whose_preview_could_not_be_rendered_keeps_the_rest_of_its_indexing()
{
_previews.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
.Returns((AnimatedPreview?)null);
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
await CollectAsync(CreateService());
// A missing preview only costs the hover animation; the card itself is unaffected.
var item = _repository.Items.Single();
item.PreviewPath.ShouldBeNull();
item.IsIndexed.ShouldBeTrue();
item.PerceptualHash.ShouldNotBeNull();
}
[Fact] [Fact]
public async Task Entries_whose_file_is_gone_are_dropped_from_the_library() public async Task Entries_whose_file_is_gone_are_dropped_from_the_library()
{ {
@@ -72,6 +156,8 @@ public sealed class LibraryServiceTests
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch); var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264")); indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
indexed.AttachThumbnail(@"C:\cache\a.jpg"); indexed.AttachThumbnail(@"C:\cache\a.jpg");
indexed.AttachPreview(@"C:\cache\a.strip.jpg", 12);
indexed.ApplyPerceptualHash(1);
_repository.Seed(indexed); _repository.Seed(indexed);
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000)); GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
@@ -79,6 +165,8 @@ public sealed class LibraryServiceTests
await CollectAsync(CreateService()); await CollectAsync(CreateService());
await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()); await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
await _previews.DidNotReceive()
.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -125,6 +213,11 @@ public sealed class LibraryServiceTests
await _thumbnails.Received(1).PurgeUnusedAsync( await _thumbnails.Received(1).PurgeUnusedAsync(
Arg.Is<IReadOnlyCollection<string>>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.jpg" })), Arg.Is<IReadOnlyCollection<string>>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.jpg" })),
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
// Every cache is swept, not just the poster frames: previews outlive their videos too.
await _previews.Received(1).PurgeUnusedAsync(
Arg.Is<IReadOnlyCollection<string>>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.strip.jpg" })),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -151,6 +244,8 @@ public sealed class LibraryServiceTests
_scanner, _scanner,
_probe, _probe,
_thumbnails, _thumbnails,
_previews,
_hasher,
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }), Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
NullLogger<LibraryService>.Instance); NullLogger<LibraryService>.Instance);
@@ -146,15 +146,19 @@ public sealed class AppSettingsStoreTests : IDisposable
{ {
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}"); DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails"); ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews");
DatabaseFile = Path.Combine(DataDirectory, "library.db"); DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(ThumbnailDirectory); Directory.CreateDirectory(ThumbnailDirectory);
Directory.CreateDirectory(PreviewDirectory);
} }
public string DataDirectory { get; } public string DataDirectory { get; }
public string ThumbnailDirectory { get; } public string ThumbnailDirectory { get; }
public string PreviewDirectory { get; }
public string DatabaseFile { get; } public string DatabaseFile { get; }
public void Dispose() => Directory.Delete(DataDirectory, recursive: true); public void Dispose() => Directory.Delete(DataDirectory, recursive: true);