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

This commit is contained in:
Leonid Pershin
2026-08-10 09:25:41 +03:00
parent 9e239e045f
commit b82b0b4555
38 changed files with 4661 additions and 3316 deletions
+270 -234
View File
@@ -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<LibraryScanEvent>`: карточки появляются по мере находок, а не после
завершения всего прохода. Тяжёлая часть (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<LibraryScanEvent>`: карточки появляются по мере находок, а не после
завершения всего прохода. Тяжёлая часть (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
```
@@ -0,0 +1,36 @@
namespace PLib.Application.Abstractions;
/// <summary>
/// What came of asking for a picture.
/// </summary>
/// <remarks>
/// Three outcomes, not two. "There is no picture" and "the host holding the picture is not
/// answering" look identical on a card — an empty square — but only one of them is worth
/// telling the user about, and only one of them means the rest of the pictures will not arrive
/// either. A bare <c>string?</c> could not tell them apart.
/// </remarks>
/// <param name="Path">Where the picture landed, or <c>null</c> if it did not.</param>
/// <param name="Problem">
/// A line for the user when the failure is worth surfacing, or <c>null</c>. Set once a host
/// has been given up on, not on every miss — a picture the source simply does not have is not
/// a problem, and a hundred identical complaints are worse than none.
/// </param>
public sealed record RemoteImage(string? Path, string? Problem = null)
{
/// <summary>Nothing to fetch, and nothing to say about it.</summary>
public static RemoteImage None { get; } = new(Path: null);
public static RemoteImage At(string path) => new(path);
public static RemoteImage Unreachable(string problem) => new(null, problem);
}
/// <summary>Fetches and keeps pictures that live on somebody else's server.</summary>
public interface IRemoteImageCache : IMediaArtifactCache
{
/// <summary>
/// Fetches the picture at <paramref name="imageUrl"/> unless it is already cached, and
/// says where it landed — or, when the host has been given up on, why it did not.
/// </summary>
Task<RemoteImage> GetOrCreateAsync(string imageUrl, CancellationToken cancellationToken = default);
}
+104 -94
View File
@@ -1,94 +1,104 @@
using PLib.Application.Metadata;
using PLib.Domain.Videos;
namespace PLib.Application.Library;
/// <summary>Use cases the UI needs in order to show and refresh the video library.</summary>
public interface ILibraryService
{
/// <summary>Everything currently stored in the library, newest first.</summary>
Task<IReadOnlyList<VideoItem>> GetLibraryAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
IAsyncEnumerable<LibraryScanEvent> ScanAsync(
IReadOnlyList<string> folders,
CancellationToken cancellationToken = default);
/// <summary>
/// What each kind of derived data currently costs, one entry per
/// <see cref="LibraryDataKind"/>, so the user can see what clearing it would free.
/// </summary>
Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default);
/// <summary>Remembers where playback stopped so the video can be resumed later.</summary>
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
/// <summary>
/// Groups of videos that look alike, by perceptual hash. Videos without a hash, and
/// groups of one, are left out.
/// </summary>
/// <param name="maxDistance">
/// 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.
/// </param>
Task<IReadOnlyList<IReadOnlyList<VideoItem>>> FindDuplicatesAsync(
int maxDistance,
CancellationToken cancellationToken = default);
/// <summary>One video with its labels loaded, or <c>null</c> if it is gone.</summary>
Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default);
/// <summary>Every tag and collection in the library, alphabetically.</summary>
Task<IReadOnlyList<LibraryLabel>> GetLabelsAsync(CancellationToken cancellationToken = default);
/// <summary>Every label with its video count, for the browsing tabs.</summary>
Task<IReadOnlyList<LabelSummary>> GetLabelSummariesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task<LibraryLabel> AttachLabelAsync(
Guid videoId,
string name,
LabelKind kind,
CancellationToken cancellationToken = default);
Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
/// <summary>
/// Asks every configured metadata source what it has for this video's fingerprint.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
Task<MetadataLookupResult> FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task ApplyMetadataAsync(
Guid videoId,
VideoMetadataMatch match,
CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync(
MetadataScanRequest request,
CancellationToken cancellationToken = default);
}
using PLib.Application.Abstractions;
using PLib.Application.Metadata;
using PLib.Domain.Videos;
namespace PLib.Application.Library;
/// <summary>Use cases the UI needs in order to show and refresh the video library.</summary>
public interface ILibraryService
{
/// <summary>Everything currently stored in the library, newest first.</summary>
Task<IReadOnlyList<VideoItem>> GetLibraryAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
IAsyncEnumerable<LibraryScanEvent> ScanAsync(
IReadOnlyList<string> folders,
CancellationToken cancellationToken = default);
/// <summary>
/// What each kind of derived data currently costs, one entry per
/// <see cref="LibraryDataKind"/>, so the user can see what clearing it would free.
/// </summary>
Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default);
/// <summary>Remembers where playback stopped so the video can be resumed later.</summary>
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
/// <summary>
/// Groups of videos that look alike, by perceptual hash. Videos without a hash, and
/// groups of one, are left out.
/// </summary>
/// <param name="maxDistance">
/// 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.
/// </param>
Task<IReadOnlyList<IReadOnlyList<VideoItem>>> FindDuplicatesAsync(
int maxDistance,
CancellationToken cancellationToken = default);
/// <summary>One video with its labels loaded, or <c>null</c> if it is gone.</summary>
Task<VideoItem?> GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default);
/// <summary>Every tag and collection in the library, alphabetically.</summary>
Task<IReadOnlyList<LibraryLabel>> GetLabelsAsync(CancellationToken cancellationToken = default);
/// <summary>Every label with its video count, for the browsing tabs.</summary>
Task<IReadOnlyList<LabelSummary>> GetLabelSummariesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task<LibraryLabel> AttachLabelAsync(
Guid videoId,
string name,
LabelKind kind,
CancellationToken cancellationToken = default);
Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a picture a metadata source pointed at, saying where it landed or why it did not.
/// </summary>
/// <remarks>
/// Separate from the lookup so that a candidate can be shown before its cover exists.
/// Waiting for the picture first means one slow picture host holds up every result.
/// </remarks>
Task<RemoteImage> FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default);
/// <summary>
/// Asks every configured metadata source what it has for this video's fingerprint.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
Task<MetadataLookupResult> FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task ApplyMetadataAsync(
Guid videoId,
VideoMetadataMatch match,
CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync(
MetadataScanRequest request,
CancellationToken cancellationToken = default);
}
+6 -1
View File
@@ -11,4 +11,9 @@ namespace PLib.Application.Library;
/// the videos behind each one to arrive at a number is the difference between a page that
/// opens and a page that does not.
/// </remarks>
public sealed record LabelSummary(Guid Id, string Name, LabelKind Kind, int VideoCount);
public sealed record LabelSummary(
Guid Id,
string Name,
LabelKind Kind,
int VideoCount,
string? ImagePath);
@@ -25,7 +25,14 @@ public enum LibraryDataKind
/// <summary>Duration, resolution and codec, as read by ffprobe.</summary>
TechnicalMetadata = 1 << 3,
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata,
/// <summary>
/// Pictures downloaded from a metadata source: performers, studios, and the covers
/// shown beside search results. Rebuilt by looking a video up again rather than by a
/// scan — the file on disk says nothing about what a performer looks like.
/// </summary>
RemoteImages = 1 << 4,
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages,
}
/// <summary>What one kind of derived data currently costs.</summary>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,14 @@
namespace PLib.Application.Metadata;
/// <summary>
/// A named thing a source attached to a scene — a tag, a performer, a studio.
/// </summary>
/// <param name="ImageUrl">
/// Where its picture lives, or <c>null</c>. Only some kinds have one: stash-box knows what a
/// performer and a studio look like, and has no picture for a tag at all.
/// </param>
public sealed record MetadataEntity(string Name, string? ImageUrl = null);
/// <summary>
/// One candidate a source returned for a video, as PLib understands it.
/// </summary>
@@ -9,14 +18,19 @@ namespace PLib.Application.Metadata;
/// </remarks>
/// <param name="SourceName">Which configured source proposed it.</param>
/// <param name="RemoteId">Its identifier at the source, for display and for reporting.</param>
/// <param name="ImageUrl">
/// The scene's own cover at the source, or <c>null</c>. Still a URL: fetching it is the
/// caller's business, and a candidate must never wait on a picture host to be shown.
/// </param>
public sealed record VideoMetadataMatch(
string SourceName,
string? RemoteId,
string Title,
string? Description,
IReadOnlyList<string> Tags,
IReadOnlyList<string> Performers,
IReadOnlyList<string> Studios);
IReadOnlyList<MetadataEntity> Tags,
IReadOnlyList<MetadataEntity> Performers,
IReadOnlyList<MetadataEntity> Studios,
string? ImageUrl = null);
/// <summary>Everything one lookup produced, including what went wrong.</summary>
/// <param name="HasPerceptualHash">
+82 -81
View File
@@ -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;
/// <summary>
/// Composition root. Everything the application is made of is wired up here and nowhere else.
/// </summary>
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<IAppPaths>(paths);
builder.Services.AddOptions<AppearanceOptions>()
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
builder.Services.AddOptions<PlaybackOptions>()
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
.ValidateDataAnnotations();
builder.Services.AddPLibInfrastructure(builder.Configuration);
builder.Services.AddSingleton<ThumbnailCache>();
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
builder.Services.AddSingleton<ISystemShell, SystemShell>();
builder.Services.AddSingleton<IThemeService, ThemeService>();
builder.Services.AddSingleton<MainWindowViewModel>();
// 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<SettingsViewModel>();
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;
/// <summary>
/// Composition root. Everything the application is made of is wired up here and nowhere else.
/// </summary>
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<IAppPaths>(paths);
builder.Services.AddOptions<AppearanceOptions>()
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
builder.Services.AddOptions<PlaybackOptions>()
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
.ValidateDataAnnotations();
builder.Services.AddPLibInfrastructure(builder.Configuration);
builder.Services.AddSingleton<ThumbnailCache>();
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
builder.Services.AddSingleton<ISystemShell, SystemShell>();
builder.Services.AddSingleton<IThemeService, ThemeService>();
builder.Services.AddSingleton<RemoteImageLoader>();
builder.Services.AddSingleton<MainWindowViewModel>();
// 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<SettingsViewModel>();
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);
}
}
@@ -0,0 +1,85 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
namespace PLib.Desktop.Services;
/// <summary>
/// Fetches the pictures a metadata source pointed at, behind whatever is already on screen.
/// </summary>
/// <remarks>
/// Nothing waits on this. A candidate is shown the moment the source answers, and its cover
/// appears when it appears — the alternative, fetching first, put a stranger's picture host on
/// the critical path of showing a result, and one that stalled froze the whole run.
///
/// Concurrency is capped because a run can produce results faster than pictures download, and
/// an unbounded fan-out would open a connection per candidate to the same host.
/// </remarks>
public sealed class RemoteImageLoader(
IServiceScopeFactory scopeFactory,
ILogger<RemoteImageLoader> logger) : IDisposable
{
private readonly SemaphoreSlim _slots = new(4);
private readonly Subject<string> _problems = new();
/// <summary>
/// Reasons pictures are not arriving, for whatever page is on screen to show.
/// </summary>
/// <remarks>
/// An empty square tells the user nothing — it looks the same whether the source has no
/// picture or the whole host is unreachable. The cache raises one of these only once it
/// has given up on a host, so this is a handful of lines, not one per candidate.
/// </remarks>
public IObservable<string> Problems => _problems.AsObservable();
public async Task<string?> LoadAsync(string? imageUrl, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(imageUrl))
{
return null;
}
try
{
await _slots.WaitAsync(cancellationToken);
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var image = await library.FetchImageAsync(imageUrl, cancellationToken);
if (image.Problem is { } problem)
{
_problems.OnNext(problem);
}
return image.Path;
}
finally
{
_slots.Release();
}
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
// A missing cover costs a thumbnail. Nothing above this is waiting for an answer,
// so there is nobody to report it to but the log.
logger.LogDebug(ex, "Could not fetch the picture at {Url}", imageUrl);
return null;
}
}
public void Dispose()
{
_problems.Dispose();
_slots.Dispose();
}
}
+4 -2
View File
@@ -136,14 +136,16 @@
<!-- One tag, performer, studio or collection in a browsing tab. -->
<Style Selector="Button.entity">
<Setter Property="Padding" Value="12,10" />
<Setter Property="Padding" Value="8,8,8,10" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Button.entity:pointerover">
@@ -18,6 +18,12 @@ public sealed class LabelSummaryViewModel
Kind = summary.Kind;
VideoCount = summary.VideoCount;
CountText = VideoCount.ToString(CultureInfo.CurrentCulture);
ImagePath = summary.ImagePath;
// Stands in for the picture. Tags never have one — stash-box holds no image for a tag
// — and a performer only gets one once a match brings it, so the fallback is the
// normal case rather than an error state.
Initial = Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
OpenCommand = ReactiveCommand.Create(() => open(this));
}
@@ -32,6 +38,12 @@ public sealed class LabelSummaryViewModel
public string CountText { get; }
/// <summary>Cached picture of this label, or <c>null</c> when there is none.</summary>
public string? ImagePath { get; }
/// <summary>First letter, drawn when there is no picture.</summary>
public string Initial { get; }
/// <summary>Narrows the video grid down to this label and switches to it.</summary>
public ReactiveCommand<RxVoid, RxVoid> OpenCommand { get; }
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
using PLib.Application.Metadata;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
@@ -9,7 +10,7 @@ namespace PLib.Desktop.ViewModels;
/// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers,
/// and a source confidently overwriting a title is far harder to undo than a button is to press.
/// </remarks>
public sealed class MetadataMatchViewModel
public sealed partial class MetadataMatchViewModel : ReactiveObject
{
public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply)
{
@@ -19,6 +20,7 @@ public sealed class MetadataMatchViewModel
SourceName = match.SourceName;
Title = match.Title;
Description = match.Description;
ImageUrl = match.ImageUrl;
Studios = Join(match.Studios);
Performers = Join(match.Performers);
@@ -35,6 +37,18 @@ public sealed class MetadataMatchViewModel
public string? Description { get; }
/// <summary>Where the candidate's cover lives at the source, or <c>null</c>.</summary>
public string? ImageUrl { get; }
/// <summary>
/// The cover once it is on disk. Reactive and initially empty: the row is shown as soon as
/// the source answers, and the picture fills in behind it. A title and a tag list say very
/// little about whether this is the right video; a frame says it at a glance — but not at
/// the price of the row waiting for it.
/// </summary>
[Reactive]
public partial string? ImagePath { get; set; }
/// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary>
public string? Studios { get; }
@@ -44,6 +58,6 @@ public sealed class MetadataMatchViewModel
public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; }
private static string? Join(IReadOnlyList<string> values) =>
values.Count == 0 ? null : string.Join(", ", values);
private static string? Join(IReadOnlyList<MetadataEntity> values) =>
values.Count == 0 ? null : string.Join(", ", values.Select(entity => entity.Name));
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
using PLib.Application.Metadata;
using PLib.Desktop.Services;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
@@ -22,6 +23,7 @@ namespace PLib.Desktop.ViewModels;
public sealed partial class MetadataScanViewModel : ViewModelBase
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly RemoteImageLoader _images;
private readonly ILogger _logger;
/// <summary>Called after the run so the grid can pick up renamed videos and new labels.</summary>
@@ -31,10 +33,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
public MetadataScanViewModel(
IServiceScopeFactory scopeFactory,
RemoteImageLoader images,
Func<Task> refreshLibrary,
ILogger logger)
{
_scopeFactory = scopeFactory;
_images = images;
_refreshLibrary = refreshLibrary;
_logger = logger;
@@ -46,6 +50,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
() => _running?.Cancel(),
this.WhenAnyValue(x => x.IsRunning));
// Pictures fail on their own schedule, long after the source answered, so this is
// a subscription rather than something the run reports.
_images.Problems
.Subscribe(problem => Dispatcher.UIThread.Post(() => Report(problem)))
.AddTo(Subscriptions);
ObserveCommandFailures();
}
@@ -134,11 +144,16 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
break;
case MetadataScanEvent.Matched matched:
Results.Insert(0, new MetadataScanResultViewModel(matched, ApplyAsync));
var result = new MetadataScanResultViewModel(matched, ApplyAsync);
Results.Insert(0, result);
// Started, not awaited: the row is on screen already, and the covers
// arrive when the picture host gets round to them.
LoadCovers(result.Matches);
break;
case MetadataScanEvent.SourceAbandoned abandoned:
Problems.Add($"{abandoned.SourceName}: {abandoned.Reason}");
Report($"{abandoned.SourceName}: {abandoned.Reason}");
break;
case MetadataScanEvent.Completed completed:
@@ -153,6 +168,35 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
}
}
/// <summary>Adds a line unless it is already there; the same host fails repeatedly.</summary>
private void Report(string problem)
{
if (!Problems.Contains(problem))
{
Problems.Add(problem);
}
}
/// <summary>
/// Fills in the covers behind rows that are already visible. Nothing awaits this:
/// fetching before showing is what made a stalled picture host freeze the whole run.
/// </summary>
private void LoadCovers(IEnumerable<MetadataMatchViewModel> matches)
{
foreach (var match in matches.Where(match => match.ImageUrl is not null))
{
_ = LoadCoverAsync(match);
}
}
private async Task LoadCoverAsync(MetadataMatchViewModel match)
{
if (await _images.LoadAsync(match.ImageUrl) is { } path)
{
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
}
}
private static string Described(int count) =>
count == 0 ? "Совпадений пока не было." : $"Совпадений найдено: {count}.";
+321 -315
View File
@@ -1,315 +1,321 @@
using System.Collections.ObjectModel;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
using PLib.Application.Metadata;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// Edits a working copy of the settings and only writes it back when the user confirms, so
/// cancelling leaves both the file and the running configuration untouched.
/// </summary>
public sealed partial class SettingsViewModel : ViewModelBase
{
private const double BytesPerMegabyte = 1024 * 1024;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAppSettingsStore _settingsStore;
private readonly IFolderPicker _folderPicker;
private readonly IThemeService _theme;
private readonly ILogger<SettingsViewModel> _logger;
/// <summary>The state the dialog opened with; the baseline every change is compared to.</summary>
private readonly AppSettings _original;
private readonly Subject<SettingsDialogOutcome> _closed = new();
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
private bool _dataWasReset;
public SettingsViewModel(
IServiceScopeFactory scopeFactory,
IAppSettingsStore settingsStore,
IFolderPicker folderPicker,
IThemeService theme,
ILogger<SettingsViewModel> logger)
{
_scopeFactory = scopeFactory;
_settingsStore = settingsStore;
_folderPicker = folderPicker;
_theme = theme;
_logger = logger;
// Read on construction rather than cached anywhere: the panel can be reopened after
// a save, and a stale snapshot would show the settings the application started with.
_original = settingsStore.Current;
Folders = [.. _original.Folders.Select(CreateEntry)];
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
ThumbnailWidth = _original.ThumbnailWidth;
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
// One gate for every clearing button: they all talk to the same database and the same
// cache directories, so letting a second one start mid-flight buys nothing.
var idle = this.WhenAnyValue(x => x.IsBusy).Select(busy => !busy);
DataKinds =
[
new(
LibraryDataKind.Thumbnails,
"Постеры",
"Кадр-обложка карточки. Соберутся заново при следующем сканировании.",
ClearAsync,
idle),
new(
LibraryDataKind.AnimatedPreviews,
"Анимированные превью",
"Кадры, которые прокручиваются под курсором. Самое объёмное на диске.",
ClearAsync,
idle),
new(
LibraryDataKind.PerceptualHashes,
"Отпечатки",
"Нужны только для поиска дублей. Считаются дольше всего: два десятка кадров на файл.",
ClearAsync,
idle),
new(
LibraryDataKind.TechnicalMetadata,
"Технические метаданные",
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
ClearAsync,
idle),
];
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
// A binding cannot negate an int, so the "nothing added yet" hint reads a bool that
// is re-raised whenever the list changes.
Folders.CollectionChanged += OnFoldersChanged;
Disposable
.Create(() => Folders.CollectionChanged -= OnFoldersChanged)
.AddTo(Subscriptions);
ObserveCommandFailures();
}
/// <summary>Fires once, when the dialog should close.</summary>
public IObservable<SettingsDialogOutcome> Closed => _closed;
public ObservableCollection<FolderEntryViewModel> Folders { get; }
public bool HasFolders => Folders.Count > 0;
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> RefreshUsageCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ClearAllCommand { get; }
/// <summary>
/// Everything a scan can rebuild, one row each. Deliberately does not include titles,
/// tags or watch progress: those are the user's, and no rescan would bring them back.
/// </summary>
public IReadOnlyList<LibraryDataViewModel> DataKinds { get; }
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
/// <summary>Width of generated poster frames; height follows the source aspect ratio.</summary>
[Reactive]
public partial int ThumbnailWidth { get; set; }
/// <summary>How far into the video the poster frame is taken, in percent.</summary>
[Reactive]
public partial double ThumbnailPositionPercent { get; set; }
[Reactive]
public partial int MaxIndexingConcurrency { get; set; }
[Reactive]
public partial double MinimumFileSizeMegabytes { get; set; }
[Reactive]
public partial ThemeOption SelectedTheme { get; set; }
[Reactive]
public partial bool IsBusy { get; set; }
[Reactive]
public partial string? Message { get; set; }
// Built from the snapshot the panel opened with, not from scratch: anything this screen
// does not edit — playback volume, for one — has to survive being saved from here.
private AppSettings CurrentDraft => _original with
{
Folders = [.. Folders.Select(entry => entry.Path)],
ThumbnailWidth = ThumbnailWidth,
// Both of these are shown in a friendlier unit than they are stored in, and that
// conversion is lossy: 65 536 bytes displays as 0,06 MB and converts back to 62 915.
// An untouched field therefore keeps the original value verbatim — otherwise merely
// opening the panel and pressing Save would rewrite settings and force a rescan.
ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio)
? _original.ThumbnailPositionRatio
: Math.Round(ThumbnailPositionPercent / 100, 4),
MaxIndexingConcurrency = MaxIndexingConcurrency,
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
? _original.MinimumFileSizeInBytes
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
Theme = SelectedTheme.Mode,
// Rows with nothing in them are what a half-finished edit looks like, and saving them
// would put empty entries in the file for the next opening to show again.
MetadataSources =
[
.. MetadataSources
.Select(entry => entry.ToOptions())
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
],
};
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2);
private async Task AddFolderAsync()
{
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
{
return;
}
Folders.Add(CreateEntry(folder));
}
private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
private FolderEntryViewModel CreateEntry(string path) =>
new(path, entry => Folders.Remove(entry));
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
new(source, entry => MetadataSources.Remove(entry));
private void AddMetadataSource() =>
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
private async Task RefreshUsageAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var usage = await library.GetDataUsageAsync();
foreach (var entry in usage)
{
DataKinds.FirstOrDefault(row => row.Kind == entry.Kind)?.Apply(entry);
}
}
private async Task ClearAsync(LibraryDataKind kinds)
{
IsBusy = true;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.ResetAsync(kinds);
await RefreshUsageAsync();
// The data is gone from disk and from the library, so the grid has to be rebuilt
// regardless of what else the user changes before closing.
_dataWasReset = true;
Message = "Очищено — недостающее соберётся при следующем сканировании";
}
finally
{
IsBusy = false;
}
}
private async Task SaveAsync()
{
var draft = CurrentDraft;
await _settingsStore.SaveAsync(draft);
// The theme is not read from configuration again while the app runs, so apply it here.
_theme.Apply(draft.Theme);
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
}
private void ObserveCommandFailures() =>
Observable
.Merge<Exception>(
[
AddFolderCommand.ThrownExceptions,
AddMetadataSourceCommand.ThrownExceptions,
RefreshUsageCommand.ThrownExceptions,
ClearAllCommand.ThrownExceptions,
SaveCommand.ThrownExceptions,
CancelCommand.ThrownExceptions,
// The per-kind buttons are commands too, and an unobserved failure in any of
// them would be rethrown on the UI thread by ReactiveUI's default handler.
.. DataKinds.Select(row => row.ClearCommand.ThrownExceptions),
])
.Subscribe(ex =>
{
_logger.LogError(ex, "A settings command failed");
Message = "Не удалось выполнить действие — подробности в журнале";
})
.AddTo(Subscriptions);
}
/// <summary>What the settings dialog left behind once it closed.</summary>
/// <param name="Saved">False when the user cancelled or closed the window.</param>
/// <param name="RescanRequired">True when the change affects what the library contains.</param>
/// <param name="Settings">The snapshot that was written, so the caller can wait for it to load.</param>
public sealed record SettingsDialogOutcome(bool Saved, bool RescanRequired, AppSettings? Settings)
{
public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
}
public sealed record ThemeOption(ThemeMode Mode, string Label)
{
public static IReadOnlyList<ThemeOption> All { get; } =
[
new(ThemeMode.System, "Как в системе"),
new(ThemeMode.Light, "Светлая"),
new(ThemeMode.Dark, "Тёмная"),
];
}
using System.Collections.ObjectModel;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
using PLib.Application.Metadata;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// Edits a working copy of the settings and only writes it back when the user confirms, so
/// cancelling leaves both the file and the running configuration untouched.
/// </summary>
public sealed partial class SettingsViewModel : ViewModelBase
{
private const double BytesPerMegabyte = 1024 * 1024;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAppSettingsStore _settingsStore;
private readonly IFolderPicker _folderPicker;
private readonly IThemeService _theme;
private readonly ILogger<SettingsViewModel> _logger;
/// <summary>The state the dialog opened with; the baseline every change is compared to.</summary>
private readonly AppSettings _original;
private readonly Subject<SettingsDialogOutcome> _closed = new();
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
private bool _dataWasReset;
public SettingsViewModel(
IServiceScopeFactory scopeFactory,
IAppSettingsStore settingsStore,
IFolderPicker folderPicker,
IThemeService theme,
ILogger<SettingsViewModel> logger)
{
_scopeFactory = scopeFactory;
_settingsStore = settingsStore;
_folderPicker = folderPicker;
_theme = theme;
_logger = logger;
// Read on construction rather than cached anywhere: the panel can be reopened after
// a save, and a stale snapshot would show the settings the application started with.
_original = settingsStore.Current;
Folders = [.. _original.Folders.Select(CreateEntry)];
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
ThumbnailWidth = _original.ThumbnailWidth;
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
// One gate for every clearing button: they all talk to the same database and the same
// cache directories, so letting a second one start mid-flight buys nothing.
var idle = this.WhenAnyValue(x => x.IsBusy).Select(busy => !busy);
DataKinds =
[
new(
LibraryDataKind.Thumbnails,
"Постеры",
"Кадр-обложка карточки. Соберутся заново при следующем сканировании.",
ClearAsync,
idle),
new(
LibraryDataKind.AnimatedPreviews,
"Анимированные превью",
"Кадры, которые прокручиваются под курсором. Самое объёмное на диске.",
ClearAsync,
idle),
new(
LibraryDataKind.PerceptualHashes,
"Отпечатки",
"Нужны только для поиска дублей. Считаются дольше всего: два десятка кадров на файл.",
ClearAsync,
idle),
new(
LibraryDataKind.TechnicalMetadata,
"Технические метаданные",
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
ClearAsync,
idle),
new(
LibraryDataKind.RemoteImages,
"Изображения меток",
"Фото актёров и логотипы студий. Скачиваются из источника метаданных, а не из файлов, поэтому сканирование их не вернёт — только повторное применение совпадения.",
ClearAsync,
idle),
];
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
// A binding cannot negate an int, so the "nothing added yet" hint reads a bool that
// is re-raised whenever the list changes.
Folders.CollectionChanged += OnFoldersChanged;
Disposable
.Create(() => Folders.CollectionChanged -= OnFoldersChanged)
.AddTo(Subscriptions);
ObserveCommandFailures();
}
/// <summary>Fires once, when the dialog should close.</summary>
public IObservable<SettingsDialogOutcome> Closed => _closed;
public ObservableCollection<FolderEntryViewModel> Folders { get; }
public bool HasFolders => Folders.Count > 0;
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> RefreshUsageCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ClearAllCommand { get; }
/// <summary>
/// Everything a scan can rebuild, one row each. Deliberately does not include titles,
/// tags or watch progress: those are the user's, and no rescan would bring them back.
/// </summary>
public IReadOnlyList<LibraryDataViewModel> DataKinds { get; }
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
/// <summary>Width of generated poster frames; height follows the source aspect ratio.</summary>
[Reactive]
public partial int ThumbnailWidth { get; set; }
/// <summary>How far into the video the poster frame is taken, in percent.</summary>
[Reactive]
public partial double ThumbnailPositionPercent { get; set; }
[Reactive]
public partial int MaxIndexingConcurrency { get; set; }
[Reactive]
public partial double MinimumFileSizeMegabytes { get; set; }
[Reactive]
public partial ThemeOption SelectedTheme { get; set; }
[Reactive]
public partial bool IsBusy { get; set; }
[Reactive]
public partial string? Message { get; set; }
// Built from the snapshot the panel opened with, not from scratch: anything this screen
// does not edit — playback volume, for one — has to survive being saved from here.
private AppSettings CurrentDraft => _original with
{
Folders = [.. Folders.Select(entry => entry.Path)],
ThumbnailWidth = ThumbnailWidth,
// Both of these are shown in a friendlier unit than they are stored in, and that
// conversion is lossy: 65 536 bytes displays as 0,06 MB and converts back to 62 915.
// An untouched field therefore keeps the original value verbatim — otherwise merely
// opening the panel and pressing Save would rewrite settings and force a rescan.
ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio)
? _original.ThumbnailPositionRatio
: Math.Round(ThumbnailPositionPercent / 100, 4),
MaxIndexingConcurrency = MaxIndexingConcurrency,
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
? _original.MinimumFileSizeInBytes
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
Theme = SelectedTheme.Mode,
// Rows with nothing in them are what a half-finished edit looks like, and saving them
// would put empty entries in the file for the next opening to show again.
MetadataSources =
[
.. MetadataSources
.Select(entry => entry.ToOptions())
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
],
};
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2);
private async Task AddFolderAsync()
{
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
{
return;
}
Folders.Add(CreateEntry(folder));
}
private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
private FolderEntryViewModel CreateEntry(string path) =>
new(path, entry => Folders.Remove(entry));
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
new(source, entry => MetadataSources.Remove(entry));
private void AddMetadataSource() =>
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
private async Task RefreshUsageAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var usage = await library.GetDataUsageAsync();
foreach (var entry in usage)
{
DataKinds.FirstOrDefault(row => row.Kind == entry.Kind)?.Apply(entry);
}
}
private async Task ClearAsync(LibraryDataKind kinds)
{
IsBusy = true;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.ResetAsync(kinds);
await RefreshUsageAsync();
// The data is gone from disk and from the library, so the grid has to be rebuilt
// regardless of what else the user changes before closing.
_dataWasReset = true;
Message = "Очищено — недостающее соберётся при следующем сканировании";
}
finally
{
IsBusy = false;
}
}
private async Task SaveAsync()
{
var draft = CurrentDraft;
await _settingsStore.SaveAsync(draft);
// The theme is not read from configuration again while the app runs, so apply it here.
_theme.Apply(draft.Theme);
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
}
private void ObserveCommandFailures() =>
Observable
.Merge<Exception>(
[
AddFolderCommand.ThrownExceptions,
AddMetadataSourceCommand.ThrownExceptions,
RefreshUsageCommand.ThrownExceptions,
ClearAllCommand.ThrownExceptions,
SaveCommand.ThrownExceptions,
CancelCommand.ThrownExceptions,
// The per-kind buttons are commands too, and an unobserved failure in any of
// them would be rethrown on the UI thread by ReactiveUI's default handler.
.. DataKinds.Select(row => row.ClearCommand.ThrownExceptions),
])
.Subscribe(ex =>
{
_logger.LogError(ex, "A settings command failed");
Message = "Не удалось выполнить действие — подробности в журнале";
})
.AddTo(Subscriptions);
}
/// <summary>What the settings dialog left behind once it closed.</summary>
/// <param name="Saved">False when the user cancelled or closed the window.</param>
/// <param name="RescanRequired">True when the change affects what the library contains.</param>
/// <param name="Settings">The snapshot that was written, so the caller can wait for it to load.</param>
public sealed record SettingsDialogOutcome(bool Saved, bool RescanRequired, AppSettings? Settings)
{
public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
}
public sealed record ThemeOption(ThemeMode Mode, string Label)
{
public static IReadOnlyList<ThemeOption> All { get; } =
[
new(ThemeMode.System, "Как в системе"),
new(ThemeMode.Light, "Светлая"),
new(ThemeMode.Dark, "Тёмная"),
];
}
@@ -1,445 +1,470 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
using PLib.Application.Metadata;
using PLib.Desktop.Services;
using PLib.Domain.Videos;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// The media page: one video with its player, its details and its labels.
/// </summary>
/// <remarks>
/// Transport state stays on the player control; what the page owns is the video's identity,
/// everything shown around the picture, and the settings that outlive the page.
/// </remarks>
public sealed partial class VideoPlayerViewModel : ViewModelBase
{
/// <summary>
/// How long the volume has to sit still before it is written. Dragging the slider
/// produces a value per pixel, and each one would otherwise be a file write.
/// </summary>
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAppSettingsStore _settingsStore;
private readonly ILogger _logger;
public VideoPlayerViewModel(
VideoCardViewModel card,
ISystemShell shell,
IServiceScopeFactory scopeFactory,
IAppSettingsStore settingsStore,
ILogger logger,
Action close)
{
_scopeFactory = scopeFactory;
_settingsStore = settingsStore;
_logger = logger;
Card = card;
VideoId = card.Id;
Title = card.Title;
FullPath = card.FullPath;
Source = new Uri(card.FullPath);
ResumeFrom = card.ResumePosition;
Details = BuildDetails(card);
Subtitle = string.Join(
" · ",
new[] { card.QualityText, card.DurationText, card.SizeText }
.Where(part => !string.IsNullOrWhiteSpace(part)));
var settings = settingsStore.Current;
Volume = settings.Volume;
IsMuted = settings.IsMuted;
CloseCommand = ReactiveCommand.Create(close);
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
// Only ever from this button: the whole point of the feature is that nothing talks to
// a remote service about the user's library on its own.
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
LookupMetadataAsync,
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
// Skip the values we just restored: they are already what is on disk.
.Skip(1)
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
.DistinctUntilChanged()
.Subscribe(state => Persist(state.volume, state.muted))
.AddTo(Subscriptions);
ObserveCommandFailures();
}
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
public VideoCardViewModel Card { get; }
public Guid VideoId { get; }
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
[Reactive]
public partial string Title { get; set; }
public string FullPath { get; }
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
public Uri Source { get; }
/// <summary>Quality, duration and size on one line, for the page header.</summary>
public string Subtitle { get; }
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
public TimeSpan? ResumeFrom { get; }
public IReadOnlyList<MetadataRow> Details { get; }
public ObservableCollection<LabelViewModel> Tags { get; } = [];
public ObservableCollection<LabelViewModel> Collections { get; } = [];
public ObservableCollection<LabelViewModel> Performers { get; } = [];
public ObservableCollection<LabelViewModel> Studios { get; } = [];
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
[Reactive]
public partial bool IsLookingUp { get; set; }
/// <summary>What the last lookup came to, including which sources failed.</summary>
[Reactive]
public partial string? MetadataMessage { get; set; }
/// <summary>Free text about the video, once a match has supplied one.</summary>
[Reactive]
public partial string? Description { get; set; }
/// <summary>
/// True while the window is given over to the video. The page hides its own header and
/// the window hides its chrome.
/// </summary>
[Reactive]
public partial bool IsFullScreen { get; set; }
/// <summary>The details and labels panel beside the video.</summary>
[Reactive]
public partial bool AreDetailsVisible { get; set; }
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
[Reactive]
public partial double Volume { get; set; }
[Reactive]
public partial bool IsMuted { get; set; }
[Reactive]
public partial string NewTag { get; set; } = string.Empty;
[Reactive]
public partial string NewCollection { get; set; } = string.Empty;
/// <summary>
/// Records where playback stopped and refreshes the card behind the page, so the grid
/// shows the new progress without waiting for a rescan.
/// </summary>
public async Task SaveProgressAsync(TimeSpan position)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.SaveProgressAsync(VideoId, position);
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
{
Card.Apply(refreshed);
}
}
catch (Exception ex)
{
// A lost resume position is not worth surfacing to someone who just closed a video.
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
}
}
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
{
var rows = new List<MetadataRow>
{
new("Длительность", card.DurationText),
new("Размер", card.SizeText),
};
if (card.Width is { } width && card.Height is { } height)
{
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
}
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
{
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
}
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
if (card.LastPlayedAt is { } lastPlayed)
{
rows.Add(new MetadataRow(
"Последний просмотр",
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
}
if (card.PlayCount > 0)
{
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
}
if (card.PerceptualHash is { } hash)
{
// Printed as hex: it is a bit pattern compared by Hamming distance, and the
// decimal form of a 64-bit value tells nobody anything.
rows.Add(new MetadataRow("pHash", hash.ToString("x16", CultureInfo.InvariantCulture)));
}
rows.Add(new MetadataRow("Файл", card.FullPath));
return rows;
}
private async Task LoadLabelsAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var video = await library.GetVideoWithLabelsAsync(VideoId);
Tags.Clear();
Collections.Clear();
Performers.Clear();
Studios.Clear();
if (video is null)
{
return;
}
Title = video.Title;
Description = video.Description;
// The grid filters by label, so the card behind this page has to hear about every
// label added or removed here — otherwise a tag would not narrow the grid until the
// library was reloaded.
Card.ApplyLabels(video.Labels);
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
{
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
}
}
private async Task LookupMetadataAsync()
{
IsLookingUp = true;
Matches.Clear();
// The results land in the side panel, so opening it is part of running the lookup —
// otherwise the button would appear to do nothing.
AreDetailsVisible = true;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var result = await library.FindMetadataAsync(VideoId);
if (!result.HasPerceptualHash)
{
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
return;
}
foreach (var match in result.Matches)
{
Matches.Add(new MetadataMatchViewModel(match, ApplyMetadataAsync));
}
MetadataMessage = Describe(result);
}
finally
{
IsLookingUp = false;
}
}
/// <summary>
/// Says what came back and what did not. A source that failed is reported even when the
/// others found something, because "one match" and "one match, and StashDB was down" call
/// for different next steps.
/// </summary>
private static string Describe(MetadataLookupResult result)
{
var found = result.Matches.Count == 0
? "Совпадений не найдено"
: $"Найдено совпадений: {result.Matches.Count}";
return result.Failures.Count == 0
? found
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
}
/// <summary>
/// Handed to every match as its apply action. It swallows failures on purpose: the
/// commands live on the match rows, which come and go with each lookup, so there is no
/// stable place to observe their exceptions — and an unobserved one takes the process down.
/// </summary>
private async Task ApplyMetadataAsync(VideoMetadataMatch match)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.ApplyMetadataAsync(VideoId, match);
await LoadLabelsAsync();
// The grid behind the page shows the old title until the card is told otherwise.
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
{
Card.Apply(refreshed);
}
Matches.Clear();
MetadataMessage = $"Применено: {match.SourceName}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
MetadataMessage = "Не удалось применить — подробности в журнале";
}
}
private async Task AttachAsync(string name, LabelKind kind)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.AttachLabelAsync(VideoId, name, kind);
if (kind == LabelKind.Tag)
{
NewTag = string.Empty;
}
else
{
NewCollection = string.Empty;
}
await LoadLabelsAsync();
}
private async Task DetachAsync(LabelViewModel label)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.DetachLabelAsync(VideoId, label.Id);
Target(label.Kind).Remove(label);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
}
}
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
{
LabelKind.Collection => Collections,
LabelKind.Performer => Performers,
LabelKind.Studio => Studios,
_ => Tags,
};
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
private async Task PersistAsync(double volume, bool isMuted)
{
try
{
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
}
catch (Exception ex)
{
// Losing a volume level is not worth interrupting playback over.
_logger.LogWarning(ex, "Could not save the playback volume");
}
}
private void ObserveCommandFailures() =>
Observable
.Merge(
CloseCommand.ThrownExceptions,
ToggleFullScreenCommand.ThrownExceptions,
ToggleMuteCommand.ThrownExceptions,
ToggleDetailsCommand.ThrownExceptions,
OpenExternallyCommand.ThrownExceptions,
RevealCommand.ThrownExceptions,
AddTagCommand.ThrownExceptions,
AddCollectionCommand.ThrownExceptions,
LoadLabelsCommand.ThrownExceptions,
LookupMetadataCommand.ThrownExceptions)
.Subscribe(ex =>
{
_logger.LogError(ex, "A media page command failed");
MetadataMessage = "Что-то пошло не так — подробности в журнале";
})
.AddTo(Subscriptions);
}
using System.Collections.ObjectModel;
using System.Globalization;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
using PLib.Application.Metadata;
using PLib.Desktop.Services;
using PLib.Domain.Videos;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// The media page: one video with its player, its details and its labels.
/// </summary>
/// <remarks>
/// Transport state stays on the player control; what the page owns is the video's identity,
/// everything shown around the picture, and the settings that outlive the page.
/// </remarks>
public sealed partial class VideoPlayerViewModel : ViewModelBase
{
/// <summary>
/// How long the volume has to sit still before it is written. Dragging the slider
/// produces a value per pixel, and each one would otherwise be a file write.
/// </summary>
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAppSettingsStore _settingsStore;
private readonly RemoteImageLoader _images;
private readonly ILogger _logger;
public VideoPlayerViewModel(
VideoCardViewModel card,
ISystemShell shell,
IServiceScopeFactory scopeFactory,
IAppSettingsStore settingsStore,
RemoteImageLoader images,
ILogger logger,
Action close)
{
_scopeFactory = scopeFactory;
_settingsStore = settingsStore;
_images = images;
_logger = logger;
Card = card;
VideoId = card.Id;
Title = card.Title;
FullPath = card.FullPath;
Source = new Uri(card.FullPath);
ResumeFrom = card.ResumePosition;
Details = BuildDetails(card);
Subtitle = string.Join(
" · ",
new[] { card.QualityText, card.DurationText, card.SizeText }
.Where(part => !string.IsNullOrWhiteSpace(part)));
var settings = settingsStore.Current;
Volume = settings.Volume;
IsMuted = settings.IsMuted;
CloseCommand = ReactiveCommand.Create(close);
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
// Only ever from this button: the whole point of the feature is that nothing talks to
// a remote service about the user's library on its own.
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
LookupMetadataAsync,
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
// Skip the values we just restored: they are already what is on disk.
.Skip(1)
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
.DistinctUntilChanged()
.Subscribe(state => Persist(state.volume, state.muted))
.AddTo(Subscriptions);
_images.Problems
.Subscribe(problem => Dispatcher.UIThread.Post(() => ImageProblem = problem))
.AddTo(Subscriptions);
ObserveCommandFailures();
}
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
public VideoCardViewModel Card { get; }
public Guid VideoId { get; }
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
[Reactive]
public partial string Title { get; set; }
public string FullPath { get; }
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
public Uri Source { get; }
/// <summary>Quality, duration and size on one line, for the page header.</summary>
public string Subtitle { get; }
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
public TimeSpan? ResumeFrom { get; }
public IReadOnlyList<MetadataRow> Details { get; }
public ObservableCollection<LabelViewModel> Tags { get; } = [];
public ObservableCollection<LabelViewModel> Collections { get; } = [];
public ObservableCollection<LabelViewModel> Performers { get; } = [];
public ObservableCollection<LabelViewModel> Studios { get; } = [];
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
[Reactive]
public partial bool IsLookingUp { get; set; }
/// <summary>What the last lookup came to, including which sources failed.</summary>
[Reactive]
public partial string? MetadataMessage { get; set; }
/// <summary>Why the candidates have no pictures, when that is the reason.</summary>
[Reactive]
public partial string? ImageProblem { get; set; }
/// <summary>Free text about the video, once a match has supplied one.</summary>
[Reactive]
public partial string? Description { get; set; }
/// <summary>
/// True while the window is given over to the video. The page hides its own header and
/// the window hides its chrome.
/// </summary>
[Reactive]
public partial bool IsFullScreen { get; set; }
/// <summary>The details and labels panel beside the video.</summary>
[Reactive]
public partial bool AreDetailsVisible { get; set; }
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
[Reactive]
public partial double Volume { get; set; }
[Reactive]
public partial bool IsMuted { get; set; }
[Reactive]
public partial string NewTag { get; set; } = string.Empty;
[Reactive]
public partial string NewCollection { get; set; } = string.Empty;
/// <summary>
/// Records where playback stopped and refreshes the card behind the page, so the grid
/// shows the new progress without waiting for a rescan.
/// </summary>
public async Task SaveProgressAsync(TimeSpan position)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.SaveProgressAsync(VideoId, position);
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
{
Card.Apply(refreshed);
}
}
catch (Exception ex)
{
// A lost resume position is not worth surfacing to someone who just closed a video.
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
}
}
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
{
var rows = new List<MetadataRow>
{
new("Длительность", card.DurationText),
new("Размер", card.SizeText),
};
if (card.Width is { } width && card.Height is { } height)
{
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
}
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
{
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
}
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
if (card.LastPlayedAt is { } lastPlayed)
{
rows.Add(new MetadataRow(
"Последний просмотр",
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
}
if (card.PlayCount > 0)
{
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
}
if (card.PerceptualHash is { } hash)
{
// Printed as hex: it is a bit pattern compared by Hamming distance, and the
// decimal form of a 64-bit value tells nobody anything.
rows.Add(new MetadataRow("pHash", hash.ToString("x16", CultureInfo.InvariantCulture)));
}
rows.Add(new MetadataRow("Файл", card.FullPath));
return rows;
}
private async Task LoadLabelsAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var video = await library.GetVideoWithLabelsAsync(VideoId);
Tags.Clear();
Collections.Clear();
Performers.Clear();
Studios.Clear();
if (video is null)
{
return;
}
Title = video.Title;
Description = video.Description;
// The grid filters by label, so the card behind this page has to hear about every
// label added or removed here — otherwise a tag would not narrow the grid until the
// library was reloaded.
Card.ApplyLabels(video.Labels);
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
{
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
}
}
private async Task LookupMetadataAsync()
{
IsLookingUp = true;
Matches.Clear();
ImageProblem = null;
// The results land in the side panel, so opening it is part of running the lookup —
// otherwise the button would appear to do nothing.
AreDetailsVisible = true;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var result = await library.FindMetadataAsync(VideoId);
if (!result.HasPerceptualHash)
{
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
return;
}
foreach (var match in result.Matches)
{
var row = new MetadataMatchViewModel(match, ApplyMetadataAsync);
Matches.Add(row);
// Started, not awaited: the candidate is on screen, and its cover follows.
_ = LoadCoverAsync(row);
}
MetadataMessage = Describe(result);
}
finally
{
IsLookingUp = false;
}
}
/// <summary>
/// Says what came back and what did not. A source that failed is reported even when the
/// others found something, because "one match" and "one match, and StashDB was down" call
/// for different next steps.
/// </summary>
private async Task LoadCoverAsync(MetadataMatchViewModel match)
{
if (await _images.LoadAsync(match.ImageUrl) is { } path)
{
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
}
}
private static string Describe(MetadataLookupResult result)
{
var found = result.Matches.Count == 0
? "Совпадений не найдено"
: $"Найдено совпадений: {result.Matches.Count}";
return result.Failures.Count == 0
? found
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
}
/// <summary>
/// Handed to every match as its apply action. It swallows failures on purpose: the
/// commands live on the match rows, which come and go with each lookup, so there is no
/// stable place to observe their exceptions — and an unobserved one takes the process down.
/// </summary>
private async Task ApplyMetadataAsync(VideoMetadataMatch match)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.ApplyMetadataAsync(VideoId, match);
await LoadLabelsAsync();
// The grid behind the page shows the old title until the card is told otherwise.
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
{
Card.Apply(refreshed);
}
Matches.Clear();
MetadataMessage = $"Применено: {match.SourceName}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
MetadataMessage = "Не удалось применить — подробности в журнале";
}
}
private async Task AttachAsync(string name, LabelKind kind)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.AttachLabelAsync(VideoId, name, kind);
if (kind == LabelKind.Tag)
{
NewTag = string.Empty;
}
else
{
NewCollection = string.Empty;
}
await LoadLabelsAsync();
}
private async Task DetachAsync(LabelViewModel label)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.DetachLabelAsync(VideoId, label.Id);
Target(label.Kind).Remove(label);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
}
}
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
{
LabelKind.Collection => Collections,
LabelKind.Performer => Performers,
LabelKind.Studio => Studios,
_ => Tags,
};
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
private async Task PersistAsync(double volume, bool isMuted)
{
try
{
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
}
catch (Exception ex)
{
// Losing a volume level is not worth interrupting playback over.
_logger.LogWarning(ex, "Could not save the playback volume");
}
}
private void ObserveCommandFailures() =>
Observable
.Merge(
CloseCommand.ThrownExceptions,
ToggleFullScreenCommand.ThrownExceptions,
ToggleMuteCommand.ThrownExceptions,
ToggleDetailsCommand.ThrownExceptions,
OpenExternallyCommand.ThrownExceptions,
RevealCommand.ThrownExceptions,
AddTagCommand.ThrownExceptions,
AddCollectionCommand.ThrownExceptions,
LoadLabelsCommand.ThrownExceptions,
LookupMetadataCommand.ThrownExceptions)
.Subscribe(ex =>
{
_logger.LogError(ex, "A media page command failed");
MetadataMessage = "Что-то пошло не так — подробности в журнале";
})
.AddTo(Subscriptions);
}
+40 -14
View File
@@ -309,25 +309,51 @@
<ItemsRepeater ItemsSource="{Binding Entities}">
<ItemsRepeater.Layout>
<UniformGridLayout ItemsStretch="Fill"
MinItemWidth="220"
MinItemHeight="56"
MinColumnSpacing="12"
MinRowSpacing="12" />
MinItemWidth="164"
MinItemHeight="212"
MinColumnSpacing="14"
MinRowSpacing="14" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="vm:LabelSummaryViewModel">
<Button Classes="entity" Command="{Binding OpenCommand}">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Text="{Binding Name}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Name}"
FontSize="13"
Foreground="{DynamicResource TextPrimaryBrush}" />
<Border Grid.Column="1" Classes="badge" VerticalAlignment="Center">
<TextBlock Text="{Binding CountText}" />
<Grid RowDefinitions="*,Auto" RowSpacing="8">
<Border Grid.Row="0"
CornerRadius="9"
ClipToBounds="True"
Background="{DynamicResource ThumbnailPlaceholderBrush}">
<Panel>
<!-- The initial sits underneath, so it shows through for every
label without a picture and while one is still decoding. -->
<TextBlock Text="{Binding Initial}"
FontSize="34"
FontWeight="SemiBold"
Opacity="0.35"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{DynamicResource TextTertiaryBrush}" />
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
<Border Classes="badge"
Margin="6"
HorizontalAlignment="Right"
VerticalAlignment="Bottom">
<TextBlock Text="{Binding CountText}" />
</Border>
</Panel>
</Border>
<TextBlock Grid.Row="1"
Text="{Binding Name}"
ToolTip.Tip="{Binding Name}"
FontSize="12.5"
MaxLines="2"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
</Grid>
</Button>
</DataTemplate>
+18 -3
View File
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
x:Class="PLib.Desktop.Views.MetadataScanView"
x:DataType="vm:MetadataScanViewModel">
@@ -96,8 +97,22 @@
<ItemsControl ItemsSource="{Binding Matches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
<StackPanel Grid.Column="0" Spacing="2">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
<!-- The candidate's own cover. Absent for a source that has none, and
the row simply loses the column rather than reserving a blank. -->
<Border Grid.Column="0"
Width="128"
Height="72"
CornerRadius="7"
ClipToBounds="True"
VerticalAlignment="Top"
Background="{DynamicResource ThumbnailPlaceholderBrush}"
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="256" />
</Border>
<StackPanel Grid.Column="1" Spacing="2">
<TextBlock Text="{Binding Title}"
FontSize="12.5"
TextWrapping="Wrap"
@@ -117,7 +132,7 @@
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
<Button Grid.Column="1"
<Button Grid.Column="2"
VerticalAlignment="Center"
Command="{Binding ApplyCommand}"
Content="Применить" />
+328 -310
View File
@@ -1,310 +1,328 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
x:Class="PLib.Desktop.Views.VideoPlayerView"
x:DataType="vm:VideoPlayerViewModel">
<UserControl.Resources>
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
<Border Background="{DynamicResource AccentSoftBrush}"
CornerRadius="12"
Padding="9,3"
Margin="0,0,6,6">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Name}"
FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource AccentBrush}" />
<Button Command="{Binding RemoveCommand}"
Classes="transport"
Padding="2"
ToolTip.Tip="Убрать">
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
</Button>
</StackPanel>
</Border>
</DataTemplate>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Button.transport">
<Setter Property="Padding" Value="8" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.time">
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="MinWidth" Value="46" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*,Auto">
<!-- ======================= Page header ======================= -->
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10" IsVisible="{Binding !IsFullScreen}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<Button Grid.Column="0"
Classes="transport"
Command="{Binding CloseCommand}"
ToolTip.Tip="Назад к библиотеке">
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
</Button>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Classes="panelTitle"
Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding FullPath}" />
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="transport"
Command="{Binding ToggleDetailsCommand}"
ToolTip.Tip="Сведения и метки">
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
</Button>
<Button Classes="transport"
Command="{Binding LookupMetadataCommand}"
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
</Button>
<Button Classes="transport"
Command="{Binding OpenExternallyCommand}"
ToolTip.Tip="Открыть во внешнем плеере">
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
</Button>
<Button Classes="transport"
Command="{Binding RevealCommand}"
ToolTip.Tip="Показать в папке">
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ======================= Video ======================= -->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
<controls:VlcVideoView Name="Player"
Source="{Binding Source}"
AutoPlay="True"
Volume="{Binding Volume}"
IsMuted="{Binding IsMuted}" />
<Border Name="ErrorBar"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="460"
CornerRadius="10"
Background="{DynamicResource SurfaceBrush}"
Padding="16,12"
IsVisible="False">
<TextBlock Name="ErrorText"
TextWrapping="Wrap"
TextAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</Border>
</Panel>
<!-- ======================= Details and labels ======================= -->
<Border Grid.Column="1"
Width="320"
IsVisible="{Binding AreDetailsVisible}"
Background="{DynamicResource PanelBackgroundBrush}">
<ScrollViewer Padding="16,14">
<StackPanel Spacing="16">
<StackPanel Spacing="8">
<TextBlock Classes="panelTitle" Text="Сведения" />
<ItemsControl ItemsSource="{Binding Details}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataRow">
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
<TextBlock Grid.Column="0"
Text="{Binding Label}"
FontSize="12"
Foreground="{DynamicResource TextTertiaryBrush}" />
<TextBlock Grid.Column="1"
Text="{Binding Value}"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
<StackPanel Spacing="8"
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Classes="panelTitle" Text="Метаданные" />
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
<ItemsControl ItemsSource="{Binding Matches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
<Border Background="{DynamicResource CardBackgroundBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="10,8"
Margin="0,0,0,8">
<StackPanel Spacing="5">
<TextBlock Text="{Binding Title}"
FontSize="12.5"
FontWeight="SemiBold"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Studios, StringFormat='Студия: {0}'}"
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Tags, StringFormat='Теги: {0}'}"
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
MaxLines="4"
TextTrimming="CharacterEllipsis"
Text="{Binding Description}"
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button HorizontalAlignment="Left"
Command="{Binding ApplyCommand}"
Content="Применить" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="8"
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Classes="panelTitle" Text="Описание" />
<TextBlock Text="{Binding Description}"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
<TextBlock Classes="panelTitle" Text="Актёры" />
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
<TextBlock Classes="panelTitle" Text="Студии" />
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Classes="panelTitle" Text="Теги" />
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Classes="panelTitle" Text="Коллекции" />
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
</TextBox.KeyBindings>
</TextBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
<!-- ======================= Transport ======================= -->
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
</Button>
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
<Slider Grid.Column="2"
Name="Seek"
Minimum="0"
Maximum="1"
VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
</Button>
<Slider Grid.Column="5"
Width="90"
Minimum="0"
Maximum="1"
Value="{Binding Volume}"
VerticalAlignment="Center" />
<Button Grid.Column="6"
Classes="transport"
Command="{Binding ToggleFullScreenCommand}"
ToolTip.Tip="Во весь экран (F11)">
<icons:MaterialIcon Name="FullScreenIcon" Kind="Fullscreen" Width="19" Height="19" />
</Button>
</Grid>
</Border>
</Grid>
</UserControl>
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
x:Class="PLib.Desktop.Views.VideoPlayerView"
x:DataType="vm:VideoPlayerViewModel">
<UserControl.Resources>
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
<Border Background="{DynamicResource AccentSoftBrush}"
CornerRadius="12"
Padding="9,3"
Margin="0,0,6,6">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Name}"
FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource AccentBrush}" />
<Button Command="{Binding RemoveCommand}"
Classes="transport"
Padding="2"
ToolTip.Tip="Убрать">
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
</Button>
</StackPanel>
</Border>
</DataTemplate>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Button.transport">
<Setter Property="Padding" Value="8" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.time">
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="MinWidth" Value="46" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*,Auto">
<!-- ======================= Page header ======================= -->
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10" IsVisible="{Binding !IsFullScreen}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<Button Grid.Column="0"
Classes="transport"
Command="{Binding CloseCommand}"
ToolTip.Tip="Назад к библиотеке">
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
</Button>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Classes="panelTitle"
Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding FullPath}" />
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="transport"
Command="{Binding ToggleDetailsCommand}"
ToolTip.Tip="Сведения и метки">
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
</Button>
<Button Classes="transport"
Command="{Binding LookupMetadataCommand}"
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
</Button>
<Button Classes="transport"
Command="{Binding OpenExternallyCommand}"
ToolTip.Tip="Открыть во внешнем плеере">
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
</Button>
<Button Classes="transport"
Command="{Binding RevealCommand}"
ToolTip.Tip="Показать в папке">
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ======================= Video ======================= -->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
<controls:VlcVideoView Name="Player"
Source="{Binding Source}"
AutoPlay="True"
Volume="{Binding Volume}"
IsMuted="{Binding IsMuted}" />
<Border Name="ErrorBar"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="460"
CornerRadius="10"
Background="{DynamicResource SurfaceBrush}"
Padding="16,12"
IsVisible="False">
<TextBlock Name="ErrorText"
TextWrapping="Wrap"
TextAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</Border>
</Panel>
<!-- ======================= Details and labels ======================= -->
<Border Grid.Column="1"
Width="320"
IsVisible="{Binding AreDetailsVisible}"
Background="{DynamicResource PanelBackgroundBrush}">
<ScrollViewer Padding="16,14">
<StackPanel Spacing="16">
<StackPanel Spacing="8">
<TextBlock Classes="panelTitle" Text="Сведения" />
<ItemsControl ItemsSource="{Binding Details}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataRow">
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
<TextBlock Grid.Column="0"
Text="{Binding Label}"
FontSize="12"
Foreground="{DynamicResource TextTertiaryBrush}" />
<TextBlock Grid.Column="1"
Text="{Binding Value}"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
<StackPanel Spacing="8"
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Classes="panelTitle" Text="Метаданные" />
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
<!-- Why the candidates have no pictures. An empty square looks the same whether
the source has none or the host is unreachable, so it has to be said. -->
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="{Binding ImageProblem}"
IsVisible="{Binding ImageProblem, Converter={x:Static ObjectConverters.IsNotNull}}" />
<ItemsControl ItemsSource="{Binding Matches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
<Border Background="{DynamicResource CardBackgroundBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="10,8"
Margin="0,0,0,8">
<StackPanel Spacing="5">
<!-- The candidate's own cover, across the top: the panel is narrow, so
side by side would leave neither the picture nor the text usable. -->
<Border Height="112"
CornerRadius="6"
ClipToBounds="True"
Background="{DynamicResource ThumbnailPlaceholderBrush}"
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
</Border>
<TextBlock Text="{Binding Title}"
FontSize="12.5"
FontWeight="SemiBold"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Studios, StringFormat='Студия: {0}'}"
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Tags, StringFormat='Теги: {0}'}"
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
MaxLines="4"
TextTrimming="CharacterEllipsis"
Text="{Binding Description}"
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button HorizontalAlignment="Left"
Command="{Binding ApplyCommand}"
Content="Применить" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="8"
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Classes="panelTitle" Text="Описание" />
<TextBlock Text="{Binding Description}"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
<TextBlock Classes="panelTitle" Text="Актёры" />
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
<TextBlock Classes="panelTitle" Text="Студии" />
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Classes="panelTitle" Text="Теги" />
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Classes="panelTitle" Text="Коллекции" />
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
</TextBox.KeyBindings>
</TextBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
<!-- ======================= Transport ======================= -->
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
</Button>
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
<Slider Grid.Column="2"
Name="Seek"
Minimum="0"
Maximum="1"
VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
</Button>
<Slider Grid.Column="5"
Width="90"
Minimum="0"
Maximum="1"
Value="{Binding Volume}"
VerticalAlignment="Center" />
<Button Grid.Column="6"
Classes="transport"
Command="{Binding ToggleFullScreenCommand}"
ToolTip.Tip="Во весь экран (F11)">
<icons:MaterialIcon Name="FullScreenIcon" Kind="Fullscreen" Width="19" Height="19" />
</Button>
</Grid>
</Border>
</Grid>
</UserControl>
+15
View File
@@ -60,6 +60,13 @@ public sealed class LibraryLabel
public LabelKind Kind { get; private set; }
/// <summary>
/// Absolute path of the cached picture for this label, or <c>null</c>. Only some labels
/// ever have one: a metadata source knows what a performer and a studio look like, and
/// has no picture at all for a tag.
/// </summary>
public string? ImagePath { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyCollection<VideoItem> 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;
}
+71 -70
View File
@@ -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
{
/// <summary>
/// Registers everything the application layer declares as an abstraction. The composition
/// root (the UI project) never sees EF Core or ffmpeg types directly.
/// </summary>
public static IServiceCollection AddPLibInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<LibraryOptions>()
.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<MetadataOptions>()
.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<IAppPaths, AppPaths>();
services.AddDbContext<LibraryDbContext>((provider, builder) =>
{
var paths = provider.GetRequiredService<IAppPaths>();
builder.UseSqlite($"Data Source={paths.DatabaseFile}");
});
services.AddScoped<IVideoRepository, EfVideoRepository>();
services.AddScoped<ILabelRepository, EfLabelRepository>();
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>();
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
services.AddSingleton<IAnimatedPreviewGenerator, FfmpegAnimatedPreviewGenerator>();
services.AddSingleton<IVideoPerceptualHasher, FfmpegVideoPerceptualHasher>();
// 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<IMetadataProvider, StashBoxMetadataProvider>();
services.AddScoped<ILibraryService, LibraryService>();
services.AddHostedService<DatabaseInitializer>();
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
{
/// <summary>
/// Registers everything the application layer declares as an abstraction. The composition
/// root (the UI project) never sees EF Core or ffmpeg types directly.
/// </summary>
public static IServiceCollection AddPLibInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<LibraryOptions>()
.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<MetadataOptions>()
.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<IAppPaths, AppPaths>();
services.AddDbContext<LibraryDbContext>((provider, builder) =>
{
var paths = provider.GetRequiredService<IAppPaths>();
builder.UseSqlite($"Data Source={paths.DatabaseFile}");
});
services.AddScoped<IVideoRepository, EfVideoRepository>();
services.AddScoped<ILabelRepository, EfLabelRepository>();
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>();
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
services.AddSingleton<IAnimatedPreviewGenerator, FfmpegAnimatedPreviewGenerator>();
services.AddSingleton<IVideoPerceptualHasher, FfmpegVideoPerceptualHasher>();
// 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<IMetadataProvider, StashBoxMetadataProvider>();
services.AddSingleton<IRemoteImageCache, HttpRemoteImageCache>();
services.AddScoped<ILibraryService, LibraryService>();
services.AddHostedService<DatabaseInitializer>();
return services;
}
}
@@ -0,0 +1,246 @@
using Microsoft.Extensions.Logging;
using PLib.Application.Abstractions;
using PLib.Infrastructure.Media;
using PLib.Infrastructure.Storage;
namespace PLib.Infrastructure.Metadata;
/// <summary>
/// Downloads and keeps the pictures a metadata source points at.
/// </summary>
/// <remarks>
/// A cache of derived files like any other, so it inherits the same housekeeping the poster
/// frames and animated previews get: staged writes, orphan collection, a reported size and a
/// clear button. What differs is only where the bytes come from.
/// </remarks>
public sealed class HttpRemoteImageCache(
IHttpClientFactory httpClientFactory,
IAppPaths paths,
ILogger<HttpRemoteImageCache> logger)
: MediaArtifactCache(paths.RemoteImageDirectory, logger), IRemoteImageCache
{
/// <summary>
/// Refused above this. A card picture is tens of kilobytes; anything far larger is either
/// the wrong URL or something that should not be downloaded unattended.
/// </summary>
private const long MaximumBytes = 8 * 1024 * 1024;
/// <summary>
/// How long one picture may take, headers and body together. Short on purpose: this is a
/// thumbnail on a row the user is already reading, and one that has not arrived in ten
/// seconds has stopped being worth the connection it is holding.
/// </summary>
private static readonly TimeSpan DownloadTimeout = TimeSpan.FromSeconds(10);
/// <summary>How many failures in a row before a host is left alone for a while.</summary>
private const int FailuresBeforeCoolOff = 3;
/// <summary>How long a host is skipped after it has failed that many times.</summary>
private static readonly TimeSpan CoolOff = TimeSpan.FromMinutes(5);
/// <summary>
/// Failure counts per host, so a picture server that is unreachable is asked a few times
/// and then left alone.
/// </summary>
/// <remarks>
/// A run over a whole library produces a cover per candidate, all from the same host. When
/// that host answers its headers and then stalls, every one of them holds a connection open
/// for the full timeout — a hundred candidates would mean a hundred stalled sockets and a
/// log full of the same line. Three tries is enough to tell a bad host from a bad picture.
/// </remarks>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, HostHealth> _hosts =
new(StringComparer.OrdinalIgnoreCase);
public async Task<RemoteImage> GetOrCreateAsync(
string imageUrl,
CancellationToken cancellationToken = default)
{
// Only ever http(s): the URL arrives from a remote server, and a scheme like file://
// would turn "fetch a picture" into "read a path of the server's choosing".
if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return RemoteImage.None;
}
var target = Path.Combine(CacheDirectory, $"{BuildCacheKey(uri)}{ExtensionOf(uri)}");
if (File.Exists(target))
{
return RemoteImage.At(target);
}
if (IsCoolingOff(uri.Host))
{
return RemoteImage.None;
}
var staging = CreateStagingPath();
try
{
if (!await DownloadAsync(uri, staging, cancellationToken))
{
return NoteFailure(uri.Host);
}
File.Move(staging, target, overwrite: true);
_hosts.TryRemove(uri.Host, out _);
return RemoteImage.At(target);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not download the picture at {Url}", uri);
return NoteFailure(uri.Host);
}
finally
{
if (File.Exists(staging))
{
File.Delete(staging);
}
}
}
private async Task<bool> DownloadAsync(Uri uri, string staging, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient(StashBoxMetadataProvider.HttpClientName);
// A deadline of our own, covering the body as well as the headers. HttpClient.Timeout
// stops applying the moment ResponseHeadersRead hands the response back, so a server
// that opens a connection and then stalls mid-picture would otherwise hang for ever.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(DownloadTimeout);
try
{
// Headers first, so an oversized body is refused before any of it is on disk.
using var response = await client.GetAsync(
uri,
HttpCompletionOption.ResponseHeadersRead,
deadline.Token);
if (!response.IsSuccessStatusCode)
{
logger.LogDebug("Picture at {Url} answered {Status}", uri, (int)response.StatusCode);
return false;
}
if (response.Content.Headers.ContentLength > MaximumBytes)
{
logger.LogWarning("Picture at {Url} is larger than the limit; skipped", uri);
return false;
}
await using var source = await response.Content.ReadAsStreamAsync(deadline.Token);
await using var destination = File.Create(staging);
// Copied through a counting guard as well: a server may answer without a length,
// and a stream that never ends would otherwise fill the disk.
var buffer = new byte[81_920];
long written = 0;
int read;
while ((read = await source.ReadAsync(buffer, deadline.Token)) > 0)
{
written += read;
if (written > MaximumBytes)
{
logger.LogWarning("Picture at {Url} exceeded the limit while downloading; skipped", uri);
return false;
}
await destination.WriteAsync(buffer.AsMemory(0, read), deadline.Token);
}
return written > 0;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Downloading the picture at {Url} timed out", uri);
return false;
}
}
private bool IsCoolingOff(string host)
{
if (!_hosts.TryGetValue(host, out var health) || health.SkipUntil is not { } until)
{
return false;
}
if (DateTimeOffset.UtcNow < until)
{
return true;
}
// The window is over; the host gets its tries back rather than one grudging attempt,
// because a network that has come back should not be judged on a single packet.
_hosts.TryRemove(host, out _);
return false;
}
/// <summary>
/// Records a failure and, once the host has run out of tries, says so out loud.
/// </summary>
/// <remarks>
/// The complaint is returned exactly once per cool-off window — at the moment the host is
/// given up on. Reporting every miss would put the same line beside every candidate; never
/// reporting would leave the user looking at rows of empty squares with no idea why.
/// </remarks>
private RemoteImage NoteFailure(string host)
{
var health = _hosts.AddOrUpdate(
host,
_ => new HostHealth(1, null),
(_, existing) => existing with { Failures = existing.Failures + 1 });
if (health.Failures < FailuresBeforeCoolOff || health.SkipUntil is not null)
{
return RemoteImage.None;
}
_hosts[host] = health with { SkipUntil = DateTimeOffset.UtcNow + CoolOff };
logger.LogWarning(
"{Host} failed {Count} picture downloads in a row; leaving it alone for {Minutes} minutes",
host,
health.Failures,
CoolOff.TotalMinutes);
return RemoteImage.Unreachable(
$"{host} не отдаёт картинки — изображения не загрузятся. Повтор через {CoolOff.TotalMinutes:0} мин.");
}
/// <summary>How a picture host has been behaving, and until when it is being skipped.</summary>
private sealed record HostHealth(int Failures, DateTimeOffset? SkipUntil);
/// <summary>
/// Keyed by the URL rather than by whatever asked for it: one photograph is reused across
/// every video a performer appears in, and two callers may legitimately want the same file.
/// </summary>
private static string BuildCacheKey(Uri uri) =>
Convert.ToHexStringLower(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32];
/// <summary>
/// The extension from the URL when it looks like an image, else <c>.jpg</c>. It is only a
/// hint for whatever opens the file later; nothing here trusts it.
/// </summary>
private static string ExtensionOf(Uri uri)
{
var extension = Path.GetExtension(uri.AbsolutePath);
return extension.ToLowerInvariant() switch
{
".jpg" or ".jpeg" or ".png" or ".webp" or ".gif" or ".bmp" => extension.ToLowerInvariant(),
_ => ".jpg",
};
}
}
@@ -27,6 +27,9 @@ public sealed class StashBoxMetadataProvider(
/// <summary>Name of the configured <see cref="HttpClient"/>; see the DI registration.</summary>
public const string HttpClientName = "metadata";
/// <summary>Below this a picture is too small for a card and gets passed over.</summary>
private const int MinimumImageWidth = 320;
/// <summary>
/// The fingerprint queries, newest first.
/// </summary>
@@ -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));
/// <summary>
/// A string property, or <c>null</c> for anything else — including when the element itself
@@ -230,7 +239,8 @@ public sealed class StashBoxMetadataProvider(
? value.GetString()
: null;
private static IReadOnlyList<string> Names(JsonElement scene, string property)
/// <summary>A named array — tags, and anything else shaped like them.</summary>
private static IReadOnlyList<MetadataEntity> Entities(JsonElement scene, string property)
{
if (scene.ValueKind != JsonValueKind.Object ||
!scene.TryGetProperty(property, out var array) ||
@@ -239,14 +249,14 @@ public sealed class StashBoxMetadataProvider(
return [];
}
return [.. array.EnumerateArray().Select(item => Text(item, "name")).OfType<string>()];
return [.. array.EnumerateArray().Select(ReadEntity).OfType<MetadataEntity>()];
}
/// <summary>
/// Performers arrive wrapped in an appearance — the same person can be credited under a
/// different name on a given scene — and it is the person's name we want.
/// different name on a given scene — and it is the person we want, name and face both.
/// </summary>
private static IReadOnlyList<string> Performers(JsonElement scene)
private static IReadOnlyList<MetadataEntity> 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<string>()
.OfType<MetadataEntity>()
];
}
@@ -271,13 +281,61 @@ public sealed class StashBoxMetadataProvider(
/// A scene has at most one studio, and often none — the shape is a list because a match
/// may have nothing to say here.
/// </summary>
private static IReadOnlyList<string> Studios(JsonElement scene) =>
private static IReadOnlyList<MetadataEntity> 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;
/// <summary>
/// Picks the picture to keep: the smallest one still wide enough for a card, and the
/// widest available when none reaches that.
/// </summary>
/// <remarks>
/// stash-box returns every size it holds, and the first is not the best — originals run to
/// several thousand pixels. Downloading one of those per performer to draw it 150 pixels
/// wide would cost megabytes a head and look no better for it. Tags have no images field
/// at all, so this simply finds nothing for them.
/// </remarks>
private static string? PickImage(JsonElement owner)
{
if (owner.ValueKind != JsonValueKind.Object ||
!owner.TryGetProperty("images", out var images) ||
images.ValueKind != JsonValueKind.Array)
{
return null;
}
var candidates = images
.EnumerateArray()
.Where(image => image.ValueKind == JsonValueKind.Object)
.Select(image => (Url: Text(image, "url"), Width: Number(image, "width")))
.Where(image => !string.IsNullOrWhiteSpace(image.Url))
.ToList();
if (candidates.Count == 0)
{
return null;
}
var enough = candidates.Where(image => image.Width >= MinimumImageWidth).ToList();
return enough.Count > 0
? enough.MinBy(image => image.Width).Url
: candidates.MaxBy(image => image.Width).Url;
}
private static int Number(JsonElement element, string property) =>
element.TryGetProperty(property, out var value) &&
value.ValueKind == JsonValueKind.Number &&
value.TryGetInt32(out var number)
? number
: 0;
/// <summary>One way of asking the same question, and how to read the answer.</summary>
/// <param name="Field">Name of the query root field, used to find it in the reply.</param>
/// <param name="IsNested">True when the result is a list of lists rather than a list.</param>
@@ -14,7 +14,12 @@ public sealed class EfLabelRepository(LibraryDbContext dbContext) : ILabelReposi
public async Task<IReadOnlyList<LabelSummary>> 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<LibraryLabel?> FindAsync(
@@ -29,6 +29,9 @@ internal sealed class LibraryLabelConfiguration : IEntityTypeConfiguration<Libra
.HasConversion<string>()
.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.
@@ -0,0 +1,169 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PLib.Infrastructure.Persistence;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LibraryDbContext))]
[Migration("20260810054626_LabelImages")]
partial class LabelImages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.Property<Guid>("LabelsId")
.HasColumnType("TEXT");
b.Property<Guid>("VideosId")
.HasColumnType("TEXT");
b.HasKey("LabelsId", "VideosId");
b.HasIndex("VideosId");
b.ToTable("VideoLabels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ImagePath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Kind", "NormalizedName")
.IsUnique();
b.ToTable("Labels", (string)null);
});
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<TimeSpan?>("Duration")
.HasColumnType("TEXT");
b.Property<long>("FileModifiedAt")
.HasColumnType("INTEGER");
b.Property<string>("FullPath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<long?>("PerceptualHash")
.HasColumnType("INTEGER");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<int>("PreviewFrameCount")
.HasColumnType("INTEGER");
b.Property<string>("PreviewPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<TimeSpan?>("ResumePosition")
.HasColumnType("TEXT");
b.Property<long>("SizeInBytes")
.HasColumnType("INTEGER");
b.Property<string>("ThumbnailPath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("VideoCodec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AddedAt");
b.HasIndex("FullPath")
.IsUnique();
b.HasIndex("LastPlayedAt");
b.HasIndex("PerceptualHash");
b.ToTable("Videos", (string)null);
});
modelBuilder.Entity("LibraryLabelVideoItem", b =>
{
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
.WithMany()
.HasForeignKey("LabelsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PLib.Domain.Videos.VideoItem", null)
.WithMany()
.HasForeignKey("VideosId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PLib.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class LabelImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImagePath",
table: "Labels",
type: "TEXT",
maxLength: 1024,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImagePath",
table: "Labels");
}
}
}
@@ -41,6 +41,10 @@ namespace PLib.Infrastructure.Persistence.Migrations
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ImagePath")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
@@ -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; }
}
@@ -15,6 +15,12 @@ public interface IAppPaths
/// </summary>
string PreviewDirectory { get; }
/// <summary>
/// Directory holding pictures fetched from a metadata source — performers,
/// studios, and the cover of every candidate a lookup offered.
/// </summary>
string RemoteImageDirectory { get; }
/// <summary>Full path of the SQLite database file.</summary>
string DatabaseFile { get; }
}
@@ -92,6 +92,7 @@ public sealed class DuplicateDetectionTests
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Substitute.For<IMetadataProvider>(),
Substitute.For<IRemoteImageCache>(),
Options.Create(new LibraryOptions()),
MetadataMonitor.Empty,
NullLogger<LibraryService>.Instance);
@@ -14,7 +14,7 @@ internal sealed class InMemoryLabelRepository : ILabelRepository
public Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LabelSummary>>(
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count))]);
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count, label.ImagePath))]);
public Task<LibraryLabel?> FindAsync(
LabelKind kind,
+85 -84
View File
@@ -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<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Substitute.For<IMetadataProvider>(),
Options.Create(new LibraryOptions()),
MetadataMonitor.Empty,
NullLogger<LibraryService>.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<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Substitute.For<IMetadataProvider>(),
Substitute.For<IRemoteImageCache>(),
Options.Create(new LibraryOptions()),
MetadataMonitor.Empty,
NullLogger<LibraryService>.Instance);
}
+102 -4
View File
@@ -21,6 +21,7 @@ public sealed class LibraryServiceTests
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
private readonly IRemoteImageCache _remoteImages = Substitute.For<IRemoteImageCache>();
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<string>(), Arg.Any<CancellationToken>())
.Returns(RemoteImage.At(@"C:\cache\images\face.jpg"));
var match = Match("Название", "StashDB") with
{
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
};
var service = CreateService();
await service.ApplyMetadataAsync(item.Id, match, Token);
var performer = item.Labels.Single(label => label.Kind == LabelKind.Performer);
performer.ImagePath.ShouldBe(@"C:\cache\images\face.jpg");
// Applying again must not go back for it: sources disagree about which photograph
// belongs to a performer, and the card would change face on every tagged video.
_remoteImages.IsAvailable(@"C:\cache\images\face.jpg").Returns(true);
await service.ApplyMetadataAsync(item.Id, match, Token);
await _remoteImages.Received(1).GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task A_lookup_hands_back_the_cover_url_without_waiting_on_the_picture_host()
{
var item = FullyIndexed();
_repository.Seed(item);
var source = Source("StashDB");
_metadata.FindByPerceptualHashAsync(source, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
.Returns([Match("Сцена", source.Name) with { ImageUrl = "https://example/cover.jpg" }]);
var result = await CreateService(sources: MetadataMonitor.With(source)).FindMetadataAsync(item.Id, Token);
// Downloading a cover before handing the candidate back put a stranger's picture host
// between "we have an answer" and "the user can see it", and one that stalled froze
// the whole run with an empty results list.
result.Matches.ShouldHaveSingleItem().ImageUrl.ShouldBe("https://example/cover.jpg");
await _remoteImages.DidNotReceive().GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Fetching_a_picture_is_asked_for_separately_and_answers_with_a_local_path()
{
_remoteImages.GetOrCreateAsync("https://example/cover.jpg", Arg.Any<CancellationToken>())
.Returns(RemoteImage.At(@"C:\cache\images\cover.jpg"));
var image = await CreateService().FetchImageAsync("https://example/cover.jpg", Token);
image.Path.ShouldBe(@"C:\cache\images\cover.jpg");
image.Problem.ShouldBeNull();
}
[Fact]
public async Task A_picture_host_that_has_been_given_up_on_says_so_rather_than_going_quiet()
{
_remoteImages.GetOrCreateAsync("https://cdn.example/cover.jpg", Arg.Any<CancellationToken>())
.Returns(RemoteImage.Unreachable("cdn.example не отдаёт картинки"));
var image = await CreateService().FetchImageAsync("https://cdn.example/cover.jpg", Token);
// An empty square looks the same whether the source has no picture or the host is
// unreachable, and only one of those is worth putting on screen.
image.Path.ShouldBeNull();
image.Problem.ShouldBe("cdn.example не отдаёт картинки");
}
[Fact]
public async Task A_picture_that_could_not_be_fetched_does_not_fail_the_match()
{
var item = FullyIndexed();
_repository.Seed(item);
_remoteImages.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<RemoteImage>(_ => throw new HttpRequestException("503"));
var match = Match("Название", "StashDB") with
{
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
};
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
// The card falls back to an initial, which is a far smaller loss than dropping the
// title, the description and every label over one picture.
item.Title.ShouldBe("Название");
item.Labels.Single(label => label.Kind == LabelKind.Performer).ImagePath.ShouldBeNull();
}
[Fact]
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<LibraryService>.Instance);
@@ -140,6 +140,7 @@ public sealed class MetadataScanTests
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
_provider,
Substitute.For<IRemoteImageCache>(),
Options.Create(new LibraryOptions()),
// No pause between requests: the delay exists to be kind to somebody else's
@@ -0,0 +1,137 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using PLib.Infrastructure.Metadata;
using PLib.Infrastructure.Storage;
using Shouldly;
namespace PLib.Tests.Metadata;
/// <summary>
/// A picture host that will not answer must cost a few attempts, not one per candidate.
/// </summary>
public sealed class RemoteImageCacheTests : IDisposable
{
private readonly TempPaths _paths = new();
private readonly CountingHandler _handler = new();
public void Dispose()
{
_paths.Dispose();
_handler.Dispose();
}
[Fact]
public async Task A_host_that_keeps_failing_is_left_alone_after_a_few_tries()
{
var cache = Create();
var problems = new List<string>();
for (var attempt = 0; attempt < 6; attempt++)
{
var image = await cache.GetOrCreateAsync($"https://cdn.example/{attempt}.jpg", Token);
image.Path.ShouldBeNull();
if (image.Problem is { } problem)
{
problems.Add(problem);
}
}
// A library-wide run produces a cover per candidate, all from the same host. Without a
// cut-off, a host that answers its headers and then stalls holds a connection open for
// the full timeout on every single one of them.
_handler.Requests.ShouldBe(3);
// Said once, at the moment the host is given up on. Every miss would put the same line
// beside every candidate; never saying it leaves rows of empty squares unexplained.
problems.ShouldHaveSingleItem().ShouldContain("cdn.example");
}
[Fact]
public async Task A_different_host_is_judged_on_its_own_behaviour()
{
var cache = Create();
for (var attempt = 0; attempt < 4; attempt++)
{
await cache.GetOrCreateAsync($"https://broken.example/{attempt}.jpg", Token);
}
await cache.GetOrCreateAsync("https://other.example/a.jpg", Token);
// Three to the broken host, then it is skipped; the fourth request is the other host.
_handler.Requests.ShouldBe(4);
}
[Fact]
public async Task An_address_that_is_not_a_web_address_is_never_fetched()
{
var cache = Create();
// The URL comes from somebody else's server, so file:// would turn "fetch a picture"
// into "read a path of the server's choosing".
(await cache.GetOrCreateAsync(@"file:///C:/Windows/win.ini", Token)).Path.ShouldBeNull();
(await cache.GetOrCreateAsync("не адрес", Token)).Path.ShouldBeNull();
_handler.Requests.ShouldBe(0);
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private HttpRemoteImageCache Create()
{
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(_handler, disposeHandler: false));
return new HttpRemoteImageCache(factory, _paths, NullLogger<HttpRemoteImageCache>.Instance);
}
/// <summary>Fails every request at once, and counts how many it was asked to make.</summary>
private sealed class CountingHandler : HttpMessageHandler
{
private int _requests;
public int Requests => _requests;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref _requests);
throw new HttpRequestException("no route to host");
}
}
private sealed class TempPaths : IAppPaths, IDisposable
{
public TempPaths()
{
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-images-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews");
RemoteImageDirectory = Path.Combine(DataDirectory, "images");
DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(RemoteImageDirectory);
}
public string DataDirectory { get; }
public string ThumbnailDirectory { get; }
public string PreviewDirectory { get; }
public string RemoteImageDirectory { get; }
public string DatabaseFile { get; }
public void Dispose()
{
if (Directory.Exists(DataDirectory))
{
Directory.Delete(DataDirectory, recursive: true);
}
}
}
}
@@ -51,11 +51,11 @@ public sealed class StashBoxPayloadTests
first.RemoteId.ShouldBe("abc");
first.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()
{
@@ -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);