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