132 lines
12 KiB
Markdown
132 lines
12 KiB
Markdown
# PLib
|
||
|
||
Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и
|
||
показывает всё сеткой карточек.
|
||
|
||
## Что уже работает
|
||
|
||
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
|
||
- Слежение за папками: новые файлы подхватываются сами, без кнопки.
|
||
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
||
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
||
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
||
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
|
||
библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется
|
||
в `settings.json` и подхватывается без перезапуска.
|
||
- Светлая, тёмная и системная темы; выбор запоминается.
|
||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||
перемотка, громкость, кнопка «назад». Полноэкранный режим по 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 не пропускает ни клик, ни оверлей.
|
||
- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них
|
||
одинаковая, различается только назначение. Одна сущность — одна таблица связей, один
|
||
репозиторий и одно правило именования; разделить потом можно переименованием и миграцией,
|
||
а держать два почти одинаковых агрегата синхронными пришлось бы всегда. Уникальность —
|
||
по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся,
|
||
а тег и коллекция с одним именем сосуществуют.
|
||
- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
|
||
событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
|
||
из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
|
||
разницу и так умеет считать сканирование.
|
||
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
||
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
||
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
|
||
прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются
|
||
только если им больше часа — иначе можно снести рендер второго запущенного экземпляра.
|
||
|
||
## Данные
|
||
|
||
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
|
||
|
||
- `library.db` — SQLite с метаданными;
|
||
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
|
||
- `settings.json` — папки, параметры превью и сканирования, тема, громкость;
|
||
перечитывается на лету;
|
||
- `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
|
||
```
|