From b82b0b4555d609c0223db5456c9a8cd96d0fd190 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 10 Aug 2026 09:25:41 +0300 Subject: [PATCH] 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. --- README.md | 504 ++--- .../Abstractions/IRemoteImageCache.cs | 36 + .../Library/ILibraryService.cs | 198 +- src/PLib.Application/Library/LabelSummary.cs | 7 +- .../Library/LibraryDataKind.cs | 9 +- .../Library/LibraryService.cs | 1570 ++++++++------- .../Metadata/VideoMetadataMatch.cs | 20 +- src/PLib.Desktop/AppHost.cs | 163 +- .../Services/RemoteImageLoader.cs | 85 + src/PLib.Desktop/Themes/LibraryStyles.axaml | 6 +- .../ViewModels/LabelSummaryViewModel.cs | 12 + .../ViewModels/MainWindowViewModel.cs | 1784 +++++++++-------- .../ViewModels/MetadataMatchViewModel.cs | 20 +- .../ViewModels/MetadataScanViewModel.cs | 48 +- .../ViewModels/SettingsViewModel.cs | 636 +++--- .../ViewModels/VideoPlayerViewModel.cs | 915 +++++---- src/PLib.Desktop/Views/MainWindow.axaml | 54 +- src/PLib.Desktop/Views/MetadataScanView.axaml | 21 +- src/PLib.Desktop/Views/VideoPlayerView.axaml | 638 +++--- src/PLib.Domain/Videos/LibraryLabel.cs | 15 + .../DependencyInjection.cs | 141 +- .../Metadata/HttpRemoteImageCache.cs | 246 +++ .../Metadata/StashBoxMetadataProvider.cs | 88 +- .../Persistence/EfLabelRepository.cs | 7 +- .../Persistence/LibraryLabelConfiguration.cs | 3 + .../20260810054626_LabelImages.Designer.cs | 169 ++ .../Migrations/20260810054626_LabelImages.cs | 29 + .../LibraryDbContextModelSnapshot.cs | 4 + src/PLib.Infrastructure/Storage/AppPaths.cs | 4 + src/PLib.Infrastructure/Storage/IAppPaths.cs | 6 + .../Library/DuplicateDetectionTests.cs | 1 + .../Library/InMemoryLabelRepository.cs | 2 +- tests/PLib.Tests/Library/LabelTests.cs | 169 +- .../PLib.Tests/Library/LibraryServiceTests.cs | 106 +- tests/PLib.Tests/Library/MetadataScanTests.cs | 1 + .../Metadata/RemoteImageCacheTests.cs | 137 ++ .../Metadata/StashBoxPayloadTests.cs | 119 +- .../Settings/AppSettingsStoreTests.cs | 4 + 38 files changed, 4661 insertions(+), 3316 deletions(-) create mode 100644 src/PLib.Application/Abstractions/IRemoteImageCache.cs create mode 100644 src/PLib.Desktop/Services/RemoteImageLoader.cs create mode 100644 src/PLib.Infrastructure/Metadata/HttpRemoteImageCache.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.Designer.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.cs create mode 100644 tests/PLib.Tests/Metadata/RemoteImageCacheTests.cs diff --git a/README.md b/README.md index 8a74e44..6800ca3 100644 --- a/README.md +++ b/README.md @@ -1,234 +1,270 @@ -# PLib - -Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и -показывает всё сеткой карточек. - -## Что уже работает - -- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется. -- Слежение за папками: новые файлы подхватываются сами, без кнопки. -- Метаданные (длительность, разрешение, кодек) через ffprobe. -- Постеры кадром из видео через ffmpeg, с кэшем на диске. -- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются - кадры, снятые по всей длительности. -- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. -- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка - (по названию или по частоте); клик по сущности показывает её видео в сетке. -- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска. -- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки - библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется - в `settings.json` и подхватывается без перезапуска. -- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические - метаданные — каждый со своей кнопкой и текущим объёмом. -- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой - stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается - списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. -- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой - и списком найденного. По желанию однозначные совпадения применяются на месте. -- Светлая, тёмная и системная темы; выбор запоминается. -- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, - перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — - Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки. - -## Требования - -- .NET 10 SDK -- `ffmpeg` и `ffprobe` в `PATH` (для превью и метаданных) - -Нативный LibVLC приезжает пакетом и в системе не нужен. - -## Запуск - -```bash -dotnet run --project src/PLib.Desktop -``` - -```bash -dotnet test -``` - -## Архитектура - -Четыре слоя, зависимости направлены только внутрь: - -| Проект | Отвечает за | Знает о | -| --- | --- | --- | -| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём | -| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain | -| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application | -| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure | - -Ключевые решения: - -- **MVVM на ReactiveUI.** Свойства — `[Reactive]` из `ReactiveUI.SourceGenerators`, команды — - `ReactiveCommand`, производные значения (`IsScanning`, `IsEmpty`) — `ToProperty`. Отмена - сканирования сделана штатным способом: скан живёт как observable, а `CancelScanCommand` - просто отписывает его через `TakeUntil`, что отменяет `CancellationToken`. -- **Сетка — проекция DynamicData, а не пересборка.** `SourceCache` → `AutoRefresh` → `Filter` - → `SortAndBind` отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл, - контейнеры `ItemsRepeater` и уже загруженные превью остаются на месте. Поиск дебаунсится - на 200 мс, изменения карточек во время скана коалесцируются в 250 мс. -- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает - `IAsyncEnumerable`: карточки появляются по мере находок, а не после - завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через - `Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям - по одному — трекер изменений EF не потокобезопасен. -- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает - каждое событие в UI явно через `Dispatcher.UIThread`. -- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в - визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с - декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. -- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт - `IServiceScopeFactory` и создаёт scope на каждый вызов. -- **Одно окно.** Настройки — колонка макета, а не второе окно и не оверлей: открываясь, она - сдвигает сетку, и та переливается в меньшее число столбцов, оставаясь целиком доступной. - В alt-tab ничего не добавляется, и приложение остаётся переносимым на - `ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует. - Панель занимает только строку контента: шапка и статус-бар остаются цельными на всю - ширину окна. Собственные заголовок и строка действий у панели заведомо легче оконных — - равные по весу читались как два приложения, сшитых по шву. -- **Снимок настроек берётся из одного места.** Файл пишется целиком, поэтому собирать - `AppSettings` вручную — верный способ затереть секцию, о которой не подумал. Все, кто - пишет, начинают с `IAppSettingsStore.Current` и правят его через `with`. -- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком - только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается - только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра - его не вызывает. -- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** VLC декодирует в память, - которую мы ему выдаём, а рисуем кадр сами: видео остаётся обычным контролом Avalonia — - участвует в hit-тесте, принимает жесты, поверх него можно класть что угодно. Цена — одно - копирование на показанный кадр, и на 4K оно становится основной стоимостью воспроизведения. - Буферов два: VLC декодирует в один, пока мы читаем другой; на `Display` они меняются - местами под коротким локом. Транспорт (позиция, длительность, play/pause) — свойства самого - контрола, поэтому им управляет code-behind страницы; дублировать это состояние во вьюмодель - значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляет - `OpenedVideo`, вью уходит из дерева, и плеер гасится вместе с буферами. - До этого пробовали два готовых пути. `MediaPlayer.Controls`: декодер работал, но кадры до - экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в - приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка - появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей. -- **Индексация в три прохода.** Сначала метаданные и постеры для всех файлов, затем - анимированные превью, и только потом отпечатки. Каждый следующий проход берёт больше кадров - на файл; вперемешку они задерживали бы каждую следующую карточку на всю цепочку, и сетка - наполнялась бы в разы медленнее. Порядок — по видимости: постер нужен, чтобы карточка - вообще появилась, анимация — чтобы она ожила под курсором, хеш всплывает только при поиске - дублей. Поэтому `IsIndexed` намеренно не включает ни анимацию, ни хеш: это готовность к - показу, а не завершённость всей обработки. Поздние проходы умеют стартовать только после - первого — кадры распределяются по длительности, а её устанавливает probe. -- **Анимированное превью — кадры стопкой в одном JPEG, не GIF.** Гифку никто ниже по течению - не проиграет: Avalonia декодирует только первый кадр анимированного изображения, так что - за GIF пришлось бы тащить отдельный декодер. Стопка кадров обходится тем же декодером, что - и постеры — `FilmstripImage` просто рисует каждый тик другой срез, — и весит долю от - 256-цветной гифки тех же кадров, а это цена за каждое видео в библиотеке. Рендерит один - процесс ffmpeg: по входу на таймкод (seek до входа, то есть прыжок на ключевой кадр, - а не декодирование до него) и один `vstack`; процесс на кадр умножил бы проход на их число. - Число кадров зашито в имя файла, поэтому смена настройки не режет старую полосу неверным - шагом — она просто промахивается мимо кэша, а лишнее подберёт очистка. - Полоса живёт только пока играет: она весит как все её кадры вместе, а под курсором всегда - одна карточка, так что держать её в общем кэше значило бы менять ограниченную память на - растущую с тем, сколько библиотеки пролистали. -- **pHash по рецепту stash.** 25 кадров сеткой 5×5, по 5% времени отброшено с каждого - конца, кадр по ширине 160 — то есть хеш описывает те же кадры, что и у stash. Декодирует - и масштабирует ffmpeg, отдавая сырой 8-битный серый, поэтому графическая библиотека не - нужна вовсе: монтаж, уменьшение до 64×64 и DCT — арифметика над массивом байт. Бит - ставится сравнением коэффициента с медианой блока 8×8. - Сравнивается хеш расстоянием Хэмминга, а не на равенство; группы дублей собираются - системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу. - **Побитовая совместимость со stash не проверена** — разные реализации ресайза способны - перевернуть биты у коэффициентов рядом с медианой. -- **Теги, коллекции, актёры и студии — одна сущность.** `LibraryLabel` с `LabelKind`: связь - с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица - связей, один репозиторий и одно правило именования; разделить потом можно переименованием - и миграцией, а держать четыре почти одинаковых агрегата синхронными пришлось бы всегда. - Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых - таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и - «комедия» не разойдутся, а тег и студия с одним именем сосуществуют. -- **Вкладки — колонки одного макета, а не `TabControl`.** Страницы, между которыми они - переключают, соседствуют с панелью настроек и страницей плеера в одной сетке, а `TabControl` - захотел бы владеть этой компоновкой целиком. Четыре вкладки сущностей делят одну панель: - различается только вид метки, и четыре почти одинаковых разметки разошлись бы при первой же - правке. - Живут они уровнем ниже оконной панели, а не в ней: сверху то, что верно для всего - приложения (поиск, тема, настройки, добавить папку), под ним — навигация и органы управления - текущей страницей. В один ряд это не влезало: вкладки съедали ширину у поиска, и на 1200 px - они наезжали друг на друга. Сортировка уехала туда же — она про страницу, а не про окно. -- **Метки лежат на карточке, а не запрашиваются.** Отбор по тегу, актёру или студии — это - предикат, который DynamicData прогоняет по каждой карточке в фоновом потоке; ходить оттуда - в базу значило бы запрос на карточку. Поэтому `GetLibraryAsync` грузит видео вместе с - метками (`AsSplitQuery` — иначе каждая строка видео вернулась бы по разу на метку), а - `ApplyLabels` намеренно отделён от `Apply`: сканирование грузит видео без меток, и пустой - список там означает «не загружены», а не «их нет». - Списки сущностей пересобираются целиком, без второй цепочки DynamicData: меток сотни там, - где видео тысячи, и машинерия обошлась бы дороже, чем экономит. Количества считает база - (`LabelSummary`), а не загрузка связей ради `Count`. -- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому - серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные», - и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни - самих файлов. - Прогон по всей библиотеке — отдельная страница, а не фоновая задача: он обращается к чужим - серверам сотни раз подряд, и это должно быть там, где пользователь на это смотрит и может - остановить. Между запросами есть пауза (`RequestDelayMilliseconds`, по умолчанию 250 мс) — - тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник, - который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом - видео, и тысяча одинаковых строк была бы всей страницей. - «Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда - — расхождение источников это ровно тот случай, ради которого страницу и смотрят. - Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили», - но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`, - поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает». - Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча - переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки, - но не удаляет чужие — то, что проставил пользователь, остаётся. - Схема — stash-box (StashDB, ThePornDB и родственники): именно поэтому список источников - вообще имеет смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же - запрос. Запросов, впрочем, два: stash-box переименовал `findSceneByFingerprint` в - `findScenesBySceneFingerprints` и оставил старое имя позади, так что какой из них знает - конкретный экземпляр — зависит от того, когда его обновляли. Они пробуются по очереди, - и «нет такого поля» ведёт к следующему, а не к ошибке; всё прочее (неверный ключ, лимит) - окончательно. Новый запрос отвечает группой сцен на группу отпечатков, поэтому его результат - на уровень глубже — про одно видео мы спрашиваем всегда, так что разница сводится к - выпрямлению списка. - GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один - объект в ответе. - **API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и - остальные настройки, то есть правами файловой системы. -- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько - событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать - из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а - разницу и так умеет считать сканирование. -- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU - на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически - на месте (`IMediaArtifactCache.IsAvailable`), и перерисовывает удалённые; после полного - прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются - только если им больше часа — иначе можно снести рендер второго запущенного экземпляра. - Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и - описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда - появился второй вид файлов. -- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет - ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные. - Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает - подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно - не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду - кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание - в обратном порядке оставило бы библиотеку с путями в никуда. - -## Данные - -Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`: - -- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash; -- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); -- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени; -- `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники - метаданных вместе с их API-ключами; - перечитывается на лету; -- `logs/` — Serilog, ротация по дням. - -Схема ведётся миграциями EF Core (`src/PLib.Infrastructure/Persistence/Migrations`) и -применяется при старте. База, созданная сборками до появления миграций, распознаётся по -отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно -пересканирование, а превью привязаны к файлам и переживают это нетронутыми. - -```bash -dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations -``` +# PLib + +Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и +показывает всё сеткой карточек. + +## Что уже работает + +- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется. +- Слежение за папками: новые файлы подхватываются сами, без кнопки. +- Метаданные (длительность, разрешение, кодек) через ffprobe. +- Постеры кадром из видео через ffmpeg, с кэшем на диске. +- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются + кадры, снятые по всей длительности. +- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. +- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка + (по названию или по частоте); клик по сущности показывает её видео в сетке. +- Карточки актёров и студий — с фото из источника метаданных. У тегов и коллекций картинок + не бывает: их нет в схеме stash-box, поэтому там рисуется первая буква. +- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска. +- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки + библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется + в `settings.json` и подхватывается без перезапуска. +- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические + метаданные — каждый со своей кнопкой и текущим объёмом. +- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой + stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается + списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. +- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой + и списком найденного. Каждый кандидат показывается со своей обложкой. По желанию + однозначные совпадения применяются на месте. +- Светлая, тёмная и системная темы; выбор запоминается. +- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, + перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — + Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки. + +## Требования + +- .NET 10 SDK +- `ffmpeg` и `ffprobe` в `PATH` (для превью и метаданных) + +Нативный LibVLC приезжает пакетом и в системе не нужен. + +## Запуск + +```bash +dotnet run --project src/PLib.Desktop +``` + +```bash +dotnet test +``` + +## Архитектура + +Четыре слоя, зависимости направлены только внутрь: + +| Проект | Отвечает за | Знает о | +| --- | --- | --- | +| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём | +| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain | +| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application | +| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure | + +Ключевые решения: + +- **MVVM на ReactiveUI.** Свойства — `[Reactive]` из `ReactiveUI.SourceGenerators`, команды — + `ReactiveCommand`, производные значения (`IsScanning`, `IsEmpty`) — `ToProperty`. Отмена + сканирования сделана штатным способом: скан живёт как observable, а `CancelScanCommand` + просто отписывает его через `TakeUntil`, что отменяет `CancellationToken`. +- **Сетка — проекция DynamicData, а не пересборка.** `SourceCache` → `AutoRefresh` → `Filter` + → `SortAndBind` отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл, + контейнеры `ItemsRepeater` и уже загруженные превью остаются на месте. Поиск дебаунсится + на 200 мс, изменения карточек во время скана коалесцируются в 250 мс. +- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает + `IAsyncEnumerable`: карточки появляются по мере находок, а не после + завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через + `Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям + по одному — трекер изменений EF не потокобезопасен. +- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает + каждое событие в UI явно через `Dispatcher.UIThread`. +- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в + визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с + декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. +- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт + `IServiceScopeFactory` и создаёт scope на каждый вызов. +- **Одно окно.** Настройки — колонка макета, а не второе окно и не оверлей: открываясь, она + сдвигает сетку, и та переливается в меньшее число столбцов, оставаясь целиком доступной. + В alt-tab ничего не добавляется, и приложение остаётся переносимым на + `ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует. + Панель занимает только строку контента: шапка и статус-бар остаются цельными на всю + ширину окна. Собственные заголовок и строка действий у панели заведомо легче оконных — + равные по весу читались как два приложения, сшитых по шву. +- **Снимок настроек берётся из одного места.** Файл пишется целиком, поэтому собирать + `AppSettings` вручную — верный способ затереть секцию, о которой не подумал. Все, кто + пишет, начинают с `IAppSettingsStore.Current` и правят его через `with`. +- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком + только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается + только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра + его не вызывает. +- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** VLC декодирует в память, + которую мы ему выдаём, а рисуем кадр сами: видео остаётся обычным контролом Avalonia — + участвует в hit-тесте, принимает жесты, поверх него можно класть что угодно. Цена — одно + копирование на показанный кадр, и на 4K оно становится основной стоимостью воспроизведения. + Буферов два: VLC декодирует в один, пока мы читаем другой; на `Display` они меняются + местами под коротким локом. Транспорт (позиция, длительность, play/pause) — свойства самого + контрола, поэтому им управляет code-behind страницы; дублировать это состояние во вьюмодель + значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляет + `OpenedVideo`, вью уходит из дерева, и плеер гасится вместе с буферами. + До этого пробовали два готовых пути. `MediaPlayer.Controls`: декодер работал, но кадры до + экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в + приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка + появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей. +- **Индексация в три прохода.** Сначала метаданные и постеры для всех файлов, затем + анимированные превью, и только потом отпечатки. Каждый следующий проход берёт больше кадров + на файл; вперемешку они задерживали бы каждую следующую карточку на всю цепочку, и сетка + наполнялась бы в разы медленнее. Порядок — по видимости: постер нужен, чтобы карточка + вообще появилась, анимация — чтобы она ожила под курсором, хеш всплывает только при поиске + дублей. Поэтому `IsIndexed` намеренно не включает ни анимацию, ни хеш: это готовность к + показу, а не завершённость всей обработки. Поздние проходы умеют стартовать только после + первого — кадры распределяются по длительности, а её устанавливает probe. +- **Анимированное превью — кадры стопкой в одном JPEG, не GIF.** Гифку никто ниже по течению + не проиграет: Avalonia декодирует только первый кадр анимированного изображения, так что + за GIF пришлось бы тащить отдельный декодер. Стопка кадров обходится тем же декодером, что + и постеры — `FilmstripImage` просто рисует каждый тик другой срез, — и весит долю от + 256-цветной гифки тех же кадров, а это цена за каждое видео в библиотеке. Рендерит один + процесс ffmpeg: по входу на таймкод (seek до входа, то есть прыжок на ключевой кадр, + а не декодирование до него) и один `vstack`; процесс на кадр умножил бы проход на их число. + Число кадров зашито в имя файла, поэтому смена настройки не режет старую полосу неверным + шагом — она просто промахивается мимо кэша, а лишнее подберёт очистка. + Полоса живёт только пока играет: она весит как все её кадры вместе, а под курсором всегда + одна карточка, так что держать её в общем кэше значило бы менять ограниченную память на + растущую с тем, сколько библиотеки пролистали. +- **pHash по рецепту stash.** 25 кадров сеткой 5×5, по 5% времени отброшено с каждого + конца, кадр по ширине 160 — то есть хеш описывает те же кадры, что и у stash. Декодирует + и масштабирует ffmpeg, отдавая сырой 8-битный серый, поэтому графическая библиотека не + нужна вовсе: монтаж, уменьшение до 64×64 и DCT — арифметика над массивом байт. Бит + ставится сравнением коэффициента с медианой блока 8×8. + Сравнивается хеш расстоянием Хэмминга, а не на равенство; группы дублей собираются + системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу. + **Побитовая совместимость со stash не проверена** — разные реализации ресайза способны + перевернуть биты у коэффициентов рядом с медианой. +- **Теги, коллекции, актёры и студии — одна сущность.** `LibraryLabel` с `LabelKind`: связь + с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица + связей, один репозиторий и одно правило именования; разделить потом можно переименованием + и миграцией, а держать четыре почти одинаковых агрегата синхронными пришлось бы всегда. + Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых + таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и + «комедия» не разойдутся, а тег и студия с одним именем сосуществуют. +- **Вкладки — колонки одного макета, а не `TabControl`.** Страницы, между которыми они + переключают, соседствуют с панелью настроек и страницей плеера в одной сетке, а `TabControl` + захотел бы владеть этой компоновкой целиком. Четыре вкладки сущностей делят одну панель: + различается только вид метки, и четыре почти одинаковых разметки разошлись бы при первой же + правке. + Живут они уровнем ниже оконной панели, а не в ней: сверху то, что верно для всего + приложения (поиск, тема, настройки, добавить папку), под ним — навигация и органы управления + текущей страницей. В один ряд это не влезало: вкладки съедали ширину у поиска, и на 1200 px + они наезжали друг на друга. Сортировка уехала туда же — она про страницу, а не про окно. +- **Метки лежат на карточке, а не запрашиваются.** Отбор по тегу, актёру или студии — это + предикат, который DynamicData прогоняет по каждой карточке в фоновом потоке; ходить оттуда + в базу значило бы запрос на карточку. Поэтому `GetLibraryAsync` грузит видео вместе с + метками (`AsSplitQuery` — иначе каждая строка видео вернулась бы по разу на метку), а + `ApplyLabels` намеренно отделён от `Apply`: сканирование грузит видео без меток, и пустой + список там означает «не загружены», а не «их нет». + Списки сущностей пересобираются целиком, без второй цепочки DynamicData: меток сотни там, + где видео тысячи, и машинерия обошлась бы дороже, чем экономит. Количества считает база + (`LabelSummary`), а не загрузка связей ради `Count`. +- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому + серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные», + и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни + самих файлов. + Прогон по всей библиотеке — отдельная страница, а не фоновая задача: он обращается к чужим + серверам сотни раз подряд, и это должно быть там, где пользователь на это смотрит и может + остановить. Между запросами есть пауза (`RequestDelayMilliseconds`, по умолчанию 250 мс) — + тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник, + который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом + видео, и тысяча одинаковых строк была бы всей страницей. + «Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда + — расхождение источников это ровно тот случай, ради которого страницу и смотрят. +- **Картинки скачиваются один раз и по размеру.** `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`, + поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает». + Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча + переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки, + но не удаляет чужие — то, что проставил пользователь, остаётся. + Схема — stash-box (StashDB, ThePornDB и родственники): именно поэтому список источников + вообще имеет смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же + запрос. Запросов, впрочем, два: stash-box переименовал `findSceneByFingerprint` в + `findScenesBySceneFingerprints` и оставил старое имя позади, так что какой из них знает + конкретный экземпляр — зависит от того, когда его обновляли. Они пробуются по очереди, + и «нет такого поля» ведёт к следующему, а не к ошибке; всё прочее (неверный ключ, лимит) + окончательно. Новый запрос отвечает группой сцен на группу отпечатков, поэтому его результат + на уровень глубже — про одно видео мы спрашиваем всегда, так что разница сводится к + выпрямлению списка. + GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один + объект в ответе. + **API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и + остальные настройки, то есть правами файловой системы. +- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько + событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать + из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а + разницу и так умеет считать сканирование. +- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU + на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически + на месте (`IMediaArtifactCache.IsAvailable`), и перерисовывает удалённые; после полного + прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются + только если им больше часа — иначе можно снести рендер второго запущенного экземпляра. + Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и + описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда + появился второй вид файлов. +- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет + ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные. + Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает + подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно + не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду + кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание + в обратном порядке оставило бы библиотеку с путями в никуда. + +## Данные + +Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`: + +- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash; +- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); +- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени; +- `images/` — картинки от источников метаданных: фото актёров, логотипы студий, обложки + найденных сцен; ключ — хеш URL; +- `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники + метаданных вместе с их API-ключами; + перечитывается на лету; +- `logs/` — Serilog, ротация по дням. + +Схема ведётся миграциями EF Core (`src/PLib.Infrastructure/Persistence/Migrations`) и +применяется при старте. База, созданная сборками до появления миграций, распознаётся по +отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно +пересканирование, а превью привязаны к файлам и переживают это нетронутыми. + +```bash +dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations +``` diff --git a/src/PLib.Application/Abstractions/IRemoteImageCache.cs b/src/PLib.Application/Abstractions/IRemoteImageCache.cs new file mode 100644 index 0000000..902ba8b --- /dev/null +++ b/src/PLib.Application/Abstractions/IRemoteImageCache.cs @@ -0,0 +1,36 @@ +namespace PLib.Application.Abstractions; + +/// +/// What came of asking for a picture. +/// +/// +/// 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 string? could not tell them apart. +/// +/// Where the picture landed, or null if it did not. +/// +/// A line for the user when the failure is worth surfacing, or null. 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. +/// +public sealed record RemoteImage(string? Path, string? Problem = null) +{ + /// Nothing to fetch, and nothing to say about it. + 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); +} + +/// Fetches and keeps pictures that live on somebody else's server. +public interface IRemoteImageCache : IMediaArtifactCache +{ + /// + /// Fetches the picture at unless it is already cached, and + /// says where it landed — or, when the host has been given up on, why it did not. + /// + Task GetOrCreateAsync(string imageUrl, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs index a3c4888..7113230 100644 --- a/src/PLib.Application/Library/ILibraryService.cs +++ b/src/PLib.Application/Library/ILibraryService.cs @@ -1,94 +1,104 @@ -using PLib.Application.Metadata; -using PLib.Domain.Videos; - -namespace PLib.Application.Library; - -/// Use cases the UI needs in order to show and refresh the video library. -public interface ILibraryService -{ - /// Everything currently stored in the library, newest first. - Task> GetLibraryAsync(CancellationToken cancellationToken = default); - - /// - /// Reconciles the library with the configured folders and then fills in metadata and - /// poster frames for anything that is missing them, streaming progress as it goes. - /// - IAsyncEnumerable ScanAsync( - IReadOnlyList folders, - CancellationToken cancellationToken = default); - - /// - /// What each kind of derived data currently costs, one entry per - /// , so the user can see what clearing it would free. - /// - Task> GetDataUsageAsync(CancellationToken cancellationToken = default); - - /// - /// Throws the named kinds of derived data away — files as well as the references to them — - /// so the next scan rebuilds them from scratch. Useful after changing a setting that - /// governs how they are produced, or when one of them is suspected of being wrong. - /// - Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default); - - /// Remembers where playback stopped so the video can be resumed later. - Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default); - - /// - /// Groups of videos that look alike, by perceptual hash. Videos without a hash, and - /// groups of one, are left out. - /// - /// - /// How many differing bits still count as the same video. Zero means visually identical; - /// the useful range for re-encodes is a handful of bits. - /// - Task>> FindDuplicatesAsync( - int maxDistance, - CancellationToken cancellationToken = default); - - /// One video with its labels loaded, or null if it is gone. - Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default); - - /// Every tag and collection in the library, alphabetically. - Task> GetLabelsAsync(CancellationToken cancellationToken = default); - - /// Every label with its video count, for the browsing tabs. - Task> GetLabelSummariesAsync(CancellationToken cancellationToken = default); - - /// - /// Attaches a label to a video, creating it if this is the first time the name is used. - /// Returns the label, whether it was new or not. - /// - Task AttachLabelAsync( - Guid videoId, - string name, - LabelKind kind, - CancellationToken cancellationToken = default); - - Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default); - - /// - /// Asks every configured metadata source what it has for this video's fingerprint. - /// - /// - /// Nothing calls this on its own: reaching out to a remote service about the user's files - /// happens when the user presses the button, and at no other time. - /// - Task FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default); - - /// - /// Writes a match onto the video: title, description, and a label per tag, performer and - /// studio. Labels already on the video are kept — applying a match adds, never prunes. - /// - Task ApplyMetadataAsync( - Guid videoId, - VideoMetadataMatch match, - CancellationToken cancellationToken = default); - - /// - /// Asks the sources about every fingerprinted video in the library, streaming what it - /// finds. Like the single lookup, it only ever runs because the user started it. - /// - IAsyncEnumerable ScanMetadataAsync( - MetadataScanRequest request, - CancellationToken cancellationToken = default); -} +using PLib.Application.Abstractions; +using PLib.Application.Metadata; +using PLib.Domain.Videos; + +namespace PLib.Application.Library; + +/// Use cases the UI needs in order to show and refresh the video library. +public interface ILibraryService +{ + /// Everything currently stored in the library, newest first. + Task> GetLibraryAsync(CancellationToken cancellationToken = default); + + /// + /// Reconciles the library with the configured folders and then fills in metadata and + /// poster frames for anything that is missing them, streaming progress as it goes. + /// + IAsyncEnumerable ScanAsync( + IReadOnlyList folders, + CancellationToken cancellationToken = default); + + /// + /// What each kind of derived data currently costs, one entry per + /// , so the user can see what clearing it would free. + /// + Task> GetDataUsageAsync(CancellationToken cancellationToken = default); + + /// + /// Throws the named kinds of derived data away — files as well as the references to them — + /// so the next scan rebuilds them from scratch. Useful after changing a setting that + /// governs how they are produced, or when one of them is suspected of being wrong. + /// + Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default); + + /// Remembers where playback stopped so the video can be resumed later. + Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default); + + /// + /// Groups of videos that look alike, by perceptual hash. Videos without a hash, and + /// groups of one, are left out. + /// + /// + /// How many differing bits still count as the same video. Zero means visually identical; + /// the useful range for re-encodes is a handful of bits. + /// + Task>> FindDuplicatesAsync( + int maxDistance, + CancellationToken cancellationToken = default); + + /// One video with its labels loaded, or null if it is gone. + Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default); + + /// Every tag and collection in the library, alphabetically. + Task> GetLabelsAsync(CancellationToken cancellationToken = default); + + /// Every label with its video count, for the browsing tabs. + Task> GetLabelSummariesAsync(CancellationToken cancellationToken = default); + + /// + /// Attaches a label to a video, creating it if this is the first time the name is used. + /// Returns the label, whether it was new or not. + /// + Task AttachLabelAsync( + Guid videoId, + string name, + LabelKind kind, + CancellationToken cancellationToken = default); + + Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default); + + /// + /// Fetches a picture a metadata source pointed at, saying where it landed or why it did not. + /// + /// + /// 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. + /// + Task FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default); + + /// + /// Asks every configured metadata source what it has for this video's fingerprint. + /// + /// + /// Nothing calls this on its own: reaching out to a remote service about the user's files + /// happens when the user presses the button, and at no other time. + /// + Task FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default); + + /// + /// Writes a match onto the video: title, description, and a label per tag, performer and + /// studio. Labels already on the video are kept — applying a match adds, never prunes. + /// + Task ApplyMetadataAsync( + Guid videoId, + VideoMetadataMatch match, + CancellationToken cancellationToken = default); + + /// + /// Asks the sources about every fingerprinted video in the library, streaming what it + /// finds. Like the single lookup, it only ever runs because the user started it. + /// + IAsyncEnumerable ScanMetadataAsync( + MetadataScanRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/LabelSummary.cs b/src/PLib.Application/Library/LabelSummary.cs index 40ee1f0..2e165f3 100644 --- a/src/PLib.Application/Library/LabelSummary.cs +++ b/src/PLib.Application/Library/LabelSummary.cs @@ -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 /// opens and a page that does not. /// -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); diff --git a/src/PLib.Application/Library/LibraryDataKind.cs b/src/PLib.Application/Library/LibraryDataKind.cs index 18b214f..0a59246 100644 --- a/src/PLib.Application/Library/LibraryDataKind.cs +++ b/src/PLib.Application/Library/LibraryDataKind.cs @@ -25,7 +25,14 @@ public enum LibraryDataKind /// Duration, resolution and codec, as read by ffprobe. TechnicalMetadata = 1 << 3, - All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata, + /// + /// 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. + /// + RemoteImages = 1 << 4, + + All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages, } /// What one kind of derived data currently costs. diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index fba34ae..10de4ff 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -1,743 +1,827 @@ -using System.Runtime.CompilerServices; -using System.Threading.Channels; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using PLib.Application.Abstractions; -using PLib.Application.Metadata; -using PLib.Domain.Videos; - -namespace PLib.Application.Library; - -/// -public sealed class LibraryService( - IVideoRepository repository, - ILabelRepository labels, - IVideoFileScanner scanner, - IMediaProbe mediaProbe, - IThumbnailGenerator thumbnailGenerator, - IAnimatedPreviewGenerator previewGenerator, - IVideoPerceptualHasher perceptualHasher, - IMetadataProvider metadataProvider, - IOptions options, - IOptionsMonitor metadataOptions, - ILogger logger) : ILibraryService -{ - /// How many indexed items to accumulate before flushing them to storage. - private const int SaveBatchSize = 25; - - private readonly LibraryOptions _options = options.Value; - - public async Task> GetLibraryAsync(CancellationToken cancellationToken = default) - { - // With labels: the grid filters by tag, performer and studio, and asking the database - // again for each card as the user clicks around would be a query per card. - var items = await repository.GetAllWithLabelsAsync(cancellationToken); - return [.. items.OrderByDescending(x => x.AddedAt)]; - } - - public async Task SaveProgressAsync( - Guid videoId, - TimeSpan position, - CancellationToken cancellationToken = default) - { - var video = await repository.FindWithLabelsAsync(videoId, cancellationToken); - - if (video is null) - { - return; - } - - video.RememberProgress(position); - await repository.SaveChangesAsync(cancellationToken); - } - - public async Task>> FindDuplicatesAsync( - int maxDistance, - CancellationToken cancellationToken = default) - { - var hashed = (await repository.GetAllAsync(cancellationToken)) - .Where(item => item.PerceptualHash is not null) - .ToList(); - - // Union-find over the pairwise comparison: two videos land in the same group if a - // chain of near-matches connects them, which is what "these are the same film" - // means when one copy sits between two others. - var groupOf = new int[hashed.Count]; - - for (var i = 0; i < groupOf.Length; i++) - { - groupOf[i] = i; - } - - int Root(int index) - { - while (groupOf[index] != index) - { - groupOf[index] = groupOf[groupOf[index]]; - index = groupOf[index]; - } - - return index; - } - - for (var i = 0; i < hashed.Count; i++) - { - for (var j = i + 1; j < hashed.Count; j++) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (hashed[i].DistanceTo(hashed[j]) <= maxDistance) - { - groupOf[Root(i)] = Root(j); - } - } - } - - return - [ - .. hashed - .Select((item, index) => (item, group: Root(index))) - .GroupBy(pair => pair.group) - .Where(group => group.Count() > 1) - .Select(IReadOnlyList (group) => [.. group.Select(pair => pair.item)]) - ]; - } - - public Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) => - repository.FindWithLabelsAsync(videoId, cancellationToken); - - public async Task> GetLabelsAsync(CancellationToken cancellationToken = default) - { - var all = await labels.GetAllAsync(cancellationToken); - return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)]; - } - - public async Task> GetLabelSummariesAsync( - CancellationToken cancellationToken = default) - { - var summaries = await labels.GetSummariesAsync(cancellationToken); - return [.. summaries.OrderBy(summary => summary.Name, StringComparer.CurrentCultureIgnoreCase)]; - } - - public async Task AttachLabelAsync( - Guid videoId, - string name, - LabelKind kind, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - - var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) - ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); - - // Reuse before create: the name is what the user thinks of as the identity of a tag, - // and two labels differing only in case would read as a duplicate. - var label = await labels.FindAsync(kind, name, cancellationToken); - - if (label is null) - { - label = new LibraryLabel(name, kind); - await labels.AddAsync(label, cancellationToken); - } - - if (video.AddLabel(label)) - { - await repository.SaveChangesAsync(cancellationToken); - logger.LogInformation("Attached {Kind} '{Name}' to {Video}", kind, label.Name, video.Title); - } - - return label; - } - - public async Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default) - { - var video = await repository.FindWithLabelsAsync(videoId, cancellationToken); - - if (video?.RemoveLabel(labelId) == true) - { - await repository.SaveChangesAsync(cancellationToken); - } - } - - public async Task FindMetadataAsync( - Guid videoId, - CancellationToken cancellationToken = default) - { - var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) - ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); - - if (video.PerceptualHash is not { } hash) - { - return new MetadataLookupResult(HasPerceptualHash: false, [], []); - } - - var matches = new List(); - var failures = new List(); - - // Sequentially and in configured order: the list is short, the sources are somebody - // else's servers, and a predictable order is worth more here than a few saved seconds. - foreach (var source in metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable)) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - matches.AddRange( - await metadataProvider.FindByPerceptualHashAsync(source, hash, cancellationToken)); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - // One source being down must not hide what the others found. - logger.LogWarning(ex, "Metadata source {Source} could not be queried", source.Name); - failures.Add($"{source.Name}: {ex.Message}"); - } - } - - return new MetadataLookupResult(HasPerceptualHash: true, matches, failures); - } - - public async IAsyncEnumerable ScanMetadataAsync( - MetadataScanRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - // Captured once: the run is long, and a source appearing halfway through would make - // the totals describe two different questions. - var sources = metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable).ToList(); - var pause = TimeSpan.FromMilliseconds(metadataOptions.CurrentValue.RequestDelayMilliseconds); - - var candidates = (await repository.GetAllAsync(cancellationToken)) - .Where(video => video.PerceptualHash is not null) - .Where(video => !request.OnlyWithoutDescription || video.Description is null) - .OrderBy(video => video.Title, StringComparer.CurrentCultureIgnoreCase) - .ToList(); - - // Reference identity, because a name is free text and two sources may share one. - var abandoned = new HashSet(); - - var processed = 0; - var matched = 0; - var applied = 0; - - foreach (var video in candidates) - { - cancellationToken.ThrowIfCancellationRequested(); - - var found = new List(); - - foreach (var source in sources.Where(source => !abandoned.Contains(source))) - { - if (pause > TimeSpan.Zero) - { - await Task.Delay(pause, cancellationToken); - } - - // The failure is captured rather than handled here: a catch block cannot - // yield, and the caller has to hear about it. - Exception? failure = null; - - try - { - found.AddRange(await metadataProvider.FindByPerceptualHashAsync( - source, - video.PerceptualHash!.Value, - cancellationToken)); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - failure = ex; - } - - if (failure is not null) - { - // Dropped for the rest of the run: a rejected key fails on every video, - // and a thousand identical lines would bury everything else. - abandoned.Add(source); - logger.LogWarning(failure, "Metadata source {Source} dropped out of the run", source.Name); - yield return new MetadataScanEvent.SourceAbandoned(source.Name, failure.Message); - } - } - - processed++; - - if (found.Count > 0) - { - matched++; - - var unambiguous = request.ApplyUnambiguous && found.Count == 1; - - if (unambiguous) - { - await ApplyMetadataAsync(video.Id, found[0], cancellationToken); - applied++; - } - - yield return new MetadataScanEvent.Matched(video.Id, video.Title, found, unambiguous); - } - - yield return new MetadataScanEvent.Progress(processed, candidates.Count); - } - - logger.LogInformation( - "Metadata run finished: {Processed} videos, {Matched} matched, {Applied} applied", - processed, - matched, - applied); - - yield return new MetadataScanEvent.Completed(processed, matched, applied); - } - - public async Task ApplyMetadataAsync( - Guid videoId, - VideoMetadataMatch match, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(match); - - var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) - ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); - - if (!string.IsNullOrWhiteSpace(match.Title)) - { - video.Rename(match.Title); - } - - video.Describe(match.Description); - - await AttachAllAsync(video, match.Tags, LabelKind.Tag, cancellationToken); - await AttachAllAsync(video, match.Performers, LabelKind.Performer, cancellationToken); - await AttachAllAsync(video, match.Studios, LabelKind.Studio, cancellationToken); - - // One save for the whole match: half an applied match is worse than none, because - // nothing on screen would say which half. - await repository.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Applied metadata from {Source} to {Video}", match.SourceName, video.Title); - } - - private async Task AttachAllAsync( - VideoItem video, - IEnumerable names, - LabelKind kind, - CancellationToken cancellationToken) - { - // A source can repeat a name within one match, and the video may already carry it; - // both have to collapse onto a single label. - var distinct = names - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.CurrentCultureIgnoreCase); - - foreach (var name in distinct) - { - var label = await labels.FindAsync(kind, name, cancellationToken); - - if (label is null) - { - label = new LibraryLabel(name, kind); - await labels.AddAsync(label, cancellationToken); - } - - video.AddLabel(label); - } - } - - public async Task> GetDataUsageAsync( - CancellationToken cancellationToken = default) - { - var items = await repository.GetAllAsync(cancellationToken); - var thumbnailBytes = await thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken); - var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken); - - return - [ - new(LibraryDataKind.Thumbnails, items.Count, items.Count(x => x.ThumbnailPath is not null), thumbnailBytes), - 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.TechnicalMetadata, items.Count, items.Count(x => x.Duration is not null), 0), - ]; - } - - public async Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default) - { - if (kinds == LibraryDataKind.None) - { - return; - } - - var items = await repository.GetAllAsync(cancellationToken); - - foreach (var item in items) - { - if (kinds.HasFlag(LibraryDataKind.Thumbnails)) - { - item.DetachThumbnail(); - } - - if (kinds.HasFlag(LibraryDataKind.AnimatedPreviews)) - { - item.DetachPreview(); - } - - if (kinds.HasFlag(LibraryDataKind.PerceptualHashes)) - { - item.ApplyPerceptualHash(null); - } - - if (kinds.HasFlag(LibraryDataKind.TechnicalMetadata)) - { - item.ApplyTechnicalInfo(VideoTechnicalInfo.Unknown); - } - } - - // 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 - // a scan notices. This order leaves at worst some orphans, which the purge eats. - await repository.SaveChangesAsync(cancellationToken); - - var removed = 0; - - if (kinds.HasFlag(LibraryDataKind.Thumbnails)) - { - removed += await thumbnailGenerator.ClearAsync(cancellationToken); - } - - if (kinds.HasFlag(LibraryDataKind.AnimatedPreviews)) - { - removed += await previewGenerator.ClearAsync(cancellationToken); - } - - logger.LogInformation("Cleared {Kinds} on request, removing {Count} files", kinds, removed); - } - - public async IAsyncEnumerable ScanAsync( - IReadOnlyList folders, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var known = (await repository.GetAllAsync(cancellationToken)) - .ToDictionary(x => x.FullPath, LibraryPathComparer.Instance); - - var discovered = await DiscoverAsync(folders, cancellationToken); - yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count); - - var pending = new List(); - - foreach (var file in discovered.Values) - { - if (known.TryGetValue(file.FullPath, out var existing)) - { - existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt); - - // The cache directory is ordinary user storage: an image we remember may - // simply have been deleted. Trusting the stored path would leave the card - // blank forever, because the item still looks indexed. - var forgotten = false; - - if (existing.ThumbnailPath is not null && - !thumbnailGenerator.IsAvailable(existing.ThumbnailPath)) - { - existing.DetachThumbnail(); - forgotten = true; - } - - if (existing.PreviewPath is not null && - !previewGenerator.IsAvailable(existing.PreviewPath)) - { - existing.DetachPreview(); - forgotten = true; - } - - if (forgotten) - { - yield return new LibraryScanEvent.ItemUpdated(existing); - } - } - else - { - existing = new VideoItem( - file.FullPath, - Path.GetFileNameWithoutExtension(file.FullPath), - file.SizeInBytes, - file.ModifiedAt); - - await repository.AddAsync(existing, cancellationToken); - known.Add(existing.FullPath, existing); - yield return new LibraryScanEvent.ItemAdded(existing); - } - - if (!existing.IsIndexed) - { - pending.Add(existing); - } - } - - foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList()) - { - await repository.RemoveAsync(orphan, cancellationToken); - known.Remove(orphan.FullPath); - yield return new LibraryScanEvent.ItemRemoved(orphan.Id); - } - - await repository.SaveChangesAsync(cancellationToken); - - // First pass: metadata and poster frames, so the grid fills in as fast as the files - // allow. - await foreach (var indexed in IndexAsync(pending, cancellationToken)) - { - yield return indexed; - } - - await repository.SaveChangesAsync(cancellationToken); - - // Second pass: animated previews. Ordered ahead of hashing because it is the one the - // user can see — a hash only ever surfaces when duplicates are searched for. - var withoutPreview = known.Values.Where(item => item.NeedsAnimatedPreview).ToList(); - - await foreach (var previewed in PreviewAsync(withoutPreview, cancellationToken)) - { - yield return previewed; - } - - await repository.SaveChangesAsync(cancellationToken); - - // Last pass: perceptual hashes. Two dozen frame grabs per file makes this the - // expensive one, and it can only start once the first pass has established the - // duration to spread those frames across. - var unhashed = known.Values.Where(item => item.NeedsPerceptualHash).ToList(); - - await foreach (var hashed in HashAsync(unhashed, cancellationToken)) - { - yield return hashed; - } - - await repository.SaveChangesAsync(cancellationToken); - await PurgeArtifactCachesAsync(known.Values, cancellationToken); - - yield return new LibraryScanEvent.Completed(known.Count); - } - - /// - /// Drops cached images nothing points at any more. Safe only here, at the end of a - /// completed scan, because that is the only moment the library is known to be whole — - /// running it mid-scan would delete the frames of items not reconciled yet. - /// - private async Task PurgeArtifactCachesAsync( - IEnumerable library, - CancellationToken cancellationToken) - { - var items = library as IReadOnlyCollection ?? [.. library]; - - var removed = await thumbnailGenerator.PurgeUnusedAsync( - [.. items.Select(x => x.ThumbnailPath).OfType()], - cancellationToken); - - removed += await previewGenerator.PurgeUnusedAsync( - [.. items.Select(x => x.PreviewPath).OfType()], - cancellationToken); - - if (removed > 0) - { - logger.LogInformation("Removed {Count} orphaned images from the cache", removed); - } - } - - private async Task> DiscoverAsync( - IReadOnlyList folders, - CancellationToken cancellationToken) - { - var discovered = new Dictionary(LibraryPathComparer.Instance); - - foreach (var folder in folders) - { - await foreach (var file in scanner.ScanAsync(folder, cancellationToken)) - { - if (file.SizeInBytes < _options.MinimumFileSizeInBytes) - { - continue; - } - - // Overlapping roots are legal, so the first sighting of a path wins. - discovered.TryAdd(file.FullPath, file); - } - } - - return discovered; - } - - /// - /// First pass: probes each file and renders its poster frame, with bounded concurrency. - /// The expensive work runs in parallel, but results are applied to the entities one at a - /// time by the consumer because change tracking is not thread safe. - /// - private async IAsyncEnumerable IndexAsync( - IReadOnlyList pending, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var processed = 0; - - var results = InParallelAsync( - pending, - async (item, token) => - { - var info = await mediaProbe.ProbeAsync(item.FullPath, token); - var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token); - return (Item: item, Info: info, Thumbnail: thumbnail); - }, - cancellationToken); - - await foreach (var result in results) - { - result.Item.ApplyTechnicalInfo(result.Info); - - if (result.Thumbnail is not null) - { - result.Item.AttachThumbnail(result.Thumbnail); - } - - processed++; - - yield return new LibraryScanEvent.ItemUpdated(result.Item); - yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count); - - if (processed % SaveBatchSize == 0) - { - await repository.SaveChangesAsync(cancellationToken); - } - } - - if (processed > 0) - { - logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count); - } - } - - /// Second pass: the animated preview of each video that does not have one yet. - private async IAsyncEnumerable PreviewAsync( - IReadOnlyList pending, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var processed = 0; - - var results = InParallelAsync( - pending, - async (item, token) => (Item: item, Preview: await previewGenerator.GetOrCreateAsync(item.FullPath, item.Duration, token)), - cancellationToken); - - await foreach (var result in results) - { - if (result.Preview is { } preview) - { - result.Item.AttachPreview(preview.Path, preview.FrameCount); - } - - processed++; - - yield return new LibraryScanEvent.ItemUpdated(result.Item); - yield return new LibraryScanEvent.PreviewProgress(processed, pending.Count); - - if (processed % SaveBatchSize == 0) - { - await repository.SaveChangesAsync(cancellationToken); - } - } - - if (processed > 0) - { - logger.LogInformation("Rendered previews for {Processed} of {Total} video files", processed, pending.Count); - } - } - - /// Last pass: the perceptual hash of each video that does not have one yet. - private async IAsyncEnumerable HashAsync( - IReadOnlyList pending, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var processed = 0; - - var results = InParallelAsync( - pending, - async (item, token) => (Item: item, Hash: await perceptualHasher.ComputeAsync(item.FullPath, item.Duration, token)), - cancellationToken); - - await foreach (var result in results) - { - result.Item.ApplyPerceptualHash(result.Hash); - processed++; - - yield return new LibraryScanEvent.ItemUpdated(result.Item); - yield return new LibraryScanEvent.HashingProgress(processed, pending.Count); - - if (processed % SaveBatchSize == 0) - { - await repository.SaveChangesAsync(cancellationToken); - } - } - - if (processed > 0) - { - logger.LogInformation("Hashed {Processed} of {Total} video files", processed, pending.Count); - } - } - - /// - /// Runs over the items with bounded concurrency and streams the - /// results back as they finish. A bounded channel is what keeps the producers from - /// running ahead of a consumer that has to apply each result one at a time. - /// - private async IAsyncEnumerable InParallelAsync( - IReadOnlyList items, - Func> work, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - if (items.Count == 0) - { - yield break; - } - - var channel = Channel.CreateBounded(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4) - { - SingleReader = true, - }); - - var producer = Task.Run( - async () => - { - try - { - var parallelOptions = new ParallelOptions - { - MaxDegreeOfParallelism = _options.MaxIndexingConcurrency, - CancellationToken = cancellationToken, - }; - - await Parallel.ForEachAsync( - items, - parallelOptions, - async (item, token) => await channel.Writer.WriteAsync(await work(item, token), token)); - - channel.Writer.Complete(); - } - catch (Exception ex) - { - channel.Writer.Complete(ex); - } - }, - cancellationToken); - - await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken)) - { - yield return result; - } - - await producer; - } - -} +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PLib.Application.Abstractions; +using PLib.Application.Metadata; +using PLib.Domain.Videos; + +namespace PLib.Application.Library; + +/// +public sealed class LibraryService( + IVideoRepository repository, + ILabelRepository labels, + IVideoFileScanner scanner, + IMediaProbe mediaProbe, + IThumbnailGenerator thumbnailGenerator, + IAnimatedPreviewGenerator previewGenerator, + IVideoPerceptualHasher perceptualHasher, + IMetadataProvider metadataProvider, + IRemoteImageCache remoteImages, + IOptions options, + IOptionsMonitor metadataOptions, + ILogger logger) : ILibraryService +{ + /// How many indexed items to accumulate before flushing them to storage. + private const int SaveBatchSize = 25; + + private readonly LibraryOptions _options = options.Value; + + public async Task> GetLibraryAsync(CancellationToken cancellationToken = default) + { + // With labels: the grid filters by tag, performer and studio, and asking the database + // again for each card as the user clicks around would be a query per card. + var items = await repository.GetAllWithLabelsAsync(cancellationToken); + return [.. items.OrderByDescending(x => x.AddedAt)]; + } + + public async Task SaveProgressAsync( + Guid videoId, + TimeSpan position, + CancellationToken cancellationToken = default) + { + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken); + + if (video is null) + { + return; + } + + video.RememberProgress(position); + await repository.SaveChangesAsync(cancellationToken); + } + + public async Task>> FindDuplicatesAsync( + int maxDistance, + CancellationToken cancellationToken = default) + { + var hashed = (await repository.GetAllAsync(cancellationToken)) + .Where(item => item.PerceptualHash is not null) + .ToList(); + + // Union-find over the pairwise comparison: two videos land in the same group if a + // chain of near-matches connects them, which is what "these are the same film" + // means when one copy sits between two others. + var groupOf = new int[hashed.Count]; + + for (var i = 0; i < groupOf.Length; i++) + { + groupOf[i] = i; + } + + int Root(int index) + { + while (groupOf[index] != index) + { + groupOf[index] = groupOf[groupOf[index]]; + index = groupOf[index]; + } + + return index; + } + + for (var i = 0; i < hashed.Count; i++) + { + for (var j = i + 1; j < hashed.Count; j++) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (hashed[i].DistanceTo(hashed[j]) <= maxDistance) + { + groupOf[Root(i)] = Root(j); + } + } + } + + return + [ + .. hashed + .Select((item, index) => (item, group: Root(index))) + .GroupBy(pair => pair.group) + .Where(group => group.Count() > 1) + .Select(IReadOnlyList (group) => [.. group.Select(pair => pair.item)]) + ]; + } + + public Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) => + repository.FindWithLabelsAsync(videoId, cancellationToken); + + public async Task> GetLabelsAsync(CancellationToken cancellationToken = default) + { + var all = await labels.GetAllAsync(cancellationToken); + return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)]; + } + + public async Task> GetLabelSummariesAsync( + CancellationToken cancellationToken = default) + { + var summaries = await labels.GetSummariesAsync(cancellationToken); + return [.. summaries.OrderBy(summary => summary.Name, StringComparer.CurrentCultureIgnoreCase)]; + } + + public async Task AttachLabelAsync( + Guid videoId, + string name, + LabelKind kind, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) + ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); + + // Reuse before create: the name is what the user thinks of as the identity of a tag, + // and two labels differing only in case would read as a duplicate. + var label = await labels.FindAsync(kind, name, cancellationToken); + + if (label is null) + { + label = new LibraryLabel(name, kind); + await labels.AddAsync(label, cancellationToken); + } + + if (video.AddLabel(label)) + { + await repository.SaveChangesAsync(cancellationToken); + logger.LogInformation("Attached {Kind} '{Name}' to {Video}", kind, label.Name, video.Title); + } + + return label; + } + + public async Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default) + { + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken); + + if (video?.RemoveLabel(labelId) == true) + { + await repository.SaveChangesAsync(cancellationToken); + } + } + + public async Task FindMetadataAsync( + Guid videoId, + CancellationToken cancellationToken = default) + { + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) + ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); + + if (video.PerceptualHash is not { } hash) + { + return new MetadataLookupResult(HasPerceptualHash: false, [], []); + } + + var matches = new List(); + var failures = new List(); + + // Sequentially and in configured order: the list is short, the sources are somebody + // else's servers, and a predictable order is worth more here than a few saved seconds. + foreach (var source in metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable)) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + matches.AddRange( + await metadataProvider.FindByPerceptualHashAsync(source, hash, cancellationToken)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // One source being down must not hide what the others found. + logger.LogWarning(ex, "Metadata source {Source} could not be queried", source.Name); + failures.Add($"{source.Name}: {ex.Message}"); + } + } + + return new MetadataLookupResult(HasPerceptualHash: true, matches, failures); + } + + /// + /// Fetches a picture a source pointed at and returns where it landed, or null. + /// + /// + /// 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. + /// + public Task FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default) => + remoteImages.GetOrCreateAsync(imageUrl, cancellationToken); + + public async IAsyncEnumerable ScanMetadataAsync( + MetadataScanRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // Captured once: the run is long, and a source appearing halfway through would make + // the totals describe two different questions. + var sources = metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable).ToList(); + var pause = TimeSpan.FromMilliseconds(metadataOptions.CurrentValue.RequestDelayMilliseconds); + + var candidates = (await repository.GetAllAsync(cancellationToken)) + .Where(video => video.PerceptualHash is not null) + .Where(video => !request.OnlyWithoutDescription || video.Description is null) + .OrderBy(video => video.Title, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + // Reference identity, because a name is free text and two sources may share one. + var abandoned = new HashSet(); + + var processed = 0; + var matched = 0; + var applied = 0; + + foreach (var video in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + + var found = new List(); + + foreach (var source in sources.Where(source => !abandoned.Contains(source))) + { + if (pause > TimeSpan.Zero) + { + await Task.Delay(pause, cancellationToken); + } + + // The failure is captured rather than handled here: a catch block cannot + // yield, and the caller has to hear about it. + Exception? failure = null; + + try + { + found.AddRange(await metadataProvider.FindByPerceptualHashAsync( + source, + video.PerceptualHash!.Value, + cancellationToken)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + failure = ex; + } + + if (failure is not null) + { + // Dropped for the rest of the run: a rejected key fails on every video, + // and a thousand identical lines would bury everything else. + abandoned.Add(source); + logger.LogWarning(failure, "Metadata source {Source} dropped out of the run", source.Name); + yield return new MetadataScanEvent.SourceAbandoned(source.Name, failure.Message); + } + } + + processed++; + + if (found.Count > 0) + { + matched++; + + var unambiguous = request.ApplyUnambiguous && found.Count == 1; + + if (unambiguous) + { + await ApplyMetadataAsync(video.Id, found[0], cancellationToken); + applied++; + } + + yield return new MetadataScanEvent.Matched(video.Id, video.Title, found, unambiguous); + } + + yield return new MetadataScanEvent.Progress(processed, candidates.Count); + } + + logger.LogInformation( + "Metadata run finished: {Processed} videos, {Matched} matched, {Applied} applied", + processed, + matched, + applied); + + yield return new MetadataScanEvent.Completed(processed, matched, applied); + } + + public async Task ApplyMetadataAsync( + Guid videoId, + VideoMetadataMatch match, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(match); + + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) + ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); + + if (!string.IsNullOrWhiteSpace(match.Title)) + { + video.Rename(match.Title); + } + + video.Describe(match.Description); + + await AttachAllAsync(video, match.Tags, LabelKind.Tag, cancellationToken); + await AttachAllAsync(video, match.Performers, LabelKind.Performer, cancellationToken); + await AttachAllAsync(video, match.Studios, LabelKind.Studio, cancellationToken); + + // One save for the whole match: half an applied match is worse than none, because + // nothing on screen would say which half. + await repository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Applied metadata from {Source} to {Video}", match.SourceName, video.Title); + } + + private async Task AttachAllAsync( + VideoItem video, + IEnumerable entities, + LabelKind kind, + CancellationToken cancellationToken) + { + // A source can repeat a name within one match, and the video may already carry it; + // both have to collapse onto a single label. + var distinct = entities + .Where(entity => !string.IsNullOrWhiteSpace(entity.Name)) + .DistinctBy(entity => entity.Name, StringComparer.CurrentCultureIgnoreCase); + + foreach (var entity in distinct) + { + var label = await labels.FindAsync(kind, entity.Name, cancellationToken); + + if (label is null) + { + label = new LibraryLabel(entity.Name, kind); + await labels.AddAsync(label, cancellationToken); + } + + await AttachImageAsync(label, entity.ImageUrl, cancellationToken); + video.AddLabel(label); + } + } + + /// + /// Downloads a label's picture the first time a source offers one. + /// + /// + /// 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. + /// + 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> GetDataUsageAsync( + CancellationToken cancellationToken = default) + { + var items = await repository.GetAllAsync(cancellationToken); + var allLabels = await labels.GetSummariesAsync(cancellationToken); + + var thumbnailBytes = await thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken); + var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken); + var imageBytes = await remoteImages.GetCacheSizeInBytesAsync(cancellationToken); + + return + [ + new(LibraryDataKind.Thumbnails, items.Count, items.Count(x => x.ThumbnailPath is not null), thumbnailBytes), + 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.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), + ]; + } + + public async Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default) + { + if (kinds == LibraryDataKind.None) + { + return; + } + + var items = await repository.GetAllAsync(cancellationToken); + + foreach (var item in items) + { + if (kinds.HasFlag(LibraryDataKind.Thumbnails)) + { + item.DetachThumbnail(); + } + + if (kinds.HasFlag(LibraryDataKind.AnimatedPreviews)) + { + item.DetachPreview(); + } + + if (kinds.HasFlag(LibraryDataKind.PerceptualHashes)) + { + item.ApplyPerceptualHash(null); + } + + if (kinds.HasFlag(LibraryDataKind.TechnicalMetadata)) + { + item.ApplyTechnicalInfo(VideoTechnicalInfo.Unknown); + } + } + + 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, + // 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. + await repository.SaveChangesAsync(cancellationToken); + + var removed = 0; + + if (kinds.HasFlag(LibraryDataKind.Thumbnails)) + { + removed += await thumbnailGenerator.ClearAsync(cancellationToken); + } + + if (kinds.HasFlag(LibraryDataKind.AnimatedPreviews)) + { + 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); + } + + public async IAsyncEnumerable ScanAsync( + IReadOnlyList folders, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var known = (await repository.GetAllAsync(cancellationToken)) + .ToDictionary(x => x.FullPath, LibraryPathComparer.Instance); + + var discovered = await DiscoverAsync(folders, cancellationToken); + yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count); + + var pending = new List(); + + foreach (var file in discovered.Values) + { + if (known.TryGetValue(file.FullPath, out var existing)) + { + existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt); + + // The cache directory is ordinary user storage: an image we remember may + // simply have been deleted. Trusting the stored path would leave the card + // blank forever, because the item still looks indexed. + var forgotten = false; + + if (existing.ThumbnailPath is not null && + !thumbnailGenerator.IsAvailable(existing.ThumbnailPath)) + { + existing.DetachThumbnail(); + forgotten = true; + } + + if (existing.PreviewPath is not null && + !previewGenerator.IsAvailable(existing.PreviewPath)) + { + existing.DetachPreview(); + forgotten = true; + } + + if (forgotten) + { + yield return new LibraryScanEvent.ItemUpdated(existing); + } + } + else + { + existing = new VideoItem( + file.FullPath, + Path.GetFileNameWithoutExtension(file.FullPath), + file.SizeInBytes, + file.ModifiedAt); + + await repository.AddAsync(existing, cancellationToken); + known.Add(existing.FullPath, existing); + yield return new LibraryScanEvent.ItemAdded(existing); + } + + if (!existing.IsIndexed) + { + pending.Add(existing); + } + } + + foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList()) + { + await repository.RemoveAsync(orphan, cancellationToken); + known.Remove(orphan.FullPath); + yield return new LibraryScanEvent.ItemRemoved(orphan.Id); + } + + await repository.SaveChangesAsync(cancellationToken); + + // First pass: metadata and poster frames, so the grid fills in as fast as the files + // allow. + await foreach (var indexed in IndexAsync(pending, cancellationToken)) + { + yield return indexed; + } + + await repository.SaveChangesAsync(cancellationToken); + + // Second pass: animated previews. Ordered ahead of hashing because it is the one the + // user can see — a hash only ever surfaces when duplicates are searched for. + var withoutPreview = known.Values.Where(item => item.NeedsAnimatedPreview).ToList(); + + await foreach (var previewed in PreviewAsync(withoutPreview, cancellationToken)) + { + yield return previewed; + } + + await repository.SaveChangesAsync(cancellationToken); + + // Last pass: perceptual hashes. Two dozen frame grabs per file makes this the + // expensive one, and it can only start once the first pass has established the + // duration to spread those frames across. + var unhashed = known.Values.Where(item => item.NeedsPerceptualHash).ToList(); + + await foreach (var hashed in HashAsync(unhashed, cancellationToken)) + { + yield return hashed; + } + + await repository.SaveChangesAsync(cancellationToken); + await PurgeArtifactCachesAsync(known.Values, cancellationToken); + + yield return new LibraryScanEvent.Completed(known.Count); + } + + /// + /// Drops cached images nothing points at any more. Safe only here, at the end of a + /// completed scan, because that is the only moment the library is known to be whole — + /// running it mid-scan would delete the frames of items not reconciled yet. + /// + private async Task PurgeArtifactCachesAsync( + IEnumerable library, + CancellationToken cancellationToken) + { + var items = library as IReadOnlyCollection ?? [.. library]; + + var removed = await thumbnailGenerator.PurgeUnusedAsync( + [.. items.Select(x => x.ThumbnailPath).OfType()], + cancellationToken); + + removed += await previewGenerator.PurgeUnusedAsync( + [.. items.Select(x => x.PreviewPath).OfType()], + 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()], + cancellationToken); + + if (removed > 0) + { + logger.LogInformation("Removed {Count} orphaned images from the cache", removed); + } + } + + private async Task> DiscoverAsync( + IReadOnlyList folders, + CancellationToken cancellationToken) + { + var discovered = new Dictionary(LibraryPathComparer.Instance); + + foreach (var folder in folders) + { + await foreach (var file in scanner.ScanAsync(folder, cancellationToken)) + { + if (file.SizeInBytes < _options.MinimumFileSizeInBytes) + { + continue; + } + + // Overlapping roots are legal, so the first sighting of a path wins. + discovered.TryAdd(file.FullPath, file); + } + } + + return discovered; + } + + /// + /// First pass: probes each file and renders its poster frame, with bounded concurrency. + /// The expensive work runs in parallel, but results are applied to the entities one at a + /// time by the consumer because change tracking is not thread safe. + /// + private async IAsyncEnumerable IndexAsync( + IReadOnlyList pending, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var processed = 0; + + var results = InParallelAsync( + pending, + async (item, token) => + { + var info = await mediaProbe.ProbeAsync(item.FullPath, token); + var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token); + return (Item: item, Info: info, Thumbnail: thumbnail); + }, + cancellationToken); + + await foreach (var result in results) + { + result.Item.ApplyTechnicalInfo(result.Info); + + if (result.Thumbnail is not null) + { + result.Item.AttachThumbnail(result.Thumbnail); + } + + processed++; + + yield return new LibraryScanEvent.ItemUpdated(result.Item); + yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count); + + if (processed % SaveBatchSize == 0) + { + await repository.SaveChangesAsync(cancellationToken); + } + } + + if (processed > 0) + { + logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count); + } + } + + /// Second pass: the animated preview of each video that does not have one yet. + private async IAsyncEnumerable PreviewAsync( + IReadOnlyList pending, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var processed = 0; + + var results = InParallelAsync( + pending, + async (item, token) => (Item: item, Preview: await previewGenerator.GetOrCreateAsync(item.FullPath, item.Duration, token)), + cancellationToken); + + await foreach (var result in results) + { + if (result.Preview is { } preview) + { + result.Item.AttachPreview(preview.Path, preview.FrameCount); + } + + processed++; + + yield return new LibraryScanEvent.ItemUpdated(result.Item); + yield return new LibraryScanEvent.PreviewProgress(processed, pending.Count); + + if (processed % SaveBatchSize == 0) + { + await repository.SaveChangesAsync(cancellationToken); + } + } + + if (processed > 0) + { + logger.LogInformation("Rendered previews for {Processed} of {Total} video files", processed, pending.Count); + } + } + + /// Last pass: the perceptual hash of each video that does not have one yet. + private async IAsyncEnumerable HashAsync( + IReadOnlyList pending, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var processed = 0; + + var results = InParallelAsync( + pending, + async (item, token) => (Item: item, Hash: await perceptualHasher.ComputeAsync(item.FullPath, item.Duration, token)), + cancellationToken); + + await foreach (var result in results) + { + result.Item.ApplyPerceptualHash(result.Hash); + processed++; + + yield return new LibraryScanEvent.ItemUpdated(result.Item); + yield return new LibraryScanEvent.HashingProgress(processed, pending.Count); + + if (processed % SaveBatchSize == 0) + { + await repository.SaveChangesAsync(cancellationToken); + } + } + + if (processed > 0) + { + logger.LogInformation("Hashed {Processed} of {Total} video files", processed, pending.Count); + } + } + + /// + /// Runs over the items with bounded concurrency and streams the + /// results back as they finish. A bounded channel is what keeps the producers from + /// running ahead of a consumer that has to apply each result one at a time. + /// + private async IAsyncEnumerable InParallelAsync( + IReadOnlyList items, + Func> work, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (items.Count == 0) + { + yield break; + } + + var channel = Channel.CreateBounded(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4) + { + SingleReader = true, + }); + + var producer = Task.Run( + async () => + { + try + { + var parallelOptions = new ParallelOptions + { + MaxDegreeOfParallelism = _options.MaxIndexingConcurrency, + CancellationToken = cancellationToken, + }; + + await Parallel.ForEachAsync( + items, + parallelOptions, + async (item, token) => await channel.Writer.WriteAsync(await work(item, token), token)); + + channel.Writer.Complete(); + } + catch (Exception ex) + { + channel.Writer.Complete(ex); + } + }, + cancellationToken); + + await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return result; + } + + await producer; + } + +} diff --git a/src/PLib.Application/Metadata/VideoMetadataMatch.cs b/src/PLib.Application/Metadata/VideoMetadataMatch.cs index be13b7a..b2d46b4 100644 --- a/src/PLib.Application/Metadata/VideoMetadataMatch.cs +++ b/src/PLib.Application/Metadata/VideoMetadataMatch.cs @@ -1,5 +1,14 @@ namespace PLib.Application.Metadata; +/// +/// A named thing a source attached to a scene — a tag, a performer, a studio. +/// +/// +/// Where its picture lives, or null. 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. +/// +public sealed record MetadataEntity(string Name, string? ImageUrl = null); + /// /// One candidate a source returned for a video, as PLib understands it. /// @@ -9,14 +18,19 @@ namespace PLib.Application.Metadata; /// /// Which configured source proposed it. /// Its identifier at the source, for display and for reporting. +/// +/// The scene's own cover at the source, or null. Still a URL: fetching it is the +/// caller's business, and a candidate must never wait on a picture host to be shown. +/// public sealed record VideoMetadataMatch( string SourceName, string? RemoteId, string Title, string? Description, - IReadOnlyList Tags, - IReadOnlyList Performers, - IReadOnlyList Studios); + IReadOnlyList Tags, + IReadOnlyList Performers, + IReadOnlyList Studios, + string? ImageUrl = null); /// Everything one lookup produced, including what went wrong. /// diff --git a/src/PLib.Desktop/AppHost.cs b/src/PLib.Desktop/AppHost.cs index 39b59a4..ac63f48 100644 --- a/src/PLib.Desktop/AppHost.cs +++ b/src/PLib.Desktop/AppHost.cs @@ -1,81 +1,82 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using PLib.Desktop.Imaging; -using PLib.Desktop.Services; -using PLib.Desktop.Settings; -using PLib.Desktop.ViewModels; -using PLib.Infrastructure; -using PLib.Infrastructure.Storage; -using Serilog; - -namespace PLib.Desktop; - -/// -/// Composition root. Everything the application is made of is wired up here and nowhere else. -/// -internal static class AppHost -{ - public static IHost Create(string[] args) - { - // The paths are needed to locate the user settings file, which is itself a - // configuration source — so they are built before the container exists and then - // handed to it as an instance. - var paths = new AppPaths(); - - // A desktop app is launched from arbitrary working directories, so the content root - // has to be the folder the executable lives in rather than Environment.CurrentDirectory. - var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings - { - Args = args, - ContentRootPath = AppContext.BaseDirectory, - }); - - builder.Configuration.AddJsonFile( - Path.Combine(paths.DataDirectory, "settings.json"), - optional: true, - reloadOnChange: true); - - ConfigureLogging(builder, paths); - - builder.Services.AddSingleton(paths); - - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName)); - - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName)) - .ValidateDataAnnotations(); - builder.Services.AddPLibInfrastructure(builder.Configuration); - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - // Scoped, not singleton: the settings dialog edits a working copy, so each opening - // gets its own model and a cancelled edit never leaks into the next one. - builder.Services.AddScoped(); - - return builder.Build(); - } - - private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths) - { - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() - .WriteTo.Console() - .WriteTo.File( - Path.Combine(paths.DataDirectory, "logs", "plib-.log"), - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 7) - .CreateLogger(); - - builder.Logging.ClearProviders(); - builder.Logging.AddSerilog(Log.Logger, dispose: true); - } -} +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PLib.Desktop.Imaging; +using PLib.Desktop.Services; +using PLib.Desktop.Settings; +using PLib.Desktop.ViewModels; +using PLib.Infrastructure; +using PLib.Infrastructure.Storage; +using Serilog; + +namespace PLib.Desktop; + +/// +/// Composition root. Everything the application is made of is wired up here and nowhere else. +/// +internal static class AppHost +{ + public static IHost Create(string[] args) + { + // The paths are needed to locate the user settings file, which is itself a + // configuration source — so they are built before the container exists and then + // handed to it as an instance. + var paths = new AppPaths(); + + // A desktop app is launched from arbitrary working directories, so the content root + // has to be the folder the executable lives in rather than Environment.CurrentDirectory. + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = args, + ContentRootPath = AppContext.BaseDirectory, + }); + + builder.Configuration.AddJsonFile( + Path.Combine(paths.DataDirectory, "settings.json"), + optional: true, + reloadOnChange: true); + + ConfigureLogging(builder, paths); + + builder.Services.AddSingleton(paths); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName)); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName)) + .ValidateDataAnnotations(); + builder.Services.AddPLibInfrastructure(builder.Configuration); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Scoped, not singleton: the settings dialog edits a working copy, so each opening + // gets its own model and a cancelled edit never leaks into the next one. + builder.Services.AddScoped(); + + return builder.Build(); + } + + private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console() + .WriteTo.File( + Path.Combine(paths.DataDirectory, "logs", "plib-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7) + .CreateLogger(); + + builder.Logging.ClearProviders(); + builder.Logging.AddSerilog(Log.Logger, dispose: true); + } +} diff --git a/src/PLib.Desktop/Services/RemoteImageLoader.cs b/src/PLib.Desktop/Services/RemoteImageLoader.cs new file mode 100644 index 0000000..8fe995d --- /dev/null +++ b/src/PLib.Desktop/Services/RemoteImageLoader.cs @@ -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; + +/// +/// Fetches the pictures a metadata source pointed at, behind whatever is already on screen. +/// +/// +/// 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. +/// +public sealed class RemoteImageLoader( + IServiceScopeFactory scopeFactory, + ILogger logger) : IDisposable +{ + private readonly SemaphoreSlim _slots = new(4); + private readonly Subject _problems = new(); + + /// + /// Reasons pictures are not arriving, for whatever page is on screen to show. + /// + /// + /// 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. + /// + public IObservable Problems => _problems.AsObservable(); + + public async Task 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(); + + 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(); + } +} diff --git a/src/PLib.Desktop/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml index 99faccd..e6993f4 100644 --- a/src/PLib.Desktop/Themes/LibraryStyles.axaml +++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml @@ -136,14 +136,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Domain/Videos/LibraryLabel.cs b/src/PLib.Domain/Videos/LibraryLabel.cs index 4b24dd8..519631d 100644 --- a/src/PLib.Domain/Videos/LibraryLabel.cs +++ b/src/PLib.Domain/Videos/LibraryLabel.cs @@ -60,6 +60,13 @@ public sealed class LibraryLabel public LabelKind Kind { get; private set; } + /// + /// Absolute path of the cached picture for this label, or null. 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. + /// + public string? ImagePath { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } public IReadOnlyCollection Videos => _videos; @@ -73,4 +80,12 @@ public sealed class LibraryLabel Name = name.Trim(); NormalizedName = Normalize(name); } + + public void AttachImage(string imagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(imagePath); + ImagePath = imagePath; + } + + public void DetachImage() => ImagePath = null; } diff --git a/src/PLib.Infrastructure/DependencyInjection.cs b/src/PLib.Infrastructure/DependencyInjection.cs index 09877b8..9ab277f 100644 --- a/src/PLib.Infrastructure/DependencyInjection.cs +++ b/src/PLib.Infrastructure/DependencyInjection.cs @@ -1,70 +1,71 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using PLib.Application.Abstractions; -using PLib.Application.Library; -using PLib.Application.Metadata; -using PLib.Infrastructure.Media; -using PLib.Infrastructure.Metadata; -using PLib.Infrastructure.Persistence; -using PLib.Infrastructure.Storage; - -namespace PLib.Infrastructure; - -public static class DependencyInjection -{ - /// - /// Registers everything the application layer declares as an abstraction. The composition - /// root (the UI project) never sees EF Core or ffmpeg types directly. - /// - public static IServiceCollection AddPLibInfrastructure( - this IServiceCollection services, - IConfiguration configuration) - { - services.AddOptions() - .Bind(configuration.GetSection(LibraryOptions.SectionName)) - .ValidateDataAnnotations() - .ValidateOnStart(); - - // Deliberately not validated on start: a half-filled source is something the user is - // in the middle of typing, and refusing to launch over it would be absurd. Whether an - // entry is worth calling is decided when it is called. - services.AddOptions() - .Bind(configuration.GetSection(MetadataOptions.SectionName)); - - // TryAdd so a composition root that already needed the paths (to locate the user - // settings file before the container exists) can share its own instance. - services.TryAddSingleton(); - - services.AddDbContext((provider, builder) => - { - var paths = provider.GetRequiredService(); - builder.UseSqlite($"Data Source={paths.DatabaseFile}"); - }); - - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // A short timeout on purpose: this runs behind a button the user is waiting on, and a - // source that has not answered in fifteen seconds is better reported than waited for. - services.AddHttpClient(StashBoxMetadataProvider.HttpClientName, client => - { - client.Timeout = TimeSpan.FromSeconds(15); - client.DefaultRequestHeaders.UserAgent.ParseAdd("PLib/1.0"); - }); - - services.AddSingleton(); - services.AddScoped(); - - services.AddHostedService(); - - return services; - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using PLib.Application.Abstractions; +using PLib.Application.Library; +using PLib.Application.Metadata; +using PLib.Infrastructure.Media; +using PLib.Infrastructure.Metadata; +using PLib.Infrastructure.Persistence; +using PLib.Infrastructure.Storage; + +namespace PLib.Infrastructure; + +public static class DependencyInjection +{ + /// + /// Registers everything the application layer declares as an abstraction. The composition + /// root (the UI project) never sees EF Core or ffmpeg types directly. + /// + public static IServiceCollection AddPLibInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(LibraryOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + // Deliberately not validated on start: a half-filled source is something the user is + // in the middle of typing, and refusing to launch over it would be absurd. Whether an + // entry is worth calling is decided when it is called. + services.AddOptions() + .Bind(configuration.GetSection(MetadataOptions.SectionName)); + + // TryAdd so a composition root that already needed the paths (to locate the user + // settings file before the container exists) can share its own instance. + services.TryAddSingleton(); + + services.AddDbContext((provider, builder) => + { + var paths = provider.GetRequiredService(); + builder.UseSqlite($"Data Source={paths.DatabaseFile}"); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // A short timeout on purpose: this runs behind a button the user is waiting on, and a + // source that has not answered in fifteen seconds is better reported than waited for. + services.AddHttpClient(StashBoxMetadataProvider.HttpClientName, client => + { + client.Timeout = TimeSpan.FromSeconds(15); + client.DefaultRequestHeaders.UserAgent.ParseAdd("PLib/1.0"); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + services.AddHostedService(); + + return services; + } +} diff --git a/src/PLib.Infrastructure/Metadata/HttpRemoteImageCache.cs b/src/PLib.Infrastructure/Metadata/HttpRemoteImageCache.cs new file mode 100644 index 0000000..59a914c --- /dev/null +++ b/src/PLib.Infrastructure/Metadata/HttpRemoteImageCache.cs @@ -0,0 +1,246 @@ +using Microsoft.Extensions.Logging; +using PLib.Application.Abstractions; +using PLib.Infrastructure.Media; +using PLib.Infrastructure.Storage; + +namespace PLib.Infrastructure.Metadata; + +/// +/// Downloads and keeps the pictures a metadata source points at. +/// +/// +/// 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. +/// +public sealed class HttpRemoteImageCache( + IHttpClientFactory httpClientFactory, + IAppPaths paths, + ILogger logger) + : MediaArtifactCache(paths.RemoteImageDirectory, logger), IRemoteImageCache +{ + /// + /// 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. + /// + private const long MaximumBytes = 8 * 1024 * 1024; + + /// + /// 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. + /// + private static readonly TimeSpan DownloadTimeout = TimeSpan.FromSeconds(10); + + /// How many failures in a row before a host is left alone for a while. + private const int FailuresBeforeCoolOff = 3; + + /// How long a host is skipped after it has failed that many times. + private static readonly TimeSpan CoolOff = TimeSpan.FromMinutes(5); + + /// + /// Failure counts per host, so a picture server that is unreachable is asked a few times + /// and then left alone. + /// + /// + /// 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. + /// + private readonly System.Collections.Concurrent.ConcurrentDictionary _hosts = + new(StringComparer.OrdinalIgnoreCase); + + public async Task 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 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; + } + + /// + /// Records a failure and, once the host has run out of tries, says so out loud. + /// + /// + /// 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. + /// + 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} мин."); + } + + /// How a picture host has been behaving, and until when it is being skipped. + private sealed record HostHealth(int Failures, DateTimeOffset? SkipUntil); + + /// + /// 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. + /// + private static string BuildCacheKey(Uri uri) => + Convert.ToHexStringLower( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32]; + + /// + /// The extension from the URL when it looks like an image, else .jpg. It is only a + /// hint for whatever opens the file later; nothing here trusts it. + /// + 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", + }; + } +} diff --git a/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs b/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs index aff3077..d080230 100644 --- a/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs +++ b/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs @@ -27,6 +27,9 @@ public sealed class StashBoxMetadataProvider( /// Name of the configured ; see the DI registration. public const string HttpClientName = "metadata"; + /// Below this a picture is too small for a card and gets passed over. + private const int MinimumImageWidth = 320; + /// /// The fingerprint queries, newest first. /// @@ -48,9 +51,10 @@ public sealed class StashBoxMetadataProvider( id title details - studio { name } + images { url width } + studio { name images { url width } } tags { name } - performers { performer { name } } + performers { performer { name images { url width } } } } } """, @@ -65,9 +69,10 @@ public sealed class StashBoxMetadataProvider( id title details - studio { name } + images { url width } + studio { name images { url width } } tags { name } - performers { performer { name } } + performers { performer { name images { url width } } } } } """, @@ -214,9 +219,13 @@ public sealed class StashBoxMetadataProvider( Text(scene, "id"), Text(scene, "title") ?? "Без названия", Text(scene, "details"), - Names(scene, "tags"), + Entities(scene, "tags"), 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)); /// /// A string property, or null for anything else — including when the element itself @@ -230,7 +239,8 @@ public sealed class StashBoxMetadataProvider( ? value.GetString() : null; - private static IReadOnlyList Names(JsonElement scene, string property) + /// A named array — tags, and anything else shaped like them. + private static IReadOnlyList Entities(JsonElement scene, string property) { if (scene.ValueKind != JsonValueKind.Object || !scene.TryGetProperty(property, out var array) || @@ -239,14 +249,14 @@ public sealed class StashBoxMetadataProvider( return []; } - return [.. array.EnumerateArray().Select(item => Text(item, "name")).OfType()]; + return [.. array.EnumerateArray().Select(ReadEntity).OfType()]; } /// /// 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. /// - private static IReadOnlyList Performers(JsonElement scene) + private static IReadOnlyList Performers(JsonElement scene) { if (scene.ValueKind != JsonValueKind.Object || !scene.TryGetProperty("performers", out var array) || @@ -261,9 +271,9 @@ public sealed class StashBoxMetadataProvider( .EnumerateArray() .Select(appearance => appearance.ValueKind == JsonValueKind.Object && appearance.TryGetProperty("performer", out var performer) - ? Text(performer, "name") + ? ReadEntity(performer) : null) - .OfType() + .OfType() ]; } @@ -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 /// may have nothing to say here. /// - private static IReadOnlyList Studios(JsonElement scene) => + private static IReadOnlyList Studios(JsonElement scene) => scene.ValueKind == JsonValueKind.Object && scene.TryGetProperty("studio", out var studio) && - Text(studio, "name") is { } name - ? [name] + ReadEntity(studio) is { } entity + ? [entity] : []; + private static MetadataEntity? ReadEntity(JsonElement element) => + Text(element, "name") is { } name ? new MetadataEntity(name, PickImage(element)) : null; + + /// + /// Picks the picture to keep: the smallest one still wide enough for a card, and the + /// widest available when none reaches that. + /// + /// + /// 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. + /// + 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; + /// One way of asking the same question, and how to read the answer. /// Name of the query root field, used to find it in the reply. /// True when the result is a list of lists rather than a list. diff --git a/src/PLib.Infrastructure/Persistence/EfLabelRepository.cs b/src/PLib.Infrastructure/Persistence/EfLabelRepository.cs index 0120478..5ef927b 100644 --- a/src/PLib.Infrastructure/Persistence/EfLabelRepository.cs +++ b/src/PLib.Infrastructure/Persistence/EfLabelRepository.cs @@ -14,7 +14,12 @@ public sealed class EfLabelRepository(LibraryDbContext dbContext) : ILabelReposi public async Task> GetSummariesAsync( CancellationToken cancellationToken = default) => 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); public Task FindAsync( diff --git a/src/PLib.Infrastructure/Persistence/LibraryLabelConfiguration.cs b/src/PLib.Infrastructure/Persistence/LibraryLabelConfiguration.cs index b3cfb98..0238f62 100644 --- a/src/PLib.Infrastructure/Persistence/LibraryLabelConfiguration.cs +++ b/src/PLib.Infrastructure/Persistence/LibraryLabelConfiguration.cs @@ -29,6 +29,9 @@ internal sealed class LibraryLabelConfiguration : IEntityTypeConfiguration() .HasMaxLength(16); + builder.Property(x => x.ImagePath) + .HasMaxLength(1024); + builder.Property(x => x.CreatedAt).HasConversion(UtcTicksConverter); // A tag and a collection may share a name; two tags may not. diff --git a/src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.Designer.cs b/src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.Designer.cs new file mode 100644 index 0000000..a07cf1b --- /dev/null +++ b/src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.Designer.cs @@ -0,0 +1,169 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("LibraryLabelVideoItem", b => + { + b.Property("LabelsId") + .HasColumnType("TEXT"); + + b.Property("VideosId") + .HasColumnType("TEXT"); + + b.HasKey("LabelsId", "VideosId"); + + b.HasIndex("VideosId"); + + b.ToTable("VideoLabels", (string)null); + }); + + modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FileModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("FullPath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("PerceptualHash") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PreviewFrameCount") + .HasColumnType("INTEGER"); + + b.Property("PreviewPath") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ResumePosition") + .HasColumnType("TEXT"); + + b.Property("SizeInBytes") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailPath") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("VideoCodec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.cs b/src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.cs new file mode 100644 index 0000000..71f91e6 --- /dev/null +++ b/src/PLib.Infrastructure/Persistence/Migrations/20260810054626_LabelImages.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PLib.Infrastructure.Persistence.Migrations +{ + /// + public partial class LabelImages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ImagePath", + table: "Labels", + type: "TEXT", + maxLength: 1024, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ImagePath", + table: "Labels"); + } + } +} diff --git a/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs b/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs index cfebc4f..ed2ed69 100644 --- a/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs +++ b/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs @@ -41,6 +41,10 @@ namespace PLib.Infrastructure.Persistence.Migrations b.Property("CreatedAt") .HasColumnType("INTEGER"); + b.Property("ImagePath") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + b.Property("Kind") .IsRequired() .HasMaxLength(16) diff --git a/src/PLib.Infrastructure/Storage/AppPaths.cs b/src/PLib.Infrastructure/Storage/AppPaths.cs index 8c5131a..0f554b1 100644 --- a/src/PLib.Infrastructure/Storage/AppPaths.cs +++ b/src/PLib.Infrastructure/Storage/AppPaths.cs @@ -12,11 +12,13 @@ public sealed class AppPaths : IAppPaths DataDirectory = Path.Combine(localAppData, "PLib"); ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails"); PreviewDirectory = Path.Combine(DataDirectory, "previews"); + RemoteImageDirectory = Path.Combine(DataDirectory, "images"); DatabaseFile = Path.Combine(DataDirectory, "library.db"); Directory.CreateDirectory(DataDirectory); Directory.CreateDirectory(ThumbnailDirectory); Directory.CreateDirectory(PreviewDirectory); + Directory.CreateDirectory(RemoteImageDirectory); } public string DataDirectory { get; } @@ -25,5 +27,7 @@ public sealed class AppPaths : IAppPaths public string PreviewDirectory { get; } + public string RemoteImageDirectory { get; } + public string DatabaseFile { get; } } diff --git a/src/PLib.Infrastructure/Storage/IAppPaths.cs b/src/PLib.Infrastructure/Storage/IAppPaths.cs index bfcb38d..8db49dd 100644 --- a/src/PLib.Infrastructure/Storage/IAppPaths.cs +++ b/src/PLib.Infrastructure/Storage/IAppPaths.cs @@ -15,6 +15,12 @@ public interface IAppPaths /// string PreviewDirectory { get; } + /// + /// Directory holding pictures fetched from a metadata source — performers, + /// studios, and the cover of every candidate a lookup offered. + /// + string RemoteImageDirectory { get; } + /// Full path of the SQLite database file. string DatabaseFile { get; } } diff --git a/tests/PLib.Tests/Library/DuplicateDetectionTests.cs b/tests/PLib.Tests/Library/DuplicateDetectionTests.cs index 64c65f5..8180464 100644 --- a/tests/PLib.Tests/Library/DuplicateDetectionTests.cs +++ b/tests/PLib.Tests/Library/DuplicateDetectionTests.cs @@ -92,6 +92,7 @@ public sealed class DuplicateDetectionTests Substitute.For(), Substitute.For(), Substitute.For(), + Substitute.For(), Options.Create(new LibraryOptions()), MetadataMonitor.Empty, NullLogger.Instance); diff --git a/tests/PLib.Tests/Library/InMemoryLabelRepository.cs b/tests/PLib.Tests/Library/InMemoryLabelRepository.cs index a7ee152..65d3be7 100644 --- a/tests/PLib.Tests/Library/InMemoryLabelRepository.cs +++ b/tests/PLib.Tests/Library/InMemoryLabelRepository.cs @@ -14,7 +14,7 @@ internal sealed class InMemoryLabelRepository : ILabelRepository public Task> GetSummariesAsync(CancellationToken cancellationToken = default) => Task.FromResult>( - [.. _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 FindAsync( LabelKind kind, diff --git a/tests/PLib.Tests/Library/LabelTests.cs b/tests/PLib.Tests/Library/LabelTests.cs index e2587c0..ff8254f 100644 --- a/tests/PLib.Tests/Library/LabelTests.cs +++ b/tests/PLib.Tests/Library/LabelTests.cs @@ -1,84 +1,85 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using NSubstitute; -using PLib.Application.Abstractions; -using PLib.Application.Library; -using PLib.Domain.Videos; -using Shouldly; - -namespace PLib.Tests.Library; - -public sealed class LabelTests -{ - private readonly InMemoryVideoRepository _videos = new(); - private readonly InMemoryLabelRepository _labels = new(); - private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch); - - public LabelTests() => _videos.Seed(_video); - - [Fact] - public async Task A_name_used_for_the_first_time_creates_the_label() - { - var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token); - - label.Name.ShouldBe("Комедия"); - _video.Labels.ShouldHaveSingleItem(); - (await _labels.GetAllAsync(Token)).ShouldHaveSingleItem(); - } - - [Fact] - public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists() - { - var service = CreateService(); - - var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token); - var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token); - - second.Id.ShouldBe(first.Id); - (await _labels.GetAllAsync(Token)).ShouldHaveSingleItem(); - - // And attaching it twice must not double it up on the video. - _video.Labels.ShouldHaveSingleItem(); - } - - [Fact] - public async Task A_tag_and_a_collection_may_share_a_name() - { - var service = CreateService(); - - var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token); - var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token); - - collection.Id.ShouldNotBe(tag.Id); - _video.Labels.Count.ShouldBe(2); - } - - [Fact] - public async Task Detaching_leaves_the_label_itself_in_the_library() - { - var service = CreateService(); - var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token); - - await service.DetachLabelAsync(_video.Id, label.Id, Token); - - _video.Labels.ShouldBeEmpty(); - - // Other videos may still use it, and re-adding must not make a second one. - (await _labels.GetAllAsync(Token)).ShouldHaveSingleItem(); - } - - private static CancellationToken Token => TestContext.Current.CancellationToken; - - private LibraryService CreateService() => new( - _videos, - _labels, - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For(), - Options.Create(new LibraryOptions()), - MetadataMonitor.Empty, - NullLogger.Instance); -} +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using PLib.Application.Abstractions; +using PLib.Application.Library; +using PLib.Domain.Videos; +using Shouldly; + +namespace PLib.Tests.Library; + +public sealed class LabelTests +{ + private readonly InMemoryVideoRepository _videos = new(); + private readonly InMemoryLabelRepository _labels = new(); + private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch); + + public LabelTests() => _videos.Seed(_video); + + [Fact] + public async Task A_name_used_for_the_first_time_creates_the_label() + { + var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token); + + label.Name.ShouldBe("Комедия"); + _video.Labels.ShouldHaveSingleItem(); + (await _labels.GetAllAsync(Token)).ShouldHaveSingleItem(); + } + + [Fact] + public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists() + { + var service = CreateService(); + + var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token); + var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token); + + second.Id.ShouldBe(first.Id); + (await _labels.GetAllAsync(Token)).ShouldHaveSingleItem(); + + // And attaching it twice must not double it up on the video. + _video.Labels.ShouldHaveSingleItem(); + } + + [Fact] + public async Task A_tag_and_a_collection_may_share_a_name() + { + var service = CreateService(); + + var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token); + var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token); + + collection.Id.ShouldNotBe(tag.Id); + _video.Labels.Count.ShouldBe(2); + } + + [Fact] + public async Task Detaching_leaves_the_label_itself_in_the_library() + { + var service = CreateService(); + var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token); + + await service.DetachLabelAsync(_video.Id, label.Id, Token); + + _video.Labels.ShouldBeEmpty(); + + // Other videos may still use it, and re-adding must not make a second one. + (await _labels.GetAllAsync(Token)).ShouldHaveSingleItem(); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private LibraryService CreateService() => new( + _videos, + _labels, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Options.Create(new LibraryOptions()), + MetadataMonitor.Empty, + NullLogger.Instance); +} diff --git a/tests/PLib.Tests/Library/LibraryServiceTests.cs b/tests/PLib.Tests/Library/LibraryServiceTests.cs index cd2ea35..9e9dfe7 100644 --- a/tests/PLib.Tests/Library/LibraryServiceTests.cs +++ b/tests/PLib.Tests/Library/LibraryServiceTests.cs @@ -21,6 +21,7 @@ public sealed class LibraryServiceTests private readonly IAnimatedPreviewGenerator _previews = Substitute.For(); private readonly IVideoPerceptualHasher _hasher = Substitute.For(); private readonly IMetadataProvider _metadata = Substitute.For(); + private readonly IRemoteImageCache _remoteImages = Substitute.For(); public LibraryServiceTests() { @@ -366,9 +367,9 @@ public sealed class LibraryServiceTests "scene-1", "Настоящее название", "Описание", - Tags: ["Драма", "драма"], - Performers: ["Актёр Один"], - Studios: ["Студия"]); + Tags: [new("Драма"), new("драма")], + Performers: [new("Актёр Один", "https://example/face.jpg")], + Studios: [new("Студия")]); 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("Студия"); } + [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(), Arg.Any()) + .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(), Arg.Any()); + } + + [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(), Arg.Any()) + .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(), Arg.Any()); + } + + [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()) + .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()) + .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(), Arg.Any()) + .Returns(_ => 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] 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(); 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. item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true); @@ -432,6 +529,7 @@ public sealed class LibraryServiceTests _previews, _hasher, _metadata, + _remoteImages, Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }), sources ?? MetadataMonitor.Empty, NullLogger.Instance); diff --git a/tests/PLib.Tests/Library/MetadataScanTests.cs b/tests/PLib.Tests/Library/MetadataScanTests.cs index 34285db..f132d10 100644 --- a/tests/PLib.Tests/Library/MetadataScanTests.cs +++ b/tests/PLib.Tests/Library/MetadataScanTests.cs @@ -140,6 +140,7 @@ public sealed class MetadataScanTests Substitute.For(), Substitute.For(), _provider, + Substitute.For(), Options.Create(new LibraryOptions()), // No pause between requests: the delay exists to be kind to somebody else's diff --git a/tests/PLib.Tests/Metadata/RemoteImageCacheTests.cs b/tests/PLib.Tests/Metadata/RemoteImageCacheTests.cs new file mode 100644 index 0000000..a4da489 --- /dev/null +++ b/tests/PLib.Tests/Metadata/RemoteImageCacheTests.cs @@ -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; + +/// +/// A picture host that will not answer must cost a few attempts, not one per candidate. +/// +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(); + + 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(); + factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(_handler, disposeHandler: false)); + + return new HttpRemoteImageCache(factory, _paths, NullLogger.Instance); + } + + /// Fails every request at once, and counts how many it was asked to make. + private sealed class CountingHandler : HttpMessageHandler + { + private int _requests; + + public int Requests => _requests; + + protected override Task 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); + } + } + } +} diff --git a/tests/PLib.Tests/Metadata/StashBoxPayloadTests.cs b/tests/PLib.Tests/Metadata/StashBoxPayloadTests.cs index 0a545cf..c3c4003 100644 --- a/tests/PLib.Tests/Metadata/StashBoxPayloadTests.cs +++ b/tests/PLib.Tests/Metadata/StashBoxPayloadTests.cs @@ -51,11 +51,11 @@ public sealed class StashBoxPayloadTests first.RemoteId.ShouldBe("abc"); first.Title.ShouldBe("Первая"); first.Description.ShouldBe("Описание"); - first.Studios.ShouldBe(["Студия"]); - first.Tags.ShouldBe(["драма", "нуар"]); + first.Studios.Select(x => x.Name).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. - first.Performers.ShouldBe(["Актёр"]); + first.Performers.Select(x => x.Name).ShouldBe(["Актёр"]); matches[1].Studios.ShouldBeEmpty(); } @@ -94,6 +94,119 @@ public sealed class StashBoxPayloadTests 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] public void Errors_are_raised_even_though_the_server_answered_two_hundred() { diff --git a/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs b/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs index e248046..aeec15a 100644 --- a/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs +++ b/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs @@ -201,10 +201,12 @@ public sealed class AppSettingsStoreTests : IDisposable DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}"); ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails"); PreviewDirectory = Path.Combine(DataDirectory, "previews"); + RemoteImageDirectory = Path.Combine(DataDirectory, "labels"); DatabaseFile = Path.Combine(DataDirectory, "library.db"); Directory.CreateDirectory(ThumbnailDirectory); Directory.CreateDirectory(PreviewDirectory); + Directory.CreateDirectory(RemoteImageDirectory); } public string DataDirectory { get; } @@ -213,6 +215,8 @@ public sealed class AppSettingsStoreTests : IDisposable public string PreviewDirectory { get; } + public string RemoteImageDirectory { get; } + public string DatabaseFile { get; } public void Dispose() => Directory.Delete(DataDirectory, recursive: true);