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