Files
PLib/README.md
T

235 lines
27 KiB
Markdown
Raw Blame History

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