Files
PLib/README.md
T

17 KiB
Raw Blame History

PLib

Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и показывает всё сеткой карточек.

Что уже работает

  • Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
  • Слежение за папками: новые файлы подхватываются сами, без кнопки.
  • Метаданные (длительность, разрешение, кодек) через ffprobe.
  • Постеры кадром из видео через ffmpeg, с кэшем на диске.
  • Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются кадры, снятые по всей длительности.
  • Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
  • Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется в settings.json и подхватывается без перезапуска.
  • Светлая, тёмная и системная темы; выбор запоминается.
  • Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки.

Требования

  • .NET 10 SDK
  • ffmpeg и ffprobe в PATH (для превью и метаданных)

Нативный LibVLC приезжает пакетом и в системе не нужен.

Запуск

dotnet run --project src/PLib.Desktop
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, а не пересборка. SourceCacheAutoRefreshFilterSortAndBind отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл, контейнеры 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: связь с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица связей, один репозиторий и одно правило именования; разделить потом можно переименованием и миграцией, а держать два почти одинаковых агрегата синхронными пришлось бы всегда. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся, а тег и коллекция с одним именем сосуществуют.
  • Наблюдатель говорит только «посмотри снова». FileSystemWatcher шлёт несколько событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а разницу и так умеет считать сканирование.
  • Кэш превью самовосстанавливается. Диск — ключ sha256(путь|размер|mtime), память — LRU на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически на месте (IMediaArtifactCache.IsAvailable), и перерисовывает удалённые; после полного прохода лишние файлы вычищаются (PurgeUnusedAsync). Незавершённые .tmp удаляются только если им больше часа — иначе можно снести рендер второго запущенного экземпляра. Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда появился второй вид файлов.

Данные

Всё пользовательское лежит в %LOCALAPPDATA%\PLib:

  • library.db — SQLite с метаданными, метками, прогрессом просмотра и pHash;
  • thumbnails/ — кэш постеров (ключ = путь + размер + время изменения файла);
  • previews/ — кэш анимированных превью, тот же ключ плюс число кадров в имени;
  • settings.json — папки, параметры превью и сканирования, тема, громкость; перечитывается на лету;
  • logs/ — Serilog, ротация по дням.

Схема ведётся миграциями EF Core (src/PLib.Infrastructure/Persistence/Migrations) и применяется при старте. База, созданная сборками до появления миграций, распознаётся по отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно пересканирование, а превью привязаны к файлам и переживают это нетронутыми.

dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations