diff --git a/CLAUDE.md b/CLAUDE.md index c5ab78e..d76aca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -241,6 +241,21 @@ throttle 250 мс — пул дёргается на каждый исход л свежая CVE не роняла сборку кода, который никто не трогал. - Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах. +## Отображение медиа + +- **Миниатюры декодируются в нужную ширину**, а не декодируются целиком и потом масштабируются. + На архиве это разница между «работает» и «кончилась память». +- **Кэш владеет своими `Bitmap` и удаляет их при вытеснении**, поэтому вызывающий не должен их + освобождать — и поэтому ёмкость кэша обязана заметно превышать страницу галереи: вытесненная + картинка, которая ещё на экране, освободилась бы под рендерером. +- **Видео не декодируется**, и попытка была бы исключением на каждой плитке. `MediaKinds.IsImage` + отсекает это до всякого обращения к диску. +- **`{DynamicResource}` с несуществующим ключом молча не срабатывает** — свойство остаётся со + значением по умолчанию, фон не красится, кисть прозрачная. Это уже дважды доезжало до + скриншота; ловит `ResourceKeyTests` в обеих темах. +- **`{l:Loc}` с ключом, которого нет в обоих resx, тест паритета не поймает** — файлы согласованы + между собой. Ловит `LocalizationCoverageTests`. + ## Границы, выбранные намеренно - **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им @@ -254,3 +269,4 @@ throttle 250 мс — пул дёргается на каждый исход л - **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE. BMP/ICO/HEIC/JPEG-XL просто отложены. - **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача. +- **Кадры из видео не извлекаются.** Это FFmpeg целиком ради одной картинки в плитке. diff --git a/README.md b/README.md index 391da37..1ec6aa7 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,20 @@ proxifly-запись с `"protocol": "https"` — это всё равно HTTP томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и действующий режим виден в настройках. +### Галерея + +Страница **Галерея** показывает то, что уже лежит в хранилище, — с фильтрами по источнику, формату +и подстроке адреса, постранично по 120 плиток. Клик открывает встроенный просмотр: полный размер +плюс откуда, когда, каким форматом и с каким хешем. + +Миниатюры декодируются сразу в нужную ширину и кэшируются: полноразмерный JPEG 4000×3000 занимает +в памяти около 48 МБ, и пары сотен таких хватило бы, чтобы приложение кончилось раньше, чем +пользователь долистает. + +**Видео не превьюится.** Показать первый кадр mp4 или webm нечем без FFmpeg, тащить который в +десктопное приложение ради превью несоразмерно; плитка получает значок формата. Гифки показываются +первым кадром — Avalonia не анимирует GIF без стороннего пакета. + ### Чистка Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и diff --git a/src/AvParser.Core/Collecting/IMediaStore.cs b/src/AvParser.Core/Collecting/IMediaStore.cs index 7717fdf..de59898 100644 --- a/src/AvParser.Core/Collecting/IMediaStore.cs +++ b/src/AvParser.Core/Collecting/IMediaStore.cs @@ -46,6 +46,72 @@ public sealed record PurgeOptions(string SourceId) /// Bytes reclaimed on disk. public readonly record struct PurgeResult(int ItemsRemoved, int BlobsRemoved, long BytesFreed); +/// One stored item as the gallery needs it. +/// Content hash; also how the file is found on disk. +/// Format. +/// Stored extension, leading dot included. +/// Size in bytes. +/// Which source collected it. +/// Where it came from. +/// When it was collected. +public sealed record StoredMedia( + string Sha256, + MediaKind Kind, + string Extension, + long Length, + string SourceId, + string Url, + DateTimeOffset CollectedUtc +) +{ + /// Pixel width, when it was cheap to read. + public int? Width { get; init; } + + /// Pixel height, when it was cheap to read. + public int? Height { get; init; } + + /// Whether the content has more than one frame. + public bool IsAnimated { get; init; } + + /// Path of the browsable copy, relative to the showcase root. + public string? ShowcasePath { get; init; } + + /// Proxy it came through, or null when the connection was direct. + public string? ProxyKey { get; init; } +} + +/// What slice of the store to look at. +/// +/// Paged rather than streamed: the gallery draws tiles, and a tile costs a decoded bitmap. Handing +/// the UI an unbounded sequence would mean deciding how much to decode in the view, which is the +/// wrong place for that decision. +/// +public sealed record MediaBrowseQuery +{ + /// Only this source; null for all of them. + public string? SourceId { get; init; } + + /// Formats to include. + public MediaKindFilter Kinds { get; init; } = MediaKindFilter.All; + + /// Only content with more than one frame. + public bool AnimatedOnly { get; init; } + + /// Substring match against the address it came from. + public string? Search { get; init; } + + /// How many items to skip. + public int Skip { get; init; } + + /// How many items to return. + public int Take { get; init; } = 120; +} + +/// One page of the store, plus how much there is in total. +/// The page. +/// How many items match the filter overall. +public sealed record MediaPage(IReadOnlyList Items, int Total); + /// Totals for the dashboard. /// Distinct byte sequences held. /// Provenance rows across every source. @@ -141,4 +207,10 @@ public interface IMediaStore /// Totals across the whole store. Task GetStatsAsync(CancellationToken cancellationToken = default); + + /// Reads one page of what is held, newest first. + Task BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default); + + /// Every source that has contributed something, for the filter. + Task> GetSourceIdsAsync(CancellationToken cancellationToken = default); } diff --git a/src/AvParser.Infrastructure/Media/MediaStore.cs b/src/AvParser.Infrastructure/Media/MediaStore.cs index bf9c260..46f9f36 100644 --- a/src/AvParser.Infrastructure/Media/MediaStore.cs +++ b/src/AvParser.Infrastructure/Media/MediaStore.cs @@ -165,6 +165,18 @@ public sealed class MediaStore( public Task GetStatsAsync(CancellationToken cancellationToken = default) => _index.GetStatsAsync(cancellationToken); + /// + public Task BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default) => + _index.BrowseAsync(query, cancellationToken); + + /// + public Task> GetSourceIdsAsync(CancellationToken cancellationToken = default) => + _index.GetSourceIdsAsync(cancellationToken); + + /// Absolute path of a stored item on disk. + /// Exposed so the UI can show and decode a file without knowing the shard layout. + public string PathFor(string sha256, string extension) => _blobs.PathFor(sha256, extension); + /// Removes the files a committed removal orphaned. /// Bytes reclaimed. private long Unlink(RemovalPlan plan) diff --git a/src/AvParser.Infrastructure/Media/SqliteMediaIndex.cs b/src/AvParser.Infrastructure/Media/SqliteMediaIndex.cs index 6f9f98f..8d53d04 100644 --- a/src/AvParser.Infrastructure/Media/SqliteMediaIndex.cs +++ b/src/AvParser.Infrastructure/Media/SqliteMediaIndex.cs @@ -608,6 +608,133 @@ public sealed class SqliteMediaIndex : IDisposable return new MediaStoreStats(reader.GetInt32(0), reader.GetInt32(1), reader.GetInt64(2), reader.GetInt32(3)); } + /// Reads one page of what is held, newest first. + /// + /// The filter is assembled rather than written out because the combinations multiply, but every + /// value still goes through a parameter — a source id or a search term reaching the SQL text + /// would be an injection hole in a table the app itself fills from remote listings. + /// + public async Task BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var conditions = new List(); + var parameters = new List<(string Name, object Value)>(); + + if (!string.IsNullOrWhiteSpace(query.SourceId)) + { + conditions.Add("i.source_id = $source"); + parameters.Add(("$source", query.SourceId)); + } + + if (query.AnimatedOnly) + { + conditions.Add("b.is_animated = 1"); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + conditions.Add("i.url LIKE $search"); + parameters.Add(("$search", $"%{query.Search.Trim()}%")); + } + + var kinds = MediaKindFilters.ToSet(query.Kinds); + if (kinds.Count < Enum.GetValues().Length - 1) + { + conditions.Add( + $"b.kind IN ({string.Join(',', kinds.Select(kind => ((int)kind).ToString(CultureInfo.InvariantCulture)))})" + ); + } + + var where = conditions.Count == 0 ? string.Empty : "WHERE " + string.Join(" AND ", conditions); + + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + + int total; + await using (var count = connection.CreateCommand()) + { + count.CommandText = $"SELECT COUNT(*) FROM item i JOIN blob b ON b.sha256 = i.sha256 {where};"; + Bind(count, parameters); + + total = Convert.ToInt32( + await count.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), + CultureInfo.InvariantCulture + ); + } + + var items = new List(Math.Max(0, query.Take)); + + await using (var page = connection.CreateCommand()) + { + page.CommandText = $""" + SELECT i.sha256, b.kind, b.extension, b.length, b.width, b.height, b.is_animated, + i.source_id, i.url, i.collected_utc, i.showcase_path, i.proxy_key + FROM item i JOIN blob b ON b.sha256 = i.sha256 + {where} + ORDER BY i.collected_utc DESC, i.id DESC + LIMIT $take OFFSET $skip; + """; + Bind(page, parameters); + page.Parameters.AddWithValue("$take", Math.Clamp(query.Take, 1, 2000)); + page.Parameters.AddWithValue("$skip", Math.Max(0, query.Skip)); + + await using var reader = await page.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + items.Add( + new StoredMedia( + reader.GetString(0), + (MediaKind)reader.GetInt32(1), + reader.GetString(2), + reader.GetInt64(3), + reader.GetString(7), + reader.GetString(8), + DateTimeOffset.Parse( + reader.GetString(9), + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind + ) + ) + { + Width = reader.IsDBNull(4) ? null : reader.GetInt32(4), + Height = reader.IsDBNull(5) ? null : reader.GetInt32(5), + IsAnimated = reader.GetInt32(6) != 0, + ShowcasePath = reader.IsDBNull(10) ? null : reader.GetString(10), + ProxyKey = reader.IsDBNull(11) ? null : reader.GetString(11), + } + ); + } + } + + return new MediaPage(items, total); + } + + /// Every source that has contributed something. + public async Task> GetSourceIdsAsync(CancellationToken cancellationToken = default) + { + var sources = new List(); + + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT DISTINCT source_id FROM item ORDER BY source_id;"; + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + sources.Add(reader.GetString(0)); + } + + return sources; + } + + private static void Bind(SqliteCommand command, List<(string Name, object Value)> parameters) + { + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + } + /// Recomputes every reference count and reports how many were wrong. /// /// ref_count is denormalised so that purging can find orphans by index instead of diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index aed38fb..e8b7fbe 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using AvParser.Core.Proxies; using AvParser.Core.Settings; using AvParser.Infrastructure.Proxies; using AvParser.Infrastructure.Storage; +using AvParser.UI.Media; using AvParser.UI.Navigation; using AvParser.UI.Services; using AvParser.UI.ViewModels; @@ -26,6 +27,10 @@ public static class UiServiceCollectionExtensions ArgumentNullException.ThrowIfNull(services); services.AddSingleton(); + services.AddSingleton(static sp => new ThumbnailCache( + sp.GetRequiredService(), + sp.GetRequiredService>() + )); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -45,9 +50,15 @@ public static class UiServiceCollectionExtensions sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp, sp.GetRequiredService>() )); + services.AddSingleton(static sp => new GalleryViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + )); services.AddSingleton(static sp => new ProxiesViewModel( sp.GetRequiredService(), sp.GetRequiredService(), @@ -59,6 +70,7 @@ public static class UiServiceCollectionExtensions // Order here is the order of the navigation rail; the first entry is the landing page. services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); diff --git a/src/AvParser.UI/Localization/Strings.resx b/src/AvParser.UI/Localization/Strings.resx index 5711548..bdc440d 100644 --- a/src/AvParser.UI/Localization/Strings.resx +++ b/src/AvParser.UI/Localization/Strings.resx @@ -634,4 +634,70 @@ Open the collector + + Gallery + + + SOURCE + + + All sources + + + FORMAT + + + ADDRESS CONTAINS + + + part of the address + + + Animated only + + + Refresh + + + ANIM + + + Nothing here yet. Collect something, or loosen the filters. + + + Could not read the store: {0} + + + Page {0} of {1} + + + Nothing here can show this format. The file is on disk. + + + ADDRESS + + + COLLECTED + + + FORMAT + + + CONTENT HASH + + + Close + + + Everything + + + Images + + + Video + + + PREVIEW + diff --git a/src/AvParser.UI/Localization/Strings.ru.resx b/src/AvParser.UI/Localization/Strings.ru.resx index 4bde3a8..b0a8cce 100644 --- a/src/AvParser.UI/Localization/Strings.ru.resx +++ b/src/AvParser.UI/Localization/Strings.ru.resx @@ -634,4 +634,70 @@ Открыть сбор + + Галерея + + + ИСТОЧНИК + + + Все источники + + + ФОРМАТ + + + АДРЕС СОДЕРЖИТ + + + часть адреса + + + Только анимированные + + + Обновить + + + АНИМ + + + Пока пусто. Соберите что-нибудь или ослабьте фильтры. + + + Не удалось прочитать хранилище: {0} + + + Страница {0} из {1} + + + Этот формат показать нечем. Файл лежит на диске. + + + АДРЕС + + + СОБРАНО + + + ФОРМАТ + + + ХЕШ СОДЕРЖИМОГО + + + Закрыть + + + Всё + + + Изображения + + + Видео + + + ПРЕВЬЮ + diff --git a/src/AvParser.UI/Media/ThumbnailCache.cs b/src/AvParser.UI/Media/ThumbnailCache.cs new file mode 100644 index 0000000..1fc58d1 --- /dev/null +++ b/src/AvParser.UI/Media/ThumbnailCache.cs @@ -0,0 +1,186 @@ +using Avalonia.Media.Imaging; +using AvParser.Core.Collecting; +using AvParser.Infrastructure.Storage; +using Microsoft.Extensions.Logging; + +namespace AvParser.UI.Media; + +/// Decodes stored blobs into bitmaps small enough to show many at once. +public interface IThumbnailCache +{ + /// + /// Returns a thumbnail for stored content, or when there is nothing to + /// show — a video, a missing file, or something no decoder recognises. + /// + Task GetAsync(string sha256, string extension, MediaKind kind, int width, CancellationToken ct = default); + + /// Drops everything, disposing the bitmaps. + void Clear(); +} + +/// +/// A bounded, least-recently-used cache of decoded thumbnails. +/// +/// +/// +/// Bounded because the alternative does not survive contact with a real archive: a 4000×3000 JPEG +/// costs about 48 MB once decoded, so a few hundred full-size decodes exhaust memory long before +/// the user has finished scrolling. Everything is decoded to the requested width instead, which is +/// both far smaller and much faster than decoding and then scaling. +/// +/// +/// The cache owns its bitmaps and disposes them on eviction, so callers must never dispose what +/// they are handed. That is safe only while the capacity comfortably exceeds one screenful — a +/// bitmap evicted while still on screen would be disposed out from under the renderer — which is +/// why is several times the gallery's page size rather than a tuned number. +/// +/// +public sealed class ThumbnailCache(IAppPaths paths, ILogger logger) : IThumbnailCache, IDisposable +{ + /// Comfortably more than a screenful, for the reason given on the type. + public const int Capacity = 600; + + private readonly IAppPaths _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly Dictionary> _byKey = new(StringComparer.Ordinal); + private readonly LinkedList _order = new(); + private readonly SemaphoreSlim _gate = new(1, 1); + + /// + public async Task GetAsync( + string sha256, + string extension, + MediaKind kind, + int width, + CancellationToken ct = default + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sha256); + + // Nothing here can decode a video container, and pretending otherwise would mean an + // exception per tile. The caller shows a format badge instead. + if (!MediaKinds.IsImage(kind)) + { + return null; + } + + var key = $"{sha256}@{width.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; + + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_byKey.TryGetValue(key, out var existing)) + { + Touch(existing); + return existing.Value.Bitmap; + } + } + finally + { + _gate.Release(); + } + + var bitmap = await Task.Run(() => Decode(sha256, extension, width), ct).ConfigureAwait(false); + + if (bitmap is null) + { + return null; + } + + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + // Another caller may have decoded the same blob while this one was working. + if (_byKey.TryGetValue(key, out var raced)) + { + bitmap.Dispose(); + Touch(raced); + return raced.Value.Bitmap; + } + + var node = _order.AddFirst(new Entry(key, bitmap)); + _byKey[key] = node; + Evict(); + + return bitmap; + } + finally + { + _gate.Release(); + } + } + + /// + public void Clear() + { + _gate.Wait(); + try + { + foreach (var entry in _order) + { + entry.Bitmap.Dispose(); + } + + _order.Clear(); + _byKey.Clear(); + } + finally + { + _gate.Release(); + } + } + + /// + public void Dispose() + { + Clear(); + _gate.Dispose(); + } + + private Bitmap? Decode(string sha256, string extension, int width) + { + var path = Path.Combine( + _paths.BlobDirectory, + new MediaBlob(sha256, MediaKind.Unknown, extension, 0).RelativePath + ); + + if (!File.Exists(path)) + { + return null; + } + + try + { + using var stream = File.OpenRead(path); + + // Decoding straight to the target width never materialises the full-size surface. + return Bitmap.DecodeToWidth(stream, width, BitmapInterpolationMode.MediumQuality); + } + catch (Exception ex) + when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // A blob the decoder cannot read is a blank tile, not a broken page. + _logger.LogDebug(ex, "Could not decode {Path} for a thumbnail", path); + return null; + } + } + + private void Touch(LinkedListNode node) + { + _order.Remove(node); + _order.AddFirst(node); + } + + private void Evict() + { + while (_order.Count > Capacity) + { + var oldest = _order.Last!; + _order.RemoveLast(); + _byKey.Remove(oldest.Value.Key); + oldest.Value.Bitmap.Dispose(); + } + } + + private readonly record struct Entry(string Key, Bitmap Bitmap); +} diff --git a/src/AvParser.UI/Styles/Icons.axaml b/src/AvParser.UI/Styles/Icons.axaml index 356d6f3..c28410a 100644 --- a/src/AvParser.UI/Styles/Icons.axaml +++ b/src/AvParser.UI/Styles/Icons.axaml @@ -31,6 +31,8 @@ M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z + M4 11h12.2l-5.6-5.6L12 4l8 8-8 8-1.4-1.4 5.6-5.6H4v-2z + M8 5v14l11-7z M6.5 6.5h11v11h-11z diff --git a/src/AvParser.UI/ViewModels/CollectViewModel.cs b/src/AvParser.UI/ViewModels/CollectViewModel.cs index 40a2fee..d7b41c4 100644 --- a/src/AvParser.UI/ViewModels/CollectViewModel.cs +++ b/src/AvParser.UI/ViewModels/CollectViewModel.cs @@ -6,6 +6,7 @@ using AvParser.Core.Parsing; using AvParser.Core.Proxies; using AvParser.Core.Settings; using AvParser.UI.Localization; +using AvParser.UI.Media; using AvParser.UI.Navigation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -38,6 +39,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable private readonly IProxyPool _proxyPool; private readonly ICollectRunner _runner; private readonly IMediaStore _store; + private readonly IThumbnailCache _thumbnails; private readonly IServiceProvider _services; private readonly ILogger _logger; private readonly ISequencer _mainThread; @@ -95,6 +97,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable /// Consulted for the live count that gates network sources. /// Runs the collection. /// Consulted for totals, and asked to purge. + /// Decodes row previews. /// Resolves the navigation service lazily, to keep pages acyclic. /// Diagnostics. /// @@ -107,6 +110,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable IProxyPool proxyPool, ICollectRunner runner, IMediaStore store, + IThumbnailCache thumbnails, IServiceProvider services, ILogger logger, ISequencer? mainThread = null @@ -117,6 +121,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable _proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool)); _runner = runner ?? throw new ArgumentNullException(nameof(runner)); _store = store ?? throw new ArgumentNullException(nameof(store)); + _thumbnails = thumbnails ?? throw new ArgumentNullException(nameof(thumbnails)); _services = services ?? throw new ArgumentNullException(nameof(services)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _mainThread = mainThread ?? RxSchedulers.MainThreadScheduler; @@ -308,7 +313,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable if (stored + duplicates + skipped <= MaxDisplayedItems) { - itemBuffer.Add(new CollectedItemViewModel(item)); + itemBuffer.Add(new CollectedItemViewModel(item, _thumbnails)); } else { diff --git a/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs b/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs index 5d4525e..9c66dd7 100644 --- a/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs +++ b/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs @@ -1,13 +1,37 @@ using System.Globalization; +using Avalonia.Media.Imaging; using AvParser.Core.Collecting; using AvParser.UI.Localization; +using AvParser.UI.Media; using ReactiveUI; +using ReactiveUI.SourceGenerators; namespace AvParser.UI.ViewModels; /// One row of the collected list. -public sealed class CollectedItemViewModel(CollectedItem item) : ReactiveObject +public sealed partial class CollectedItemViewModel(CollectedItem item, IThumbnailCache? thumbnails = null) + : ReactiveObject { + /// Row thumbnails are small; the list is a log, not a gallery. + public const int ThumbnailWidth = 64; + + /// Borrowed from the cache, which owns and disposes it. Never dispose this. + [Reactive] + public partial Bitmap? Thumbnail { get; private set; } + + /// Loads the row thumbnail, if there is a cache and something to decode. + public async Task LoadThumbnailAsync(CancellationToken cancellationToken = default) + { + if (thumbnails is null || Item.Status == CollectStatus.Skipped) + { + return; + } + + Thumbnail = await thumbnails + .GetAsync(Item.Blob.Sha256, Item.Blob.Extension, Item.Blob.Kind, ThumbnailWidth, cancellationToken) + .ConfigureAwait(false); + } + /// The underlying result. public CollectedItem Item { get; } = item ?? throw new ArgumentNullException(nameof(item)); diff --git a/src/AvParser.UI/ViewModels/GalleryItemViewModel.cs b/src/AvParser.UI/ViewModels/GalleryItemViewModel.cs new file mode 100644 index 0000000..322aec5 --- /dev/null +++ b/src/AvParser.UI/ViewModels/GalleryItemViewModel.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using Avalonia.Media.Imaging; +using AvParser.Core.Collecting; +using AvParser.UI.Localization; +using AvParser.UI.Media; +using ReactiveUI; +using ReactiveUI.SourceGenerators; + +namespace AvParser.UI.ViewModels; + +/// One tile in the gallery. +/// +/// The bitmap is loaded on demand rather than in the constructor: a page is a hundred-odd tiles, +/// and decoding them all before the first one appears would make the page look frozen. It is also +/// borrowed, never owned — disposes it, so nothing here may. +/// +public partial class GalleryItemViewModel(StoredMedia media, IThumbnailCache thumbnails) : ReactiveObject +{ + /// Width thumbnails are decoded to. Matches the tile so nothing is scaled twice. + public const int ThumbnailWidth = 220; + + private readonly IThumbnailCache _thumbnails = thumbnails ?? throw new ArgumentNullException(nameof(thumbnails)); + private bool _requested; + + /// The stored item. + public StoredMedia Media { get; } = media ?? throw new ArgumentNullException(nameof(media)); + + /// Decoded thumbnail; null until loaded, or for content nothing can decode. + [Reactive] + public partial Bitmap? Thumbnail { get; private set; } + + /// Whether a decode has been attempted and produced nothing. + [Reactive] + public partial bool HasNoPreview { get; private set; } + + /// Address it came from. + public string Address => Media.Url; + + /// Short hash, enough to recognise a blob without filling the tile. + public string ShortHash => Media.Sha256.Length >= 10 ? Media.Sha256[..10] : Media.Sha256; + + /// Format name, uppercased. + public string KindText => Media.Kind == MediaKind.Unknown ? "?" : Media.Kind.ToString().ToUpperInvariant(); + + /// Human-readable size. + public string SizeText => CollectedItemViewModel.FormatSize(Media.Length); + + /// Pixel dimensions, when they were cheap to read. + public string DimensionsText => + Media is { Width: { } width, Height: { } height } + ? $"{width.ToString(CultureInfo.CurrentCulture)}×{height.ToString(CultureInfo.CurrentCulture)}" + : string.Empty; + + /// Whether the content has more than one frame. + public bool IsAnimated => Media.IsAnimated; + + /// When it was collected, in the current culture's short form. + public string CollectedText => Media.CollectedUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture); + + /// Which source brought it in. + public string SourceText => Localizer.Instance.GetOrDefault($"Source.{Media.SourceId}.Name", Media.SourceId); + + /// Decodes the thumbnail, at most once per tile. + public async Task LoadThumbnailAsync(CancellationToken cancellationToken = default) + { + if (_requested) + { + return; + } + + _requested = true; + + var bitmap = await _thumbnails + .GetAsync(Media.Sha256, Media.Extension, Media.Kind, ThumbnailWidth, cancellationToken) + .ConfigureAwait(false); + + Thumbnail = bitmap; + HasNoPreview = bitmap is null; + } + + /// Re-reads everything derived from the current language or culture. + public void Refresh() + { + this.RaisePropertyChanged(nameof(SizeText)); + this.RaisePropertyChanged(nameof(DimensionsText)); + this.RaisePropertyChanged(nameof(CollectedText)); + this.RaisePropertyChanged(nameof(SourceText)); + } +} diff --git a/src/AvParser.UI/ViewModels/GalleryViewModel.cs b/src/AvParser.UI/ViewModels/GalleryViewModel.cs new file mode 100644 index 0000000..c735fb9 --- /dev/null +++ b/src/AvParser.UI/ViewModels/GalleryViewModel.cs @@ -0,0 +1,328 @@ +using System.Collections.ObjectModel; +using Avalonia.Media.Imaging; +using AvParser.Core.Collecting; +using AvParser.Infrastructure.Media; +using AvParser.UI.Localization; +using AvParser.UI.Media; +using Microsoft.Extensions.Logging; +using ReactiveUI; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.SourceGenerators; + +namespace AvParser.UI.ViewModels; + +/// Browses what the store holds. +/// +/// Paged rather than scrolled-forever: a tile costs a decoded bitmap, so the number on screen has +/// to be something the page decides rather than something the archive's size decides. +/// +public partial class GalleryViewModel : PageViewModel, IDisposable +{ + /// Tiles per page. Well under the thumbnail cache's capacity, on purpose. + private const int PageSize = 120; + + /// Width the preview is decoded to — large enough to look at, small enough to be quick. + private const int PreviewWidth = 1400; + + private readonly IMediaStore _store; + private readonly IThumbnailCache _thumbnails; + private readonly MediaStore? _paths; + private readonly ILogger _logger; + private readonly ISequencer _mainThread; + + private CancellationTokenSource? _loading; + + /// Filter by source; empty means all of them. + [Reactive] + public partial string? SelectedSourceId { get; set; } + + /// Filter by format. + [Reactive] + public partial LocalizedOption SelectedKinds { get; set; } + + /// Only content with more than one frame. + [Reactive] + public partial bool AnimatedOnly { get; set; } + + /// Substring match against the address. + [Reactive] + public partial string SearchText { get; set; } + + /// Zero-based page currently shown. + [Reactive] + public partial int PageIndex { get; set; } + + /// How many items match the filter overall. + [Reactive] + public partial int TotalCount { get; set; } + + /// Whether a page is being read. + [Reactive] + public partial bool IsLoading { get; set; } + + /// Tile the user opened, or null when the viewer is closed. + [Reactive] + public partial GalleryItemViewModel? Selected { get; set; } + + /// Full-size bitmap of ; null while loading or unviewable. + [Reactive] + public partial Bitmap? Preview { get; private set; } + + /// Absolute path of the selected item on disk. + [Reactive] + public partial string? SelectedPath { get; set; } + + /// Message shown when nothing matches. + [Reactive] + public partial string? StatusMessage { get; set; } + + /// Creates the page. + /// Where the content is. + /// Decodes and caches tile bitmaps. + /// Diagnostics. + /// Scheduler for UI-affine updates; tests pass an immediate one. + public GalleryViewModel( + IMediaStore store, + IThumbnailCache thumbnails, + ILogger logger, + ISequencer? mainThread = null + ) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _thumbnails = thumbnails ?? throw new ArgumentNullException(nameof(thumbnails)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _mainThread = mainThread ?? RxSchedulers.MainThreadScheduler; + + // Only the concrete store knows the shard layout; the viewer needs a real path to show. + _paths = store as MediaStore; + + SearchText = string.Empty; + SelectedKinds = KindFilters[0]; + + var idle = this.WhenAnyValue(x => x.IsLoading).Select(static loading => !loading); + + RefreshCommand = ReactiveCommand.CreateFromTask(() => LoadAsync(0), idle, _mainThread); + + NextPageCommand = ReactiveCommand.CreateFromTask( + () => LoadAsync(PageIndex + 1), + this.WhenAnyValue(x => x.PageIndex, x => x.TotalCount, x => x.IsLoading, HasNextPage), + _mainThread + ); + + PreviousPageCommand = ReactiveCommand.CreateFromTask( + () => LoadAsync(PageIndex - 1), + this.WhenAnyValue(x => x.PageIndex, x => x.IsLoading, static (page, loading) => page > 0 && !loading), + _mainThread + ); + + CloseViewerCommand = ReactiveCommand.Create(() => Selected = null, outputScheduler: _mainThread); + + // Changing a filter starts again from the first page: staying on page seven of a different + // result set shows nothing and looks broken. + this.WhenAnyValue( + x => x.SelectedSourceId, + x => x.SelectedKinds, + x => x.AnimatedOnly, + x => x.SearchText, + (_, _, _, _) => RxVoid.Default + ) + // Skipping the first: WhenAnyValue publishes the current values on subscription, and + // treating that as a filter change would load the first page twice on every open. + .Skip(1) + .Throttle(TimeSpan.FromMilliseconds(250), _mainThread) + .ObserveOn(_mainThread) + .Subscribe(change => Forget(LoadAsync(0))); + + this.WhenAnyValue(x => x.Selected).ObserveOn(_mainThread).Subscribe(item => Forget(OpenAsync(item))); + + foreach (var command in new[] { RefreshCommand, NextPageCommand, PreviousPageCommand }) + { + command.ThrownExceptions.Subscribe(error => _logger.LogError(error, "The gallery failed to load")); + } + + Forget(LoadAsync(0)); + } + + /// + /// Starts work nothing waits for. + /// + /// + /// A named method rather than a discard at the call site: _ => _ = ... reads as a + /// discard but assigns to the lambda's parameter, which the compiler accepts in some shapes and + /// rejects in others. Both of these paths already report their own failures. + /// + private static void Forget(Task task) => _ = task; + + /// + public override string TitleKey => "Page.Gallery"; + + /// + public override string IconKey => "IconImage"; + + /// Tiles on the current page. + public ObservableCollection Items { get; } = []; + + /// Sources that have contributed something, for the picker. + public ObservableCollection Sources { get; } = []; + + /// Format filters offered by the picker. + public IReadOnlyList> KindFilters { get; } = + LocalizedOption.For(MediaKindFilter.All, MediaKindFilter.Images, MediaKindFilter.Videos); + + /// Re-reads the current page. + public ReactiveCommand RefreshCommand { get; } + + /// Moves forward one page. + public ReactiveCommand NextPageCommand { get; } + + /// Moves back one page. + public ReactiveCommand PreviousPageCommand { get; } + + /// Closes the viewer. + public ReactiveCommand CloseViewerCommand { get; } + + /// Whether the viewer is open. + public bool IsViewerOpen => Selected is not null; + + /// Which page of how many, for the pager. + public string PageText => + TotalCount == 0 + ? string.Empty + : Localizer.Instance.Format( + "Gallery.Page", + PageIndex + 1, + Math.Max(1, (TotalCount + PageSize - 1) / PageSize) + ); + + /// Reads one page and rebuilds the tiles. + public async Task LoadAsync(int page, CancellationToken cancellationToken = default) + { + // A filter changed while the previous page was still reading; that page is now wrong. + var previous = Interlocked.Exchange( + ref _loading, + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + ); + previous?.Cancel(); + previous?.Dispose(); + + var token = _loading!.Token; + OnUi(() => IsLoading = true); + + try + { + var query = new MediaBrowseQuery + { + SourceId = string.IsNullOrWhiteSpace(SelectedSourceId) ? null : SelectedSourceId, + Kinds = SelectedKinds.Value, + AnimatedOnly = AnimatedOnly, + Search = SearchText, + Skip = Math.Max(0, page) * PageSize, + Take = PageSize, + }; + + var result = await _store.BrowseAsync(query, token).ConfigureAwait(false); + var sources = await _store.GetSourceIdsAsync(token).ConfigureAwait(false); + + token.ThrowIfCancellationRequested(); + + var tiles = result.Items.Select(item => new GalleryItemViewModel(item, _thumbnails)).ToArray(); + + OnUi(() => + { + Items.Clear(); + foreach (var tile in tiles) + { + Items.Add(tile); + } + + Sources.Clear(); + foreach (var source in sources) + { + Sources.Add(source); + } + + PageIndex = Math.Max(0, page); + TotalCount = result.Total; + StatusMessage = result.Total == 0 ? Localizer.Instance["Gallery.Empty"] : null; + + this.RaisePropertyChanged(nameof(PageText)); + }); + + // Decoding after the tiles are on screen, so the page appears immediately and fills in. + foreach (var tile in tiles) + { + if (token.IsCancellationRequested) + { + return; + } + + await tile.LoadThumbnailAsync(token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Superseded by a newer load. + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not read the gallery"); + OnUi(() => StatusMessage = Localizer.Instance.Format("Gallery.Failed", ex.Message)); + } + finally + { + OnUi(() => IsLoading = false); + } + } + + /// Loads the full-size view of the opened tile. + private async Task OpenAsync(GalleryItemViewModel? item) + { + this.RaisePropertyChanged(nameof(IsViewerOpen)); + + if (item is null) + { + OnUi(() => + { + Preview = null; + SelectedPath = null; + }); + + return; + } + + var path = _paths?.PathFor(item.Media.Sha256, item.Media.Extension); + OnUi(() => SelectedPath = path); + + var bitmap = await _thumbnails + .GetAsync(item.Media.Sha256, item.Media.Extension, item.Media.Kind, PreviewWidth) + .ConfigureAwait(false); + + OnUi(() => Preview = bitmap); + } + + private static bool HasNextPage(int page, int total, bool loading) => !loading && (page + 1) * PageSize < total; + + /// + protected override void OnLanguageChanged() + { + base.OnLanguageChanged(); + + this.RaisePropertyChanged(nameof(PageText)); + + foreach (var item in Items) + { + item.Refresh(); + } + } + + /// + public void Dispose() + { + _loading?.Cancel(); + _loading?.Dispose(); + GC.SuppressFinalize(this); + } + + private void OnUi(Action action) => _mainThread.Schedule(action); +} diff --git a/src/AvParser.UI/Views/GalleryView.axaml b/src/AvParser.UI/Views/GalleryView.axaml new file mode 100644 index 0000000..e00ca9b --- /dev/null +++ b/src/AvParser.UI/Views/GalleryView.axaml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/GalleryView.axaml.cs b/src/AvParser.UI/Views/GalleryView.axaml.cs new file mode 100644 index 0000000..0b0eb99 --- /dev/null +++ b/src/AvParser.UI/Views/GalleryView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// Tiles over the store, with a viewer layered above them. +public partial class GalleryView : UserControl +{ + /// Creates the view. + public GalleryView() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs b/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs index e26f05a..1337959 100644 --- a/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs +++ b/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs @@ -71,6 +71,7 @@ public class CollectViewTests new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()), new IdleRunner(), new FakeMediaStore(), + new FakeThumbnailCache(), new EmptyServiceProvider(), NullLogger.Instance, ImmediateSequencer.Instance diff --git a/tests/AvParser.UI.HeadlessTests/FakeMediaStore.cs b/tests/AvParser.UI.HeadlessTests/FakeMediaStore.cs index 2d8a309..d5d2508 100644 --- a/tests/AvParser.UI.HeadlessTests/FakeMediaStore.cs +++ b/tests/AvParser.UI.HeadlessTests/FakeMediaStore.cs @@ -9,6 +9,16 @@ internal sealed class FakeMediaStore : IMediaStore public MediaStoreStats Stats { get; set; } = new(0, 0, 0, 0); + public List Browse { get; } = []; + + public List SourceIds { get; } = []; + + public MediaBrowseQuery? LastBrowse { get; private set; } + + public int? TotalOverride { get; set; } + + public bool BrowseThrows { get; set; } + public PurgeResult PurgeResult { get; set; } = new(3, 2, 4096); public ShowcaseMode Mode { get; private set; } = ShowcaseMode.HardLink; @@ -61,4 +71,16 @@ internal sealed class FakeMediaStore : IMediaStore Task.FromResult(0); public Task GetStatsAsync(CancellationToken cancellationToken = default) => Task.FromResult(Stats); + + public Task BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default) + { + LastBrowse = query; + + return BrowseThrows + ? throw new InvalidOperationException("the index is unreadable") + : Task.FromResult(new MediaPage(Browse, TotalOverride ?? Browse.Count)); + } + + public Task> GetSourceIdsAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(SourceIds); } diff --git a/tests/AvParser.UI.HeadlessTests/FakeThumbnailCache.cs b/tests/AvParser.UI.HeadlessTests/FakeThumbnailCache.cs new file mode 100644 index 0000000..8ecfbd1 --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/FakeThumbnailCache.cs @@ -0,0 +1,37 @@ +using Avalonia.Media.Imaging; +using AvParser.Core.Collecting; +using AvParser.UI.Media; + +namespace AvParser.UI.HeadlessTests; + +/// +/// A cache that decodes nothing. +/// +/// +/// Returning null is exactly what the real cache does for a video or a missing blob, so the view +/// models are already required to cope with it — which is what makes this an honest stand-in +/// rather than a convenient one. It records what was asked for, so tests can assert that a page +/// requested previews at all. +/// +internal sealed class FakeThumbnailCache : IThumbnailCache +{ + public List<(string Sha256, int Width)> Requested { get; } = []; + + public Task GetAsync( + string sha256, + string extension, + MediaKind kind, + int width, + CancellationToken ct = default + ) + { + lock (Requested) + { + Requested.Add((sha256, width)); + } + + return Task.FromResult(null); + } + + public void Clear() => Requested.Clear(); +} diff --git a/tests/AvParser.UI.HeadlessTests/ResourceKeyTests.cs b/tests/AvParser.UI.HeadlessTests/ResourceKeyTests.cs new file mode 100644 index 0000000..aec307d --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/ResourceKeyTests.cs @@ -0,0 +1,72 @@ +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Styling; + +namespace AvParser.UI.HeadlessTests; + +/// +/// Every resource key the XAML asks for must actually resolve. +/// +/// +/// A DynamicResource naming a key that does not exist fails silently: the property simply +/// keeps its default, so a background never paints and a brush is transparent. That shipped twice — +/// most recently as a viewer overlay you could see straight through, caught only by looking at a +/// screenshot. Both themes are checked, because a key can exist in one dictionary and not the other. +/// +public partial class ResourceKeyTests +{ + [GeneratedRegex(@"\{(?:Dynamic|Static)Resource\s+([A-Za-z0-9_.]+)\s*\}", RegexOptions.Compiled)] + private static partial Regex ResourceMarkup(); + + /// Keys defined inside a view's own Resources block rather than in a theme. + private static readonly HashSet Local = new(StringComparer.Ordinal) { "LocalizedOptionTemplate" }; + + private static DirectoryInfo RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AvParser.slnx"))) + { + directory = directory.Parent; + } + + return directory ?? throw new InvalidOperationException("Could not find the repository root."); + } + + [AvaloniaTheory] + [InlineData("Light")] + [InlineData("Dark")] + public void Every_resource_key_used_in_xaml_resolves(string theme) + { + var application = Application.Current.ShouldNotBeNull(); + application.RequestedThemeVariant = theme == "Light" ? ThemeVariant.Light : ThemeVariant.Dark; + + var views = Path.Combine(RepositoryRoot().FullName, "src", "AvParser.UI"); + var files = Directory.GetFiles(views, "*.axaml", SearchOption.AllDirectories); + + files.ShouldNotBeEmpty(); + + var missing = new SortedSet(StringComparer.Ordinal); + + foreach (var file in files) + { + foreach (Match match in ResourceMarkup().Matches(File.ReadAllText(file))) + { + var key = match.Groups[1].Value; + + if (Local.Contains(key)) + { + continue; + } + + if (!application.TryGetResource(key, application.ActualThemeVariant, out _)) + { + missing.Add($"{key} ({Path.GetFileName(file)})"); + } + } + } + + missing.ShouldBeEmpty(); + } +} diff --git a/tests/AvParser.UI.Tests/CollectViewModelTests.cs b/tests/AvParser.UI.Tests/CollectViewModelTests.cs index fcdbe10..f0d8457 100644 --- a/tests/AvParser.UI.Tests/CollectViewModelTests.cs +++ b/tests/AvParser.UI.Tests/CollectViewModelTests.cs @@ -110,6 +110,7 @@ public class CollectViewModelTests proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()), runner, store, + new FakeThumbnailCache(), new EmptyServiceProvider(), NullLogger.Instance, ImmediateSequencer.Instance diff --git a/tests/AvParser.UI.Tests/Fakes/FakeMediaStore.cs b/tests/AvParser.UI.Tests/Fakes/FakeMediaStore.cs index bfd6b68..5ead222 100644 --- a/tests/AvParser.UI.Tests/Fakes/FakeMediaStore.cs +++ b/tests/AvParser.UI.Tests/Fakes/FakeMediaStore.cs @@ -9,6 +9,16 @@ internal sealed class FakeMediaStore : IMediaStore public MediaStoreStats Stats { get; set; } = new(0, 0, 0, 0); + public List Browse { get; } = []; + + public List SourceIds { get; } = []; + + public MediaBrowseQuery? LastBrowse { get; private set; } + + public int? TotalOverride { get; set; } + + public bool BrowseThrows { get; set; } + public PurgeResult PurgeResult { get; set; } = new(3, 2, 4096); public ShowcaseMode Mode { get; private set; } = ShowcaseMode.HardLink; @@ -61,4 +71,16 @@ internal sealed class FakeMediaStore : IMediaStore Task.FromResult(0); public Task GetStatsAsync(CancellationToken cancellationToken = default) => Task.FromResult(Stats); + + public Task BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default) + { + LastBrowse = query; + + return BrowseThrows + ? throw new InvalidOperationException("the index is unreadable") + : Task.FromResult(new MediaPage(Browse, TotalOverride ?? Browse.Count)); + } + + public Task> GetSourceIdsAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(SourceIds); } diff --git a/tests/AvParser.UI.Tests/Fakes/FakeThumbnailCache.cs b/tests/AvParser.UI.Tests/Fakes/FakeThumbnailCache.cs new file mode 100644 index 0000000..98853b8 --- /dev/null +++ b/tests/AvParser.UI.Tests/Fakes/FakeThumbnailCache.cs @@ -0,0 +1,37 @@ +using Avalonia.Media.Imaging; +using AvParser.Core.Collecting; +using AvParser.UI.Media; + +namespace AvParser.UI.Tests.Fakes; + +/// +/// A cache that decodes nothing. +/// +/// +/// Returning null is exactly what the real cache does for a video or a missing blob, so the view +/// models are already required to cope with it — which is what makes this an honest stand-in +/// rather than a convenient one. It records what was asked for, so tests can assert that a page +/// requested previews at all. +/// +internal sealed class FakeThumbnailCache : IThumbnailCache +{ + public List<(string Sha256, int Width)> Requested { get; } = []; + + public Task GetAsync( + string sha256, + string extension, + MediaKind kind, + int width, + CancellationToken ct = default + ) + { + lock (Requested) + { + Requested.Add((sha256, width)); + } + + return Task.FromResult(null); + } + + public void Clear() => Requested.Clear(); +} diff --git a/tests/AvParser.UI.Tests/GalleryViewModelTests.cs b/tests/AvParser.UI.Tests/GalleryViewModelTests.cs new file mode 100644 index 0000000..96c30bf --- /dev/null +++ b/tests/AvParser.UI.Tests/GalleryViewModelTests.cs @@ -0,0 +1,173 @@ +using AvParser.Core.Collecting; +using AvParser.UI.Tests.Fakes; +using AvParser.UI.ViewModels; +using Microsoft.Extensions.Logging.Abstractions; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Concurrency; + +namespace AvParser.UI.Tests; + +public class GalleryViewModelTests +{ + private static StoredMedia Media(string url, MediaKind kind = MediaKind.Png, bool animated = false) => + new( + Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"), + kind, + MediaKinds.ExtensionFor(kind), + 4096, + "url-list", + url, + DateTimeOffset.UtcNow + ) + { + Width = 800, + Height = 600, + IsAnimated = animated, + }; + + private static (GalleryViewModel Page, FakeMediaStore Store, FakeThumbnailCache Thumbnails) Build( + params StoredMedia[] items + ) + { + var store = new FakeMediaStore(); + store.Browse.AddRange(items); + store.SourceIds.Add("url-list"); + + var thumbnails = new FakeThumbnailCache(); + var page = new GalleryViewModel( + store, + thumbnails, + NullLogger.Instance, + ImmediateSequencer.Instance + ); + + return (page, store, thumbnails); + } + + [Fact] + public async Task What_the_store_holds_becomes_tiles() + { + var (page, _, _) = Build(Media("https://a.test/1.png"), Media("https://a.test/2.gif", MediaKind.Gif)); + + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + page.Items.Count.ShouldBe(2); + page.TotalCount.ShouldBe(2); + page.StatusMessage.ShouldBeNull(); + } + + [Fact] + public async Task Every_tile_asks_for_a_thumbnail() + { + var (page, _, thumbnails) = Build(Media("https://a.test/1.png"), Media("https://a.test/2.png")); + + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + // By distinct hash rather than call count: the page also loads once on construction, and + // what matters is that every tile got asked for, at the tile's own width. + thumbnails.Requested.Select(r => r.Sha256).Distinct().Count().ShouldBe(2); + thumbnails.Requested.ShouldAllBe(r => r.Width == GalleryItemViewModel.ThumbnailWidth); + } + + [Fact] + public async Task Content_nothing_can_decode_is_flagged_rather_than_left_blank() + { + // The fake decodes nothing, which is exactly what the real cache does for a video. + var (page, _, _) = Build(Media("https://a.test/clip.mp4", MediaKind.Mp4)); + + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + var tile = page.Items.ShouldHaveSingleItem(); + tile.Thumbnail.ShouldBeNull(); + tile.HasNoPreview.ShouldBeTrue(); + tile.KindText.ShouldBe("MP4"); + } + + [Fact] + public async Task An_empty_store_says_so_instead_of_showing_a_blank_page() + { + var (page, _, _) = Build(); + + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + page.Items.ShouldBeEmpty(); + page.StatusMessage.ShouldNotBeNull().ShouldContain("Nothing here yet"); + } + + [Fact] + public async Task The_filters_reach_the_query() + { + var (page, store, _) = Build(Media("https://a.test/1.png")); + + page.SelectedSourceId = "url-list"; + page.AnimatedOnly = true; + page.SearchText = "kitten"; + page.SelectedKinds = page.KindFilters.Single(option => option.Value == MediaKindFilter.Images); + + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + var query = store.LastBrowse.ShouldNotBeNull(); + query.SourceId.ShouldBe("url-list"); + query.AnimatedOnly.ShouldBeTrue(); + query.Search.ShouldBe("kitten"); + query.Kinds.ShouldBe(MediaKindFilter.Images); + } + + [Fact] + public async Task An_unset_source_filter_means_all_of_them() + { + var (page, store, _) = Build(Media("https://a.test/1.png")); + + page.SelectedSourceId = " "; + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + store.LastBrowse.ShouldNotBeNull().SourceId.ShouldBeNull(); + } + + [Fact] + public async Task Paging_moves_the_offset_and_stops_at_the_ends() + { + var (page, store, _) = Build(Media("https://a.test/1.png")); + store.TotalOverride = 250; + + await page.LoadAsync(0, TestContext.Current.CancellationToken); + page.PageText.ShouldContain("1"); + + await page.LoadAsync(1, TestContext.Current.CancellationToken); + store.LastBrowse!.Skip.ShouldBe(120); + + // A negative page is a clamp, not a crash. + await page.LoadAsync(-3, TestContext.Current.CancellationToken); + page.PageIndex.ShouldBe(0); + } + + [Fact] + public async Task Opening_a_tile_opens_the_viewer_and_closing_it_clears_the_selection() + { + var (page, _, _) = Build(Media("https://a.test/1.png")); + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + page.IsViewerOpen.ShouldBeFalse(); + + page.Selected = page.Items[0]; + page.IsViewerOpen.ShouldBeTrue(); + + await page.CloseViewerCommand.Execute().ToTask(TestContext.Current.CancellationToken); + + page.Selected.ShouldBeNull(); + page.IsViewerOpen.ShouldBeFalse(); + page.Preview.ShouldBeNull(); + } + + [Fact] + public async Task A_store_that_throws_reports_it_rather_than_taking_the_page_down() + { + var (page, store, _) = Build(Media("https://a.test/1.png")); + store.BrowseThrows = true; + + await page.LoadAsync(0, TestContext.Current.CancellationToken); + + page.StatusMessage.ShouldNotBeNull().ShouldContain("Could not read the store"); + page.IsLoading.ShouldBeFalse(); + } +} diff --git a/tests/AvParser.UI.Tests/ThumbnailCacheTests.cs b/tests/AvParser.UI.Tests/ThumbnailCacheTests.cs new file mode 100644 index 0000000..62bb893 --- /dev/null +++ b/tests/AvParser.UI.Tests/ThumbnailCacheTests.cs @@ -0,0 +1,80 @@ +using AvParser.Core.Collecting; +using AvParser.Infrastructure.Storage; +using AvParser.UI.Media; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AvParser.UI.Tests; + +/// +/// The cache's refusals. +/// +/// +/// Only the paths that answer before touching a decoder are covered here: decoding needs an +/// Avalonia rendering platform, which this project deliberately does not start. The decode itself +/// is one framework call; what is worth pinning is that the cache never reaches it for content it +/// cannot handle, because doing so would mean an exception per tile. +/// +public sealed class ThumbnailCacheTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N")); + private readonly ThumbnailCache _cache; + + public ThumbnailCacheTests() + { + var paths = new AppPaths(_root); + paths.EnsureCreated(); + + _cache = new ThumbnailCache(paths, NullLogger.Instance); + } + + public void Dispose() + { + _cache.Dispose(); + + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Theory] + [InlineData(MediaKind.Mp4)] + [InlineData(MediaKind.WebM)] + public async Task A_video_is_refused_without_looking_at_the_disk(MediaKind kind) + { + var result = await _cache.GetAsync( + new string('a', 64), + ".mp4", + kind, + 220, + TestContext.Current.CancellationToken + ); + + result.ShouldBeNull(); + } + + [Fact] + public async Task A_missing_blob_is_a_blank_tile_rather_than_a_throw() + { + var result = await _cache.GetAsync( + new string('b', 64), + ".png", + MediaKind.Png, + 220, + TestContext.Current.CancellationToken + ); + + result.ShouldBeNull(); + } + + [Fact] + public void Clearing_an_empty_cache_is_harmless() => _cache.Clear(); + + [Fact] + public void The_capacity_leaves_room_for_more_than_one_screenful() + { + // The cache disposes what it evicts, so a capacity near the page size would dispose + // bitmaps that are still on screen. + ThumbnailCache.Capacity.ShouldBeGreaterThan(120 * 2); + } +}