Files
PLib/README.md
T

132 lines
12 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` и подхватывается без перезапуска.
- Светлая, тёмная и системная темы; выбор запоминается.
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
перемотка, громкость, кнопка «назад». Полноэкранный режим по 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
```