231 lines
26 KiB
Markdown
231 lines
26 KiB
Markdown
# PLib
|
||
|
||
Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и
|
||
показывает всё сеткой карточек.
|
||
|
||
## Что уже работает
|
||
|
||
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
|
||
- Слежение за папками: новые файлы подхватываются сами, без кнопки.
|
||
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
||
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
||
- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются
|
||
кадры, снятые по всей длительности.
|
||
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
||
- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка
|
||
(по названию или по частоте); клик по сущности показывает её видео в сетке.
|
||
- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска.
|
||
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
|
||
библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется
|
||
в `settings.json` и подхватывается без перезапуска.
|
||
- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические
|
||
метаданные — каждый со своей кнопкой и текущим объёмом.
|
||
- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой
|
||
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
|
||
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
|
||
- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой
|
||
и списком найденного. По желанию однозначные совпадения применяются на месте.
|
||
- Светлая, тёмная и системная темы; выбор запоминается.
|
||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
||
Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки.
|
||
|
||
## Требования
|
||
|
||
- .NET 10 SDK
|
||
- `ffmpeg` и `ffprobe` в `PATH` (для превью и метаданных)
|
||
|
||
Нативный LibVLC приезжает пакетом и в системе не нужен.
|
||
|
||
## Запуск
|
||
|
||
```bash
|
||
dotnet run --project src/PLib.Desktop
|
||
```
|
||
|
||
```bash
|
||
dotnet test
|
||
```
|
||
|
||
## Архитектура
|
||
|
||
Четыре слоя, зависимости направлены только внутрь:
|
||
|
||
| Проект | Отвечает за | Знает о |
|
||
| --- | --- | --- |
|
||
| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём |
|
||
| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain |
|
||
| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application |
|
||
| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure |
|
||
|
||
Ключевые решения:
|
||
|
||
- **MVVM на ReactiveUI.** Свойства — `[Reactive]` из `ReactiveUI.SourceGenerators`, команды —
|
||
`ReactiveCommand`, производные значения (`IsScanning`, `IsEmpty`) — `ToProperty`. Отмена
|
||
сканирования сделана штатным способом: скан живёт как observable, а `CancelScanCommand`
|
||
просто отписывает его через `TakeUntil`, что отменяет `CancellationToken`.
|
||
- **Сетка — проекция DynamicData, а не пересборка.** `SourceCache` → `AutoRefresh` → `Filter`
|
||
→ `SortAndBind` отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл,
|
||
контейнеры `ItemsRepeater` и уже загруженные превью остаются на месте. Поиск дебаунсится
|
||
на 200 мс, изменения карточек во время скана коалесцируются в 250 мс.
|
||
- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает
|
||
`IAsyncEnumerable<LibraryScanEvent>`: карточки появляются по мере находок, а не после
|
||
завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через
|
||
`Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям
|
||
по одному — трекер изменений EF не потокобезопасен.
|
||
- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает
|
||
каждое событие в UI явно через `Dispatcher.UIThread`.
|
||
- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в
|
||
визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с
|
||
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
|
||
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
|
||
`IServiceScopeFactory` и создаёт scope на каждый вызов.
|
||
- **Одно окно.** Настройки — колонка макета, а не второе окно и не оверлей: открываясь, она
|
||
сдвигает сетку, и та переливается в меньшее число столбцов, оставаясь целиком доступной.
|
||
В alt-tab ничего не добавляется, и приложение остаётся переносимым на
|
||
`ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует.
|
||
Панель занимает только строку контента: шапка и статус-бар остаются цельными на всю
|
||
ширину окна. Собственные заголовок и строка действий у панели заведомо легче оконных —
|
||
равные по весу читались как два приложения, сшитых по шву.
|
||
- **Снимок настроек берётся из одного места.** Файл пишется целиком, поэтому собирать
|
||
`AppSettings` вручную — верный способ затереть секцию, о которой не подумал. Все, кто
|
||
пишет, начинают с `IAppSettingsStore.Current` и правят его через `with`.
|
||
- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком
|
||
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
||
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
||
его не вызывает.
|
||
- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** VLC декодирует в память,
|
||
которую мы ему выдаём, а рисуем кадр сами: видео остаётся обычным контролом Avalonia —
|
||
участвует в hit-тесте, принимает жесты, поверх него можно класть что угодно. Цена — одно
|
||
копирование на показанный кадр, и на 4K оно становится основной стоимостью воспроизведения.
|
||
Буферов два: VLC декодирует в один, пока мы читаем другой; на `Display` они меняются
|
||
местами под коротким локом. Транспорт (позиция, длительность, play/pause) — свойства самого
|
||
контрола, поэтому им управляет code-behind страницы; дублировать это состояние во вьюмодель
|
||
значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляет
|
||
`OpenedVideo`, вью уходит из дерева, и плеер гасится вместе с буферами.
|
||
До этого пробовали два готовых пути. `MediaPlayer.Controls`: декодер работал, но кадры до
|
||
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
|
||
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
|
||
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
|
||
- **Индексация в три прохода.** Сначала метаданные и постеры для всех файлов, затем
|
||
анимированные превью, и только потом отпечатки. Каждый следующий проход берёт больше кадров
|
||
на файл; вперемешку они задерживали бы каждую следующую карточку на всю цепочку, и сетка
|
||
наполнялась бы в разы медленнее. Порядок — по видимости: постер нужен, чтобы карточка
|
||
вообще появилась, анимация — чтобы она ожила под курсором, хеш всплывает только при поиске
|
||
дублей. Поэтому `IsIndexed` намеренно не включает ни анимацию, ни хеш: это готовность к
|
||
показу, а не завершённость всей обработки. Поздние проходы умеют стартовать только после
|
||
первого — кадры распределяются по длительности, а её устанавливает probe.
|
||
- **Анимированное превью — кадры стопкой в одном JPEG, не GIF.** Гифку никто ниже по течению
|
||
не проиграет: Avalonia декодирует только первый кадр анимированного изображения, так что
|
||
за GIF пришлось бы тащить отдельный декодер. Стопка кадров обходится тем же декодером, что
|
||
и постеры — `FilmstripImage` просто рисует каждый тик другой срез, — и весит долю от
|
||
256-цветной гифки тех же кадров, а это цена за каждое видео в библиотеке. Рендерит один
|
||
процесс ffmpeg: по входу на таймкод (seek до входа, то есть прыжок на ключевой кадр,
|
||
а не декодирование до него) и один `vstack`; процесс на кадр умножил бы проход на их число.
|
||
Число кадров зашито в имя файла, поэтому смена настройки не режет старую полосу неверным
|
||
шагом — она просто промахивается мимо кэша, а лишнее подберёт очистка.
|
||
Полоса живёт только пока играет: она весит как все её кадры вместе, а под курсором всегда
|
||
одна карточка, так что держать её в общем кэше значило бы менять ограниченную память на
|
||
растущую с тем, сколько библиотеки пролистали.
|
||
- **pHash по рецепту stash.** 25 кадров сеткой 5×5, по 5% времени отброшено с каждого
|
||
конца, кадр по ширине 160 — то есть хеш описывает те же кадры, что и у stash. Декодирует
|
||
и масштабирует ffmpeg, отдавая сырой 8-битный серый, поэтому графическая библиотека не
|
||
нужна вовсе: монтаж, уменьшение до 64×64 и DCT — арифметика над массивом байт. Бит
|
||
ставится сравнением коэффициента с медианой блока 8×8.
|
||
Сравнивается хеш расстоянием Хэмминга, а не на равенство; группы дублей собираются
|
||
системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу.
|
||
**Побитовая совместимость со stash не проверена** — разные реализации ресайза способны
|
||
перевернуть биты у коэффициентов рядом с медианой.
|
||
- **Теги, коллекции, актёры и студии — одна сущность.** `LibraryLabel` с `LabelKind`: связь
|
||
с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица
|
||
связей, один репозиторий и одно правило именования; разделить потом можно переименованием
|
||
и миграцией, а держать четыре почти одинаковых агрегата синхронными пришлось бы всегда.
|
||
Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых
|
||
таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и
|
||
«комедия» не разойдутся, а тег и студия с одним именем сосуществуют.
|
||
- **Вкладки — колонки одного макета, а не `TabControl`.** Страницы, между которыми они
|
||
переключают, соседствуют с панелью настроек и страницей плеера в одной сетке, а `TabControl`
|
||
захотел бы владеть этой компоновкой целиком. Четыре вкладки сущностей делят одну панель:
|
||
различается только вид метки, и четыре почти одинаковых разметки разошлись бы при первой же
|
||
правке.
|
||
- **Метки лежат на карточке, а не запрашиваются.** Отбор по тегу, актёру или студии — это
|
||
предикат, который DynamicData прогоняет по каждой карточке в фоновом потоке; ходить оттуда
|
||
в базу значило бы запрос на карточку. Поэтому `GetLibraryAsync` грузит видео вместе с
|
||
метками (`AsSplitQuery` — иначе каждая строка видео вернулась бы по разу на метку), а
|
||
`ApplyLabels` намеренно отделён от `Apply`: сканирование грузит видео без меток, и пустой
|
||
список там означает «не загружены», а не «их нет».
|
||
Списки сущностей пересобираются целиком, без второй цепочки DynamicData: меток сотни там,
|
||
где видео тысячи, и машинерия обошлась бы дороже, чем экономит. Количества считает база
|
||
(`LabelSummary`), а не загрузка связей ради `Count`.
|
||
- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому
|
||
серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные»,
|
||
и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни
|
||
самих файлов.
|
||
Прогон по всей библиотеке — отдельная страница, а не фоновая задача: он обращается к чужим
|
||
серверам сотни раз подряд, и это должно быть там, где пользователь на это смотрит и может
|
||
остановить. Между запросами есть пауза (`RequestDelayMilliseconds`, по умолчанию 250 мс) —
|
||
тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник,
|
||
который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом
|
||
видео, и тысяча одинаковых строк была бы всей страницей.
|
||
«Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда
|
||
— расхождение источников это ровно тот случай, ради которого страницу и смотрят.
|
||
Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили»,
|
||
но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`,
|
||
поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает».
|
||
Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча
|
||
переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки,
|
||
но не удаляет чужие — то, что проставил пользователь, остаётся.
|
||
Схема — stash-box (StashDB, ThePornDB и родственники): именно поэтому список источников
|
||
вообще имеет смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же
|
||
запрос. Запросов, впрочем, два: stash-box переименовал `findSceneByFingerprint` в
|
||
`findScenesBySceneFingerprints` и оставил старое имя позади, так что какой из них знает
|
||
конкретный экземпляр — зависит от того, когда его обновляли. Они пробуются по очереди,
|
||
и «нет такого поля» ведёт к следующему, а не к ошибке; всё прочее (неверный ключ, лимит)
|
||
окончательно. Новый запрос отвечает группой сцен на группу отпечатков, поэтому его результат
|
||
на уровень глубже — про одно видео мы спрашиваем всегда, так что разница сводится к
|
||
выпрямлению списка.
|
||
GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один
|
||
объект в ответе.
|
||
**API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и
|
||
остальные настройки, то есть правами файловой системы.
|
||
- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
|
||
событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
|
||
из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
|
||
разницу и так умеет считать сканирование.
|
||
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
||
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
||
на месте (`IMediaArtifactCache.IsAvailable`), и перерисовывает удалённые; после полного
|
||
прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются
|
||
только если им больше часа — иначе можно снести рендер второго запущенного экземпляра.
|
||
Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и
|
||
описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда
|
||
появился второй вид файлов.
|
||
- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет
|
||
ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные.
|
||
Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает
|
||
подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно
|
||
не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду
|
||
кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание
|
||
в обратном порядке оставило бы библиотеку с путями в никуда.
|
||
|
||
## Данные
|
||
|
||
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
|
||
|
||
- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash;
|
||
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
|
||
- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени;
|
||
- `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники
|
||
метаданных вместе с их API-ключами;
|
||
перечитывается на лету;
|
||
- `logs/` — Serilog, ротация по дням.
|
||
|
||
Схема ведётся миграциями EF Core (`src/PLib.Infrastructure/Persistence/Migrations`) и
|
||
применяется при старте. База, созданная сборками до появления миграций, распознаётся по
|
||
отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно
|
||
пересканирование, а превью привязаны к файлам и переживают это нетронутыми.
|
||
|
||
```bash
|
||
dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations
|
||
```
|