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:
@@ -1,234 +1,270 @@
|
|||||||
# PLib
|
# PLib
|
||||||
|
|
||||||
Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и
|
Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и
|
||||||
показывает всё сеткой карточек.
|
показывает всё сеткой карточек.
|
||||||
|
|
||||||
## Что уже работает
|
## Что уже работает
|
||||||
|
|
||||||
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
|
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
|
||||||
- Слежение за папками: новые файлы подхватываются сами, без кнопки.
|
- Слежение за папками: новые файлы подхватываются сами, без кнопки.
|
||||||
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
||||||
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
||||||
- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются
|
- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются
|
||||||
кадры, снятые по всей длительности.
|
кадры, снятые по всей длительности.
|
||||||
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
||||||
- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка
|
- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка
|
||||||
(по названию или по частоте); клик по сущности показывает её видео в сетке.
|
(по названию или по частоте); клик по сущности показывает её видео в сетке.
|
||||||
- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска.
|
- Карточки актёров и студий — с фото из источника метаданных. У тегов и коллекций картинок
|
||||||
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
|
не бывает: их нет в схеме stash-box, поэтому там рисуется первая буква.
|
||||||
библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется
|
- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска.
|
||||||
в `settings.json` и подхватывается без перезапуска.
|
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
|
||||||
- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические
|
библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется
|
||||||
метаданные — каждый со своей кнопкой и текущим объёмом.
|
в `settings.json` и подхватывается без перезапуска.
|
||||||
- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой
|
- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические
|
||||||
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
|
метаданные — каждый со своей кнопкой и текущим объёмом.
|
||||||
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
|
- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой
|
||||||
- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой
|
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
|
||||||
и списком найденного. По желанию однозначные совпадения применяются на месте.
|
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
|
||||||
- Светлая, тёмная и системная темы; выбор запоминается.
|
- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой
|
||||||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
и списком найденного. Каждый кандидат показывается со своей обложкой. По желанию
|
||||||
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
однозначные совпадения применяются на месте.
|
||||||
Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки.
|
- Светлая, тёмная и системная темы; выбор запоминается.
|
||||||
|
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||||||
## Требования
|
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
||||||
|
Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки.
|
||||||
- .NET 10 SDK
|
|
||||||
- `ffmpeg` и `ffprobe` в `PATH` (для превью и метаданных)
|
## Требования
|
||||||
|
|
||||||
Нативный LibVLC приезжает пакетом и в системе не нужен.
|
- .NET 10 SDK
|
||||||
|
- `ffmpeg` и `ffprobe` в `PATH` (для превью и метаданных)
|
||||||
## Запуск
|
|
||||||
|
Нативный LibVLC приезжает пакетом и в системе не нужен.
|
||||||
```bash
|
|
||||||
dotnet run --project src/PLib.Desktop
|
## Запуск
|
||||||
```
|
|
||||||
|
```bash
|
||||||
```bash
|
dotnet run --project src/PLib.Desktop
|
||||||
dotnet test
|
```
|
||||||
```
|
|
||||||
|
```bash
|
||||||
## Архитектура
|
dotnet test
|
||||||
|
```
|
||||||
Четыре слоя, зависимости направлены только внутрь:
|
|
||||||
|
## Архитектура
|
||||||
| Проект | Отвечает за | Знает о |
|
|
||||||
| --- | --- | --- |
|
Четыре слоя, зависимости направлены только внутрь:
|
||||||
| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём |
|
|
||||||
| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain |
|
| Проект | Отвечает за | Знает о |
|
||||||
| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application |
|
| --- | --- | --- |
|
||||||
| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure |
|
| `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`.
|
- **MVVM на ReactiveUI.** Свойства — `[Reactive]` из `ReactiveUI.SourceGenerators`, команды —
|
||||||
- **Сетка — проекция DynamicData, а не пересборка.** `SourceCache` → `AutoRefresh` → `Filter`
|
`ReactiveCommand`, производные значения (`IsScanning`, `IsEmpty`) — `ToProperty`. Отмена
|
||||||
→ `SortAndBind` отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл,
|
сканирования сделана штатным способом: скан живёт как observable, а `CancelScanCommand`
|
||||||
контейнеры `ItemsRepeater` и уже загруженные превью остаются на месте. Поиск дебаунсится
|
просто отписывает его через `TakeUntil`, что отменяет `CancellationToken`.
|
||||||
на 200 мс, изменения карточек во время скана коалесцируются в 250 мс.
|
- **Сетка — проекция DynamicData, а не пересборка.** `SourceCache` → `AutoRefresh` → `Filter`
|
||||||
- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает
|
→ `SortAndBind` отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл,
|
||||||
`IAsyncEnumerable<LibraryScanEvent>`: карточки появляются по мере находок, а не после
|
контейнеры `ItemsRepeater` и уже загруженные превью остаются на месте. Поиск дебаунсится
|
||||||
завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через
|
на 200 мс, изменения карточек во время скана коалесцируются в 250 мс.
|
||||||
`Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям
|
- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает
|
||||||
по одному — трекер изменений EF не потокобезопасен.
|
`IAsyncEnumerable<LibraryScanEvent>`: карточки появляются по мере находок, а не после
|
||||||
- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает
|
завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через
|
||||||
каждое событие в UI явно через `Dispatcher.UIThread`.
|
`Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям
|
||||||
- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в
|
по одному — трекер изменений EF не потокобезопасен.
|
||||||
визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с
|
- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает
|
||||||
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
|
каждое событие в UI явно через `Dispatcher.UIThread`.
|
||||||
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
|
- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в
|
||||||
`IServiceScopeFactory` и создаёт scope на каждый вызов.
|
визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с
|
||||||
- **Одно окно.** Настройки — колонка макета, а не второе окно и не оверлей: открываясь, она
|
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
|
||||||
сдвигает сетку, и та переливается в меньшее число столбцов, оставаясь целиком доступной.
|
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
|
||||||
В alt-tab ничего не добавляется, и приложение остаётся переносимым на
|
`IServiceScopeFactory` и создаёт scope на каждый вызов.
|
||||||
`ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует.
|
- **Одно окно.** Настройки — колонка макета, а не второе окно и не оверлей: открываясь, она
|
||||||
Панель занимает только строку контента: шапка и статус-бар остаются цельными на всю
|
сдвигает сетку, и та переливается в меньшее число столбцов, оставаясь целиком доступной.
|
||||||
ширину окна. Собственные заголовок и строка действий у панели заведомо легче оконных —
|
В alt-tab ничего не добавляется, и приложение остаётся переносимым на
|
||||||
равные по весу читались как два приложения, сшитых по шву.
|
`ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует.
|
||||||
- **Снимок настроек берётся из одного места.** Файл пишется целиком, поэтому собирать
|
Панель занимает только строку контента: шапка и статус-бар остаются цельными на всю
|
||||||
`AppSettings` вручную — верный способ затереть секцию, о которой не подумал. Все, кто
|
ширину окна. Собственные заголовок и строка действий у панели заведомо легче оконных —
|
||||||
пишет, начинают с `IAppSettingsStore.Current` и правят его через `with`.
|
равные по весу читались как два приложения, сшитых по шву.
|
||||||
- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком
|
- **Снимок настроек берётся из одного места.** Файл пишется целиком, поэтому собирать
|
||||||
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
`AppSettings` вручную — верный способ затереть секцию, о которой не подумал. Все, кто
|
||||||
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
пишет, начинают с `IAppSettingsStore.Current` и правят его через `with`.
|
||||||
его не вызывает.
|
- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком
|
||||||
- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** VLC декодирует в память,
|
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
||||||
которую мы ему выдаём, а рисуем кадр сами: видео остаётся обычным контролом Avalonia —
|
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
||||||
участвует в hit-тесте, принимает жесты, поверх него можно класть что угодно. Цена — одно
|
его не вызывает.
|
||||||
копирование на показанный кадр, и на 4K оно становится основной стоимостью воспроизведения.
|
- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** VLC декодирует в память,
|
||||||
Буферов два: VLC декодирует в один, пока мы читаем другой; на `Display` они меняются
|
которую мы ему выдаём, а рисуем кадр сами: видео остаётся обычным контролом Avalonia —
|
||||||
местами под коротким локом. Транспорт (позиция, длительность, play/pause) — свойства самого
|
участвует в hit-тесте, принимает жесты, поверх него можно класть что угодно. Цена — одно
|
||||||
контрола, поэтому им управляет code-behind страницы; дублировать это состояние во вьюмодель
|
копирование на показанный кадр, и на 4K оно становится основной стоимостью воспроизведения.
|
||||||
значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляет
|
Буферов два: VLC декодирует в один, пока мы читаем другой; на `Display` они меняются
|
||||||
`OpenedVideo`, вью уходит из дерева, и плеер гасится вместе с буферами.
|
местами под коротким локом. Транспорт (позиция, длительность, play/pause) — свойства самого
|
||||||
До этого пробовали два готовых пути. `MediaPlayer.Controls`: декодер работал, но кадры до
|
контрола, поэтому им управляет code-behind страницы; дублировать это состояние во вьюмодель
|
||||||
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
|
значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляет
|
||||||
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
|
`OpenedVideo`, вью уходит из дерева, и плеер гасится вместе с буферами.
|
||||||
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
|
До этого пробовали два готовых пути. `MediaPlayer.Controls`: декодер работал, но кадры до
|
||||||
- **Индексация в три прохода.** Сначала метаданные и постеры для всех файлов, затем
|
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
|
||||||
анимированные превью, и только потом отпечатки. Каждый следующий проход берёт больше кадров
|
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
|
||||||
на файл; вперемешку они задерживали бы каждую следующую карточку на всю цепочку, и сетка
|
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
|
||||||
наполнялась бы в разы медленнее. Порядок — по видимости: постер нужен, чтобы карточка
|
- **Индексация в три прохода.** Сначала метаданные и постеры для всех файлов, затем
|
||||||
вообще появилась, анимация — чтобы она ожила под курсором, хеш всплывает только при поиске
|
анимированные превью, и только потом отпечатки. Каждый следующий проход берёт больше кадров
|
||||||
дублей. Поэтому `IsIndexed` намеренно не включает ни анимацию, ни хеш: это готовность к
|
на файл; вперемешку они задерживали бы каждую следующую карточку на всю цепочку, и сетка
|
||||||
показу, а не завершённость всей обработки. Поздние проходы умеют стартовать только после
|
наполнялась бы в разы медленнее. Порядок — по видимости: постер нужен, чтобы карточка
|
||||||
первого — кадры распределяются по длительности, а её устанавливает probe.
|
вообще появилась, анимация — чтобы она ожила под курсором, хеш всплывает только при поиске
|
||||||
- **Анимированное превью — кадры стопкой в одном JPEG, не GIF.** Гифку никто ниже по течению
|
дублей. Поэтому `IsIndexed` намеренно не включает ни анимацию, ни хеш: это готовность к
|
||||||
не проиграет: Avalonia декодирует только первый кадр анимированного изображения, так что
|
показу, а не завершённость всей обработки. Поздние проходы умеют стартовать только после
|
||||||
за GIF пришлось бы тащить отдельный декодер. Стопка кадров обходится тем же декодером, что
|
первого — кадры распределяются по длительности, а её устанавливает probe.
|
||||||
и постеры — `FilmstripImage` просто рисует каждый тик другой срез, — и весит долю от
|
- **Анимированное превью — кадры стопкой в одном JPEG, не GIF.** Гифку никто ниже по течению
|
||||||
256-цветной гифки тех же кадров, а это цена за каждое видео в библиотеке. Рендерит один
|
не проиграет: Avalonia декодирует только первый кадр анимированного изображения, так что
|
||||||
процесс ffmpeg: по входу на таймкод (seek до входа, то есть прыжок на ключевой кадр,
|
за GIF пришлось бы тащить отдельный декодер. Стопка кадров обходится тем же декодером, что
|
||||||
а не декодирование до него) и один `vstack`; процесс на кадр умножил бы проход на их число.
|
и постеры — `FilmstripImage` просто рисует каждый тик другой срез, — и весит долю от
|
||||||
Число кадров зашито в имя файла, поэтому смена настройки не режет старую полосу неверным
|
256-цветной гифки тех же кадров, а это цена за каждое видео в библиотеке. Рендерит один
|
||||||
шагом — она просто промахивается мимо кэша, а лишнее подберёт очистка.
|
процесс ffmpeg: по входу на таймкод (seek до входа, то есть прыжок на ключевой кадр,
|
||||||
Полоса живёт только пока играет: она весит как все её кадры вместе, а под курсором всегда
|
а не декодирование до него) и один `vstack`; процесс на кадр умножил бы проход на их число.
|
||||||
одна карточка, так что держать её в общем кэше значило бы менять ограниченную память на
|
Число кадров зашито в имя файла, поэтому смена настройки не режет старую полосу неверным
|
||||||
растущую с тем, сколько библиотеки пролистали.
|
шагом — она просто промахивается мимо кэша, а лишнее подберёт очистка.
|
||||||
- **pHash по рецепту stash.** 25 кадров сеткой 5×5, по 5% времени отброшено с каждого
|
Полоса живёт только пока играет: она весит как все её кадры вместе, а под курсором всегда
|
||||||
конца, кадр по ширине 160 — то есть хеш описывает те же кадры, что и у stash. Декодирует
|
одна карточка, так что держать её в общем кэше значило бы менять ограниченную память на
|
||||||
и масштабирует ffmpeg, отдавая сырой 8-битный серый, поэтому графическая библиотека не
|
растущую с тем, сколько библиотеки пролистали.
|
||||||
нужна вовсе: монтаж, уменьшение до 64×64 и DCT — арифметика над массивом байт. Бит
|
- **pHash по рецепту stash.** 25 кадров сеткой 5×5, по 5% времени отброшено с каждого
|
||||||
ставится сравнением коэффициента с медианой блока 8×8.
|
конца, кадр по ширине 160 — то есть хеш описывает те же кадры, что и у stash. Декодирует
|
||||||
Сравнивается хеш расстоянием Хэмминга, а не на равенство; группы дублей собираются
|
и масштабирует ffmpeg, отдавая сырой 8-битный серый, поэтому графическая библиотека не
|
||||||
системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу.
|
нужна вовсе: монтаж, уменьшение до 64×64 и DCT — арифметика над массивом байт. Бит
|
||||||
**Побитовая совместимость со stash не проверена** — разные реализации ресайза способны
|
ставится сравнением коэффициента с медианой блока 8×8.
|
||||||
перевернуть биты у коэффициентов рядом с медианой.
|
Сравнивается хеш расстоянием Хэмминга, а не на равенство; группы дублей собираются
|
||||||
- **Теги, коллекции, актёры и студии — одна сущность.** `LibraryLabel` с `LabelKind`: связь
|
системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу.
|
||||||
с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица
|
**Побитовая совместимость со stash не проверена** — разные реализации ресайза способны
|
||||||
связей, один репозиторий и одно правило именования; разделить потом можно переименованием
|
перевернуть биты у коэффициентов рядом с медианой.
|
||||||
и миграцией, а держать четыре почти одинаковых агрегата синхронными пришлось бы всегда.
|
- **Теги, коллекции, актёры и студии — одна сущность.** `LibraryLabel` с `LabelKind`: связь
|
||||||
Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых
|
с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица
|
||||||
таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и
|
связей, один репозиторий и одно правило именования; разделить потом можно переименованием
|
||||||
«комедия» не разойдутся, а тег и студия с одним именем сосуществуют.
|
и миграцией, а держать четыре почти одинаковых агрегата синхронными пришлось бы всегда.
|
||||||
- **Вкладки — колонки одного макета, а не `TabControl`.** Страницы, между которыми они
|
Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых
|
||||||
переключают, соседствуют с панелью настроек и страницей плеера в одной сетке, а `TabControl`
|
таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и
|
||||||
захотел бы владеть этой компоновкой целиком. Четыре вкладки сущностей делят одну панель:
|
«комедия» не разойдутся, а тег и студия с одним именем сосуществуют.
|
||||||
различается только вид метки, и четыре почти одинаковых разметки разошлись бы при первой же
|
- **Вкладки — колонки одного макета, а не `TabControl`.** Страницы, между которыми они
|
||||||
правке.
|
переключают, соседствуют с панелью настроек и страницей плеера в одной сетке, а `TabControl`
|
||||||
Живут они уровнем ниже оконной панели, а не в ней: сверху то, что верно для всего
|
захотел бы владеть этой компоновкой целиком. Четыре вкладки сущностей делят одну панель:
|
||||||
приложения (поиск, тема, настройки, добавить папку), под ним — навигация и органы управления
|
различается только вид метки, и четыре почти одинаковых разметки разошлись бы при первой же
|
||||||
текущей страницей. В один ряд это не влезало: вкладки съедали ширину у поиска, и на 1200 px
|
правке.
|
||||||
они наезжали друг на друга. Сортировка уехала туда же — она про страницу, а не про окно.
|
Живут они уровнем ниже оконной панели, а не в ней: сверху то, что верно для всего
|
||||||
- **Метки лежат на карточке, а не запрашиваются.** Отбор по тегу, актёру или студии — это
|
приложения (поиск, тема, настройки, добавить папку), под ним — навигация и органы управления
|
||||||
предикат, который DynamicData прогоняет по каждой карточке в фоновом потоке; ходить оттуда
|
текущей страницей. В один ряд это не влезало: вкладки съедали ширину у поиска, и на 1200 px
|
||||||
в базу значило бы запрос на карточку. Поэтому `GetLibraryAsync` грузит видео вместе с
|
они наезжали друг на друга. Сортировка уехала туда же — она про страницу, а не про окно.
|
||||||
метками (`AsSplitQuery` — иначе каждая строка видео вернулась бы по разу на метку), а
|
- **Метки лежат на карточке, а не запрашиваются.** Отбор по тегу, актёру или студии — это
|
||||||
`ApplyLabels` намеренно отделён от `Apply`: сканирование грузит видео без меток, и пустой
|
предикат, который DynamicData прогоняет по каждой карточке в фоновом потоке; ходить оттуда
|
||||||
список там означает «не загружены», а не «их нет».
|
в базу значило бы запрос на карточку. Поэтому `GetLibraryAsync` грузит видео вместе с
|
||||||
Списки сущностей пересобираются целиком, без второй цепочки DynamicData: меток сотни там,
|
метками (`AsSplitQuery` — иначе каждая строка видео вернулась бы по разу на метку), а
|
||||||
где видео тысячи, и машинерия обошлась бы дороже, чем экономит. Количества считает база
|
`ApplyLabels` намеренно отделён от `Apply`: сканирование грузит видео без меток, и пустой
|
||||||
(`LabelSummary`), а не загрузка связей ради `Count`.
|
список там означает «не загружены», а не «их нет».
|
||||||
- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому
|
Списки сущностей пересобираются целиком, без второй цепочки DynamicData: меток сотни там,
|
||||||
серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные»,
|
где видео тысячи, и машинерия обошлась бы дороже, чем экономит. Количества считает база
|
||||||
и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни
|
(`LabelSummary`), а не загрузка связей ради `Count`.
|
||||||
самих файлов.
|
- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому
|
||||||
Прогон по всей библиотеке — отдельная страница, а не фоновая задача: он обращается к чужим
|
серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные»,
|
||||||
серверам сотни раз подряд, и это должно быть там, где пользователь на это смотрит и может
|
и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни
|
||||||
остановить. Между запросами есть пауза (`RequestDelayMilliseconds`, по умолчанию 250 мс) —
|
самих файлов.
|
||||||
тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник,
|
Прогон по всей библиотеке — отдельная страница, а не фоновая задача: он обращается к чужим
|
||||||
который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом
|
серверам сотни раз подряд, и это должно быть там, где пользователь на это смотрит и может
|
||||||
видео, и тысяча одинаковых строк была бы всей страницей.
|
остановить. Между запросами есть пауза (`RequestDelayMilliseconds`, по умолчанию 250 мс) —
|
||||||
«Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда
|
тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник,
|
||||||
— расхождение источников это ровно тот случай, ради которого страницу и смотрят.
|
который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом
|
||||||
Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили»,
|
видео, и тысяча одинаковых строк была бы всей страницей.
|
||||||
но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`,
|
«Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда
|
||||||
поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает».
|
— расхождение источников это ровно тот случай, ради которого страницу и смотрят.
|
||||||
Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча
|
- **Картинки скачиваются один раз и по размеру.** `images` в stash-box есть у сцены,
|
||||||
переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки,
|
у актёра и у студии; у тега такого поля нет вовсе, поэтому там карточка показывает первую
|
||||||
но не удаляет чужие — то, что проставил пользователь, остаётся.
|
букву — это норма, а не отсутствие данных. Обложка сцены нужна там, где выбирают из
|
||||||
Схема — stash-box (StashDB, ThePornDB и родственники): именно поэтому список источников
|
кандидатов: название и список тегов почти ничего не говорят о том, то ли это видео,
|
||||||
вообще имеет смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же
|
а кадр говорит с одного взгляда.
|
||||||
запрос. Запросов, впрочем, два: stash-box переименовал `findSceneByFingerprint` в
|
Картинка никогда не задерживает то, ради чего её показывают. Сначала так и было: обложка
|
||||||
`findScenesBySceneFingerprints` и оставил старое имя позади, так что какой из них знает
|
скачивалась до того, как кандидат отдавался наверх, — и один зависший хост картинок
|
||||||
конкретный экземпляр — зависит от того, когда его обновляли. Они пробуются по очереди,
|
морозил весь прогон, оставляя список результатов пустым при ползущем прогрессе.
|
||||||
и «нет такого поля» ведёт к следующему, а не к ошибке; всё прочее (неверный ключ, лимит)
|
Теперь `VideoMetadataMatch` несёт только `ImageUrl`, строка появляется сразу, а файл
|
||||||
окончательно. Новый запрос отвечает группой сцен на группу отпечатков, поэтому его результат
|
подгружается за ней (`RemoteImageLoader`, не более четырёх загрузок разом).
|
||||||
на уровень глубже — про одно видео мы спрашиваем всегда, так что разница сводится к
|
У самой загрузки есть свой дедлайн — 10 секунд на заголовки и тело вместе.
|
||||||
выпрямлению списка.
|
`HttpClient.Timeout` перестаёт действовать в момент, когда `ResponseHeadersRead`
|
||||||
GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один
|
возвращает ответ, поэтому сервер, открывший соединение и замерший на середине
|
||||||
объект в ответе.
|
картинки, висел бы вечно. Ровно это и случилось с CDN одного из источников: 200 за
|
||||||
**API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и
|
100 мс и тишина до самого таймаута.
|
||||||
остальные настройки, то есть правами файловой системы.
|
Хост, упавший три раза подряд, отключается на 5 минут: при прогоне по библиотеке
|
||||||
- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
|
обложка приходится на каждого кандидата, и без отсечки это сотня мёртвых сокетов.
|
||||||
событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
|
Молчать об этом нельзя — пустой квадрат выглядит одинаково и когда картинки нет,
|
||||||
из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
|
и когда хост недоступен. Поэтому `IRemoteImageCache` возвращает не `string?`, а
|
||||||
разницу и так умеет считать сканирование.
|
`RemoteImage` с необязательной причиной, и она всплывает на странице: одной строкой,
|
||||||
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
один раз за окно отключения, а не рядом с каждым кандидатом. Из всех размеров берётся **самый маленький, всё
|
||||||
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
ещё пригодный для карточки** (от 320 px), и самый большой, если ни один не дотягивает:
|
||||||
на месте (`IMediaArtifactCache.IsAvailable`), и перерисовывает удалённые; после полного
|
оригиналы бывают в несколько тысяч пикселей, и качать их, чтобы нарисовать 150 px, —
|
||||||
прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются
|
мегабайты на голову без выигрыша в виде.
|
||||||
только если им больше часа — иначе можно снести рендер второго запущенного экземпляра.
|
Скачивается только если у метки картинки ещё нет: источники расходятся в том, какое фото
|
||||||
Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и
|
принадлежит актёру, и обновление на каждом совпадении меняло бы лицо на карточке при каждом
|
||||||
описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда
|
размеченном видео. Неудачная загрузка не роняет применение — потерять фото дешевле, чем
|
||||||
появился второй вид файлов.
|
название, описание и все метки разом.
|
||||||
- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет
|
Размер ограничен 8 МБ и проверяется дважды: по заголовку и по ходу чтения, потому что
|
||||||
ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные.
|
сервер может ответить без длины. Схемы, кроме http(s), отвергаются — URL приходит с чужого
|
||||||
Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает
|
сервера, и `file://` превратил бы «скачай картинку» в «прочитай файл по своему выбору».
|
||||||
подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно
|
Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили»,
|
||||||
не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду
|
но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`,
|
||||||
кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание
|
поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает».
|
||||||
в обратном порядке оставило бы библиотеку с путями в никуда.
|
Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча
|
||||||
|
переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки,
|
||||||
## Данные
|
но не удаляет чужие — то, что проставил пользователь, остаётся.
|
||||||
|
Схема — stash-box (StashDB, ThePornDB и родственники): именно поэтому список источников
|
||||||
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
|
вообще имеет смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же
|
||||||
|
запрос. Запросов, впрочем, два: stash-box переименовал `findSceneByFingerprint` в
|
||||||
- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash;
|
`findScenesBySceneFingerprints` и оставил старое имя позади, так что какой из них знает
|
||||||
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
|
конкретный экземпляр — зависит от того, когда его обновляли. Они пробуются по очереди,
|
||||||
- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени;
|
и «нет такого поля» ведёт к следующему, а не к ошибке; всё прочее (неверный ключ, лимит)
|
||||||
- `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники
|
окончательно. Новый запрос отвечает группой сцен на группу отпечатков, поэтому его результат
|
||||||
метаданных вместе с их API-ключами;
|
на уровень глубже — про одно видео мы спрашиваем всегда, так что разница сводится к
|
||||||
перечитывается на лету;
|
выпрямлению списка.
|
||||||
- `logs/` — Serilog, ротация по дням.
|
GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один
|
||||||
|
объект в ответе.
|
||||||
Схема ведётся миграциями EF Core (`src/PLib.Infrastructure/Persistence/Migrations`) и
|
**API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и
|
||||||
применяется при старте. База, созданная сборками до появления миграций, распознаётся по
|
остальные настройки, то есть правами файловой системы.
|
||||||
отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно
|
- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
|
||||||
пересканирование, а превью привязаны к файлам и переживают это нетронутыми.
|
событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
|
||||||
|
из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
|
||||||
```bash
|
разницу и так умеет считать сканирование.
|
||||||
dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations
|
- **Кэш превью самовосстанавливается.** Диск — ключ `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);
|
||||||
|
}
|
||||||
@@ -1,94 +1,104 @@
|
|||||||
using PLib.Application.Metadata;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Application.Metadata;
|
||||||
|
using PLib.Domain.Videos;
|
||||||
namespace PLib.Application.Library;
|
|
||||||
|
namespace PLib.Application.Library;
|
||||||
/// <summary>Use cases the UI needs in order to show and refresh the video library.</summary>
|
|
||||||
public interface ILibraryService
|
/// <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>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
|
/// <summary>
|
||||||
/// poster frames for anything that is missing them, streaming progress as it goes.
|
/// Reconciles the library with the configured folders and then fills in metadata and
|
||||||
/// </summary>
|
/// poster frames for anything that is missing them, streaming progress as it goes.
|
||||||
IAsyncEnumerable<LibraryScanEvent> ScanAsync(
|
/// </summary>
|
||||||
IReadOnlyList<string> folders,
|
IAsyncEnumerable<LibraryScanEvent> ScanAsync(
|
||||||
CancellationToken cancellationToken = default);
|
IReadOnlyList<string> folders,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
/// <summary>
|
|
||||||
/// What each kind of derived data currently costs, one entry per
|
/// <summary>
|
||||||
/// <see cref="LibraryDataKind"/>, so the user can see what clearing it would free.
|
/// What each kind of derived data currently costs, one entry per
|
||||||
/// </summary>
|
/// <see cref="LibraryDataKind"/>, so the user can see what clearing it would free.
|
||||||
Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(CancellationToken cancellationToken = default);
|
/// </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 —
|
/// <summary>
|
||||||
/// so the next scan rebuilds them from scratch. Useful after changing a setting that
|
/// Throws the named kinds of derived data away — files as well as the references to them —
|
||||||
/// governs how they are produced, or when one of them is suspected of being wrong.
|
/// so the next scan rebuilds them from scratch. Useful after changing a setting that
|
||||||
/// </summary>
|
/// governs how they are produced, or when one of them is suspected of being wrong.
|
||||||
Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default);
|
/// </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>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
|
/// <summary>
|
||||||
/// groups of one, are left out.
|
/// Groups of videos that look alike, by perceptual hash. Videos without a hash, and
|
||||||
/// </summary>
|
/// groups of one, are left out.
|
||||||
/// <param name="maxDistance">
|
/// </summary>
|
||||||
/// How many differing bits still count as the same video. Zero means visually identical;
|
/// <param name="maxDistance">
|
||||||
/// the useful range for re-encodes is a handful of bits.
|
/// How many differing bits still count as the same video. Zero means visually identical;
|
||||||
/// </param>
|
/// the useful range for re-encodes is a handful of bits.
|
||||||
Task<IReadOnlyList<IReadOnlyList<VideoItem>>> FindDuplicatesAsync(
|
/// </param>
|
||||||
int maxDistance,
|
Task<IReadOnlyList<IReadOnlyList<VideoItem>>> FindDuplicatesAsync(
|
||||||
CancellationToken cancellationToken = default);
|
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>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 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>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.
|
/// <summary>
|
||||||
/// Returns the label, whether it was new or not.
|
/// Attaches a label to a video, creating it if this is the first time the name is used.
|
||||||
/// </summary>
|
/// Returns the label, whether it was new or not.
|
||||||
Task<LibraryLabel> AttachLabelAsync(
|
/// </summary>
|
||||||
Guid videoId,
|
Task<LibraryLabel> AttachLabelAsync(
|
||||||
string name,
|
Guid videoId,
|
||||||
LabelKind kind,
|
string name,
|
||||||
CancellationToken cancellationToken = default);
|
LabelKind kind,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
Task DetachLabelAsync(Guid videoId, Guid labelId, 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>
|
||||||
/// </summary>
|
/// Fetches a picture a metadata source pointed at, saying where it landed or why it did not.
|
||||||
/// <remarks>
|
/// </summary>
|
||||||
/// Nothing calls this on its own: reaching out to a remote service about the user's files
|
/// <remarks>
|
||||||
/// happens when the user presses the button, and at no other time.
|
/// Separate from the lookup so that a candidate can be shown before its cover exists.
|
||||||
/// </remarks>
|
/// Waiting for the picture first means one slow picture host holds up every result.
|
||||||
Task<MetadataLookupResult> FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default);
|
/// </remarks>
|
||||||
|
Task<RemoteImage> FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default);
|
||||||
/// <summary>
|
|
||||||
/// Writes a match onto the video: title, description, and a label per tag, performer and
|
/// <summary>
|
||||||
/// studio. Labels already on the video are kept — applying a match adds, never prunes.
|
/// Asks every configured metadata source what it has for this video's fingerprint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task ApplyMetadataAsync(
|
/// <remarks>
|
||||||
Guid videoId,
|
/// Nothing calls this on its own: reaching out to a remote service about the user's files
|
||||||
VideoMetadataMatch match,
|
/// happens when the user presses the button, and at no other time.
|
||||||
CancellationToken cancellationToken = default);
|
/// </remarks>
|
||||||
|
Task<MetadataLookupResult> FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default);
|
||||||
/// <summary>
|
|
||||||
/// Asks the sources about every fingerprinted video in the library, streaming what it
|
/// <summary>
|
||||||
/// finds. Like the single lookup, it only ever runs because the user started it.
|
/// Writes a match onto the video: title, description, and a label per tag, performer and
|
||||||
/// </summary>
|
/// studio. Labels already on the video are kept — applying a match adds, never prunes.
|
||||||
IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync(
|
/// </summary>
|
||||||
MetadataScanRequest request,
|
Task ApplyMetadataAsync(
|
||||||
CancellationToken cancellationToken = default);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,4 +11,9 @@ namespace PLib.Application.Library;
|
|||||||
/// the videos behind each one to arrive at a number is the difference between a page that
|
/// the videos behind each one to arrive at a number is the difference between a page that
|
||||||
/// opens and a page that does not.
|
/// opens and a page that does not.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record LabelSummary(Guid Id, string Name, LabelKind Kind, int VideoCount);
|
public sealed record LabelSummary(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
LabelKind Kind,
|
||||||
|
int VideoCount,
|
||||||
|
string? ImagePath);
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ public enum LibraryDataKind
|
|||||||
/// <summary>Duration, resolution and codec, as read by ffprobe.</summary>
|
/// <summary>Duration, resolution and codec, as read by ffprobe.</summary>
|
||||||
TechnicalMetadata = 1 << 3,
|
TechnicalMetadata = 1 << 3,
|
||||||
|
|
||||||
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata,
|
/// <summary>
|
||||||
|
/// Pictures downloaded from a metadata source: performers, studios, and the covers
|
||||||
|
/// shown beside search results. Rebuilt by looking a video up again rather than by a
|
||||||
|
/// scan — the file on disk says nothing about what a performer looks like.
|
||||||
|
/// </summary>
|
||||||
|
RemoteImages = 1 << 4,
|
||||||
|
|
||||||
|
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What one kind of derived data currently costs.</summary>
|
/// <summary>What one kind of derived data currently costs.</summary>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,14 @@
|
|||||||
namespace PLib.Application.Metadata;
|
namespace PLib.Application.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A named thing a source attached to a scene — a tag, a performer, a studio.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ImageUrl">
|
||||||
|
/// Where its picture lives, or <c>null</c>. Only some kinds have one: stash-box knows what a
|
||||||
|
/// performer and a studio look like, and has no picture for a tag at all.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MetadataEntity(string Name, string? ImageUrl = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One candidate a source returned for a video, as PLib understands it.
|
/// One candidate a source returned for a video, as PLib understands it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -9,14 +18,19 @@ namespace PLib.Application.Metadata;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="SourceName">Which configured source proposed it.</param>
|
/// <param name="SourceName">Which configured source proposed it.</param>
|
||||||
/// <param name="RemoteId">Its identifier at the source, for display and for reporting.</param>
|
/// <param name="RemoteId">Its identifier at the source, for display and for reporting.</param>
|
||||||
|
/// <param name="ImageUrl">
|
||||||
|
/// The scene's own cover at the source, or <c>null</c>. Still a URL: fetching it is the
|
||||||
|
/// caller's business, and a candidate must never wait on a picture host to be shown.
|
||||||
|
/// </param>
|
||||||
public sealed record VideoMetadataMatch(
|
public sealed record VideoMetadataMatch(
|
||||||
string SourceName,
|
string SourceName,
|
||||||
string? RemoteId,
|
string? RemoteId,
|
||||||
string Title,
|
string Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
IReadOnlyList<string> Tags,
|
IReadOnlyList<MetadataEntity> Tags,
|
||||||
IReadOnlyList<string> Performers,
|
IReadOnlyList<MetadataEntity> Performers,
|
||||||
IReadOnlyList<string> Studios);
|
IReadOnlyList<MetadataEntity> Studios,
|
||||||
|
string? ImageUrl = null);
|
||||||
|
|
||||||
/// <summary>Everything one lookup produced, including what went wrong.</summary>
|
/// <summary>Everything one lookup produced, including what went wrong.</summary>
|
||||||
/// <param name="HasPerceptualHash">
|
/// <param name="HasPerceptualHash">
|
||||||
|
|||||||
+82
-81
@@ -1,81 +1,82 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PLib.Desktop.Imaging;
|
using PLib.Desktop.Imaging;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
using PLib.Desktop.ViewModels;
|
using PLib.Desktop.ViewModels;
|
||||||
using PLib.Infrastructure;
|
using PLib.Infrastructure;
|
||||||
using PLib.Infrastructure.Storage;
|
using PLib.Infrastructure.Storage;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace PLib.Desktop;
|
namespace PLib.Desktop;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composition root. Everything the application is made of is wired up here and nowhere else.
|
/// Composition root. Everything the application is made of is wired up here and nowhere else.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class AppHost
|
internal static class AppHost
|
||||||
{
|
{
|
||||||
public static IHost Create(string[] args)
|
public static IHost Create(string[] args)
|
||||||
{
|
{
|
||||||
// The paths are needed to locate the user settings file, which is itself a
|
// 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
|
// configuration source — so they are built before the container exists and then
|
||||||
// handed to it as an instance.
|
// handed to it as an instance.
|
||||||
var paths = new AppPaths();
|
var paths = new AppPaths();
|
||||||
|
|
||||||
// A desktop app is launched from arbitrary working directories, so the content root
|
// 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.
|
// has to be the folder the executable lives in rather than Environment.CurrentDirectory.
|
||||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
|
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
|
||||||
{
|
{
|
||||||
Args = args,
|
Args = args,
|
||||||
ContentRootPath = AppContext.BaseDirectory,
|
ContentRootPath = AppContext.BaseDirectory,
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Configuration.AddJsonFile(
|
builder.Configuration.AddJsonFile(
|
||||||
Path.Combine(paths.DataDirectory, "settings.json"),
|
Path.Combine(paths.DataDirectory, "settings.json"),
|
||||||
optional: true,
|
optional: true,
|
||||||
reloadOnChange: true);
|
reloadOnChange: true);
|
||||||
|
|
||||||
ConfigureLogging(builder, paths);
|
ConfigureLogging(builder, paths);
|
||||||
|
|
||||||
builder.Services.AddSingleton<IAppPaths>(paths);
|
builder.Services.AddSingleton<IAppPaths>(paths);
|
||||||
|
|
||||||
builder.Services.AddOptions<AppearanceOptions>()
|
builder.Services.AddOptions<AppearanceOptions>()
|
||||||
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
|
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
|
||||||
|
|
||||||
builder.Services.AddOptions<PlaybackOptions>()
|
builder.Services.AddOptions<PlaybackOptions>()
|
||||||
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
|
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
|
||||||
.ValidateDataAnnotations();
|
.ValidateDataAnnotations();
|
||||||
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddSingleton<ThumbnailCache>();
|
builder.Services.AddSingleton<ThumbnailCache>();
|
||||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||||
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
|
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
|
||||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||||
builder.Services.AddSingleton<MainWindowViewModel>();
|
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.
|
// Scoped, not singleton: the settings dialog edits a working copy, so each opening
|
||||||
builder.Services.AddScoped<SettingsViewModel>();
|
// gets its own model and a cancelled edit never leaks into the next one.
|
||||||
|
builder.Services.AddScoped<SettingsViewModel>();
|
||||||
return builder.Build();
|
|
||||||
}
|
return builder.Build();
|
||||||
|
}
|
||||||
private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths)
|
|
||||||
{
|
private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths)
|
||||||
Log.Logger = new LoggerConfiguration()
|
{
|
||||||
.MinimumLevel.Information()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.WriteTo.Console()
|
.MinimumLevel.Information()
|
||||||
.WriteTo.File(
|
.WriteTo.Console()
|
||||||
Path.Combine(paths.DataDirectory, "logs", "plib-.log"),
|
.WriteTo.File(
|
||||||
rollingInterval: RollingInterval.Day,
|
Path.Combine(paths.DataDirectory, "logs", "plib-.log"),
|
||||||
retainedFileCountLimit: 7)
|
rollingInterval: RollingInterval.Day,
|
||||||
.CreateLogger();
|
retainedFileCountLimit: 7)
|
||||||
|
.CreateLogger();
|
||||||
builder.Logging.ClearProviders();
|
|
||||||
builder.Logging.AddSerilog(Log.Logger, dispose: true);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,14 +136,16 @@
|
|||||||
|
|
||||||
<!-- One tag, performer, studio or collection in a browsing tab. -->
|
<!-- One tag, performer, studio or collection in a browsing tab. -->
|
||||||
<Style Selector="Button.entity">
|
<Style Selector="Button.entity">
|
||||||
<Setter Property="Padding" Value="12,10" />
|
<Setter Property="Padding" Value="8,8,8,10" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.entity:pointerover">
|
<Style Selector="Button.entity:pointerover">
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public sealed class LabelSummaryViewModel
|
|||||||
Kind = summary.Kind;
|
Kind = summary.Kind;
|
||||||
VideoCount = summary.VideoCount;
|
VideoCount = summary.VideoCount;
|
||||||
CountText = VideoCount.ToString(CultureInfo.CurrentCulture);
|
CountText = VideoCount.ToString(CultureInfo.CurrentCulture);
|
||||||
|
ImagePath = summary.ImagePath;
|
||||||
|
|
||||||
|
// Stands in for the picture. Tags never have one — stash-box holds no image for a tag
|
||||||
|
// — and a performer only gets one once a match brings it, so the fallback is the
|
||||||
|
// normal case rather than an error state.
|
||||||
|
Initial = Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
|
||||||
|
|
||||||
OpenCommand = ReactiveCommand.Create(() => open(this));
|
OpenCommand = ReactiveCommand.Create(() => open(this));
|
||||||
}
|
}
|
||||||
@@ -32,6 +38,12 @@ public sealed class LabelSummaryViewModel
|
|||||||
|
|
||||||
public string CountText { get; }
|
public string CountText { get; }
|
||||||
|
|
||||||
|
/// <summary>Cached picture of this label, or <c>null</c> when there is none.</summary>
|
||||||
|
public string? ImagePath { get; }
|
||||||
|
|
||||||
|
/// <summary>First letter, drawn when there is no picture.</summary>
|
||||||
|
public string Initial { get; }
|
||||||
|
|
||||||
/// <summary>Narrows the video grid down to this label and switches to it.</summary>
|
/// <summary>Narrows the video grid down to this label and switches to it.</summary>
|
||||||
public ReactiveCommand<RxVoid, RxVoid> OpenCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> OpenCommand { get; }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
using PLib.Application.Metadata;
|
using PLib.Application.Metadata;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
|
using ReactiveUI.SourceGenerators;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
namespace PLib.Desktop.ViewModels;
|
namespace PLib.Desktop.ViewModels;
|
||||||
@@ -9,7 +10,7 @@ namespace PLib.Desktop.ViewModels;
|
|||||||
/// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers,
|
/// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers,
|
||||||
/// and a source confidently overwriting a title is far harder to undo than a button is to press.
|
/// and a source confidently overwriting a title is far harder to undo than a button is to press.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class MetadataMatchViewModel
|
public sealed partial class MetadataMatchViewModel : ReactiveObject
|
||||||
{
|
{
|
||||||
public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply)
|
public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply)
|
||||||
{
|
{
|
||||||
@@ -19,6 +20,7 @@ public sealed class MetadataMatchViewModel
|
|||||||
SourceName = match.SourceName;
|
SourceName = match.SourceName;
|
||||||
Title = match.Title;
|
Title = match.Title;
|
||||||
Description = match.Description;
|
Description = match.Description;
|
||||||
|
ImageUrl = match.ImageUrl;
|
||||||
|
|
||||||
Studios = Join(match.Studios);
|
Studios = Join(match.Studios);
|
||||||
Performers = Join(match.Performers);
|
Performers = Join(match.Performers);
|
||||||
@@ -35,6 +37,18 @@ public sealed class MetadataMatchViewModel
|
|||||||
|
|
||||||
public string? Description { get; }
|
public string? Description { get; }
|
||||||
|
|
||||||
|
/// <summary>Where the candidate's cover lives at the source, or <c>null</c>.</summary>
|
||||||
|
public string? ImageUrl { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cover once it is on disk. Reactive and initially empty: the row is shown as soon as
|
||||||
|
/// the source answers, and the picture fills in behind it. A title and a tag list say very
|
||||||
|
/// little about whether this is the right video; a frame says it at a glance — but not at
|
||||||
|
/// the price of the row waiting for it.
|
||||||
|
/// </summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial string? ImagePath { get; set; }
|
||||||
|
|
||||||
/// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary>
|
/// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary>
|
||||||
public string? Studios { get; }
|
public string? Studios { get; }
|
||||||
|
|
||||||
@@ -44,6 +58,6 @@ public sealed class MetadataMatchViewModel
|
|||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; }
|
||||||
|
|
||||||
private static string? Join(IReadOnlyList<string> values) =>
|
private static string? Join(IReadOnlyList<MetadataEntity> values) =>
|
||||||
values.Count == 0 ? null : string.Join(", ", values);
|
values.Count == 0 ? null : string.Join(", ", values.Select(entity => entity.Name));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Application.Metadata;
|
using PLib.Application.Metadata;
|
||||||
|
using PLib.Desktop.Services;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using ReactiveUI.SourceGenerators;
|
using ReactiveUI.SourceGenerators;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
@@ -22,6 +23,7 @@ namespace PLib.Desktop.ViewModels;
|
|||||||
public sealed partial class MetadataScanViewModel : ViewModelBase
|
public sealed partial class MetadataScanViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly RemoteImageLoader _images;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
/// <summary>Called after the run so the grid can pick up renamed videos and new labels.</summary>
|
/// <summary>Called after the run so the grid can pick up renamed videos and new labels.</summary>
|
||||||
@@ -31,10 +33,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
|
|
||||||
public MetadataScanViewModel(
|
public MetadataScanViewModel(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
|
RemoteImageLoader images,
|
||||||
Func<Task> refreshLibrary,
|
Func<Task> refreshLibrary,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
|
_images = images;
|
||||||
_refreshLibrary = refreshLibrary;
|
_refreshLibrary = refreshLibrary;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -46,6 +50,12 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
() => _running?.Cancel(),
|
() => _running?.Cancel(),
|
||||||
this.WhenAnyValue(x => x.IsRunning));
|
this.WhenAnyValue(x => x.IsRunning));
|
||||||
|
|
||||||
|
// Pictures fail on their own schedule, long after the source answered, so this is
|
||||||
|
// a subscription rather than something the run reports.
|
||||||
|
_images.Problems
|
||||||
|
.Subscribe(problem => Dispatcher.UIThread.Post(() => Report(problem)))
|
||||||
|
.AddTo(Subscriptions);
|
||||||
|
|
||||||
ObserveCommandFailures();
|
ObserveCommandFailures();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +144,16 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MetadataScanEvent.Matched matched:
|
case MetadataScanEvent.Matched matched:
|
||||||
Results.Insert(0, new MetadataScanResultViewModel(matched, ApplyAsync));
|
var result = new MetadataScanResultViewModel(matched, ApplyAsync);
|
||||||
|
Results.Insert(0, result);
|
||||||
|
|
||||||
|
// Started, not awaited: the row is on screen already, and the covers
|
||||||
|
// arrive when the picture host gets round to them.
|
||||||
|
LoadCovers(result.Matches);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MetadataScanEvent.SourceAbandoned abandoned:
|
case MetadataScanEvent.SourceAbandoned abandoned:
|
||||||
Problems.Add($"{abandoned.SourceName}: {abandoned.Reason}");
|
Report($"{abandoned.SourceName}: {abandoned.Reason}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MetadataScanEvent.Completed completed:
|
case MetadataScanEvent.Completed completed:
|
||||||
@@ -153,6 +168,35 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds a line unless it is already there; the same host fails repeatedly.</summary>
|
||||||
|
private void Report(string problem)
|
||||||
|
{
|
||||||
|
if (!Problems.Contains(problem))
|
||||||
|
{
|
||||||
|
Problems.Add(problem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fills in the covers behind rows that are already visible. Nothing awaits this:
|
||||||
|
/// fetching before showing is what made a stalled picture host freeze the whole run.
|
||||||
|
/// </summary>
|
||||||
|
private void LoadCovers(IEnumerable<MetadataMatchViewModel> matches)
|
||||||
|
{
|
||||||
|
foreach (var match in matches.Where(match => match.ImageUrl is not null))
|
||||||
|
{
|
||||||
|
_ = LoadCoverAsync(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCoverAsync(MetadataMatchViewModel match)
|
||||||
|
{
|
||||||
|
if (await _images.LoadAsync(match.ImageUrl) is { } path)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string Described(int count) =>
|
private static string Described(int count) =>
|
||||||
count == 0 ? "Совпадений пока не было." : $"Совпадений найдено: {count}.";
|
count == 0 ? "Совпадений пока не было." : $"Совпадений найдено: {count}.";
|
||||||
|
|
||||||
|
|||||||
@@ -1,315 +1,321 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Reactive.Disposables;
|
using System.Reactive.Disposables;
|
||||||
using System.Reactive.Linq;
|
using System.Reactive.Linq;
|
||||||
using System.Reactive.Subjects;
|
using System.Reactive.Subjects;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Application.Metadata;
|
using PLib.Application.Metadata;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using ReactiveUI.SourceGenerators;
|
using ReactiveUI.SourceGenerators;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
namespace PLib.Desktop.ViewModels;
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Edits a working copy of the settings and only writes it back when the user confirms, so
|
/// 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.
|
/// cancelling leaves both the file and the running configuration untouched.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class SettingsViewModel : ViewModelBase
|
public sealed partial class SettingsViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private const double BytesPerMegabyte = 1024 * 1024;
|
private const double BytesPerMegabyte = 1024 * 1024;
|
||||||
|
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IAppSettingsStore _settingsStore;
|
private readonly IAppSettingsStore _settingsStore;
|
||||||
private readonly IFolderPicker _folderPicker;
|
private readonly IFolderPicker _folderPicker;
|
||||||
private readonly IThemeService _theme;
|
private readonly IThemeService _theme;
|
||||||
private readonly ILogger<SettingsViewModel> _logger;
|
private readonly ILogger<SettingsViewModel> _logger;
|
||||||
|
|
||||||
/// <summary>The state the dialog opened with; the baseline every change is compared to.</summary>
|
/// <summary>The state the dialog opened with; the baseline every change is compared to.</summary>
|
||||||
private readonly AppSettings _original;
|
private readonly AppSettings _original;
|
||||||
|
|
||||||
private readonly Subject<SettingsDialogOutcome> _closed = new();
|
private readonly Subject<SettingsDialogOutcome> _closed = new();
|
||||||
|
|
||||||
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
|
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
|
||||||
private bool _dataWasReset;
|
private bool _dataWasReset;
|
||||||
|
|
||||||
public SettingsViewModel(
|
public SettingsViewModel(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IAppSettingsStore settingsStore,
|
IAppSettingsStore settingsStore,
|
||||||
IFolderPicker folderPicker,
|
IFolderPicker folderPicker,
|
||||||
IThemeService theme,
|
IThemeService theme,
|
||||||
ILogger<SettingsViewModel> logger)
|
ILogger<SettingsViewModel> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_settingsStore = settingsStore;
|
_settingsStore = settingsStore;
|
||||||
_folderPicker = folderPicker;
|
_folderPicker = folderPicker;
|
||||||
_theme = theme;
|
_theme = theme;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
// Read on construction rather than cached anywhere: the panel can be reopened after
|
// 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.
|
// a save, and a stale snapshot would show the settings the application started with.
|
||||||
_original = settingsStore.Current;
|
_original = settingsStore.Current;
|
||||||
|
|
||||||
Folders = [.. _original.Folders.Select(CreateEntry)];
|
Folders = [.. _original.Folders.Select(CreateEntry)];
|
||||||
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
|
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
|
||||||
ThumbnailWidth = _original.ThumbnailWidth;
|
ThumbnailWidth = _original.ThumbnailWidth;
|
||||||
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
||||||
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
||||||
MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
|
MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
|
||||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||||
|
|
||||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||||
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
|
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
|
||||||
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
||||||
|
|
||||||
// One gate for every clearing button: they all talk to the same database and the same
|
// 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.
|
// cache directories, so letting a second one start mid-flight buys nothing.
|
||||||
var idle = this.WhenAnyValue(x => x.IsBusy).Select(busy => !busy);
|
var idle = this.WhenAnyValue(x => x.IsBusy).Select(busy => !busy);
|
||||||
|
|
||||||
DataKinds =
|
DataKinds =
|
||||||
[
|
[
|
||||||
new(
|
new(
|
||||||
LibraryDataKind.Thumbnails,
|
LibraryDataKind.Thumbnails,
|
||||||
"Постеры",
|
"Постеры",
|
||||||
"Кадр-обложка карточки. Соберутся заново при следующем сканировании.",
|
"Кадр-обложка карточки. Соберутся заново при следующем сканировании.",
|
||||||
ClearAsync,
|
ClearAsync,
|
||||||
idle),
|
idle),
|
||||||
new(
|
new(
|
||||||
LibraryDataKind.AnimatedPreviews,
|
LibraryDataKind.AnimatedPreviews,
|
||||||
"Анимированные превью",
|
"Анимированные превью",
|
||||||
"Кадры, которые прокручиваются под курсором. Самое объёмное на диске.",
|
"Кадры, которые прокручиваются под курсором. Самое объёмное на диске.",
|
||||||
ClearAsync,
|
ClearAsync,
|
||||||
idle),
|
idle),
|
||||||
new(
|
new(
|
||||||
LibraryDataKind.PerceptualHashes,
|
LibraryDataKind.PerceptualHashes,
|
||||||
"Отпечатки",
|
"Отпечатки",
|
||||||
"Нужны только для поиска дублей. Считаются дольше всего: два десятка кадров на файл.",
|
"Нужны только для поиска дублей. Считаются дольше всего: два десятка кадров на файл.",
|
||||||
ClearAsync,
|
ClearAsync,
|
||||||
idle),
|
idle),
|
||||||
new(
|
new(
|
||||||
LibraryDataKind.TechnicalMetadata,
|
LibraryDataKind.TechnicalMetadata,
|
||||||
"Технические метаданные",
|
"Технические метаданные",
|
||||||
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
|
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
|
||||||
ClearAsync,
|
ClearAsync,
|
||||||
idle),
|
idle),
|
||||||
];
|
new(
|
||||||
|
LibraryDataKind.RemoteImages,
|
||||||
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
|
"Изображения меток",
|
||||||
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
|
"Фото актёров и логотипы студий. Скачиваются из источника метаданных, а не из файлов, поэтому сканирование их не вернёт — только повторное применение совпадения.",
|
||||||
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
|
ClearAsync,
|
||||||
|
idle),
|
||||||
// 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;
|
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
|
||||||
Disposable
|
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
|
||||||
.Create(() => Folders.CollectionChanged -= OnFoldersChanged)
|
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
|
||||||
.AddTo(Subscriptions);
|
|
||||||
|
// A binding cannot negate an int, so the "nothing added yet" hint reads a bool that
|
||||||
ObserveCommandFailures();
|
// is re-raised whenever the list changes.
|
||||||
}
|
Folders.CollectionChanged += OnFoldersChanged;
|
||||||
|
Disposable
|
||||||
/// <summary>Fires once, when the dialog should close.</summary>
|
.Create(() => Folders.CollectionChanged -= OnFoldersChanged)
|
||||||
public IObservable<SettingsDialogOutcome> Closed => _closed;
|
.AddTo(Subscriptions);
|
||||||
|
|
||||||
public ObservableCollection<FolderEntryViewModel> Folders { get; }
|
ObserveCommandFailures();
|
||||||
|
}
|
||||||
public bool HasFolders => Folders.Count > 0;
|
|
||||||
|
/// <summary>Fires once, when the dialog should close.</summary>
|
||||||
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
|
public IObservable<SettingsDialogOutcome> Closed => _closed;
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
|
public ObservableCollection<FolderEntryViewModel> Folders { get; }
|
||||||
|
|
||||||
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
public bool HasFolders => Folders.Count > 0;
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> RefreshUsageCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ClearAllCommand { get; }
|
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
||||||
|
|
||||||
/// <summary>
|
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
||||||
/// 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.
|
public ReactiveCommand<RxVoid, RxVoid> RefreshUsageCommand { get; }
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<LibraryDataViewModel> DataKinds { get; }
|
public ReactiveCommand<RxVoid, RxVoid> ClearAllCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
|
/// <summary>
|
||||||
|
/// Everything a scan can rebuild, one row each. Deliberately does not include titles,
|
||||||
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
|
/// tags or watch progress: those are the user's, and no rescan would bring them back.
|
||||||
|
/// </summary>
|
||||||
/// <summary>Width of generated poster frames; height follows the source aspect ratio.</summary>
|
public IReadOnlyList<LibraryDataViewModel> DataKinds { get; }
|
||||||
[Reactive]
|
|
||||||
public partial int ThumbnailWidth { get; set; }
|
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
|
||||||
|
|
||||||
/// <summary>How far into the video the poster frame is taken, in percent.</summary>
|
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
|
||||||
[Reactive]
|
|
||||||
public partial double ThumbnailPositionPercent { get; set; }
|
/// <summary>Width of generated poster frames; height follows the source aspect ratio.</summary>
|
||||||
|
[Reactive]
|
||||||
[Reactive]
|
public partial int ThumbnailWidth { get; set; }
|
||||||
public partial int MaxIndexingConcurrency { get; set; }
|
|
||||||
|
/// <summary>How far into the video the poster frame is taken, in percent.</summary>
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial double MinimumFileSizeMegabytes { get; set; }
|
public partial double ThumbnailPositionPercent { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial ThemeOption SelectedTheme { get; set; }
|
public partial int MaxIndexingConcurrency { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial double MinimumFileSizeMegabytes { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial string? Message { get; set; }
|
public partial ThemeOption SelectedTheme { get; set; }
|
||||||
|
|
||||||
// Built from the snapshot the panel opened with, not from scratch: anything this screen
|
[Reactive]
|
||||||
// does not edit — playback volume, for one — has to survive being saved from here.
|
public partial bool IsBusy { get; set; }
|
||||||
private AppSettings CurrentDraft => _original with
|
|
||||||
{
|
[Reactive]
|
||||||
Folders = [.. Folders.Select(entry => entry.Path)],
|
public partial string? Message { get; set; }
|
||||||
ThumbnailWidth = ThumbnailWidth,
|
|
||||||
|
// Built from the snapshot the panel opened with, not from scratch: anything this screen
|
||||||
// Both of these are shown in a friendlier unit than they are stored in, and that
|
// does not edit — playback volume, for one — has to survive being saved from here.
|
||||||
// conversion is lossy: 65 536 bytes displays as 0,06 MB and converts back to 62 915.
|
private AppSettings CurrentDraft => _original with
|
||||||
// An untouched field therefore keeps the original value verbatim — otherwise merely
|
{
|
||||||
// opening the panel and pressing Save would rewrite settings and force a rescan.
|
Folders = [.. Folders.Select(entry => entry.Path)],
|
||||||
ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio)
|
ThumbnailWidth = ThumbnailWidth,
|
||||||
? _original.ThumbnailPositionRatio
|
|
||||||
: Math.Round(ThumbnailPositionPercent / 100, 4),
|
// 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.
|
||||||
MaxIndexingConcurrency = MaxIndexingConcurrency,
|
// An untouched field therefore keeps the original value verbatim — otherwise merely
|
||||||
|
// opening the panel and pressing Save would rewrite settings and force a rescan.
|
||||||
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
|
ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio)
|
||||||
? _original.MinimumFileSizeInBytes
|
? _original.ThumbnailPositionRatio
|
||||||
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
: Math.Round(ThumbnailPositionPercent / 100, 4),
|
||||||
|
|
||||||
Theme = SelectedTheme.Mode,
|
MaxIndexingConcurrency = MaxIndexingConcurrency,
|
||||||
|
|
||||||
// Rows with nothing in them are what a half-finished edit looks like, and saving them
|
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
|
||||||
// would put empty entries in the file for the next opening to show again.
|
? _original.MinimumFileSizeInBytes
|
||||||
MetadataSources =
|
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||||
[
|
|
||||||
.. MetadataSources
|
Theme = SelectedTheme.Mode,
|
||||||
.Select(entry => entry.ToOptions())
|
|
||||||
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
|
// 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 =
|
||||||
|
[
|
||||||
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
|
.. MetadataSources
|
||||||
|
.Select(entry => entry.ToOptions())
|
||||||
private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2);
|
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
|
||||||
|
],
|
||||||
private async Task AddFolderAsync()
|
};
|
||||||
{
|
|
||||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
|
||||||
|
|
||||||
if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
|
private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2);
|
||||||
{
|
|
||||||
return;
|
private async Task AddFolderAsync()
|
||||||
}
|
{
|
||||||
|
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||||
Folders.Add(CreateEntry(folder));
|
|
||||||
}
|
if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
|
||||||
|
{
|
||||||
private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
|
return;
|
||||||
|
}
|
||||||
private FolderEntryViewModel CreateEntry(string path) =>
|
|
||||||
new(path, entry => Folders.Remove(entry));
|
Folders.Add(CreateEntry(folder));
|
||||||
|
}
|
||||||
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
|
|
||||||
new(source, entry => MetadataSources.Remove(entry));
|
private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
|
||||||
|
|
||||||
private void AddMetadataSource() =>
|
private FolderEntryViewModel CreateEntry(string path) =>
|
||||||
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
|
new(path, entry => Folders.Remove(entry));
|
||||||
|
|
||||||
private async Task RefreshUsageAsync()
|
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
|
||||||
{
|
new(source, entry => MetadataSources.Remove(entry));
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
private void AddMetadataSource() =>
|
||||||
|
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
|
||||||
var usage = await library.GetDataUsageAsync();
|
|
||||||
|
private async Task RefreshUsageAsync()
|
||||||
foreach (var entry in usage)
|
{
|
||||||
{
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
DataKinds.FirstOrDefault(row => row.Kind == entry.Kind)?.Apply(entry);
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
}
|
|
||||||
}
|
var usage = await library.GetDataUsageAsync();
|
||||||
|
|
||||||
private async Task ClearAsync(LibraryDataKind kinds)
|
foreach (var entry in usage)
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
DataKinds.FirstOrDefault(row => row.Kind == entry.Kind)?.Apply(entry);
|
||||||
|
}
|
||||||
try
|
}
|
||||||
{
|
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
private async Task ClearAsync(LibraryDataKind kinds)
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
{
|
||||||
|
IsBusy = true;
|
||||||
await library.ResetAsync(kinds);
|
|
||||||
await RefreshUsageAsync();
|
try
|
||||||
|
{
|
||||||
// The data is gone from disk and from the library, so the grid has to be rebuilt
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
// regardless of what else the user changes before closing.
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
_dataWasReset = true;
|
|
||||||
Message = "Очищено — недостающее соберётся при следующем сканировании";
|
await library.ResetAsync(kinds);
|
||||||
}
|
await RefreshUsageAsync();
|
||||||
finally
|
|
||||||
{
|
// The data is gone from disk and from the library, so the grid has to be rebuilt
|
||||||
IsBusy = false;
|
// regardless of what else the user changes before closing.
|
||||||
}
|
_dataWasReset = true;
|
||||||
}
|
Message = "Очищено — недостающее соберётся при следующем сканировании";
|
||||||
|
}
|
||||||
private async Task SaveAsync()
|
finally
|
||||||
{
|
{
|
||||||
var draft = CurrentDraft;
|
IsBusy = false;
|
||||||
|
}
|
||||||
await _settingsStore.SaveAsync(draft);
|
}
|
||||||
|
|
||||||
// The theme is not read from configuration again while the app runs, so apply it here.
|
private async Task SaveAsync()
|
||||||
_theme.Apply(draft.Theme);
|
{
|
||||||
|
var draft = CurrentDraft;
|
||||||
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
|
|
||||||
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
|
await _settingsStore.SaveAsync(draft);
|
||||||
}
|
|
||||||
|
// The theme is not read from configuration again while the app runs, so apply it here.
|
||||||
private void ObserveCommandFailures() =>
|
_theme.Apply(draft.Theme);
|
||||||
Observable
|
|
||||||
.Merge<Exception>(
|
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
|
||||||
[
|
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
|
||||||
AddFolderCommand.ThrownExceptions,
|
}
|
||||||
AddMetadataSourceCommand.ThrownExceptions,
|
|
||||||
RefreshUsageCommand.ThrownExceptions,
|
private void ObserveCommandFailures() =>
|
||||||
ClearAllCommand.ThrownExceptions,
|
Observable
|
||||||
SaveCommand.ThrownExceptions,
|
.Merge<Exception>(
|
||||||
CancelCommand.ThrownExceptions,
|
[
|
||||||
|
AddFolderCommand.ThrownExceptions,
|
||||||
// The per-kind buttons are commands too, and an unobserved failure in any of
|
AddMetadataSourceCommand.ThrownExceptions,
|
||||||
// them would be rethrown on the UI thread by ReactiveUI's default handler.
|
RefreshUsageCommand.ThrownExceptions,
|
||||||
.. DataKinds.Select(row => row.ClearCommand.ThrownExceptions),
|
ClearAllCommand.ThrownExceptions,
|
||||||
])
|
SaveCommand.ThrownExceptions,
|
||||||
.Subscribe(ex =>
|
CancelCommand.ThrownExceptions,
|
||||||
{
|
|
||||||
_logger.LogError(ex, "A settings command failed");
|
// The per-kind buttons are commands too, and an unobserved failure in any of
|
||||||
Message = "Не удалось выполнить действие — подробности в журнале";
|
// them would be rethrown on the UI thread by ReactiveUI's default handler.
|
||||||
})
|
.. DataKinds.Select(row => row.ClearCommand.ThrownExceptions),
|
||||||
.AddTo(Subscriptions);
|
])
|
||||||
}
|
.Subscribe(ex =>
|
||||||
|
{
|
||||||
/// <summary>What the settings dialog left behind once it closed.</summary>
|
_logger.LogError(ex, "A settings command failed");
|
||||||
/// <param name="Saved">False when the user cancelled or closed the window.</param>
|
Message = "Не удалось выполнить действие — подробности в журнале";
|
||||||
/// <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>
|
.AddTo(Subscriptions);
|
||||||
public sealed record SettingsDialogOutcome(bool Saved, bool RescanRequired, AppSettings? Settings)
|
}
|
||||||
{
|
|
||||||
public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
|
/// <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>
|
||||||
public sealed record ThemeOption(ThemeMode Mode, string Label)
|
/// <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 IReadOnlyList<ThemeOption> All { get; } =
|
{
|
||||||
[
|
public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
|
||||||
new(ThemeMode.System, "Как в системе"),
|
}
|
||||||
new(ThemeMode.Light, "Светлая"),
|
|
||||||
new(ThemeMode.Dark, "Тёмная"),
|
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.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reactive.Concurrency;
|
using System.Reactive.Concurrency;
|
||||||
using System.Reactive.Linq;
|
using System.Reactive.Linq;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Avalonia.Threading;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PLib.Application.Library;
|
using Microsoft.Extensions.Logging;
|
||||||
using PLib.Application.Metadata;
|
using PLib.Application.Library;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Application.Metadata;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Desktop.Services;
|
||||||
using ReactiveUI;
|
using PLib.Domain.Videos;
|
||||||
using ReactiveUI.SourceGenerators;
|
using ReactiveUI;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using ReactiveUI.SourceGenerators;
|
||||||
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
namespace PLib.Desktop.ViewModels;
|
|
||||||
|
namespace PLib.Desktop.ViewModels;
|
||||||
/// <summary>
|
|
||||||
/// The media page: one video with its player, its details and its labels.
|
/// <summary>
|
||||||
/// </summary>
|
/// The media page: one video with its player, its details and its labels.
|
||||||
/// <remarks>
|
/// </summary>
|
||||||
/// Transport state stays on the player control; what the page owns is the video's identity,
|
/// <remarks>
|
||||||
/// everything shown around the picture, and the settings that outlive the page.
|
/// Transport state stays on the player control; what the page owns is the video's identity,
|
||||||
/// </remarks>
|
/// everything shown around the picture, and the settings that outlive the page.
|
||||||
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
/// </remarks>
|
||||||
{
|
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||||
/// <summary>
|
{
|
||||||
/// How long the volume has to sit still before it is written. Dragging the slider
|
/// <summary>
|
||||||
/// produces a value per pixel, and each one would otherwise be a file write.
|
/// How long the volume has to sit still before it is written. Dragging the slider
|
||||||
/// </summary>
|
/// produces a value per pixel, and each one would otherwise be a file write.
|
||||||
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
|
/// </summary>
|
||||||
|
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
|
||||||
private readonly IAppSettingsStore _settingsStore;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly ILogger _logger;
|
private readonly IAppSettingsStore _settingsStore;
|
||||||
|
private readonly RemoteImageLoader _images;
|
||||||
public VideoPlayerViewModel(
|
private readonly ILogger _logger;
|
||||||
VideoCardViewModel card,
|
|
||||||
ISystemShell shell,
|
public VideoPlayerViewModel(
|
||||||
IServiceScopeFactory scopeFactory,
|
VideoCardViewModel card,
|
||||||
IAppSettingsStore settingsStore,
|
ISystemShell shell,
|
||||||
ILogger logger,
|
IServiceScopeFactory scopeFactory,
|
||||||
Action close)
|
IAppSettingsStore settingsStore,
|
||||||
{
|
RemoteImageLoader images,
|
||||||
_scopeFactory = scopeFactory;
|
ILogger logger,
|
||||||
_settingsStore = settingsStore;
|
Action close)
|
||||||
_logger = logger;
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
Card = card;
|
_settingsStore = settingsStore;
|
||||||
VideoId = card.Id;
|
_images = images;
|
||||||
Title = card.Title;
|
_logger = logger;
|
||||||
FullPath = card.FullPath;
|
|
||||||
Source = new Uri(card.FullPath);
|
Card = card;
|
||||||
ResumeFrom = card.ResumePosition;
|
VideoId = card.Id;
|
||||||
Details = BuildDetails(card);
|
Title = card.Title;
|
||||||
|
FullPath = card.FullPath;
|
||||||
Subtitle = string.Join(
|
Source = new Uri(card.FullPath);
|
||||||
" · ",
|
ResumeFrom = card.ResumePosition;
|
||||||
new[] { card.QualityText, card.DurationText, card.SizeText }
|
Details = BuildDetails(card);
|
||||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
|
||||||
|
Subtitle = string.Join(
|
||||||
var settings = settingsStore.Current;
|
" · ",
|
||||||
Volume = settings.Volume;
|
new[] { card.QualityText, card.DurationText, card.SizeText }
|
||||||
IsMuted = settings.IsMuted;
|
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||||
|
|
||||||
CloseCommand = ReactiveCommand.Create(close);
|
var settings = settingsStore.Current;
|
||||||
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
Volume = settings.Volume;
|
||||||
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
|
IsMuted = settings.IsMuted;
|
||||||
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
|
|
||||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
CloseCommand = ReactiveCommand.Create(close);
|
||||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
||||||
|
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
|
||||||
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
|
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
|
||||||
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||||
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
||||||
|
|
||||||
// Only ever from this button: the whole point of the feature is that nothing talks to
|
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
|
||||||
// a remote service about the user's library on its own.
|
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
||||||
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
|
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
||||||
LookupMetadataAsync,
|
|
||||||
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
|
// 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.
|
||||||
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
|
||||||
// Skip the values we just restored: they are already what is on disk.
|
LookupMetadataAsync,
|
||||||
.Skip(1)
|
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
|
||||||
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
|
|
||||||
.DistinctUntilChanged()
|
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
||||||
.Subscribe(state => Persist(state.volume, state.muted))
|
// Skip the values we just restored: they are already what is on disk.
|
||||||
.AddTo(Subscriptions);
|
.Skip(1)
|
||||||
|
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
|
||||||
ObserveCommandFailures();
|
.DistinctUntilChanged()
|
||||||
}
|
.Subscribe(state => Persist(state.volume, state.muted))
|
||||||
|
.AddTo(Subscriptions);
|
||||||
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
|
|
||||||
public VideoCardViewModel Card { get; }
|
_images.Problems
|
||||||
|
.Subscribe(problem => Dispatcher.UIThread.Post(() => ImageProblem = problem))
|
||||||
public Guid VideoId { get; }
|
.AddTo(Subscriptions);
|
||||||
|
|
||||||
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
|
ObserveCommandFailures();
|
||||||
[Reactive]
|
}
|
||||||
public partial string Title { get; set; }
|
|
||||||
|
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
|
||||||
public string FullPath { get; }
|
public VideoCardViewModel Card { get; }
|
||||||
|
|
||||||
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
|
public Guid VideoId { get; }
|
||||||
public Uri Source { get; }
|
|
||||||
|
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
|
||||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
[Reactive]
|
||||||
public string Subtitle { get; }
|
public partial string Title { get; set; }
|
||||||
|
|
||||||
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
|
public string FullPath { get; }
|
||||||
public TimeSpan? ResumeFrom { get; }
|
|
||||||
|
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
|
||||||
public IReadOnlyList<MetadataRow> Details { get; }
|
public Uri Source { get; }
|
||||||
|
|
||||||
public ObservableCollection<LabelViewModel> Tags { get; } = [];
|
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
||||||
|
public string Subtitle { get; }
|
||||||
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
|
||||||
|
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
|
||||||
public ObservableCollection<LabelViewModel> Performers { get; } = [];
|
public TimeSpan? ResumeFrom { get; }
|
||||||
|
|
||||||
public ObservableCollection<LabelViewModel> Studios { get; } = [];
|
public IReadOnlyList<MetadataRow> Details { get; }
|
||||||
|
|
||||||
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
|
public ObservableCollection<LabelViewModel> Tags { get; } = [];
|
||||||
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
|
||||||
|
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
|
||||||
|
public ObservableCollection<LabelViewModel> Performers { get; } = [];
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
|
||||||
|
public ObservableCollection<LabelViewModel> Studios { get; } = [];
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
|
||||||
|
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
|
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||||
|
|
||||||
[Reactive]
|
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
|
||||||
public partial bool IsLookingUp { get; set; }
|
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
|
||||||
/// <summary>What the last lookup came to, including which sources failed.</summary>
|
|
||||||
[Reactive]
|
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
||||||
public partial string? MetadataMessage { get; set; }
|
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
|
||||||
/// <summary>Free text about the video, once a match has supplied one.</summary>
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial string? Description { get; set; }
|
public partial bool IsLookingUp { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>What the last lookup came to, including which sources failed.</summary>
|
||||||
/// True while the window is given over to the video. The page hides its own header and
|
[Reactive]
|
||||||
/// the window hides its chrome.
|
public partial string? MetadataMessage { get; set; }
|
||||||
/// </summary>
|
|
||||||
[Reactive]
|
/// <summary>Why the candidates have no pictures, when that is the reason.</summary>
|
||||||
public partial bool IsFullScreen { get; set; }
|
[Reactive]
|
||||||
|
public partial string? ImageProblem { get; set; }
|
||||||
/// <summary>The details and labels panel beside the video.</summary>
|
|
||||||
[Reactive]
|
/// <summary>Free text about the video, once a match has supplied one.</summary>
|
||||||
public partial bool AreDetailsVisible { get; set; }
|
[Reactive]
|
||||||
|
public partial string? Description { get; set; }
|
||||||
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
|
|
||||||
[Reactive]
|
/// <summary>
|
||||||
public partial double Volume { get; set; }
|
/// True while the window is given over to the video. The page hides its own header and
|
||||||
|
/// the window hides its chrome.
|
||||||
[Reactive]
|
/// </summary>
|
||||||
public partial bool IsMuted { get; set; }
|
[Reactive]
|
||||||
|
public partial bool IsFullScreen { get; set; }
|
||||||
[Reactive]
|
|
||||||
public partial string NewTag { get; set; } = string.Empty;
|
/// <summary>The details and labels panel beside the video.</summary>
|
||||||
|
[Reactive]
|
||||||
[Reactive]
|
public partial bool AreDetailsVisible { get; set; }
|
||||||
public partial string NewCollection { get; set; } = string.Empty;
|
|
||||||
|
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
|
||||||
/// <summary>
|
[Reactive]
|
||||||
/// Records where playback stopped and refreshes the card behind the page, so the grid
|
public partial double Volume { get; set; }
|
||||||
/// shows the new progress without waiting for a rescan.
|
|
||||||
/// </summary>
|
[Reactive]
|
||||||
public async Task SaveProgressAsync(TimeSpan position)
|
public partial bool IsMuted { get; set; }
|
||||||
{
|
|
||||||
try
|
[Reactive]
|
||||||
{
|
public partial string NewTag { get; set; } = string.Empty;
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
[Reactive]
|
||||||
|
public partial string NewCollection { get; set; } = string.Empty;
|
||||||
await library.SaveProgressAsync(VideoId, position);
|
|
||||||
|
/// <summary>
|
||||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
/// Records where playback stopped and refreshes the card behind the page, so the grid
|
||||||
{
|
/// shows the new progress without waiting for a rescan.
|
||||||
Card.Apply(refreshed);
|
/// </summary>
|
||||||
}
|
public async Task SaveProgressAsync(TimeSpan position)
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
try
|
||||||
{
|
{
|
||||||
// A lost resume position is not worth surfacing to someone who just closed a video.
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
}
|
|
||||||
}
|
await library.SaveProgressAsync(VideoId, position);
|
||||||
|
|
||||||
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
|
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||||
{
|
{
|
||||||
var rows = new List<MetadataRow>
|
Card.Apply(refreshed);
|
||||||
{
|
}
|
||||||
new("Длительность", card.DurationText),
|
}
|
||||||
new("Размер", card.SizeText),
|
catch (Exception ex)
|
||||||
};
|
{
|
||||||
|
// A lost resume position is not worth surfacing to someone who just closed a video.
|
||||||
if (card.Width is { } width && card.Height is { } height)
|
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
|
||||||
{
|
}
|
||||||
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
|
}
|
||||||
}
|
|
||||||
|
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
|
||||||
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
|
{
|
||||||
{
|
var rows = new List<MetadataRow>
|
||||||
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
|
{
|
||||||
}
|
new("Длительность", card.DurationText),
|
||||||
|
new("Размер", card.SizeText),
|
||||||
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
};
|
||||||
|
|
||||||
if (card.LastPlayedAt is { } lastPlayed)
|
if (card.Width is { } width && card.Height is { } height)
|
||||||
{
|
{
|
||||||
rows.Add(new MetadataRow(
|
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
|
||||||
"Последний просмотр",
|
}
|
||||||
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
|
||||||
}
|
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
|
||||||
|
{
|
||||||
if (card.PlayCount > 0)
|
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
|
||||||
{
|
}
|
||||||
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
|
|
||||||
}
|
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||||
|
|
||||||
if (card.PerceptualHash is { } hash)
|
if (card.LastPlayedAt is { } lastPlayed)
|
||||||
{
|
{
|
||||||
// Printed as hex: it is a bit pattern compared by Hamming distance, and the
|
rows.Add(new MetadataRow(
|
||||||
// decimal form of a 64-bit value tells nobody anything.
|
"Последний просмотр",
|
||||||
rows.Add(new MetadataRow("pHash", hash.ToString("x16", CultureInfo.InvariantCulture)));
|
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.Add(new MetadataRow("Файл", card.FullPath));
|
if (card.PlayCount > 0)
|
||||||
|
{
|
||||||
return rows;
|
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadLabelsAsync()
|
if (card.PerceptualHash is { } hash)
|
||||||
{
|
{
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
// Printed as hex: it is a bit pattern compared by Hamming distance, and the
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
// decimal form of a 64-bit value tells nobody anything.
|
||||||
|
rows.Add(new MetadataRow("pHash", hash.ToString("x16", CultureInfo.InvariantCulture)));
|
||||||
var video = await library.GetVideoWithLabelsAsync(VideoId);
|
}
|
||||||
|
|
||||||
Tags.Clear();
|
rows.Add(new MetadataRow("Файл", card.FullPath));
|
||||||
Collections.Clear();
|
|
||||||
Performers.Clear();
|
return rows;
|
||||||
Studios.Clear();
|
}
|
||||||
|
|
||||||
if (video is null)
|
private async Task LoadLabelsAsync()
|
||||||
{
|
{
|
||||||
return;
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
}
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
Title = video.Title;
|
var video = await library.GetVideoWithLabelsAsync(VideoId);
|
||||||
Description = video.Description;
|
|
||||||
|
Tags.Clear();
|
||||||
// The grid filters by label, so the card behind this page has to hear about every
|
Collections.Clear();
|
||||||
// label added or removed here — otherwise a tag would not narrow the grid until the
|
Performers.Clear();
|
||||||
// library was reloaded.
|
Studios.Clear();
|
||||||
Card.ApplyLabels(video.Labels);
|
|
||||||
|
if (video is null)
|
||||||
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
{
|
||||||
{
|
return;
|
||||||
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
}
|
||||||
}
|
|
||||||
}
|
Title = video.Title;
|
||||||
|
Description = video.Description;
|
||||||
private async Task LookupMetadataAsync()
|
|
||||||
{
|
// The grid filters by label, so the card behind this page has to hear about every
|
||||||
IsLookingUp = true;
|
// label added or removed here — otherwise a tag would not narrow the grid until the
|
||||||
Matches.Clear();
|
// library was reloaded.
|
||||||
|
Card.ApplyLabels(video.Labels);
|
||||||
// The results land in the side panel, so opening it is part of running the lookup —
|
|
||||||
// otherwise the button would appear to do nothing.
|
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||||
AreDetailsVisible = true;
|
{
|
||||||
|
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||||
try
|
}
|
||||||
{
|
}
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
private async Task LookupMetadataAsync()
|
||||||
|
{
|
||||||
var result = await library.FindMetadataAsync(VideoId);
|
IsLookingUp = true;
|
||||||
|
Matches.Clear();
|
||||||
if (!result.HasPerceptualHash)
|
ImageProblem = null;
|
||||||
{
|
|
||||||
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
|
// The results land in the side panel, so opening it is part of running the lookup —
|
||||||
return;
|
// otherwise the button would appear to do nothing.
|
||||||
}
|
AreDetailsVisible = true;
|
||||||
|
|
||||||
foreach (var match in result.Matches)
|
try
|
||||||
{
|
{
|
||||||
Matches.Add(new MetadataMatchViewModel(match, ApplyMetadataAsync));
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
}
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
MetadataMessage = Describe(result);
|
var result = await library.FindMetadataAsync(VideoId);
|
||||||
}
|
|
||||||
finally
|
if (!result.HasPerceptualHash)
|
||||||
{
|
{
|
||||||
IsLookingUp = false;
|
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
foreach (var match in result.Matches)
|
||||||
/// 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
|
var row = new MetadataMatchViewModel(match, ApplyMetadataAsync);
|
||||||
/// for different next steps.
|
Matches.Add(row);
|
||||||
/// </summary>
|
|
||||||
private static string Describe(MetadataLookupResult result)
|
// Started, not awaited: the candidate is on screen, and its cover follows.
|
||||||
{
|
_ = LoadCoverAsync(row);
|
||||||
var found = result.Matches.Count == 0
|
}
|
||||||
? "Совпадений не найдено"
|
|
||||||
: $"Найдено совпадений: {result.Matches.Count}";
|
MetadataMessage = Describe(result);
|
||||||
|
}
|
||||||
return result.Failures.Count == 0
|
finally
|
||||||
? found
|
{
|
||||||
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
|
IsLookingUp = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/// <summary>
|
|
||||||
/// Handed to every match as its apply action. It swallows failures on purpose: the
|
/// <summary>
|
||||||
/// commands live on the match rows, which come and go with each lookup, so there is no
|
/// Says what came back and what did not. A source that failed is reported even when the
|
||||||
/// stable place to observe their exceptions — and an unobserved one takes the process down.
|
/// others found something, because "one match" and "one match, and StashDB was down" call
|
||||||
/// </summary>
|
/// for different next steps.
|
||||||
private async Task ApplyMetadataAsync(VideoMetadataMatch match)
|
/// </summary>
|
||||||
{
|
private async Task LoadCoverAsync(MetadataMatchViewModel match)
|
||||||
try
|
{
|
||||||
{
|
if (await _images.LoadAsync(match.ImageUrl) is { } path)
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
{
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
await Dispatcher.UIThread.InvokeAsync(() => match.ImagePath = path);
|
||||||
|
}
|
||||||
await library.ApplyMetadataAsync(VideoId, match);
|
}
|
||||||
await LoadLabelsAsync();
|
|
||||||
|
private static string Describe(MetadataLookupResult result)
|
||||||
// The grid behind the page shows the old title until the card is told otherwise.
|
{
|
||||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
var found = result.Matches.Count == 0
|
||||||
{
|
? "Совпадений не найдено"
|
||||||
Card.Apply(refreshed);
|
: $"Найдено совпадений: {result.Matches.Count}";
|
||||||
}
|
|
||||||
|
return result.Failures.Count == 0
|
||||||
Matches.Clear();
|
? found
|
||||||
MetadataMessage = $"Применено: {match.SourceName}";
|
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
/// <summary>
|
||||||
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
|
/// Handed to every match as its apply action. It swallows failures on purpose: the
|
||||||
MetadataMessage = "Не удалось применить — подробности в журнале";
|
/// 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)
|
||||||
private async Task AttachAsync(string name, LabelKind kind)
|
{
|
||||||
{
|
try
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
{
|
||||||
{
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
return;
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
}
|
|
||||||
|
await library.ApplyMetadataAsync(VideoId, match);
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
await LoadLabelsAsync();
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
|
||||||
|
// The grid behind the page shows the old title until the card is told otherwise.
|
||||||
await library.AttachLabelAsync(VideoId, name, kind);
|
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||||
|
{
|
||||||
if (kind == LabelKind.Tag)
|
Card.Apply(refreshed);
|
||||||
{
|
}
|
||||||
NewTag = string.Empty;
|
|
||||||
}
|
Matches.Clear();
|
||||||
else
|
MetadataMessage = $"Применено: {match.SourceName}";
|
||||||
{
|
}
|
||||||
NewCollection = string.Empty;
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
|
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
|
||||||
await LoadLabelsAsync();
|
MetadataMessage = "Не удалось применить — подробности в журнале";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private async Task DetachAsync(LabelViewModel label)
|
|
||||||
{
|
private async Task AttachAsync(string name, LabelKind kind)
|
||||||
try
|
{
|
||||||
{
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
{
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
return;
|
||||||
|
}
|
||||||
await library.DetachLabelAsync(VideoId, label.Id);
|
|
||||||
Target(label.Kind).Remove(label);
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
}
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
catch (Exception ex)
|
|
||||||
{
|
await library.AttachLabelAsync(VideoId, name, kind);
|
||||||
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
|
|
||||||
}
|
if (kind == LabelKind.Tag)
|
||||||
}
|
{
|
||||||
|
NewTag = string.Empty;
|
||||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
|
}
|
||||||
{
|
else
|
||||||
LabelKind.Collection => Collections,
|
{
|
||||||
LabelKind.Performer => Performers,
|
NewCollection = string.Empty;
|
||||||
LabelKind.Studio => Studios,
|
}
|
||||||
_ => Tags,
|
|
||||||
};
|
await LoadLabelsAsync();
|
||||||
|
}
|
||||||
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
|
||||||
|
private async Task DetachAsync(LabelViewModel label)
|
||||||
private async Task PersistAsync(double volume, bool isMuted)
|
{
|
||||||
{
|
try
|
||||||
try
|
{
|
||||||
{
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
}
|
|
||||||
catch (Exception ex)
|
await library.DetachLabelAsync(VideoId, label.Id);
|
||||||
{
|
Target(label.Kind).Remove(label);
|
||||||
// Losing a volume level is not worth interrupting playback over.
|
}
|
||||||
_logger.LogWarning(ex, "Could not save the playback volume");
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
}
|
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
|
||||||
|
}
|
||||||
private void ObserveCommandFailures() =>
|
}
|
||||||
Observable
|
|
||||||
.Merge(
|
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
|
||||||
CloseCommand.ThrownExceptions,
|
{
|
||||||
ToggleFullScreenCommand.ThrownExceptions,
|
LabelKind.Collection => Collections,
|
||||||
ToggleMuteCommand.ThrownExceptions,
|
LabelKind.Performer => Performers,
|
||||||
ToggleDetailsCommand.ThrownExceptions,
|
LabelKind.Studio => Studios,
|
||||||
OpenExternallyCommand.ThrownExceptions,
|
_ => Tags,
|
||||||
RevealCommand.ThrownExceptions,
|
};
|
||||||
AddTagCommand.ThrownExceptions,
|
|
||||||
AddCollectionCommand.ThrownExceptions,
|
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
||||||
LoadLabelsCommand.ThrownExceptions,
|
|
||||||
LookupMetadataCommand.ThrownExceptions)
|
private async Task PersistAsync(double volume, bool isMuted)
|
||||||
.Subscribe(ex =>
|
{
|
||||||
{
|
try
|
||||||
_logger.LogError(ex, "A media page command failed");
|
{
|
||||||
MetadataMessage = "Что-то пошло не так — подробности в журнале";
|
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
|
||||||
})
|
}
|
||||||
.AddTo(Subscriptions);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -309,25 +309,51 @@
|
|||||||
<ItemsRepeater ItemsSource="{Binding Entities}">
|
<ItemsRepeater ItemsSource="{Binding Entities}">
|
||||||
<ItemsRepeater.Layout>
|
<ItemsRepeater.Layout>
|
||||||
<UniformGridLayout ItemsStretch="Fill"
|
<UniformGridLayout ItemsStretch="Fill"
|
||||||
MinItemWidth="220"
|
MinItemWidth="164"
|
||||||
MinItemHeight="56"
|
MinItemHeight="212"
|
||||||
MinColumnSpacing="12"
|
MinColumnSpacing="14"
|
||||||
MinRowSpacing="12" />
|
MinRowSpacing="14" />
|
||||||
</ItemsRepeater.Layout>
|
</ItemsRepeater.Layout>
|
||||||
<ItemsRepeater.ItemTemplate>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:LabelSummaryViewModel">
|
<DataTemplate x:DataType="vm:LabelSummaryViewModel">
|
||||||
<Button Classes="entity" Command="{Binding OpenCommand}">
|
<Button Classes="entity" Command="{Binding OpenCommand}">
|
||||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
<Grid RowDefinitions="*,Auto" RowSpacing="8">
|
||||||
<TextBlock Grid.Column="0"
|
|
||||||
VerticalAlignment="Center"
|
<Border Grid.Row="0"
|
||||||
Text="{Binding Name}"
|
CornerRadius="9"
|
||||||
TextTrimming="CharacterEllipsis"
|
ClipToBounds="True"
|
||||||
ToolTip.Tip="{Binding Name}"
|
Background="{DynamicResource ThumbnailPlaceholderBrush}">
|
||||||
FontSize="13"
|
<Panel>
|
||||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
<!-- The initial sits underneath, so it shows through for every
|
||||||
<Border Grid.Column="1" Classes="badge" VerticalAlignment="Center">
|
label without a picture and while one is still decoding. -->
|
||||||
<TextBlock Text="{Binding CountText}" />
|
<TextBlock Text="{Binding Initial}"
|
||||||
|
FontSize="34"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Opacity="0.35"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||||
|
|
||||||
|
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
|
||||||
|
|
||||||
|
<Border Classes="badge"
|
||||||
|
Margin="6"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom">
|
||||||
|
<TextBlock Text="{Binding CountText}" />
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
ToolTip.Tip="{Binding Name}"
|
||||||
|
FontSize="12.5"
|
||||||
|
MaxLines="2"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
|
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||||
x:Class="PLib.Desktop.Views.MetadataScanView"
|
x:Class="PLib.Desktop.Views.MetadataScanView"
|
||||||
x:DataType="vm:MetadataScanViewModel">
|
x:DataType="vm:MetadataScanViewModel">
|
||||||
@@ -96,8 +97,22 @@
|
|||||||
<ItemsControl ItemsSource="{Binding Matches}">
|
<ItemsControl ItemsSource="{Binding Matches}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
|
||||||
<StackPanel Grid.Column="0" Spacing="2">
|
|
||||||
|
<!-- The candidate's own cover. Absent for a source that has none, and
|
||||||
|
the row simply loses the column rather than reserving a blank. -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Width="128"
|
||||||
|
Height="72"
|
||||||
|
CornerRadius="7"
|
||||||
|
ClipToBounds="True"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Background="{DynamicResource ThumbnailPlaceholderBrush}"
|
||||||
|
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="256" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Spacing="2">
|
||||||
<TextBlock Text="{Binding Title}"
|
<TextBlock Text="{Binding Title}"
|
||||||
FontSize="12.5"
|
FontSize="12.5"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
@@ -117,7 +132,7 @@
|
|||||||
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="2"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Command="{Binding ApplyCommand}"
|
Command="{Binding ApplyCommand}"
|
||||||
Content="Применить" />
|
Content="Применить" />
|
||||||
|
|||||||
@@ -1,310 +1,328 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||||
x:DataType="vm:VideoPlayerViewModel">
|
x:DataType="vm:VideoPlayerViewModel">
|
||||||
|
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
|
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
|
||||||
<Border Background="{DynamicResource AccentSoftBrush}"
|
<Border Background="{DynamicResource AccentSoftBrush}"
|
||||||
CornerRadius="12"
|
CornerRadius="12"
|
||||||
Padding="9,3"
|
Padding="9,3"
|
||||||
Margin="0,0,6,6">
|
Margin="0,0,6,6">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Text="{Binding Name}"
|
<TextBlock Text="{Binding Name}"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource AccentBrush}" />
|
Foreground="{DynamicResource AccentBrush}" />
|
||||||
<Button Command="{Binding RemoveCommand}"
|
<Button Command="{Binding RemoveCommand}"
|
||||||
Classes="transport"
|
Classes="transport"
|
||||||
Padding="2"
|
Padding="2"
|
||||||
ToolTip.Tip="Убрать">
|
ToolTip.Tip="Убрать">
|
||||||
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
|
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</UserControl.Resources>
|
</UserControl.Resources>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="Button.transport">
|
<Style Selector="Button.transport">
|
||||||
<Setter Property="Padding" Value="8" />
|
<Setter Property="Padding" Value="8" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.time">
|
<Style Selector="TextBlock.time">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
<Setter Property="MinWidth" Value="46" />
|
<Setter Property="MinWidth" Value="46" />
|
||||||
<Setter Property="TextAlignment" Value="Center" />
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- ======================= Page header ======================= -->
|
<!-- ======================= Page header ======================= -->
|
||||||
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10" IsVisible="{Binding !IsFullScreen}">
|
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10" IsVisible="{Binding !IsFullScreen}">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||||
|
|
||||||
<Button Grid.Column="0"
|
<Button Grid.Column="0"
|
||||||
Classes="transport"
|
Classes="transport"
|
||||||
Command="{Binding CloseCommand}"
|
Command="{Binding CloseCommand}"
|
||||||
ToolTip.Tip="Назад к библиотеке">
|
ToolTip.Tip="Назад к библиотеке">
|
||||||
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
|
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="panelTitle"
|
<TextBlock Classes="panelTitle"
|
||||||
Text="{Binding Title}"
|
Text="{Binding Title}"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
ToolTip.Tip="{Binding FullPath}" />
|
ToolTip.Tip="{Binding FullPath}" />
|
||||||
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
|
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding ToggleDetailsCommand}"
|
Command="{Binding ToggleDetailsCommand}"
|
||||||
ToolTip.Tip="Сведения и метки">
|
ToolTip.Tip="Сведения и метки">
|
||||||
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding LookupMetadataCommand}"
|
Command="{Binding LookupMetadataCommand}"
|
||||||
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
|
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
|
||||||
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
|
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding OpenExternallyCommand}"
|
Command="{Binding OpenExternallyCommand}"
|
||||||
ToolTip.Tip="Открыть во внешнем плеере">
|
ToolTip.Tip="Открыть во внешнем плеере">
|
||||||
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
|
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding RevealCommand}"
|
Command="{Binding RevealCommand}"
|
||||||
ToolTip.Tip="Показать в папке">
|
ToolTip.Tip="Показать в папке">
|
||||||
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
|
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ======================= Video ======================= -->
|
<!-- ======================= Video ======================= -->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||||
|
|
||||||
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
|
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
|
||||||
<controls:VlcVideoView Name="Player"
|
<controls:VlcVideoView Name="Player"
|
||||||
Source="{Binding Source}"
|
Source="{Binding Source}"
|
||||||
AutoPlay="True"
|
AutoPlay="True"
|
||||||
Volume="{Binding Volume}"
|
Volume="{Binding Volume}"
|
||||||
IsMuted="{Binding IsMuted}" />
|
IsMuted="{Binding IsMuted}" />
|
||||||
|
|
||||||
<Border Name="ErrorBar"
|
<Border Name="ErrorBar"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
MaxWidth="460"
|
MaxWidth="460"
|
||||||
CornerRadius="10"
|
CornerRadius="10"
|
||||||
Background="{DynamicResource SurfaceBrush}"
|
Background="{DynamicResource SurfaceBrush}"
|
||||||
Padding="16,12"
|
Padding="16,12"
|
||||||
IsVisible="False">
|
IsVisible="False">
|
||||||
<TextBlock Name="ErrorText"
|
<TextBlock Name="ErrorText"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
TextAlignment="Center"
|
TextAlignment="Center"
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ======================= Details and labels ======================= -->
|
<!-- ======================= Details and labels ======================= -->
|
||||||
<Border Grid.Column="1"
|
<Border Grid.Column="1"
|
||||||
Width="320"
|
Width="320"
|
||||||
IsVisible="{Binding AreDetailsVisible}"
|
IsVisible="{Binding AreDetailsVisible}"
|
||||||
Background="{DynamicResource PanelBackgroundBrush}">
|
Background="{DynamicResource PanelBackgroundBrush}">
|
||||||
<ScrollViewer Padding="16,14">
|
<ScrollViewer Padding="16,14">
|
||||||
<StackPanel Spacing="16">
|
<StackPanel Spacing="16">
|
||||||
|
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Classes="panelTitle" Text="Сведения" />
|
<TextBlock Classes="panelTitle" Text="Сведения" />
|
||||||
<ItemsControl ItemsSource="{Binding Details}">
|
<ItemsControl ItemsSource="{Binding Details}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:MetadataRow">
|
<DataTemplate x:DataType="vm:MetadataRow">
|
||||||
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
|
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
|
||||||
<TextBlock Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
Text="{Binding Label}"
|
Text="{Binding Label}"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Foreground="{DynamicResource TextTertiaryBrush}" />
|
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||||
<TextBlock Grid.Column="1"
|
<TextBlock Grid.Column="1"
|
||||||
Text="{Binding Value}"
|
Text="{Binding Value}"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
|
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
|
||||||
<StackPanel Spacing="8"
|
<StackPanel Spacing="8"
|
||||||
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
|
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
<TextBlock Classes="panelTitle" Text="Метаданные" />
|
<TextBlock Classes="panelTitle" Text="Метаданные" />
|
||||||
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
|
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Matches}">
|
<!-- Why the candidates have no pictures. An empty square looks the same whether
|
||||||
<ItemsControl.ItemTemplate>
|
the source has none or the host is unreachable, so it has to be said. -->
|
||||||
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
<TextBlock Classes="cardMeta"
|
||||||
<Border Background="{DynamicResource CardBackgroundBrush}"
|
TextWrapping="Wrap"
|
||||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||||
BorderThickness="1"
|
Text="{Binding ImageProblem}"
|
||||||
CornerRadius="8"
|
IsVisible="{Binding ImageProblem, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
Padding="10,8"
|
|
||||||
Margin="0,0,0,8">
|
<ItemsControl ItemsSource="{Binding Matches}">
|
||||||
<StackPanel Spacing="5">
|
<ItemsControl.ItemTemplate>
|
||||||
<TextBlock Text="{Binding Title}"
|
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||||
FontSize="12.5"
|
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||||
FontWeight="SemiBold"
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
TextWrapping="Wrap"
|
BorderThickness="1"
|
||||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
CornerRadius="8"
|
||||||
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
|
Padding="10,8"
|
||||||
|
Margin="0,0,0,8">
|
||||||
<TextBlock Classes="cardMeta"
|
<StackPanel Spacing="5">
|
||||||
TextWrapping="Wrap"
|
<!-- The candidate's own cover, across the top: the panel is narrow, so
|
||||||
Text="{Binding Studios, StringFormat='Студия: {0}'}"
|
side by side would leave neither the picture nor the text usable. -->
|
||||||
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
<Border Height="112"
|
||||||
<TextBlock Classes="cardMeta"
|
CornerRadius="6"
|
||||||
TextWrapping="Wrap"
|
ClipToBounds="True"
|
||||||
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
|
Background="{DynamicResource ThumbnailPlaceholderBrush}"
|
||||||
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
<TextBlock Classes="cardMeta"
|
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="320" />
|
||||||
TextWrapping="Wrap"
|
</Border>
|
||||||
Text="{Binding Tags, StringFormat='Теги: {0}'}"
|
|
||||||
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
<TextBlock Text="{Binding Title}"
|
||||||
<TextBlock Classes="cardMeta"
|
FontSize="12.5"
|
||||||
TextWrapping="Wrap"
|
FontWeight="SemiBold"
|
||||||
MaxLines="4"
|
TextWrapping="Wrap"
|
||||||
TextTrimming="CharacterEllipsis"
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
Text="{Binding Description}"
|
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
|
||||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
<Button HorizontalAlignment="Left"
|
TextWrapping="Wrap"
|
||||||
Command="{Binding ApplyCommand}"
|
Text="{Binding Studios, StringFormat='Студия: {0}'}"
|
||||||
Content="Применить" />
|
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
</StackPanel>
|
<TextBlock Classes="cardMeta"
|
||||||
</Border>
|
TextWrapping="Wrap"
|
||||||
</DataTemplate>
|
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
|
||||||
</ItemsControl.ItemTemplate>
|
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
</ItemsControl>
|
<TextBlock Classes="cardMeta"
|
||||||
</StackPanel>
|
TextWrapping="Wrap"
|
||||||
|
Text="{Binding Tags, StringFormat='Теги: {0}'}"
|
||||||
<StackPanel Spacing="8"
|
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
|
<TextBlock Classes="cardMeta"
|
||||||
<TextBlock Classes="panelTitle" Text="Описание" />
|
TextWrapping="Wrap"
|
||||||
<TextBlock Text="{Binding Description}"
|
MaxLines="4"
|
||||||
FontSize="12"
|
TextTrimming="CharacterEllipsis"
|
||||||
TextWrapping="Wrap"
|
Text="{Binding Description}"
|
||||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
</StackPanel>
|
|
||||||
|
<Button HorizontalAlignment="Left"
|
||||||
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
|
Command="{Binding ApplyCommand}"
|
||||||
<TextBlock Classes="panelTitle" Text="Актёры" />
|
Content="Применить" />
|
||||||
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
</StackPanel>
|
||||||
<ItemsControl.ItemsPanel>
|
</Border>
|
||||||
<ItemsPanelTemplate>
|
</DataTemplate>
|
||||||
<WrapPanel />
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsPanelTemplate>
|
</ItemsControl>
|
||||||
</ItemsControl.ItemsPanel>
|
</StackPanel>
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
<StackPanel Spacing="8"
|
||||||
|
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
|
<TextBlock Classes="panelTitle" Text="Описание" />
|
||||||
<TextBlock Classes="panelTitle" Text="Студии" />
|
<TextBlock Text="{Binding Description}"
|
||||||
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
FontSize="12"
|
||||||
<ItemsControl.ItemsPanel>
|
TextWrapping="Wrap"
|
||||||
<ItemsPanelTemplate>
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
<WrapPanel />
|
</StackPanel>
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
|
||||||
</ItemsControl>
|
<TextBlock Classes="panelTitle" Text="Актёры" />
|
||||||
</StackPanel>
|
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
<StackPanel Spacing="8">
|
<ItemsPanelTemplate>
|
||||||
<TextBlock Classes="panelTitle" Text="Теги" />
|
<WrapPanel />
|
||||||
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
</ItemsPanelTemplate>
|
||||||
<ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
</ItemsControl>
|
||||||
<WrapPanel />
|
</StackPanel>
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
|
||||||
</ItemsControl>
|
<TextBlock Classes="panelTitle" Text="Студии" />
|
||||||
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
|
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
<TextBox.KeyBindings>
|
<ItemsControl.ItemsPanel>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
|
<ItemsPanelTemplate>
|
||||||
</TextBox.KeyBindings>
|
<WrapPanel />
|
||||||
</TextBox>
|
</ItemsPanelTemplate>
|
||||||
</StackPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
<StackPanel Spacing="8">
|
</StackPanel>
|
||||||
<TextBlock Classes="panelTitle" Text="Коллекции" />
|
|
||||||
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
<StackPanel Spacing="8">
|
||||||
<ItemsControl.ItemsPanel>
|
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||||
<ItemsPanelTemplate>
|
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
<WrapPanel />
|
<ItemsControl.ItemsPanel>
|
||||||
</ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
<WrapPanel />
|
||||||
</ItemsControl>
|
</ItemsPanelTemplate>
|
||||||
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
|
</ItemsControl.ItemsPanel>
|
||||||
<TextBox.KeyBindings>
|
</ItemsControl>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
|
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
|
||||||
</TextBox.KeyBindings>
|
<TextBox.KeyBindings>
|
||||||
</TextBox>
|
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
|
||||||
</StackPanel>
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
|
||||||
</Border>
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Коллекции" />
|
||||||
</Grid>
|
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
<!-- ======================= Transport ======================= -->
|
<ItemsPanelTemplate>
|
||||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
<WrapPanel />
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
</ItemsControl>
|
||||||
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
|
||||||
</Button>
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
|
||||||
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
<Slider Grid.Column="2"
|
</StackPanel>
|
||||||
Name="Seek"
|
|
||||||
Minimum="0"
|
</StackPanel>
|
||||||
Maximum="1"
|
</ScrollViewer>
|
||||||
VerticalAlignment="Center" />
|
</Border>
|
||||||
|
|
||||||
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
|
</Grid>
|
||||||
|
|
||||||
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
|
<!-- ======================= Transport ======================= -->
|
||||||
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
|
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||||
</Button>
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
||||||
|
|
||||||
<Slider Grid.Column="5"
|
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
||||||
Width="90"
|
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
||||||
Minimum="0"
|
</Button>
|
||||||
Maximum="1"
|
|
||||||
Value="{Binding Volume}"
|
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
|
||||||
VerticalAlignment="Center" />
|
|
||||||
|
<Slider Grid.Column="2"
|
||||||
<Button Grid.Column="6"
|
Name="Seek"
|
||||||
Classes="transport"
|
Minimum="0"
|
||||||
Command="{Binding ToggleFullScreenCommand}"
|
Maximum="1"
|
||||||
ToolTip.Tip="Во весь экран (F11)">
|
VerticalAlignment="Center" />
|
||||||
<icons:MaterialIcon Name="FullScreenIcon" Kind="Fullscreen" Width="19" Height="19" />
|
|
||||||
</Button>
|
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
|
||||||
|
|
||||||
</Grid>
|
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
|
||||||
</Border>
|
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
<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>
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ public sealed class LibraryLabel
|
|||||||
|
|
||||||
public LabelKind Kind { get; private set; }
|
public LabelKind Kind { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute path of the cached picture for this label, or <c>null</c>. Only some labels
|
||||||
|
/// ever have one: a metadata source knows what a performer and a studio look like, and
|
||||||
|
/// has no picture at all for a tag.
|
||||||
|
/// </summary>
|
||||||
|
public string? ImagePath { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
public IReadOnlyCollection<VideoItem> Videos => _videos;
|
public IReadOnlyCollection<VideoItem> Videos => _videos;
|
||||||
@@ -73,4 +80,12 @@ public sealed class LibraryLabel
|
|||||||
Name = name.Trim();
|
Name = name.Trim();
|
||||||
NormalizedName = Normalize(name);
|
NormalizedName = Normalize(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AttachImage(string imagePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
|
||||||
|
ImagePath = imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DetachImage() => ImagePath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,71 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Application.Metadata;
|
using PLib.Application.Metadata;
|
||||||
using PLib.Infrastructure.Media;
|
using PLib.Infrastructure.Media;
|
||||||
using PLib.Infrastructure.Metadata;
|
using PLib.Infrastructure.Metadata;
|
||||||
using PLib.Infrastructure.Persistence;
|
using PLib.Infrastructure.Persistence;
|
||||||
using PLib.Infrastructure.Storage;
|
using PLib.Infrastructure.Storage;
|
||||||
|
|
||||||
namespace PLib.Infrastructure;
|
namespace PLib.Infrastructure;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers everything the application layer declares as an abstraction. The composition
|
/// Registers everything the application layer declares as an abstraction. The composition
|
||||||
/// root (the UI project) never sees EF Core or ffmpeg types directly.
|
/// root (the UI project) never sees EF Core or ffmpeg types directly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddPLibInfrastructure(
|
public static IServiceCollection AddPLibInfrastructure(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddOptions<LibraryOptions>()
|
services.AddOptions<LibraryOptions>()
|
||||||
.Bind(configuration.GetSection(LibraryOptions.SectionName))
|
.Bind(configuration.GetSection(LibraryOptions.SectionName))
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
// Deliberately not validated on start: a half-filled source is something the user is
|
// 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
|
// 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.
|
// entry is worth calling is decided when it is called.
|
||||||
services.AddOptions<MetadataOptions>()
|
services.AddOptions<MetadataOptions>()
|
||||||
.Bind(configuration.GetSection(MetadataOptions.SectionName));
|
.Bind(configuration.GetSection(MetadataOptions.SectionName));
|
||||||
|
|
||||||
// TryAdd so a composition root that already needed the paths (to locate the user
|
// 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.
|
// settings file before the container exists) can share its own instance.
|
||||||
services.TryAddSingleton<IAppPaths, AppPaths>();
|
services.TryAddSingleton<IAppPaths, AppPaths>();
|
||||||
|
|
||||||
services.AddDbContext<LibraryDbContext>((provider, builder) =>
|
services.AddDbContext<LibraryDbContext>((provider, builder) =>
|
||||||
{
|
{
|
||||||
var paths = provider.GetRequiredService<IAppPaths>();
|
var paths = provider.GetRequiredService<IAppPaths>();
|
||||||
builder.UseSqlite($"Data Source={paths.DatabaseFile}");
|
builder.UseSqlite($"Data Source={paths.DatabaseFile}");
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddScoped<IVideoRepository, EfVideoRepository>();
|
services.AddScoped<IVideoRepository, EfVideoRepository>();
|
||||||
services.AddScoped<ILabelRepository, EfLabelRepository>();
|
services.AddScoped<ILabelRepository, EfLabelRepository>();
|
||||||
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
|
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
|
||||||
services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>();
|
services.AddSingleton<ILibraryWatcher, FileSystemLibraryWatcher>();
|
||||||
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
|
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
|
||||||
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
||||||
services.AddSingleton<IAnimatedPreviewGenerator, FfmpegAnimatedPreviewGenerator>();
|
services.AddSingleton<IAnimatedPreviewGenerator, FfmpegAnimatedPreviewGenerator>();
|
||||||
services.AddSingleton<IVideoPerceptualHasher, FfmpegVideoPerceptualHasher>();
|
services.AddSingleton<IVideoPerceptualHasher, FfmpegVideoPerceptualHasher>();
|
||||||
|
|
||||||
// A short timeout on purpose: this runs behind a button the user is waiting on, and a
|
// 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.
|
// source that has not answered in fifteen seconds is better reported than waited for.
|
||||||
services.AddHttpClient(StashBoxMetadataProvider.HttpClientName, client =>
|
services.AddHttpClient(StashBoxMetadataProvider.HttpClientName, client =>
|
||||||
{
|
{
|
||||||
client.Timeout = TimeSpan.FromSeconds(15);
|
client.Timeout = TimeSpan.FromSeconds(15);
|
||||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("PLib/1.0");
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("PLib/1.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddSingleton<IMetadataProvider, StashBoxMetadataProvider>();
|
services.AddSingleton<IMetadataProvider, StashBoxMetadataProvider>();
|
||||||
services.AddScoped<ILibraryService, LibraryService>();
|
services.AddSingleton<IRemoteImageCache, HttpRemoteImageCache>();
|
||||||
|
services.AddScoped<ILibraryService, LibraryService>();
|
||||||
services.AddHostedService<DatabaseInitializer>();
|
|
||||||
|
services.AddHostedService<DatabaseInitializer>();
|
||||||
return services;
|
|
||||||
}
|
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>
|
/// <summary>Name of the configured <see cref="HttpClient"/>; see the DI registration.</summary>
|
||||||
public const string HttpClientName = "metadata";
|
public const string HttpClientName = "metadata";
|
||||||
|
|
||||||
|
/// <summary>Below this a picture is too small for a card and gets passed over.</summary>
|
||||||
|
private const int MinimumImageWidth = 320;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The fingerprint queries, newest first.
|
/// The fingerprint queries, newest first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -48,9 +51,10 @@ public sealed class StashBoxMetadataProvider(
|
|||||||
id
|
id
|
||||||
title
|
title
|
||||||
details
|
details
|
||||||
studio { name }
|
images { url width }
|
||||||
|
studio { name images { url width } }
|
||||||
tags { name }
|
tags { name }
|
||||||
performers { performer { name } }
|
performers { performer { name images { url width } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""",
|
""",
|
||||||
@@ -65,9 +69,10 @@ public sealed class StashBoxMetadataProvider(
|
|||||||
id
|
id
|
||||||
title
|
title
|
||||||
details
|
details
|
||||||
studio { name }
|
images { url width }
|
||||||
|
studio { name images { url width } }
|
||||||
tags { name }
|
tags { name }
|
||||||
performers { performer { name } }
|
performers { performer { name images { url width } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""",
|
""",
|
||||||
@@ -214,9 +219,13 @@ public sealed class StashBoxMetadataProvider(
|
|||||||
Text(scene, "id"),
|
Text(scene, "id"),
|
||||||
Text(scene, "title") ?? "Без названия",
|
Text(scene, "title") ?? "Без названия",
|
||||||
Text(scene, "details"),
|
Text(scene, "details"),
|
||||||
Names(scene, "tags"),
|
Entities(scene, "tags"),
|
||||||
Performers(scene),
|
Performers(scene),
|
||||||
Studios(scene));
|
Studios(scene),
|
||||||
|
|
||||||
|
// The scene's own cover. Left as a URL here — fetching it is the application layer's
|
||||||
|
// call, because whether it is worth downloading depends on what asked for the match.
|
||||||
|
PickImage(scene));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A string property, or <c>null</c> for anything else — including when the element itself
|
/// A string property, or <c>null</c> for anything else — including when the element itself
|
||||||
@@ -230,7 +239,8 @@ public sealed class StashBoxMetadataProvider(
|
|||||||
? value.GetString()
|
? value.GetString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
private static IReadOnlyList<string> Names(JsonElement scene, string property)
|
/// <summary>A named array — tags, and anything else shaped like them.</summary>
|
||||||
|
private static IReadOnlyList<MetadataEntity> Entities(JsonElement scene, string property)
|
||||||
{
|
{
|
||||||
if (scene.ValueKind != JsonValueKind.Object ||
|
if (scene.ValueKind != JsonValueKind.Object ||
|
||||||
!scene.TryGetProperty(property, out var array) ||
|
!scene.TryGetProperty(property, out var array) ||
|
||||||
@@ -239,14 +249,14 @@ public sealed class StashBoxMetadataProvider(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. array.EnumerateArray().Select(item => Text(item, "name")).OfType<string>()];
|
return [.. array.EnumerateArray().Select(ReadEntity).OfType<MetadataEntity>()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performers arrive wrapped in an appearance — the same person can be credited under a
|
/// Performers arrive wrapped in an appearance — the same person can be credited under a
|
||||||
/// different name on a given scene — and it is the person's name we want.
|
/// different name on a given scene — and it is the person we want, name and face both.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<string> Performers(JsonElement scene)
|
private static IReadOnlyList<MetadataEntity> Performers(JsonElement scene)
|
||||||
{
|
{
|
||||||
if (scene.ValueKind != JsonValueKind.Object ||
|
if (scene.ValueKind != JsonValueKind.Object ||
|
||||||
!scene.TryGetProperty("performers", out var array) ||
|
!scene.TryGetProperty("performers", out var array) ||
|
||||||
@@ -261,9 +271,9 @@ public sealed class StashBoxMetadataProvider(
|
|||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(appearance => appearance.ValueKind == JsonValueKind.Object &&
|
.Select(appearance => appearance.ValueKind == JsonValueKind.Object &&
|
||||||
appearance.TryGetProperty("performer", out var performer)
|
appearance.TryGetProperty("performer", out var performer)
|
||||||
? Text(performer, "name")
|
? ReadEntity(performer)
|
||||||
: null)
|
: null)
|
||||||
.OfType<string>()
|
.OfType<MetadataEntity>()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,13 +281,61 @@ public sealed class StashBoxMetadataProvider(
|
|||||||
/// A scene has at most one studio, and often none — the shape is a list because a match
|
/// A scene has at most one studio, and often none — the shape is a list because a match
|
||||||
/// may have nothing to say here.
|
/// may have nothing to say here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<string> Studios(JsonElement scene) =>
|
private static IReadOnlyList<MetadataEntity> Studios(JsonElement scene) =>
|
||||||
scene.ValueKind == JsonValueKind.Object &&
|
scene.ValueKind == JsonValueKind.Object &&
|
||||||
scene.TryGetProperty("studio", out var studio) &&
|
scene.TryGetProperty("studio", out var studio) &&
|
||||||
Text(studio, "name") is { } name
|
ReadEntity(studio) is { } entity
|
||||||
? [name]
|
? [entity]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
private static MetadataEntity? ReadEntity(JsonElement element) =>
|
||||||
|
Text(element, "name") is { } name ? new MetadataEntity(name, PickImage(element)) : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the picture to keep: the smallest one still wide enough for a card, and the
|
||||||
|
/// widest available when none reaches that.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// stash-box returns every size it holds, and the first is not the best — originals run to
|
||||||
|
/// several thousand pixels. Downloading one of those per performer to draw it 150 pixels
|
||||||
|
/// wide would cost megabytes a head and look no better for it. Tags have no images field
|
||||||
|
/// at all, so this simply finds nothing for them.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? PickImage(JsonElement owner)
|
||||||
|
{
|
||||||
|
if (owner.ValueKind != JsonValueKind.Object ||
|
||||||
|
!owner.TryGetProperty("images", out var images) ||
|
||||||
|
images.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = images
|
||||||
|
.EnumerateArray()
|
||||||
|
.Where(image => image.ValueKind == JsonValueKind.Object)
|
||||||
|
.Select(image => (Url: Text(image, "url"), Width: Number(image, "width")))
|
||||||
|
.Where(image => !string.IsNullOrWhiteSpace(image.Url))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var enough = candidates.Where(image => image.Width >= MinimumImageWidth).ToList();
|
||||||
|
|
||||||
|
return enough.Count > 0
|
||||||
|
? enough.MinBy(image => image.Width).Url
|
||||||
|
: candidates.MaxBy(image => image.Width).Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Number(JsonElement element, string property) =>
|
||||||
|
element.TryGetProperty(property, out var value) &&
|
||||||
|
value.ValueKind == JsonValueKind.Number &&
|
||||||
|
value.TryGetInt32(out var number)
|
||||||
|
? number
|
||||||
|
: 0;
|
||||||
|
|
||||||
/// <summary>One way of asking the same question, and how to read the answer.</summary>
|
/// <summary>One way of asking the same question, and how to read the answer.</summary>
|
||||||
/// <param name="Field">Name of the query root field, used to find it in the reply.</param>
|
/// <param name="Field">Name of the query root field, used to find it in the reply.</param>
|
||||||
/// <param name="IsNested">True when the result is a list of lists rather than a list.</param>
|
/// <param name="IsNested">True when the result is a list of lists rather than a list.</param>
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ public sealed class EfLabelRepository(LibraryDbContext dbContext) : ILabelReposi
|
|||||||
public async Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(
|
public async Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await dbContext.Labels
|
await dbContext.Labels
|
||||||
.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count))
|
.Select(label => new LabelSummary(
|
||||||
|
label.Id,
|
||||||
|
label.Name,
|
||||||
|
label.Kind,
|
||||||
|
label.Videos.Count,
|
||||||
|
label.ImagePath))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
public Task<LibraryLabel?> FindAsync(
|
public Task<LibraryLabel?> FindAsync(
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ internal sealed class LibraryLabelConfiguration : IEntityTypeConfiguration<Libra
|
|||||||
.HasConversion<string>()
|
.HasConversion<string>()
|
||||||
.HasMaxLength(16);
|
.HasMaxLength(16);
|
||||||
|
|
||||||
|
builder.Property(x => x.ImagePath)
|
||||||
|
.HasMaxLength(1024);
|
||||||
|
|
||||||
builder.Property(x => x.CreatedAt).HasConversion(UtcTicksConverter);
|
builder.Property(x => x.CreatedAt).HasConversion(UtcTicksConverter);
|
||||||
|
|
||||||
// A tag and a collection may share a name; two tags may not.
|
// A tag and a collection may share a name; two tags may not.
|
||||||
|
|||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibraryDbContext))]
|
||||||
|
[Migration("20260810054626_LabelImages")]
|
||||||
|
partial class LabelImages
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("LabelsId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("VideosId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("LabelsId", "VideosId");
|
||||||
|
|
||||||
|
b.HasIndex("VideosId");
|
||||||
|
|
||||||
|
b.ToTable("VideoLabels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ImagePath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Kind", "NormalizedName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Labels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("Duration")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("FullPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PerceptualHash")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PreviewFrameCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("ResumePosition")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeInBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("VideoCodec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AddedAt");
|
||||||
|
|
||||||
|
b.HasIndex("FullPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("LastPlayedAt");
|
||||||
|
|
||||||
|
b.HasIndex("PerceptualHash");
|
||||||
|
|
||||||
|
b.ToTable("Videos", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LabelsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PLib.Domain.Videos.VideoItem", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VideosId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class LabelImages : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ImagePath",
|
||||||
|
table: "Labels",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 1024,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ImagePath",
|
||||||
|
table: "Labels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ namespace PLib.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<long>("CreatedAt")
|
b.Property<long>("CreatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ImagePath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Kind")
|
b.Property<string>("Kind")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ public sealed class AppPaths : IAppPaths
|
|||||||
DataDirectory = Path.Combine(localAppData, "PLib");
|
DataDirectory = Path.Combine(localAppData, "PLib");
|
||||||
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
||||||
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
||||||
|
RemoteImageDirectory = Path.Combine(DataDirectory, "images");
|
||||||
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
||||||
|
|
||||||
Directory.CreateDirectory(DataDirectory);
|
Directory.CreateDirectory(DataDirectory);
|
||||||
Directory.CreateDirectory(ThumbnailDirectory);
|
Directory.CreateDirectory(ThumbnailDirectory);
|
||||||
Directory.CreateDirectory(PreviewDirectory);
|
Directory.CreateDirectory(PreviewDirectory);
|
||||||
|
Directory.CreateDirectory(RemoteImageDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DataDirectory { get; }
|
public string DataDirectory { get; }
|
||||||
@@ -25,5 +27,7 @@ public sealed class AppPaths : IAppPaths
|
|||||||
|
|
||||||
public string PreviewDirectory { get; }
|
public string PreviewDirectory { get; }
|
||||||
|
|
||||||
|
public string RemoteImageDirectory { get; }
|
||||||
|
|
||||||
public string DatabaseFile { get; }
|
public string DatabaseFile { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public interface IAppPaths
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
string PreviewDirectory { get; }
|
string PreviewDirectory { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Directory holding pictures fetched from a metadata source — performers,
|
||||||
|
/// studios, and the cover of every candidate a lookup offered.
|
||||||
|
/// </summary>
|
||||||
|
string RemoteImageDirectory { get; }
|
||||||
|
|
||||||
/// <summary>Full path of the SQLite database file.</summary>
|
/// <summary>Full path of the SQLite database file.</summary>
|
||||||
string DatabaseFile { get; }
|
string DatabaseFile { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ public sealed class DuplicateDetectionTests
|
|||||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||||
Substitute.For<IVideoPerceptualHasher>(),
|
Substitute.For<IVideoPerceptualHasher>(),
|
||||||
Substitute.For<IMetadataProvider>(),
|
Substitute.For<IMetadataProvider>(),
|
||||||
|
Substitute.For<IRemoteImageCache>(),
|
||||||
Options.Create(new LibraryOptions()),
|
Options.Create(new LibraryOptions()),
|
||||||
MetadataMonitor.Empty,
|
MetadataMonitor.Empty,
|
||||||
NullLogger<LibraryService>.Instance);
|
NullLogger<LibraryService>.Instance);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal sealed class InMemoryLabelRepository : ILabelRepository
|
|||||||
|
|
||||||
public Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default) =>
|
public Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult<IReadOnlyList<LabelSummary>>(
|
Task.FromResult<IReadOnlyList<LabelSummary>>(
|
||||||
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count))]);
|
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count, label.ImagePath))]);
|
||||||
|
|
||||||
public Task<LibraryLabel?> FindAsync(
|
public Task<LibraryLabel?> FindAsync(
|
||||||
LabelKind kind,
|
LabelKind kind,
|
||||||
|
|||||||
@@ -1,84 +1,85 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
|
||||||
namespace PLib.Tests.Library;
|
namespace PLib.Tests.Library;
|
||||||
|
|
||||||
public sealed class LabelTests
|
public sealed class LabelTests
|
||||||
{
|
{
|
||||||
private readonly InMemoryVideoRepository _videos = new();
|
private readonly InMemoryVideoRepository _videos = new();
|
||||||
private readonly InMemoryLabelRepository _labels = new();
|
private readonly InMemoryLabelRepository _labels = new();
|
||||||
private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch);
|
private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch);
|
||||||
|
|
||||||
public LabelTests() => _videos.Seed(_video);
|
public LabelTests() => _videos.Seed(_video);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_name_used_for_the_first_time_creates_the_label()
|
public async Task A_name_used_for_the_first_time_creates_the_label()
|
||||||
{
|
{
|
||||||
var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||||
|
|
||||||
label.Name.ShouldBe("Комедия");
|
label.Name.ShouldBe("Комедия");
|
||||||
_video.Labels.ShouldHaveSingleItem();
|
_video.Labels.ShouldHaveSingleItem();
|
||||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists()
|
public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists()
|
||||||
{
|
{
|
||||||
var service = CreateService();
|
var service = CreateService();
|
||||||
|
|
||||||
var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||||
var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token);
|
var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token);
|
||||||
|
|
||||||
second.Id.ShouldBe(first.Id);
|
second.Id.ShouldBe(first.Id);
|
||||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||||
|
|
||||||
// And attaching it twice must not double it up on the video.
|
// And attaching it twice must not double it up on the video.
|
||||||
_video.Labels.ShouldHaveSingleItem();
|
_video.Labels.ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_tag_and_a_collection_may_share_a_name()
|
public async Task A_tag_and_a_collection_may_share_a_name()
|
||||||
{
|
{
|
||||||
var service = CreateService();
|
var service = CreateService();
|
||||||
|
|
||||||
var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token);
|
var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token);
|
||||||
var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token);
|
var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token);
|
||||||
|
|
||||||
collection.Id.ShouldNotBe(tag.Id);
|
collection.Id.ShouldNotBe(tag.Id);
|
||||||
_video.Labels.Count.ShouldBe(2);
|
_video.Labels.Count.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Detaching_leaves_the_label_itself_in_the_library()
|
public async Task Detaching_leaves_the_label_itself_in_the_library()
|
||||||
{
|
{
|
||||||
var service = CreateService();
|
var service = CreateService();
|
||||||
var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||||
|
|
||||||
await service.DetachLabelAsync(_video.Id, label.Id, Token);
|
await service.DetachLabelAsync(_video.Id, label.Id, Token);
|
||||||
|
|
||||||
_video.Labels.ShouldBeEmpty();
|
_video.Labels.ShouldBeEmpty();
|
||||||
|
|
||||||
// Other videos may still use it, and re-adding must not make a second one.
|
// Other videos may still use it, and re-adding must not make a second one.
|
||||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
private LibraryService CreateService() => new(
|
private LibraryService CreateService() => new(
|
||||||
_videos,
|
_videos,
|
||||||
_labels,
|
_labels,
|
||||||
Substitute.For<IVideoFileScanner>(),
|
Substitute.For<IVideoFileScanner>(),
|
||||||
Substitute.For<IMediaProbe>(),
|
Substitute.For<IMediaProbe>(),
|
||||||
Substitute.For<IThumbnailGenerator>(),
|
Substitute.For<IThumbnailGenerator>(),
|
||||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||||
Substitute.For<IVideoPerceptualHasher>(),
|
Substitute.For<IVideoPerceptualHasher>(),
|
||||||
Substitute.For<IMetadataProvider>(),
|
Substitute.For<IMetadataProvider>(),
|
||||||
Options.Create(new LibraryOptions()),
|
Substitute.For<IRemoteImageCache>(),
|
||||||
MetadataMonitor.Empty,
|
Options.Create(new LibraryOptions()),
|
||||||
NullLogger<LibraryService>.Instance);
|
MetadataMonitor.Empty,
|
||||||
}
|
NullLogger<LibraryService>.Instance);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class LibraryServiceTests
|
|||||||
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
|
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
|
||||||
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
|
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
|
||||||
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
|
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
|
||||||
|
private readonly IRemoteImageCache _remoteImages = Substitute.For<IRemoteImageCache>();
|
||||||
|
|
||||||
public LibraryServiceTests()
|
public LibraryServiceTests()
|
||||||
{
|
{
|
||||||
@@ -366,9 +367,9 @@ public sealed class LibraryServiceTests
|
|||||||
"scene-1",
|
"scene-1",
|
||||||
"Настоящее название",
|
"Настоящее название",
|
||||||
"Описание",
|
"Описание",
|
||||||
Tags: ["Драма", "драма"],
|
Tags: [new("Драма"), new("драма")],
|
||||||
Performers: ["Актёр Один"],
|
Performers: [new("Актёр Один", "https://example/face.jpg")],
|
||||||
Studios: ["Студия"]);
|
Studios: [new("Студия")]);
|
||||||
|
|
||||||
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
||||||
|
|
||||||
@@ -381,6 +382,102 @@ public sealed class LibraryServiceTests
|
|||||||
item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия");
|
item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_picture_is_fetched_for_a_label_that_has_none_and_only_then()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
_remoteImages.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(RemoteImage.At(@"C:\cache\images\face.jpg"));
|
||||||
|
|
||||||
|
var match = Match("Название", "StashDB") with
|
||||||
|
{
|
||||||
|
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = CreateService();
|
||||||
|
await service.ApplyMetadataAsync(item.Id, match, Token);
|
||||||
|
|
||||||
|
var performer = item.Labels.Single(label => label.Kind == LabelKind.Performer);
|
||||||
|
performer.ImagePath.ShouldBe(@"C:\cache\images\face.jpg");
|
||||||
|
|
||||||
|
// Applying again must not go back for it: sources disagree about which photograph
|
||||||
|
// belongs to a performer, and the card would change face on every tagged video.
|
||||||
|
_remoteImages.IsAvailable(@"C:\cache\images\face.jpg").Returns(true);
|
||||||
|
await service.ApplyMetadataAsync(item.Id, match, Token);
|
||||||
|
|
||||||
|
await _remoteImages.Received(1).GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_lookup_hands_back_the_cover_url_without_waiting_on_the_picture_host()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
var source = Source("StashDB");
|
||||||
|
|
||||||
|
_metadata.FindByPerceptualHashAsync(source, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns([Match("Сцена", source.Name) with { ImageUrl = "https://example/cover.jpg" }]);
|
||||||
|
|
||||||
|
var result = await CreateService(sources: MetadataMonitor.With(source)).FindMetadataAsync(item.Id, Token);
|
||||||
|
|
||||||
|
// Downloading a cover before handing the candidate back put a stranger's picture host
|
||||||
|
// between "we have an answer" and "the user can see it", and one that stalled froze
|
||||||
|
// the whole run with an empty results list.
|
||||||
|
result.Matches.ShouldHaveSingleItem().ImageUrl.ShouldBe("https://example/cover.jpg");
|
||||||
|
await _remoteImages.DidNotReceive().GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Fetching_a_picture_is_asked_for_separately_and_answers_with_a_local_path()
|
||||||
|
{
|
||||||
|
_remoteImages.GetOrCreateAsync("https://example/cover.jpg", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(RemoteImage.At(@"C:\cache\images\cover.jpg"));
|
||||||
|
|
||||||
|
var image = await CreateService().FetchImageAsync("https://example/cover.jpg", Token);
|
||||||
|
|
||||||
|
image.Path.ShouldBe(@"C:\cache\images\cover.jpg");
|
||||||
|
image.Problem.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_picture_host_that_has_been_given_up_on_says_so_rather_than_going_quiet()
|
||||||
|
{
|
||||||
|
_remoteImages.GetOrCreateAsync("https://cdn.example/cover.jpg", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(RemoteImage.Unreachable("cdn.example не отдаёт картинки"));
|
||||||
|
|
||||||
|
var image = await CreateService().FetchImageAsync("https://cdn.example/cover.jpg", Token);
|
||||||
|
|
||||||
|
// An empty square looks the same whether the source has no picture or the host is
|
||||||
|
// unreachable, and only one of those is worth putting on screen.
|
||||||
|
image.Path.ShouldBeNull();
|
||||||
|
image.Problem.ShouldBe("cdn.example не отдаёт картинки");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_picture_that_could_not_be_fetched_does_not_fail_the_match()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
_remoteImages.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns<RemoteImage>(_ => throw new HttpRequestException("503"));
|
||||||
|
|
||||||
|
var match = Match("Название", "StashDB") with
|
||||||
|
{
|
||||||
|
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
|
||||||
|
};
|
||||||
|
|
||||||
|
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
||||||
|
|
||||||
|
// The card falls back to an initial, which is a far smaller loss than dropping the
|
||||||
|
// title, the description and every label over one picture.
|
||||||
|
item.Title.ShouldBe("Название");
|
||||||
|
item.Labels.Single(label => label.Kind == LabelKind.Performer).ImagePath.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Applying_a_match_adds_to_the_labels_already_on_the_video()
|
public async Task Applying_a_match_adds_to_the_labels_already_on_the_video()
|
||||||
{
|
{
|
||||||
@@ -390,7 +487,7 @@ public sealed class LibraryServiceTests
|
|||||||
var service = CreateService();
|
var service = CreateService();
|
||||||
await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token);
|
await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token);
|
||||||
|
|
||||||
await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = ["Их"] }, Token);
|
await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = [new MetadataEntity("Их")] }, Token);
|
||||||
|
|
||||||
// A match is a proposal, not a replacement: what the user put there stays.
|
// A match is a proposal, not a replacement: what the user put there stays.
|
||||||
item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true);
|
item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true);
|
||||||
@@ -432,6 +529,7 @@ public sealed class LibraryServiceTests
|
|||||||
_previews,
|
_previews,
|
||||||
_hasher,
|
_hasher,
|
||||||
_metadata,
|
_metadata,
|
||||||
|
_remoteImages,
|
||||||
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
||||||
sources ?? MetadataMonitor.Empty,
|
sources ?? MetadataMonitor.Empty,
|
||||||
NullLogger<LibraryService>.Instance);
|
NullLogger<LibraryService>.Instance);
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ public sealed class MetadataScanTests
|
|||||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||||
Substitute.For<IVideoPerceptualHasher>(),
|
Substitute.For<IVideoPerceptualHasher>(),
|
||||||
_provider,
|
_provider,
|
||||||
|
Substitute.For<IRemoteImageCache>(),
|
||||||
Options.Create(new LibraryOptions()),
|
Options.Create(new LibraryOptions()),
|
||||||
|
|
||||||
// No pause between requests: the delay exists to be kind to somebody else's
|
// No pause between requests: the delay exists to be kind to somebody else's
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using PLib.Infrastructure.Metadata;
|
||||||
|
using PLib.Infrastructure.Storage;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace PLib.Tests.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A picture host that will not answer must cost a few attempts, not one per candidate.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RemoteImageCacheTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TempPaths _paths = new();
|
||||||
|
private readonly CountingHandler _handler = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_paths.Dispose();
|
||||||
|
_handler.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_host_that_keeps_failing_is_left_alone_after_a_few_tries()
|
||||||
|
{
|
||||||
|
var cache = Create();
|
||||||
|
var problems = new List<string>();
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 6; attempt++)
|
||||||
|
{
|
||||||
|
var image = await cache.GetOrCreateAsync($"https://cdn.example/{attempt}.jpg", Token);
|
||||||
|
|
||||||
|
image.Path.ShouldBeNull();
|
||||||
|
|
||||||
|
if (image.Problem is { } problem)
|
||||||
|
{
|
||||||
|
problems.Add(problem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A library-wide run produces a cover per candidate, all from the same host. Without a
|
||||||
|
// cut-off, a host that answers its headers and then stalls holds a connection open for
|
||||||
|
// the full timeout on every single one of them.
|
||||||
|
_handler.Requests.ShouldBe(3);
|
||||||
|
|
||||||
|
// Said once, at the moment the host is given up on. Every miss would put the same line
|
||||||
|
// beside every candidate; never saying it leaves rows of empty squares unexplained.
|
||||||
|
problems.ShouldHaveSingleItem().ShouldContain("cdn.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_different_host_is_judged_on_its_own_behaviour()
|
||||||
|
{
|
||||||
|
var cache = Create();
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 4; attempt++)
|
||||||
|
{
|
||||||
|
await cache.GetOrCreateAsync($"https://broken.example/{attempt}.jpg", Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
await cache.GetOrCreateAsync("https://other.example/a.jpg", Token);
|
||||||
|
|
||||||
|
// Three to the broken host, then it is skipped; the fourth request is the other host.
|
||||||
|
_handler.Requests.ShouldBe(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_address_that_is_not_a_web_address_is_never_fetched()
|
||||||
|
{
|
||||||
|
var cache = Create();
|
||||||
|
|
||||||
|
// The URL comes from somebody else's server, so file:// would turn "fetch a picture"
|
||||||
|
// into "read a path of the server's choosing".
|
||||||
|
(await cache.GetOrCreateAsync(@"file:///C:/Windows/win.ini", Token)).Path.ShouldBeNull();
|
||||||
|
(await cache.GetOrCreateAsync("не адрес", Token)).Path.ShouldBeNull();
|
||||||
|
|
||||||
|
_handler.Requests.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private HttpRemoteImageCache Create()
|
||||||
|
{
|
||||||
|
var factory = Substitute.For<IHttpClientFactory>();
|
||||||
|
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(_handler, disposeHandler: false));
|
||||||
|
|
||||||
|
return new HttpRemoteImageCache(factory, _paths, NullLogger<HttpRemoteImageCache>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fails every request at once, and counts how many it was asked to make.</summary>
|
||||||
|
private sealed class CountingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private int _requests;
|
||||||
|
|
||||||
|
public int Requests => _requests;
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _requests);
|
||||||
|
throw new HttpRequestException("no route to host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempPaths : IAppPaths, IDisposable
|
||||||
|
{
|
||||||
|
public TempPaths()
|
||||||
|
{
|
||||||
|
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-images-{Guid.CreateVersion7()}");
|
||||||
|
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
||||||
|
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
||||||
|
RemoteImageDirectory = Path.Combine(DataDirectory, "images");
|
||||||
|
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(RemoteImageDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DataDirectory { get; }
|
||||||
|
|
||||||
|
public string ThumbnailDirectory { get; }
|
||||||
|
|
||||||
|
public string PreviewDirectory { get; }
|
||||||
|
|
||||||
|
public string RemoteImageDirectory { get; }
|
||||||
|
|
||||||
|
public string DatabaseFile { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(DataDirectory))
|
||||||
|
{
|
||||||
|
Directory.Delete(DataDirectory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,11 +51,11 @@ public sealed class StashBoxPayloadTests
|
|||||||
first.RemoteId.ShouldBe("abc");
|
first.RemoteId.ShouldBe("abc");
|
||||||
first.Title.ShouldBe("Первая");
|
first.Title.ShouldBe("Первая");
|
||||||
first.Description.ShouldBe("Описание");
|
first.Description.ShouldBe("Описание");
|
||||||
first.Studios.ShouldBe(["Студия"]);
|
first.Studios.Select(x => x.Name).ShouldBe(["Студия"]);
|
||||||
first.Tags.ShouldBe(["драма", "нуар"]);
|
first.Tags.Select(x => x.Name).ShouldBe(["драма", "нуар"]);
|
||||||
|
|
||||||
// The credited alias is not the person; the label has to be the performer's own name.
|
// The credited alias is not the person; the label has to be the performer's own name.
|
||||||
first.Performers.ShouldBe(["Актёр"]);
|
first.Performers.Select(x => x.Name).ShouldBe(["Актёр"]);
|
||||||
|
|
||||||
matches[1].Studios.ShouldBeEmpty();
|
matches[1].Studios.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
@@ -94,6 +94,119 @@ public sealed class StashBoxPayloadTests
|
|||||||
match.Tags.ShouldBeEmpty();
|
match.Tags.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_smallest_picture_still_wide_enough_for_a_card_is_the_one_kept()
|
||||||
|
{
|
||||||
|
// stash-box returns every size it holds, and the first is not the best: originals run
|
||||||
|
// to several thousand pixels, and one of those per performer to draw it 150 wide would
|
||||||
|
// cost megabytes a head.
|
||||||
|
const string payload = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"findSceneByFingerprint": [
|
||||||
|
{
|
||||||
|
"id": "abc",
|
||||||
|
"title": "Сцена",
|
||||||
|
"studio": { "name": "Студия", "images": [ { "url": "s/4000.jpg", "width": 4000 }, { "url": "s/500.jpg", "width": 500 } ] },
|
||||||
|
"tags": [ { "name": "драма" } ],
|
||||||
|
"performers": [
|
||||||
|
{ "performer": { "name": "Актёр", "images": [
|
||||||
|
{ "url": "p/2000.jpg", "width": 2000 },
|
||||||
|
{ "url": "p/400.jpg", "width": 400 },
|
||||||
|
{ "url": "p/100.jpg", "width": 100 } ] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var match = Read(payload, Flat).ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg");
|
||||||
|
match.Studios.Single().ImageUrl.ShouldBe("s/500.jpg");
|
||||||
|
|
||||||
|
// stash-box holds no picture for a tag at all, so there is nothing to find.
|
||||||
|
match.Tags.Single().ImageUrl.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_scene_carries_its_own_cover_apart_from_the_pictures_of_its_people()
|
||||||
|
{
|
||||||
|
const string payload = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"findSceneByFingerprint": [
|
||||||
|
{
|
||||||
|
"id": "abc",
|
||||||
|
"title": "Сцена",
|
||||||
|
"images": [ { "url": "scene/1920.jpg", "width": 1920 }, { "url": "scene/640.jpg", "width": 640 } ],
|
||||||
|
"performers": [ { "performer": { "name": "Актёр", "images": [ { "url": "p/400.jpg", "width": 400 } ] } } ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var match = Read(payload, Flat).ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
match.ImageUrl.ShouldBe("scene/640.jpg");
|
||||||
|
match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg");
|
||||||
|
|
||||||
|
// Still a URL, and it stays one: whether to spend the bandwidth is the caller's
|
||||||
|
// decision, and a candidate must never wait on a picture host to be shown.
|
||||||
|
match.ImageUrl.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_every_picture_is_too_small_the_widest_is_taken_rather_than_none()
|
||||||
|
{
|
||||||
|
const string payload = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"findSceneByFingerprint": [
|
||||||
|
{
|
||||||
|
"id": "abc",
|
||||||
|
"title": "Сцена",
|
||||||
|
"performers": [
|
||||||
|
{ "performer": { "name": "Актёр", "images": [
|
||||||
|
{ "url": "p/80.jpg", "width": 80 },
|
||||||
|
{ "url": "p/200.jpg", "width": 200 } ] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
// A small picture beats an empty card.
|
||||||
|
Read(payload, Flat).Single().Performers.Single().ImageUrl.ShouldBe("p/200.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_entity_with_no_pictures_at_all_is_still_a_match()
|
||||||
|
{
|
||||||
|
const string payload = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"findSceneByFingerprint": [
|
||||||
|
{
|
||||||
|
"id": "abc",
|
||||||
|
"title": "Сцена",
|
||||||
|
"studio": { "name": "Студия", "images": [] },
|
||||||
|
"performers": [ { "performer": { "name": "Актёр" } } ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var match = Read(payload, Flat).ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
match.Studios.Single().ImageUrl.ShouldBeNull();
|
||||||
|
match.Performers.Single().Name.ShouldBe("Актёр");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Errors_are_raised_even_though_the_server_answered_two_hundred()
|
public void Errors_are_raised_even_though_the_server_answered_two_hundred()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -201,10 +201,12 @@ public sealed class AppSettingsStoreTests : IDisposable
|
|||||||
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
|
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
|
||||||
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
||||||
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
||||||
|
RemoteImageDirectory = Path.Combine(DataDirectory, "labels");
|
||||||
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
||||||
|
|
||||||
Directory.CreateDirectory(ThumbnailDirectory);
|
Directory.CreateDirectory(ThumbnailDirectory);
|
||||||
Directory.CreateDirectory(PreviewDirectory);
|
Directory.CreateDirectory(PreviewDirectory);
|
||||||
|
Directory.CreateDirectory(RemoteImageDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DataDirectory { get; }
|
public string DataDirectory { get; }
|
||||||
@@ -213,6 +215,8 @@ public sealed class AppSettingsStoreTests : IDisposable
|
|||||||
|
|
||||||
public string PreviewDirectory { get; }
|
public string PreviewDirectory { get; }
|
||||||
|
|
||||||
|
public string RemoteImageDirectory { get; }
|
||||||
|
|
||||||
public string DatabaseFile { get; }
|
public string DatabaseFile { get; }
|
||||||
|
|
||||||
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
|
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
|
||||||
|
|||||||
Reference in New Issue
Block a user