Refactor ILibraryService and LibraryService to support new metadata handling features, including remote image management and enhanced label summaries. Update LabelSummary to include ImagePath for better visual representation. Revise MetadataMatchViewModel and MetadataScanViewModel to accommodate new image loading logic. Enhance README.md to document these updates and new functionalities.

This commit is contained in:
Leonid Pershin
2026-08-10 09:25:41 +03:00
parent 9e239e045f
commit b82b0b4555
38 changed files with 4661 additions and 3316 deletions
+37 -1
View File
@@ -14,6 +14,8 @@
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка - Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка
(по названию или по частоте); клик по сущности показывает её видео в сетке. (по названию или по частоте); клик по сущности показывает её видео в сетке.
- Карточки актёров и студий — с фото из источника метаданных. У тегов и коллекций картинок
не бывает: их нет в схеме stash-box, поэтому там рисуется первая буква.
- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска. - Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска.
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки - Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется
@@ -24,7 +26,8 @@
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой - Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой
и списком найденного. По желанию однозначные совпадения применяются на месте. и списком найденного. Каждый кандидат показывается со своей обложкой. По желанию
однозначные совпадения применяются на месте.
- Светлая, тёмная и системная темы; выбор запоминается. - Светлая, тёмная и системная темы; выбор запоминается.
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, - Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
@@ -173,6 +176,37 @@ dotnet test
видео, и тысяча одинаковых строк была бы всей страницей. видео, и тысяча одинаковых строк была бы всей страницей.
«Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда «Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда
— расхождение источников это ровно тот случай, ради которого страницу и смотрят. — расхождение источников это ровно тот случай, ради которого страницу и смотрят.
- **Картинки скачиваются один раз и по размеру.** `images` в stash-box есть у сцены,
у актёра и у студии; у тега такого поля нет вовсе, поэтому там карточка показывает первую
букву — это норма, а не отсутствие данных. Обложка сцены нужна там, где выбирают из
кандидатов: название и список тегов почти ничего не говорят о том, то ли это видео,
а кадр говорит с одного взгляда.
Картинка никогда не задерживает то, ради чего её показывают. Сначала так и было: обложка
скачивалась до того, как кандидат отдавался наверх, — и один зависший хост картинок
морозил весь прогон, оставляя список результатов пустым при ползущем прогрессе.
Теперь `VideoMetadataMatch` несёт только `ImageUrl`, строка появляется сразу, а файл
подгружается за ней (`RemoteImageLoader`, не более четырёх загрузок разом).
У самой загрузки есть свой дедлайн — 10 секунд на заголовки и тело вместе.
`HttpClient.Timeout` перестаёт действовать в момент, когда `ResponseHeadersRead`
возвращает ответ, поэтому сервер, открывший соединение и замерший на середине
картинки, висел бы вечно. Ровно это и случилось с CDN одного из источников: 200 за
100 мс и тишина до самого таймаута.
Хост, упавший три раза подряд, отключается на 5 минут: при прогоне по библиотеке
обложка приходится на каждого кандидата, и без отсечки это сотня мёртвых сокетов.
Молчать об этом нельзя — пустой квадрат выглядит одинаково и когда картинки нет,
и когда хост недоступен. Поэтому `IRemoteImageCache` возвращает не `string?`, а
`RemoteImage` с необязательной причиной, и она всплывает на странице: одной строкой,
один раз за окно отключения, а не рядом с каждым кандидатом. Из всех размеров берётся **самый маленький, всё
ещё пригодный для карточки** (от 320 px), и самый большой, если ни один не дотягивает:
оригиналы бывают в несколько тысяч пикселей, и качать их, чтобы нарисовать 150 px, —
мегабайты на голову без выигрыша в виде.
Скачивается только если у метки картинки ещё нет: источники расходятся в том, какое фото
принадлежит актёру, и обновление на каждом совпадении меняло бы лицо на карточке при каждом
размеченном видео. Неудачная загрузка не роняет применение — потерять фото дешевле, чем
название, описание и все метки разом.
Размер ограничен 8 МБ и проверяется дважды: по заголовку и по ходу чтения, потому что
сервер может ответить без длины. Схемы, кроме http(s), отвергаются — URL приходит с чужого
сервера, и `file://` превратил бы «скачай картинку» в «прочитай файл по своему выбору».
Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили», Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили»,
но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`, но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`,
поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает». поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает».
@@ -219,6 +253,8 @@ dotnet test
- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash; - `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash;
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); - `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени; - `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени;
- `images/` — картинки от источников метаданных: фото актёров, логотипы студий, обложки
найденных сцен; ключ — хеш URL;
- `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники - `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники
метаданных вместе с их API-ключами; метаданных вместе с их API-ключами;
перечитывается на лету; перечитывается на лету;
@@ -0,0 +1,36 @@
namespace PLib.Application.Abstractions;
/// <summary>
/// What came of asking for a picture.
/// </summary>
/// <remarks>
/// Three outcomes, not two. "There is no picture" and "the host holding the picture is not
/// answering" look identical on a card — an empty square — but only one of them is worth
/// telling the user about, and only one of them means the rest of the pictures will not arrive
/// either. A bare <c>string?</c> could not tell them apart.
/// </remarks>
/// <param name="Path">Where the picture landed, or <c>null</c> if it did not.</param>
/// <param name="Problem">
/// A line for the user when the failure is worth surfacing, or <c>null</c>. Set once a host
/// has been given up on, not on every miss — a picture the source simply does not have is not
/// a problem, and a hundred identical complaints are worse than none.
/// </param>
public sealed record RemoteImage(string? Path, string? Problem = null)
{
/// <summary>Nothing to fetch, and nothing to say about it.</summary>
public static RemoteImage None { get; } = new(Path: null);
public static RemoteImage At(string path) => new(path);
public static RemoteImage Unreachable(string problem) => new(null, problem);
}
/// <summary>Fetches and keeps pictures that live on somebody else's server.</summary>
public interface IRemoteImageCache : IMediaArtifactCache
{
/// <summary>
/// Fetches the picture at <paramref name="imageUrl"/> unless it is already cached, and
/// says where it landed — or, when the host has been given up on, why it did not.
/// </summary>
Task<RemoteImage> GetOrCreateAsync(string imageUrl, CancellationToken cancellationToken = default);
}
@@ -1,3 +1,4 @@
using PLib.Application.Abstractions;
using PLib.Application.Metadata; using PLib.Application.Metadata;
using PLib.Domain.Videos; using PLib.Domain.Videos;
@@ -66,6 +67,15 @@ public interface ILibraryService
Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default); Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a picture a metadata source pointed at, saying where it landed or why it did not.
/// </summary>
/// <remarks>
/// Separate from the lookup so that a candidate can be shown before its cover exists.
/// Waiting for the picture first means one slow picture host holds up every result.
/// </remarks>
Task<RemoteImage> FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asks every configured metadata source what it has for this video's fingerprint. /// Asks every configured metadata source what it has for this video's fingerprint.
/// </summary> /// </summary>
+6 -1
View File
@@ -11,4 +11,9 @@ namespace PLib.Application.Library;
/// the videos behind each one to arrive at a number is the difference between a page that /// the videos behind each one to arrive at a number is the difference between a page that
/// opens and a page that does not. /// opens and a page that does not.
/// </remarks> /// </remarks>
public sealed record LabelSummary(Guid Id, string Name, LabelKind Kind, int VideoCount); public sealed record LabelSummary(
Guid Id,
string Name,
LabelKind Kind,
int VideoCount,
string? ImagePath);
@@ -25,7 +25,14 @@ public enum LibraryDataKind
/// <summary>Duration, resolution and codec, as read by ffprobe.</summary> /// <summary>Duration, resolution and codec, as read by ffprobe.</summary>
TechnicalMetadata = 1 << 3, TechnicalMetadata = 1 << 3,
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata, /// <summary>
/// Pictures downloaded from a metadata source: performers, studios, and the covers
/// shown beside search results. Rebuilt by looking a video up again rather than by a
/// scan — the file on disk says nothing about what a performer looks like.
/// </summary>
RemoteImages = 1 << 4,
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages,
} }
/// <summary>What one kind of derived data currently costs.</summary> /// <summary>What one kind of derived data currently costs.</summary>
+91 -7
View File
@@ -18,6 +18,7 @@ public sealed class LibraryService(
IAnimatedPreviewGenerator previewGenerator, IAnimatedPreviewGenerator previewGenerator,
IVideoPerceptualHasher perceptualHasher, IVideoPerceptualHasher perceptualHasher,
IMetadataProvider metadataProvider, IMetadataProvider metadataProvider,
IRemoteImageCache remoteImages,
IOptions<LibraryOptions> options, IOptions<LibraryOptions> options,
IOptionsMonitor<MetadataOptions> metadataOptions, IOptionsMonitor<MetadataOptions> metadataOptions,
ILogger<LibraryService> logger) : ILibraryService ILogger<LibraryService> logger) : ILibraryService
@@ -200,6 +201,19 @@ public sealed class LibraryService(
return new MetadataLookupResult(HasPerceptualHash: true, matches, failures); return new MetadataLookupResult(HasPerceptualHash: true, matches, failures);
} }
/// <summary>
/// Fetches a picture a source pointed at and returns where it landed, or <c>null</c>.
/// </summary>
/// <remarks>
/// Exposed on its own rather than folded into the lookup on purpose. Downloading a cover
/// before handing a candidate back put a network round trip between "we have an answer" and
/// "the user can see it" — a stalled picture host held up the entire run, and the results
/// list stayed empty while the progress bar crawled. The candidate arrives first now, and
/// its picture catches up.
/// </remarks>
public Task<RemoteImage> FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default) =>
remoteImages.GetOrCreateAsync(imageUrl, cancellationToken);
public async IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync( public async IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync(
MetadataScanRequest request, MetadataScanRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default)
@@ -326,36 +340,77 @@ public sealed class LibraryService(
private async Task AttachAllAsync( private async Task AttachAllAsync(
VideoItem video, VideoItem video,
IEnumerable<string> names, IEnumerable<MetadataEntity> entities,
LabelKind kind, LabelKind kind,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// A source can repeat a name within one match, and the video may already carry it; // A source can repeat a name within one match, and the video may already carry it;
// both have to collapse onto a single label. // both have to collapse onto a single label.
var distinct = names var distinct = entities
.Where(name => !string.IsNullOrWhiteSpace(name)) .Where(entity => !string.IsNullOrWhiteSpace(entity.Name))
.Distinct(StringComparer.CurrentCultureIgnoreCase); .DistinctBy(entity => entity.Name, StringComparer.CurrentCultureIgnoreCase);
foreach (var name in distinct) foreach (var entity in distinct)
{ {
var label = await labels.FindAsync(kind, name, cancellationToken); var label = await labels.FindAsync(kind, entity.Name, cancellationToken);
if (label is null) if (label is null)
{ {
label = new LibraryLabel(name, kind); label = new LibraryLabel(entity.Name, kind);
await labels.AddAsync(label, cancellationToken); await labels.AddAsync(label, cancellationToken);
} }
await AttachImageAsync(label, entity.ImageUrl, cancellationToken);
video.AddLabel(label); video.AddLabel(label);
} }
} }
/// <summary>
/// Downloads a label's picture the first time a source offers one.
/// </summary>
/// <remarks>
/// Only when the label has none. Sources disagree about which photograph belongs to a
/// performer, and re-downloading on every match would make the card change face each time
/// a video was tagged — the first answer is as good as the fifth and far less surprising.
/// A failed download is left alone: the card falls back to an initial, which is a smaller
/// loss than failing the whole match over a picture.
/// </remarks>
private async Task AttachImageAsync(
LibraryLabel label,
string? imageUrl,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(imageUrl) || remoteImages.IsAvailable(label.ImagePath))
{
return;
}
try
{
if ((await remoteImages.GetOrCreateAsync(imageUrl, cancellationToken)).Path is { } path)
{
label.AttachImage(path);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not fetch the picture for {Label}", label.Name);
}
}
public async Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync( public async Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var items = await repository.GetAllAsync(cancellationToken); var items = await repository.GetAllAsync(cancellationToken);
var allLabels = await labels.GetSummariesAsync(cancellationToken);
var thumbnailBytes = await thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken); var thumbnailBytes = await thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken); var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken);
var imageBytes = await remoteImages.GetCacheSizeInBytesAsync(cancellationToken);
return return
[ [
@@ -363,6 +418,14 @@ public sealed class LibraryService(
new(LibraryDataKind.AnimatedPreviews, items.Count, items.Count(x => x.PreviewPath is not null), previewBytes), new(LibraryDataKind.AnimatedPreviews, items.Count, items.Count(x => x.PreviewPath is not null), previewBytes),
new(LibraryDataKind.PerceptualHashes, items.Count, items.Count(x => x.PerceptualHash is not null), 0), new(LibraryDataKind.PerceptualHashes, items.Count, items.Count(x => x.PerceptualHash is not null), 0),
new(LibraryDataKind.TechnicalMetadata, items.Count, items.Count(x => x.Duration is not null), 0), new(LibraryDataKind.TechnicalMetadata, items.Count, items.Count(x => x.Duration is not null), 0),
// Counted against the labels rather than the videos: most tags will never have a
// picture, and "12 из 4000 видео" would read as a failure rather than a fact.
new(
LibraryDataKind.RemoteImages,
allLabels.Count,
allLabels.Count(x => x.ImagePath is not null),
imageBytes),
]; ];
} }
@@ -398,6 +461,14 @@ public sealed class LibraryService(
} }
} }
if (kinds.HasFlag(LibraryDataKind.RemoteImages))
{
foreach (var label in await labels.GetAllAsync(cancellationToken))
{
label.DetachImage();
}
}
// Forget the references before deleting the files. Interrupted the other way round, // Forget the references before deleting the files. Interrupted the other way round,
// the library would point at images that no longer exist — recoverable, but only once // the library would point at images that no longer exist — recoverable, but only once
// a scan notices. This order leaves at worst some orphans, which the purge eats. // a scan notices. This order leaves at worst some orphans, which the purge eats.
@@ -415,6 +486,11 @@ public sealed class LibraryService(
removed += await previewGenerator.ClearAsync(cancellationToken); removed += await previewGenerator.ClearAsync(cancellationToken);
} }
if (kinds.HasFlag(LibraryDataKind.RemoteImages))
{
removed += await remoteImages.ClearAsync(cancellationToken);
}
logger.LogInformation("Cleared {Kinds} on request, removing {Count} files", kinds, removed); logger.LogInformation("Cleared {Kinds} on request, removing {Count} files", kinds, removed);
} }
@@ -543,6 +619,14 @@ public sealed class LibraryService(
[.. items.Select(x => x.PreviewPath).OfType<string>()], [.. items.Select(x => x.PreviewPath).OfType<string>()],
cancellationToken); cancellationToken);
// Label pictures belong to a different aggregate, but they go stale the same way — a
// label renamed or deleted leaves its picture behind with nothing pointing at it.
var labelSummaries = await labels.GetSummariesAsync(cancellationToken);
removed += await remoteImages.PurgeUnusedAsync(
[.. labelSummaries.Select(x => x.ImagePath).OfType<string>()],
cancellationToken);
if (removed > 0) if (removed > 0)
{ {
logger.LogInformation("Removed {Count} orphaned images from the cache", removed); logger.LogInformation("Removed {Count} orphaned images from the cache", removed);
@@ -1,5 +1,14 @@
namespace PLib.Application.Metadata; namespace PLib.Application.Metadata;
/// <summary>
/// A named thing a source attached to a scene — a tag, a performer, a studio.
/// </summary>
/// <param name="ImageUrl">
/// Where its picture lives, or <c>null</c>. Only some kinds have one: stash-box knows what a
/// performer and a studio look like, and has no picture for a tag at all.
/// </param>
public sealed record MetadataEntity(string Name, string? ImageUrl = null);
/// <summary> /// <summary>
/// One candidate a source returned for a video, as PLib understands it. /// One candidate a source returned for a video, as PLib understands it.
/// </summary> /// </summary>
@@ -9,14 +18,19 @@ namespace PLib.Application.Metadata;
/// </remarks> /// </remarks>
/// <param name="SourceName">Which configured source proposed it.</param> /// <param name="SourceName">Which configured source proposed it.</param>
/// <param name="RemoteId">Its identifier at the source, for display and for reporting.</param> /// <param name="RemoteId">Its identifier at the source, for display and for reporting.</param>
/// <param name="ImageUrl">
/// The scene's own cover at the source, or <c>null</c>. Still a URL: fetching it is the
/// caller's business, and a candidate must never wait on a picture host to be shown.
/// </param>
public sealed record VideoMetadataMatch( public sealed record VideoMetadataMatch(
string SourceName, string SourceName,
string? RemoteId, string? RemoteId,
string Title, string Title,
string? Description, string? Description,
IReadOnlyList<string> Tags, IReadOnlyList<MetadataEntity> Tags,
IReadOnlyList<string> Performers, IReadOnlyList<MetadataEntity> Performers,
IReadOnlyList<string> Studios); IReadOnlyList<MetadataEntity> Studios,
string? ImageUrl = null);
/// <summary>Everything one lookup produced, including what went wrong.</summary> /// <summary>Everything one lookup produced, including what went wrong.</summary>
/// <param name="HasPerceptualHash"> /// <param name="HasPerceptualHash">
+1
View File
@@ -55,6 +55,7 @@ internal static class AppHost
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>(); builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
builder.Services.AddSingleton<ISystemShell, SystemShell>(); builder.Services.AddSingleton<ISystemShell, SystemShell>();
builder.Services.AddSingleton<IThemeService, ThemeService>(); builder.Services.AddSingleton<IThemeService, ThemeService>();
builder.Services.AddSingleton<RemoteImageLoader>();
builder.Services.AddSingleton<MainWindowViewModel>(); builder.Services.AddSingleton<MainWindowViewModel>();
// Scoped, not singleton: the settings dialog edits a working copy, so each opening // Scoped, not singleton: the settings dialog edits a working copy, so each opening
@@ -0,0 +1,85 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
namespace PLib.Desktop.Services;
/// <summary>
/// Fetches the pictures a metadata source pointed at, behind whatever is already on screen.
/// </summary>
/// <remarks>
/// Nothing waits on this. A candidate is shown the moment the source answers, and its cover
/// appears when it appears — the alternative, fetching first, put a stranger's picture host on
/// the critical path of showing a result, and one that stalled froze the whole run.
///
/// Concurrency is capped because a run can produce results faster than pictures download, and
/// an unbounded fan-out would open a connection per candidate to the same host.
/// </remarks>
public sealed class RemoteImageLoader(
IServiceScopeFactory scopeFactory,
ILogger<RemoteImageLoader> logger) : IDisposable
{
private readonly SemaphoreSlim _slots = new(4);
private readonly Subject<string> _problems = new();
/// <summary>
/// Reasons pictures are not arriving, for whatever page is on screen to show.
/// </summary>
/// <remarks>
/// An empty square tells the user nothing — it looks the same whether the source has no
/// picture or the whole host is unreachable. The cache raises one of these only once it
/// has given up on a host, so this is a handful of lines, not one per candidate.
/// </remarks>
public IObservable<string> Problems => _problems.AsObservable();
public async Task<string?> LoadAsync(string? imageUrl, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(imageUrl))
{
return null;
}
try
{
await _slots.WaitAsync(cancellationToken);
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var image = await library.FetchImageAsync(imageUrl, cancellationToken);
if (image.Problem is { } problem)
{
_problems.OnNext(problem);
}
return image.Path;
}
finally
{
_slots.Release();
}
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
// A missing cover costs a thumbnail. Nothing above this is waiting for an answer,
// so there is nobody to report it to but the log.
logger.LogDebug(ex, "Could not fetch the picture at {Url}", imageUrl);
return null;
}
}
public void Dispose()
{
_problems.Dispose();
_slots.Dispose();
}
}
+4 -2
View File
@@ -136,14 +136,16 @@
<!-- One tag, performer, studio or collection in a browsing tab. --> <!-- One tag, performer, studio or collection in a browsing tab. -->
<Style Selector="Button.entity"> <Style Selector="Button.entity">
<Setter Property="Padding" Value="12,10" /> <Setter Property="Padding" Value="8,8,8,10" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" /> <Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Cursor" Value="Hand" /> <Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" /> <Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" /> <Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" /> <Setter Property="CornerRadius" Value="12" />
</Style> </Style>
<Style Selector="Button.entity:pointerover"> <Style Selector="Button.entity:pointerover">
@@ -18,6 +18,12 @@ public sealed class LabelSummaryViewModel
Kind = summary.Kind; Kind = summary.Kind;
VideoCount = summary.VideoCount; VideoCount = summary.VideoCount;
CountText = VideoCount.ToString(CultureInfo.CurrentCulture); CountText = VideoCount.ToString(CultureInfo.CurrentCulture);
ImagePath = summary.ImagePath;
// Stands in for the picture. Tags never have one — stash-box holds no image for a tag
// — and a performer only gets one once a match brings it, so the fallback is the
// normal case rather than an error state.
Initial = Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
OpenCommand = ReactiveCommand.Create(() => open(this)); OpenCommand = ReactiveCommand.Create(() => open(this));
} }
@@ -32,6 +38,12 @@ public sealed class LabelSummaryViewModel
public string CountText { get; } public string CountText { get; }
/// <summary>Cached picture of this label, or <c>null</c> when there is none.</summary>
public string? ImagePath { get; }
/// <summary>First letter, drawn when there is no picture.</summary>
public string Initial { get; }
/// <summary>Narrows the video grid down to this label and switches to it.</summary> /// <summary>Narrows the video grid down to this label and switches to it.</summary>
public ReactiveCommand<RxVoid, RxVoid> OpenCommand { get; } public ReactiveCommand<RxVoid, RxVoid> OpenCommand { get; }
@@ -45,6 +45,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IThemeService _theme; private readonly IThemeService _theme;
private readonly IFolderPicker _folderPicker; private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell; private readonly ISystemShell _shell;
private readonly RemoteImageLoader _images;
private readonly ILogger<MainWindowViewModel> _logger; private readonly ILogger<MainWindowViewModel> _logger;
/// <summary> /// <summary>
@@ -85,6 +86,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
ISystemShell shell, ISystemShell shell,
ILibraryWatcher watcher, ILibraryWatcher watcher,
IThemeService theme, IThemeService theme,
RemoteImageLoader images,
ILogger<MainWindowViewModel> logger) ILogger<MainWindowViewModel> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
@@ -94,6 +96,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
_theme = theme; _theme = theme;
_folderPicker = folderPicker; _folderPicker = folderPicker;
_shell = shell; _shell = shell;
_images = images;
_logger = logger; _logger = logger;
SelectedSort = SortOption.All[0]; SelectedSort = SortOption.All[0];
@@ -388,7 +391,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{ {
// Built on first visit and kept: the page holds the results of a run that may // Built on first visit and kept: the page holds the results of a run that may
// have taken minutes, and switching tabs must not throw them away. // have taken minutes, and switching tabs must not throw them away.
MetadataScan ??= new MetadataScanViewModel(_scopeFactory, RefreshLibraryAsync, _logger); MetadataScan ??= new MetadataScanViewModel(_scopeFactory, _images, RefreshLibraryAsync, _logger);
} }
} }
@@ -493,7 +496,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenVideo(VideoCardViewModel card) private void OpenVideo(VideoCardViewModel card)
{ {
OpenedVideo?.Dispose(); OpenedVideo?.Dispose();
OpenedVideo = new VideoPlayerViewModel(card, _shell, _scopeFactory, _settingsStore, _logger, () => OpenedVideo = null); OpenedVideo = new VideoPlayerViewModel(
card,
_shell,
_scopeFactory,
_settingsStore,
_images,
_logger,
() => OpenedVideo = null);
} }
/// <summary> /// <summary>
@@ -1,5 +1,6 @@
using PLib.Application.Metadata; using PLib.Application.Metadata;
using ReactiveUI; using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid; using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels; namespace PLib.Desktop.ViewModels;
@@ -9,7 +10,7 @@ namespace PLib.Desktop.ViewModels;
/// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers, /// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers,
/// and a source confidently overwriting a title is far harder to undo than a button is to press. /// and a source confidently overwriting a title is far harder to undo than a button is to press.
/// </remarks> /// </remarks>
public sealed class MetadataMatchViewModel public sealed partial class MetadataMatchViewModel : ReactiveObject
{ {
public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply) public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply)
{ {
@@ -19,6 +20,7 @@ public sealed class MetadataMatchViewModel
SourceName = match.SourceName; SourceName = match.SourceName;
Title = match.Title; Title = match.Title;
Description = match.Description; Description = match.Description;
ImageUrl = match.ImageUrl;
Studios = Join(match.Studios); Studios = Join(match.Studios);
Performers = Join(match.Performers); Performers = Join(match.Performers);
@@ -35,6 +37,18 @@ public sealed class MetadataMatchViewModel
public string? Description { get; } public string? Description { get; }
/// <summary>Where the candidate's cover lives at the source, or <c>null</c>.</summary>
public string? ImageUrl { get; }
/// <summary>
/// The cover once it is on disk. Reactive and initially empty: the row is shown as soon as
/// the source answers, and the picture fills in behind it. A title and a tag list say very
/// little about whether this is the right video; a frame says it at a glance — but not at
/// the price of the row waiting for it.
/// </summary>
[Reactive]
public partial string? ImagePath { get; set; }
/// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary> /// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary>
public string? Studios { get; } public string? Studios { get; }
@@ -44,6 +58,6 @@ public sealed class MetadataMatchViewModel
public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; } public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; }
private static string? Join(IReadOnlyList<string> values) => private static string? Join(IReadOnlyList<MetadataEntity> values) =>
values.Count == 0 ? null : string.Join(", ", values); values.Count == 0 ? null : string.Join(", ", values.Select(entity => entity.Name));
} }
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PLib.Application.Library; using PLib.Application.Library;
using PLib.Application.Metadata; using PLib.Application.Metadata;
using PLib.Desktop.Services;
using ReactiveUI; using ReactiveUI;
using ReactiveUI.SourceGenerators; using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid; using RxVoid = ReactiveUI.Primitives.RxVoid;
@@ -22,6 +23,7 @@ namespace PLib.Desktop.ViewModels;
public sealed partial class MetadataScanViewModel : ViewModelBase public sealed partial class MetadataScanViewModel : ViewModelBase
{ {
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly RemoteImageLoader _images;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary>Called after the run so the grid can pick up renamed videos and new labels.</summary> /// <summary>Called after the run so the grid can pick up renamed videos and new labels.</summary>
@@ -31,10 +33,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
public MetadataScanViewModel( public MetadataScanViewModel(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
RemoteImageLoader images,
Func<Task> refreshLibrary, Func<Task> refreshLibrary,
ILogger logger) ILogger logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_images = images;
_refreshLibrary = refreshLibrary; _refreshLibrary = refreshLibrary;
_logger = logger; _logger = logger;
@@ -46,6 +50,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
() => _running?.Cancel(), () => _running?.Cancel(),
this.WhenAnyValue(x => x.IsRunning)); this.WhenAnyValue(x => x.IsRunning));
// Pictures fail on their own schedule, long after the source answered, so this is
// a subscription rather than something the run reports.
_images.Problems
.Subscribe(problem => Dispatcher.UIThread.Post(() => Report(problem)))
.AddTo(Subscriptions);
ObserveCommandFailures(); ObserveCommandFailures();
} }
@@ -134,11 +144,16 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
break; break;
case MetadataScanEvent.Matched matched: case MetadataScanEvent.Matched matched:
Results.Insert(0, new MetadataScanResultViewModel(matched, ApplyAsync)); var result = new MetadataScanResultViewModel(matched, ApplyAsync);
Results.Insert(0, result);
// Started, not awaited: the row is on screen already, and the covers
// arrive when the picture host gets round to them.
LoadCovers(result.Matches);
break; break;
case MetadataScanEvent.SourceAbandoned abandoned: case MetadataScanEvent.SourceAbandoned abandoned:
Problems.Add($"{abandoned.SourceName}: {abandoned.Reason}"); Report($"{abandoned.SourceName}: {abandoned.Reason}");
break; break;
case MetadataScanEvent.Completed completed: case MetadataScanEvent.Completed completed:
@@ -153,6 +168,35 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
} }
} }
/// <summary>Adds a line unless it is already there; the same host fails repeatedly.</summary>
private void Report(string problem)
{
if (!Problems.Contains(problem))
{
Problems.Add(problem);
}
}
/// <summary>
/// Fills in the covers behind rows that are already visible. Nothing awaits this:
/// fetching before showing is what made a stalled picture host freeze the whole run.
/// </summary>
private void LoadCovers(IEnumerable<MetadataMatchViewModel> matches)
{
foreach (var match in matches.Where(match => match.ImageUrl is not null))
{
_ = LoadCoverAsync(match);
}
}
private async Task LoadCoverAsync(MetadataMatchViewModel match)
{
if (await _images.LoadAsync(match.ImageUrl) is { } path)
{
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
}
}
private static string Described(int count) => private static string Described(int count) =>
count == 0 ? "Совпадений пока не было." : $"Совпадений найдено: {count}."; count == 0 ? "Совпадений пока не было." : $"Совпадений найдено: {count}.";
@@ -95,6 +95,12 @@ public sealed partial class SettingsViewModel : ViewModelBase
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.", "Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
ClearAsync, ClearAsync,
idle), idle),
new(
LibraryDataKind.RemoteImages,
"Изображения меток",
"Фото актёров и логотипы студий. Скачиваются из источника метаданных, а не из файлов, поэтому сканирование их не вернёт — только повторное применение совпадения.",
ClearAsync,
idle),
]; ];
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle); ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Reactive.Concurrency; using System.Reactive.Concurrency;
using System.Reactive.Linq; using System.Reactive.Linq;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PLib.Application.Library; using PLib.Application.Library;
@@ -31,6 +32,7 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly IAppSettingsStore _settingsStore; private readonly IAppSettingsStore _settingsStore;
private readonly RemoteImageLoader _images;
private readonly ILogger _logger; private readonly ILogger _logger;
public VideoPlayerViewModel( public VideoPlayerViewModel(
@@ -38,11 +40,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
ISystemShell shell, ISystemShell shell,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IAppSettingsStore settingsStore, IAppSettingsStore settingsStore,
RemoteImageLoader images,
ILogger logger, ILogger logger,
Action close) Action close)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_settingsStore = settingsStore; _settingsStore = settingsStore;
_images = images;
_logger = logger; _logger = logger;
Card = card; Card = card;
@@ -87,6 +91,10 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
.Subscribe(state => Persist(state.volume, state.muted)) .Subscribe(state => Persist(state.volume, state.muted))
.AddTo(Subscriptions); .AddTo(Subscriptions);
_images.Problems
.Subscribe(problem => Dispatcher.UIThread.Post(() => ImageProblem = problem))
.AddTo(Subscriptions);
ObserveCommandFailures(); ObserveCommandFailures();
} }
@@ -150,6 +158,10 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
[Reactive] [Reactive]
public partial string? MetadataMessage { get; set; } public partial string? MetadataMessage { get; set; }
/// <summary>Why the candidates have no pictures, when that is the reason.</summary>
[Reactive]
public partial string? ImageProblem { get; set; }
/// <summary>Free text about the video, once a match has supplied one.</summary> /// <summary>Free text about the video, once a match has supplied one.</summary>
[Reactive] [Reactive]
public partial string? Description { get; set; } public partial string? Description { get; set; }
@@ -282,6 +294,7 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
{ {
IsLookingUp = true; IsLookingUp = true;
Matches.Clear(); Matches.Clear();
ImageProblem = null;
// The results land in the side panel, so opening it is part of running the lookup — // The results land in the side panel, so opening it is part of running the lookup —
// otherwise the button would appear to do nothing. // otherwise the button would appear to do nothing.
@@ -302,7 +315,11 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
foreach (var match in result.Matches) foreach (var match in result.Matches)
{ {
Matches.Add(new MetadataMatchViewModel(match, ApplyMetadataAsync)); var row = new MetadataMatchViewModel(match, ApplyMetadataAsync);
Matches.Add(row);
// Started, not awaited: the candidate is on screen, and its cover follows.
_ = LoadCoverAsync(row);
} }
MetadataMessage = Describe(result); MetadataMessage = Describe(result);
@@ -318,6 +335,14 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
/// others found something, because "one match" and "one match, and StashDB was down" call /// others found something, because "one match" and "one match, and StashDB was down" call
/// for different next steps. /// for different next steps.
/// </summary> /// </summary>
private async Task LoadCoverAsync(MetadataMatchViewModel match)
{
if (await _images.LoadAsync(match.ImageUrl) is { } path)
{
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
}
}
private static string Describe(MetadataLookupResult result) private static string Describe(MetadataLookupResult result)
{ {
var found = result.Matches.Count == 0 var found = result.Matches.Count == 0
+40 -14
View File
@@ -309,25 +309,51 @@
<ItemsRepeater ItemsSource="{Binding Entities}"> <ItemsRepeater ItemsSource="{Binding Entities}">
<ItemsRepeater.Layout> <ItemsRepeater.Layout>
<UniformGridLayout ItemsStretch="Fill" <UniformGridLayout ItemsStretch="Fill"
MinItemWidth="220" MinItemWidth="164"
MinItemHeight="56" MinItemHeight="212"
MinColumnSpacing="12" MinColumnSpacing="14"
MinRowSpacing="12" /> MinRowSpacing="14" />
</ItemsRepeater.Layout> </ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate> <ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="vm:LabelSummaryViewModel"> <DataTemplate x:DataType="vm:LabelSummaryViewModel">
<Button Classes="entity" Command="{Binding OpenCommand}"> <Button Classes="entity" Command="{Binding OpenCommand}">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10"> <Grid RowDefinitions="*,Auto" RowSpacing="8">
<TextBlock Grid.Column="0"
VerticalAlignment="Center" <Border Grid.Row="0"
Text="{Binding Name}" CornerRadius="9"
TextTrimming="CharacterEllipsis" ClipToBounds="True"
ToolTip.Tip="{Binding Name}" Background="{DynamicResource ThumbnailPlaceholderBrush}">
FontSize="13" <Panel>
Foreground="{DynamicResource TextPrimaryBrush}" /> <!-- The initial sits underneath, so it shows through for every
<Border Grid.Column="1" Classes="badge" VerticalAlignment="Center"> label without a picture and while one is still decoding. -->
<TextBlock Text="{Binding CountText}" /> <TextBlock Text="{Binding Initial}"
FontSize="34"
FontWeight="SemiBold"
Opacity="0.35"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{DynamicResource TextTertiaryBrush}" />
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
<Border Classes="badge"
Margin="6"
HorizontalAlignment="Right"
VerticalAlignment="Bottom">
<TextBlock Text="{Binding CountText}" />
</Border>
</Panel>
</Border> </Border>
<TextBlock Grid.Row="1"
Text="{Binding Name}"
ToolTip.Tip="{Binding Name}"
FontSize="12.5"
MaxLines="2"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
</Grid> </Grid>
</Button> </Button>
</DataTemplate> </DataTemplate>
+18 -3
View File
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels" xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
x:Class="PLib.Desktop.Views.MetadataScanView" x:Class="PLib.Desktop.Views.MetadataScanView"
x:DataType="vm:MetadataScanViewModel"> x:DataType="vm:MetadataScanViewModel">
@@ -96,8 +97,22 @@
<ItemsControl ItemsSource="{Binding Matches}"> <ItemsControl ItemsSource="{Binding Matches}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataMatchViewModel"> <DataTemplate x:DataType="vm:MetadataMatchViewModel">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10" Margin="0,0,0,6"> <Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
<StackPanel Grid.Column="0" Spacing="2">
<!-- The candidate's own cover. Absent for a source that has none, and
the row simply loses the column rather than reserving a blank. -->
<Border Grid.Column="0"
Width="128"
Height="72"
CornerRadius="7"
ClipToBounds="True"
VerticalAlignment="Top"
Background="{DynamicResource ThumbnailPlaceholderBrush}"
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="256" />
</Border>
<StackPanel Grid.Column="1" Spacing="2">
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
FontSize="12.5" FontSize="12.5"
TextWrapping="Wrap" TextWrapping="Wrap"
@@ -117,7 +132,7 @@
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" /> IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" <Button Grid.Column="2"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding ApplyCommand}" Command="{Binding ApplyCommand}"
Content="Применить" /> Content="Применить" />
@@ -152,6 +152,14 @@
<TextBlock Classes="panelTitle" Text="Метаданные" /> <TextBlock Classes="panelTitle" Text="Метаданные" />
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" /> <TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
<!-- Why the candidates have no pictures. An empty square looks the same whether
the source has none or the host is unreachable, so it has to be said. -->
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="{Binding ImageProblem}"
IsVisible="{Binding ImageProblem, Converter={x:Static ObjectConverters.IsNotNull}}" />
<ItemsControl ItemsSource="{Binding Matches}"> <ItemsControl ItemsSource="{Binding Matches}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataMatchViewModel"> <DataTemplate x:DataType="vm:MetadataMatchViewModel">
@@ -162,6 +170,16 @@
Padding="10,8" Padding="10,8"
Margin="0,0,0,8"> Margin="0,0,0,8">
<StackPanel Spacing="5"> <StackPanel Spacing="5">
<!-- The candidate's own cover, across the top: the panel is narrow, so
side by side would leave neither the picture nor the text usable. -->
<Border Height="112"
CornerRadius="6"
ClipToBounds="True"
Background="{DynamicResource ThumbnailPlaceholderBrush}"
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
</Border>
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
FontSize="12.5" FontSize="12.5"
FontWeight="SemiBold" FontWeight="SemiBold"
+15
View File
@@ -60,6 +60,13 @@ public sealed class LibraryLabel
public LabelKind Kind { get; private set; } public LabelKind Kind { get; private set; }
/// <summary>
/// Absolute path of the cached picture for this label, or <c>null</c>. Only some labels
/// ever have one: a metadata source knows what a performer and a studio look like, and
/// has no picture at all for a tag.
/// </summary>
public string? ImagePath { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyCollection<VideoItem> Videos => _videos; public IReadOnlyCollection<VideoItem> Videos => _videos;
@@ -73,4 +80,12 @@ public sealed class LibraryLabel
Name = name.Trim(); Name = name.Trim();
NormalizedName = Normalize(name); NormalizedName = Normalize(name);
} }
public void AttachImage(string imagePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
ImagePath = imagePath;
}
public void DetachImage() => ImagePath = null;
} }
@@ -61,6 +61,7 @@ public static class DependencyInjection
}); });
services.AddSingleton<IMetadataProvider, StashBoxMetadataProvider>(); services.AddSingleton<IMetadataProvider, StashBoxMetadataProvider>();
services.AddSingleton<IRemoteImageCache, HttpRemoteImageCache>();
services.AddScoped<ILibraryService, LibraryService>(); services.AddScoped<ILibraryService, LibraryService>();
services.AddHostedService<DatabaseInitializer>(); services.AddHostedService<DatabaseInitializer>();
@@ -0,0 +1,246 @@
using Microsoft.Extensions.Logging;
using PLib.Application.Abstractions;
using PLib.Infrastructure.Media;
using PLib.Infrastructure.Storage;
namespace PLib.Infrastructure.Metadata;
/// <summary>
/// Downloads and keeps the pictures a metadata source points at.
/// </summary>
/// <remarks>
/// A cache of derived files like any other, so it inherits the same housekeeping the poster
/// frames and animated previews get: staged writes, orphan collection, a reported size and a
/// clear button. What differs is only where the bytes come from.
/// </remarks>
public sealed class HttpRemoteImageCache(
IHttpClientFactory httpClientFactory,
IAppPaths paths,
ILogger<HttpRemoteImageCache> logger)
: MediaArtifactCache(paths.RemoteImageDirectory, logger), IRemoteImageCache
{
/// <summary>
/// Refused above this. A card picture is tens of kilobytes; anything far larger is either
/// the wrong URL or something that should not be downloaded unattended.
/// </summary>
private const long MaximumBytes = 8 * 1024 * 1024;
/// <summary>
/// How long one picture may take, headers and body together. Short on purpose: this is a
/// thumbnail on a row the user is already reading, and one that has not arrived in ten
/// seconds has stopped being worth the connection it is holding.
/// </summary>
private static readonly TimeSpan DownloadTimeout = TimeSpan.FromSeconds(10);
/// <summary>How many failures in a row before a host is left alone for a while.</summary>
private const int FailuresBeforeCoolOff = 3;
/// <summary>How long a host is skipped after it has failed that many times.</summary>
private static readonly TimeSpan CoolOff = TimeSpan.FromMinutes(5);
/// <summary>
/// Failure counts per host, so a picture server that is unreachable is asked a few times
/// and then left alone.
/// </summary>
/// <remarks>
/// A run over a whole library produces a cover per candidate, all from the same host. When
/// that host answers its headers and then stalls, every one of them holds a connection open
/// for the full timeout — a hundred candidates would mean a hundred stalled sockets and a
/// log full of the same line. Three tries is enough to tell a bad host from a bad picture.
/// </remarks>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, HostHealth> _hosts =
new(StringComparer.OrdinalIgnoreCase);
public async Task<RemoteImage> GetOrCreateAsync(
string imageUrl,
CancellationToken cancellationToken = default)
{
// Only ever http(s): the URL arrives from a remote server, and a scheme like file://
// would turn "fetch a picture" into "read a path of the server's choosing".
if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return RemoteImage.None;
}
var target = Path.Combine(CacheDirectory, $"{BuildCacheKey(uri)}{ExtensionOf(uri)}");
if (File.Exists(target))
{
return RemoteImage.At(target);
}
if (IsCoolingOff(uri.Host))
{
return RemoteImage.None;
}
var staging = CreateStagingPath();
try
{
if (!await DownloadAsync(uri, staging, cancellationToken))
{
return NoteFailure(uri.Host);
}
File.Move(staging, target, overwrite: true);
_hosts.TryRemove(uri.Host, out _);
return RemoteImage.At(target);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not download the picture at {Url}", uri);
return NoteFailure(uri.Host);
}
finally
{
if (File.Exists(staging))
{
File.Delete(staging);
}
}
}
private async Task<bool> DownloadAsync(Uri uri, string staging, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient(StashBoxMetadataProvider.HttpClientName);
// A deadline of our own, covering the body as well as the headers. HttpClient.Timeout
// stops applying the moment ResponseHeadersRead hands the response back, so a server
// that opens a connection and then stalls mid-picture would otherwise hang for ever.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(DownloadTimeout);
try
{
// Headers first, so an oversized body is refused before any of it is on disk.
using var response = await client.GetAsync(
uri,
HttpCompletionOption.ResponseHeadersRead,
deadline.Token);
if (!response.IsSuccessStatusCode)
{
logger.LogDebug("Picture at {Url} answered {Status}", uri, (int)response.StatusCode);
return false;
}
if (response.Content.Headers.ContentLength > MaximumBytes)
{
logger.LogWarning("Picture at {Url} is larger than the limit; skipped", uri);
return false;
}
await using var source = await response.Content.ReadAsStreamAsync(deadline.Token);
await using var destination = File.Create(staging);
// Copied through a counting guard as well: a server may answer without a length,
// and a stream that never ends would otherwise fill the disk.
var buffer = new byte[81_920];
long written = 0;
int read;
while ((read = await source.ReadAsync(buffer, deadline.Token)) > 0)
{
written += read;
if (written > MaximumBytes)
{
logger.LogWarning("Picture at {Url} exceeded the limit while downloading; skipped", uri);
return false;
}
await destination.WriteAsync(buffer.AsMemory(0, read), deadline.Token);
}
return written > 0;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Downloading the picture at {Url} timed out", uri);
return false;
}
}
private bool IsCoolingOff(string host)
{
if (!_hosts.TryGetValue(host, out var health) || health.SkipUntil is not { } until)
{
return false;
}
if (DateTimeOffset.UtcNow < until)
{
return true;
}
// The window is over; the host gets its tries back rather than one grudging attempt,
// because a network that has come back should not be judged on a single packet.
_hosts.TryRemove(host, out _);
return false;
}
/// <summary>
/// Records a failure and, once the host has run out of tries, says so out loud.
/// </summary>
/// <remarks>
/// The complaint is returned exactly once per cool-off window — at the moment the host is
/// given up on. Reporting every miss would put the same line beside every candidate; never
/// reporting would leave the user looking at rows of empty squares with no idea why.
/// </remarks>
private RemoteImage NoteFailure(string host)
{
var health = _hosts.AddOrUpdate(
host,
_ => new HostHealth(1, null),
(_, existing) => existing with { Failures = existing.Failures + 1 });
if (health.Failures < FailuresBeforeCoolOff || health.SkipUntil is not null)
{
return RemoteImage.None;
}
_hosts[host] = health with { SkipUntil = DateTimeOffset.UtcNow + CoolOff };
logger.LogWarning(
"{Host} failed {Count} picture downloads in a row; leaving it alone for {Minutes} minutes",
host,
health.Failures,
CoolOff.TotalMinutes);
return RemoteImage.Unreachable(
$"{host} не отдаёт картинки — изображения не загрузятся. Повтор через {CoolOff.TotalMinutes:0} мин.");
}
/// <summary>How a picture host has been behaving, and until when it is being skipped.</summary>
private sealed record HostHealth(int Failures, DateTimeOffset? SkipUntil);
/// <summary>
/// Keyed by the URL rather than by whatever asked for it: one photograph is reused across
/// every video a performer appears in, and two callers may legitimately want the same file.
/// </summary>
private static string BuildCacheKey(Uri uri) =>
Convert.ToHexStringLower(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32];
/// <summary>
/// The extension from the URL when it looks like an image, else <c>.jpg</c>. It is only a
/// hint for whatever opens the file later; nothing here trusts it.
/// </summary>
private static string ExtensionOf(Uri uri)
{
var extension = Path.GetExtension(uri.AbsolutePath);
return extension.ToLowerInvariant() switch
{
".jpg" or ".jpeg" or ".png" or ".webp" or ".gif" or ".bmp" => extension.ToLowerInvariant(),
_ => ".jpg",
};
}
}
@@ -27,6 +27,9 @@ public sealed class StashBoxMetadataProvider(
/// <summary>Name of the configured <see cref="HttpClient"/>; see the DI registration.</summary> /// <summary>Name of the configured <see cref="HttpClient"/>; see the DI registration.</summary>
public const string HttpClientName = "metadata"; public const string HttpClientName = "metadata";
/// <summary>Below this a picture is too small for a card and gets passed over.</summary>
private const int MinimumImageWidth = 320;
/// <summary> /// <summary>
/// The fingerprint queries, newest first. /// The fingerprint queries, newest first.
/// </summary> /// </summary>
@@ -48,9 +51,10 @@ public sealed class StashBoxMetadataProvider(
id id
title title
details details
studio { name } images { url width }
studio { name images { url width } }
tags { name } tags { name }
performers { performer { name } } performers { performer { name images { url width } } }
} }
} }
""", """,
@@ -65,9 +69,10 @@ public sealed class StashBoxMetadataProvider(
id id
title title
details details
studio { name } images { url width }
studio { name images { url width } }
tags { name } tags { name }
performers { performer { name } } performers { performer { name images { url width } } }
} }
} }
""", """,
@@ -214,9 +219,13 @@ public sealed class StashBoxMetadataProvider(
Text(scene, "id"), Text(scene, "id"),
Text(scene, "title") ?? "Без названия", Text(scene, "title") ?? "Без названия",
Text(scene, "details"), Text(scene, "details"),
Names(scene, "tags"), Entities(scene, "tags"),
Performers(scene), Performers(scene),
Studios(scene)); Studios(scene),
// The scene's own cover. Left as a URL here — fetching it is the application layer's
// call, because whether it is worth downloading depends on what asked for the match.
PickImage(scene));
/// <summary> /// <summary>
/// A string property, or <c>null</c> for anything else — including when the element itself /// A string property, or <c>null</c> for anything else — including when the element itself
@@ -230,7 +239,8 @@ public sealed class StashBoxMetadataProvider(
? value.GetString() ? value.GetString()
: null; : null;
private static IReadOnlyList<string> Names(JsonElement scene, string property) /// <summary>A named array — tags, and anything else shaped like them.</summary>
private static IReadOnlyList<MetadataEntity> Entities(JsonElement scene, string property)
{ {
if (scene.ValueKind != JsonValueKind.Object || if (scene.ValueKind != JsonValueKind.Object ||
!scene.TryGetProperty(property, out var array) || !scene.TryGetProperty(property, out var array) ||
@@ -239,14 +249,14 @@ public sealed class StashBoxMetadataProvider(
return []; return [];
} }
return [.. array.EnumerateArray().Select(item => Text(item, "name")).OfType<string>()]; return [.. array.EnumerateArray().Select(ReadEntity).OfType<MetadataEntity>()];
} }
/// <summary> /// <summary>
/// Performers arrive wrapped in an appearance — the same person can be credited under a /// Performers arrive wrapped in an appearance — the same person can be credited under a
/// different name on a given scene — and it is the person's name we want. /// different name on a given scene — and it is the person we want, name and face both.
/// </summary> /// </summary>
private static IReadOnlyList<string> Performers(JsonElement scene) private static IReadOnlyList<MetadataEntity> Performers(JsonElement scene)
{ {
if (scene.ValueKind != JsonValueKind.Object || if (scene.ValueKind != JsonValueKind.Object ||
!scene.TryGetProperty("performers", out var array) || !scene.TryGetProperty("performers", out var array) ||
@@ -261,9 +271,9 @@ public sealed class StashBoxMetadataProvider(
.EnumerateArray() .EnumerateArray()
.Select(appearance => appearance.ValueKind == JsonValueKind.Object && .Select(appearance => appearance.ValueKind == JsonValueKind.Object &&
appearance.TryGetProperty("performer", out var performer) appearance.TryGetProperty("performer", out var performer)
? Text(performer, "name") ? ReadEntity(performer)
: null) : null)
.OfType<string>() .OfType<MetadataEntity>()
]; ];
} }
@@ -271,13 +281,61 @@ public sealed class StashBoxMetadataProvider(
/// A scene has at most one studio, and often none — the shape is a list because a match /// A scene has at most one studio, and often none — the shape is a list because a match
/// may have nothing to say here. /// may have nothing to say here.
/// </summary> /// </summary>
private static IReadOnlyList<string> Studios(JsonElement scene) => private static IReadOnlyList<MetadataEntity> Studios(JsonElement scene) =>
scene.ValueKind == JsonValueKind.Object && scene.ValueKind == JsonValueKind.Object &&
scene.TryGetProperty("studio", out var studio) && scene.TryGetProperty("studio", out var studio) &&
Text(studio, "name") is { } name ReadEntity(studio) is { } entity
? [name] ? [entity]
: []; : [];
private static MetadataEntity? ReadEntity(JsonElement element) =>
Text(element, "name") is { } name ? new MetadataEntity(name, PickImage(element)) : null;
/// <summary>
/// Picks the picture to keep: the smallest one still wide enough for a card, and the
/// widest available when none reaches that.
/// </summary>
/// <remarks>
/// stash-box returns every size it holds, and the first is not the best — originals run to
/// several thousand pixels. Downloading one of those per performer to draw it 150 pixels
/// wide would cost megabytes a head and look no better for it. Tags have no images field
/// at all, so this simply finds nothing for them.
/// </remarks>
private static string? PickImage(JsonElement owner)
{
if (owner.ValueKind != JsonValueKind.Object ||
!owner.TryGetProperty("images", out var images) ||
images.ValueKind != JsonValueKind.Array)
{
return null;
}
var candidates = images
.EnumerateArray()
.Where(image => image.ValueKind == JsonValueKind.Object)
.Select(image => (Url: Text(image, "url"), Width: Number(image, "width")))
.Where(image => !string.IsNullOrWhiteSpace(image.Url))
.ToList();
if (candidates.Count == 0)
{
return null;
}
var enough = candidates.Where(image => image.Width >= MinimumImageWidth).ToList();
return enough.Count > 0
? enough.MinBy(image => image.Width).Url
: candidates.MaxBy(image => image.Width).Url;
}
private static int Number(JsonElement element, string property) =>
element.TryGetProperty(property, out var value) &&
value.ValueKind == JsonValueKind.Number &&
value.TryGetInt32(out var number)
? number
: 0;
/// <summary>One way of asking the same question, and how to read the answer.</summary> /// <summary>One way of asking the same question, and how to read the answer.</summary>
/// <param name="Field">Name of the query root field, used to find it in the reply.</param> /// <param name="Field">Name of the query root field, used to find it in the reply.</param>
/// <param name="IsNested">True when the result is a list of lists rather than a list.</param> /// <param name="IsNested">True when the result is a list of lists rather than a list.</param>
@@ -14,7 +14,12 @@ public sealed class EfLabelRepository(LibraryDbContext dbContext) : ILabelReposi
public async Task<IReadOnlyList<LabelSummary>> GetSummariesAsync( public async Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
await dbContext.Labels await dbContext.Labels
.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count)) .Select(label => new LabelSummary(
label.Id,
label.Name,
label.Kind,
label.Videos.Count,
label.ImagePath))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
public Task<LibraryLabel?> FindAsync( public Task<LibraryLabel?> FindAsync(
@@ -29,6 +29,9 @@ internal sealed class LibraryLabelConfiguration : IEntityTypeConfiguration<Libra
.HasConversion<string>() .HasConversion<string>()
.HasMaxLength(16); .HasMaxLength(16);
builder.Property(x => x.ImagePath)
.HasMaxLength(1024);
builder.Property(x => x.CreatedAt).HasConversion(UtcTicksConverter); builder.Property(x => x.CreatedAt).HasConversion(UtcTicksConverter);
// A tag and a collection may share a name; two tags may not. // A tag and a collection may share a name; two tags may not.
@@ -0,0 +1,169 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PLib.Infrastructure.Persistence;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LibraryDbContext))]
[Migration("20260810054626_LabelImages")]
partial class LabelImages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.Property<Guid>("LabelsId")
.HasColumnType("TEXT");
b.Property<Guid>("VideosId")
.HasColumnType("TEXT");
b.HasKey("LabelsId", "VideosId");
b.HasIndex("VideosId");
b.ToTable("VideoLabels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ImagePath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Kind", "NormalizedName")
.IsUnique();
b.ToTable("Labels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<TimeSpan?>("Duration")
.HasColumnType("TEXT");
b.Property<long>("FileModifiedAt")
.HasColumnType("INTEGER");
b.Property<string>("FullPath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<long?>("PerceptualHash")
.HasColumnType("INTEGER");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<int>("PreviewFrameCount")
.HasColumnType("INTEGER");
b.Property<string>("PreviewPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<TimeSpan?>("ResumePosition")
.HasColumnType("TEXT");
b.Property<long>("SizeInBytes")
.HasColumnType("INTEGER");
b.Property<string>("ThumbnailPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("VideoCodec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AddedAt");
b.HasIndex("FullPath")
.IsUnique();
b.HasIndex("LastPlayedAt");
b.HasIndex("PerceptualHash");
b.ToTable("Videos", (string)null);
});
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
.WithMany()
.HasForeignKey("LabelsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PLib.Domain.Videos.VideoItem", null)
.WithMany()
.HasForeignKey("VideosId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class LabelImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImagePath",
table: "Labels",
type: "TEXT",
maxLength: 1024,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImagePath",
table: "Labels");
}
}
}
@@ -41,6 +41,10 @@ namespace PLib.Infrastructure.Persistence.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("ImagePath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Kind") b.Property<string>("Kind")
.IsRequired() .IsRequired()
.HasMaxLength(16) .HasMaxLength(16)
@@ -12,11 +12,13 @@ public sealed class AppPaths : IAppPaths
DataDirectory = Path.Combine(localAppData, "PLib"); DataDirectory = Path.Combine(localAppData, "PLib");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails"); ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews"); PreviewDirectory = Path.Combine(DataDirectory, "previews");
RemoteImageDirectory = Path.Combine(DataDirectory, "images");
DatabaseFile = Path.Combine(DataDirectory, "library.db"); DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(DataDirectory); Directory.CreateDirectory(DataDirectory);
Directory.CreateDirectory(ThumbnailDirectory); Directory.CreateDirectory(ThumbnailDirectory);
Directory.CreateDirectory(PreviewDirectory); Directory.CreateDirectory(PreviewDirectory);
Directory.CreateDirectory(RemoteImageDirectory);
} }
public string DataDirectory { get; } public string DataDirectory { get; }
@@ -25,5 +27,7 @@ public sealed class AppPaths : IAppPaths
public string PreviewDirectory { get; } public string PreviewDirectory { get; }
public string RemoteImageDirectory { get; }
public string DatabaseFile { get; } public string DatabaseFile { get; }
} }
@@ -15,6 +15,12 @@ public interface IAppPaths
/// </summary> /// </summary>
string PreviewDirectory { get; } string PreviewDirectory { get; }
/// <summary>
/// Directory holding pictures fetched from a metadata source — performers,
/// studios, and the cover of every candidate a lookup offered.
/// </summary>
string RemoteImageDirectory { get; }
/// <summary>Full path of the SQLite database file.</summary> /// <summary>Full path of the SQLite database file.</summary>
string DatabaseFile { get; } string DatabaseFile { get; }
} }
@@ -92,6 +92,7 @@ public sealed class DuplicateDetectionTests
Substitute.For<IAnimatedPreviewGenerator>(), Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(), Substitute.For<IVideoPerceptualHasher>(),
Substitute.For<IMetadataProvider>(), Substitute.For<IMetadataProvider>(),
Substitute.For<IRemoteImageCache>(),
Options.Create(new LibraryOptions()), Options.Create(new LibraryOptions()),
MetadataMonitor.Empty, MetadataMonitor.Empty,
NullLogger<LibraryService>.Instance); NullLogger<LibraryService>.Instance);
@@ -14,7 +14,7 @@ internal sealed class InMemoryLabelRepository : ILabelRepository
public Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default) => public Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LabelSummary>>( Task.FromResult<IReadOnlyList<LabelSummary>>(
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count))]); [.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count, label.ImagePath))]);
public Task<LibraryLabel?> FindAsync( public Task<LibraryLabel?> FindAsync(
LabelKind kind, LabelKind kind,
+1
View File
@@ -78,6 +78,7 @@ public sealed class LabelTests
Substitute.For<IAnimatedPreviewGenerator>(), Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(), Substitute.For<IVideoPerceptualHasher>(),
Substitute.For<IMetadataProvider>(), Substitute.For<IMetadataProvider>(),
Substitute.For<IRemoteImageCache>(),
Options.Create(new LibraryOptions()), Options.Create(new LibraryOptions()),
MetadataMonitor.Empty, MetadataMonitor.Empty,
NullLogger<LibraryService>.Instance); NullLogger<LibraryService>.Instance);
+102 -4
View File
@@ -21,6 +21,7 @@ public sealed class LibraryServiceTests
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>(); private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>(); private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>(); private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
private readonly IRemoteImageCache _remoteImages = Substitute.For<IRemoteImageCache>();
public LibraryServiceTests() public LibraryServiceTests()
{ {
@@ -366,9 +367,9 @@ public sealed class LibraryServiceTests
"scene-1", "scene-1",
"Настоящее название", "Настоящее название",
"Описание", "Описание",
Tags: ["Драма", "драма"], Tags: [new("Драма"), new("драма")],
Performers: ["Актёр Один"], Performers: [new("Актёр Один", "https://example/face.jpg")],
Studios: ["Студия"]); Studios: [new("Студия")]);
await CreateService().ApplyMetadataAsync(item.Id, match, Token); await CreateService().ApplyMetadataAsync(item.Id, match, Token);
@@ -381,6 +382,102 @@ public sealed class LibraryServiceTests
item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия"); item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия");
} }
[Fact]
public async Task A_picture_is_fetched_for_a_label_that_has_none_and_only_then()
{
var item = FullyIndexed();
_repository.Seed(item);
_remoteImages.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(RemoteImage.At(@"C:\cache\images\face.jpg"));
var match = Match("Название", "StashDB") with
{
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
};
var service = CreateService();
await service.ApplyMetadataAsync(item.Id, match, Token);
var performer = item.Labels.Single(label => label.Kind == LabelKind.Performer);
performer.ImagePath.ShouldBe(@"C:\cache\images\face.jpg");
// Applying again must not go back for it: sources disagree about which photograph
// belongs to a performer, and the card would change face on every tagged video.
_remoteImages.IsAvailable(@"C:\cache\images\face.jpg").Returns(true);
await service.ApplyMetadataAsync(item.Id, match, Token);
await _remoteImages.Received(1).GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task A_lookup_hands_back_the_cover_url_without_waiting_on_the_picture_host()
{
var item = FullyIndexed();
_repository.Seed(item);
var source = Source("StashDB");
_metadata.FindByPerceptualHashAsync(source, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
.Returns([Match("Сцена", source.Name) with { ImageUrl = "https://example/cover.jpg" }]);
var result = await CreateService(sources: MetadataMonitor.With(source)).FindMetadataAsync(item.Id, Token);
// Downloading a cover before handing the candidate back put a stranger's picture host
// between "we have an answer" and "the user can see it", and one that stalled froze
// the whole run with an empty results list.
result.Matches.ShouldHaveSingleItem().ImageUrl.ShouldBe("https://example/cover.jpg");
await _remoteImages.DidNotReceive().GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Fetching_a_picture_is_asked_for_separately_and_answers_with_a_local_path()
{
_remoteImages.GetOrCreateAsync("https://example/cover.jpg", Arg.Any<CancellationToken>())
.Returns(RemoteImage.At(@"C:\cache\images\cover.jpg"));
var image = await CreateService().FetchImageAsync("https://example/cover.jpg", Token);
image.Path.ShouldBe(@"C:\cache\images\cover.jpg");
image.Problem.ShouldBeNull();
}
[Fact]
public async Task A_picture_host_that_has_been_given_up_on_says_so_rather_than_going_quiet()
{
_remoteImages.GetOrCreateAsync("https://cdn.example/cover.jpg", Arg.Any<CancellationToken>())
.Returns(RemoteImage.Unreachable("cdn.example не отдаёт картинки"));
var image = await CreateService().FetchImageAsync("https://cdn.example/cover.jpg", Token);
// An empty square looks the same whether the source has no picture or the host is
// unreachable, and only one of those is worth putting on screen.
image.Path.ShouldBeNull();
image.Problem.ShouldBe("cdn.example не отдаёт картинки");
}
[Fact]
public async Task A_picture_that_could_not_be_fetched_does_not_fail_the_match()
{
var item = FullyIndexed();
_repository.Seed(item);
_remoteImages.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<RemoteImage>(_ => throw new HttpRequestException("503"));
var match = Match("Название", "StashDB") with
{
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
};
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
// The card falls back to an initial, which is a far smaller loss than dropping the
// title, the description and every label over one picture.
item.Title.ShouldBe("Название");
item.Labels.Single(label => label.Kind == LabelKind.Performer).ImagePath.ShouldBeNull();
}
[Fact] [Fact]
public async Task Applying_a_match_adds_to_the_labels_already_on_the_video() public async Task Applying_a_match_adds_to_the_labels_already_on_the_video()
{ {
@@ -390,7 +487,7 @@ public sealed class LibraryServiceTests
var service = CreateService(); var service = CreateService();
await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token); await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token);
await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = ["Их"] }, Token); await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = [new MetadataEntity("Их")] }, Token);
// A match is a proposal, not a replacement: what the user put there stays. // A match is a proposal, not a replacement: what the user put there stays.
item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true); item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true);
@@ -432,6 +529,7 @@ public sealed class LibraryServiceTests
_previews, _previews,
_hasher, _hasher,
_metadata, _metadata,
_remoteImages,
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }), Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
sources ?? MetadataMonitor.Empty, sources ?? MetadataMonitor.Empty,
NullLogger<LibraryService>.Instance); NullLogger<LibraryService>.Instance);
@@ -140,6 +140,7 @@ public sealed class MetadataScanTests
Substitute.For<IAnimatedPreviewGenerator>(), Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(), Substitute.For<IVideoPerceptualHasher>(),
_provider, _provider,
Substitute.For<IRemoteImageCache>(),
Options.Create(new LibraryOptions()), Options.Create(new LibraryOptions()),
// No pause between requests: the delay exists to be kind to somebody else's // No pause between requests: the delay exists to be kind to somebody else's
@@ -0,0 +1,137 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using PLib.Infrastructure.Metadata;
using PLib.Infrastructure.Storage;
using Shouldly;
namespace PLib.Tests.Metadata;
/// <summary>
/// A picture host that will not answer must cost a few attempts, not one per candidate.
/// </summary>
public sealed class RemoteImageCacheTests : IDisposable
{
private readonly TempPaths _paths = new();
private readonly CountingHandler _handler = new();
public void Dispose()
{
_paths.Dispose();
_handler.Dispose();
}
[Fact]
public async Task A_host_that_keeps_failing_is_left_alone_after_a_few_tries()
{
var cache = Create();
var problems = new List<string>();
for (var attempt = 0; attempt < 6; attempt++)
{
var image = await cache.GetOrCreateAsync($"https://cdn.example/{attempt}.jpg", Token);
image.Path.ShouldBeNull();
if (image.Problem is { } problem)
{
problems.Add(problem);
}
}
// A library-wide run produces a cover per candidate, all from the same host. Without a
// cut-off, a host that answers its headers and then stalls holds a connection open for
// the full timeout on every single one of them.
_handler.Requests.ShouldBe(3);
// Said once, at the moment the host is given up on. Every miss would put the same line
// beside every candidate; never saying it leaves rows of empty squares unexplained.
problems.ShouldHaveSingleItem().ShouldContain("cdn.example");
}
[Fact]
public async Task A_different_host_is_judged_on_its_own_behaviour()
{
var cache = Create();
for (var attempt = 0; attempt < 4; attempt++)
{
await cache.GetOrCreateAsync($"https://broken.example/{attempt}.jpg", Token);
}
await cache.GetOrCreateAsync("https://other.example/a.jpg", Token);
// Three to the broken host, then it is skipped; the fourth request is the other host.
_handler.Requests.ShouldBe(4);
}
[Fact]
public async Task An_address_that_is_not_a_web_address_is_never_fetched()
{
var cache = Create();
// The URL comes from somebody else's server, so file:// would turn "fetch a picture"
// into "read a path of the server's choosing".
(await cache.GetOrCreateAsync(@"file:///C:/Windows/win.ini", Token)).Path.ShouldBeNull();
(await cache.GetOrCreateAsync("не адрес", Token)).Path.ShouldBeNull();
_handler.Requests.ShouldBe(0);
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private HttpRemoteImageCache Create()
{
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(_handler, disposeHandler: false));
return new HttpRemoteImageCache(factory, _paths, NullLogger<HttpRemoteImageCache>.Instance);
}
/// <summary>Fails every request at once, and counts how many it was asked to make.</summary>
private sealed class CountingHandler : HttpMessageHandler
{
private int _requests;
public int Requests => _requests;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref _requests);
throw new HttpRequestException("no route to host");
}
}
private sealed class TempPaths : IAppPaths, IDisposable
{
public TempPaths()
{
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-images-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews");
RemoteImageDirectory = Path.Combine(DataDirectory, "images");
DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(RemoteImageDirectory);
}
public string DataDirectory { get; }
public string ThumbnailDirectory { get; }
public string PreviewDirectory { get; }
public string RemoteImageDirectory { get; }
public string DatabaseFile { get; }
public void Dispose()
{
if (Directory.Exists(DataDirectory))
{
Directory.Delete(DataDirectory, recursive: true);
}
}
}
}
@@ -51,11 +51,11 @@ public sealed class StashBoxPayloadTests
first.RemoteId.ShouldBe("abc"); first.RemoteId.ShouldBe("abc");
first.Title.ShouldBe("Первая"); first.Title.ShouldBe("Первая");
first.Description.ShouldBe("Описание"); first.Description.ShouldBe("Описание");
first.Studios.ShouldBe(["Студия"]); first.Studios.Select(x => x.Name).ShouldBe(["Студия"]);
first.Tags.ShouldBe(["драма", "нуар"]); first.Tags.Select(x => x.Name).ShouldBe(["драма", "нуар"]);
// The credited alias is not the person; the label has to be the performer's own name. // The credited alias is not the person; the label has to be the performer's own name.
first.Performers.ShouldBe(["Актёр"]); first.Performers.Select(x => x.Name).ShouldBe(["Актёр"]);
matches[1].Studios.ShouldBeEmpty(); matches[1].Studios.ShouldBeEmpty();
} }
@@ -94,6 +94,119 @@ public sealed class StashBoxPayloadTests
match.Tags.ShouldBeEmpty(); match.Tags.ShouldBeEmpty();
} }
[Fact]
public void The_smallest_picture_still_wide_enough_for_a_card_is_the_one_kept()
{
// stash-box returns every size it holds, and the first is not the best: originals run
// to several thousand pixels, and one of those per performer to draw it 150 wide would
// cost megabytes a head.
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"studio": { "name": "Студия", "images": [ { "url": "s/4000.jpg", "width": 4000 }, { "url": "s/500.jpg", "width": 500 } ] },
"tags": [ { "name": "драма" } ],
"performers": [
{ "performer": { "name": "Актёр", "images": [
{ "url": "p/2000.jpg", "width": 2000 },
{ "url": "p/400.jpg", "width": 400 },
{ "url": "p/100.jpg", "width": 100 } ] } }
]
}
]
}
}
""";
var match = Read(payload, Flat).ShouldHaveSingleItem();
match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg");
match.Studios.Single().ImageUrl.ShouldBe("s/500.jpg");
// stash-box holds no picture for a tag at all, so there is nothing to find.
match.Tags.Single().ImageUrl.ShouldBeNull();
}
[Fact]
public void The_scene_carries_its_own_cover_apart_from_the_pictures_of_its_people()
{
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"images": [ { "url": "scene/1920.jpg", "width": 1920 }, { "url": "scene/640.jpg", "width": 640 } ],
"performers": [ { "performer": { "name": "Актёр", "images": [ { "url": "p/400.jpg", "width": 400 } ] } } ]
}
]
}
}
""";
var match = Read(payload, Flat).ShouldHaveSingleItem();
match.ImageUrl.ShouldBe("scene/640.jpg");
match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg");
// Still a URL, and it stays one: whether to spend the bandwidth is the caller's
// decision, and a candidate must never wait on a picture host to be shown.
match.ImageUrl.ShouldNotBeNull();
}
[Fact]
public void When_every_picture_is_too_small_the_widest_is_taken_rather_than_none()
{
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"performers": [
{ "performer": { "name": "Актёр", "images": [
{ "url": "p/80.jpg", "width": 80 },
{ "url": "p/200.jpg", "width": 200 } ] } }
]
}
]
}
}
""";
// A small picture beats an empty card.
Read(payload, Flat).Single().Performers.Single().ImageUrl.ShouldBe("p/200.jpg");
}
[Fact]
public void An_entity_with_no_pictures_at_all_is_still_a_match()
{
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"studio": { "name": "Студия", "images": [] },
"performers": [ { "performer": { "name": "Актёр" } } ]
}
]
}
}
""";
var match = Read(payload, Flat).ShouldHaveSingleItem();
match.Studios.Single().ImageUrl.ShouldBeNull();
match.Performers.Single().Name.ShouldBe("Актёр");
}
[Fact] [Fact]
public void Errors_are_raised_even_though_the_server_answered_two_hundred() public void Errors_are_raised_even_though_the_server_answered_two_hundred()
{ {
@@ -201,10 +201,12 @@ public sealed class AppSettingsStoreTests : IDisposable
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}"); DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails"); ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews"); PreviewDirectory = Path.Combine(DataDirectory, "previews");
RemoteImageDirectory = Path.Combine(DataDirectory, "labels");
DatabaseFile = Path.Combine(DataDirectory, "library.db"); DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(ThumbnailDirectory); Directory.CreateDirectory(ThumbnailDirectory);
Directory.CreateDirectory(PreviewDirectory); Directory.CreateDirectory(PreviewDirectory);
Directory.CreateDirectory(RemoteImageDirectory);
} }
public string DataDirectory { get; } public string DataDirectory { get; }
@@ -213,6 +215,8 @@ public sealed class AppSettingsStoreTests : IDisposable
public string PreviewDirectory { get; } public string PreviewDirectory { get; }
public string RemoteImageDirectory { get; }
public string DatabaseFile { get; } public string DatabaseFile { get; }
public void Dispose() => Directory.Delete(DataDirectory, recursive: true); public void Dispose() => Directory.Delete(DataDirectory, recursive: true);