# PLib Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и показывает всё сеткой карточек. ## Что уже работает - Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется. - Слежение за папками: новые файлы подхватываются сами, без кнопки. - Метаданные (длительность, разрешение, кодек) через ffprobe. - Постеры кадром из видео через ffmpeg, с кэшем на диске. - Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются кадры, снятые по всей длительности. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. - Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка (по названию или по частоте); клик по сущности показывает её видео в сетке. - Карточки актёров и студий — с фото из источника метаданных. У тегов и коллекций картинок не бывает: их нет в схеме stash-box, поэтому там рисуется первая буква. - Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска. - Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется в `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`: карточки появляются по мере находок, а не после завершения всего прохода. Тяжёлая часть (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 мс) — тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник, который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом видео, и тысяча одинаковых строк была бы всей страницей. «Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда — расхождение источников это ровно тот случай, ради которого страницу и смотрят. - **Картинки скачиваются один раз и по размеру.** `images` в stash-box есть у сцены, у актёра и у студии; у тега такого поля нет вовсе, поэтому там карточка показывает первую букву — это норма, а не отсутствие данных. Обложка сцены нужна там, где выбирают из кандидатов: название и список тегов почти ничего не говорят о том, то ли это видео, а кадр говорит с одного взгляда. Картинка никогда не задерживает то, ради чего её показывают. Сначала так и было: обложка скачивалась до того, как кандидат отдавался наверх, — и один зависший хост картинок морозил весь прогон, оставляя список результатов пустым при ползущем прогрессе. Теперь `VideoMetadataMatch` несёт только `ImageUrl`, строка появляется сразу, а файл подгружается за ней (`RemoteImageLoader`, не более четырёх загрузок разом). У самой загрузки есть свой дедлайн — 10 секунд на заголовки и тело вместе. `HttpClient.Timeout` перестаёт действовать в момент, когда `ResponseHeadersRead` возвращает ответ, поэтому сервер, открывший соединение и замерший на середине картинки, висел бы вечно. Ровно это и случилось с CDN одного из источников: 200 за 100 мс и тишина до самого таймаута. Хост, упавший три раза подряд, отключается на 5 минут: при прогоне по библиотеке обложка приходится на каждого кандидата, и без отсечки это сотня мёртвых сокетов. Молчать об этом нельзя — пустой квадрат выглядит одинаково и когда картинки нет, и когда хост недоступен. Поэтому `IRemoteImageCache` возвращает не `string?`, а `RemoteImage` с необязательной причиной, и она всплывает на странице: одной строкой, один раз за окно отключения, а не рядом с каждым кандидатом. Из всех размеров берётся **самый маленький, всё ещё пригодный для карточки** (от 320 px), и самый большой, если ни один не дотягивает: оригиналы бывают в несколько тысяч пикселей, и качать их, чтобы нарисовать 150 px, — мегабайты на голову без выигрыша в виде. Скачивается только если у метки картинки ещё нет: источники расходятся в том, какое фото принадлежит актёру, и обновление на каждом совпадении меняло бы лицо на карточке при каждом размеченном видео. Неудачная загрузка не роняет применение — потерять фото дешевле, чем название, описание и все метки разом. Размер ограничен 8 МБ и проверяется дважды: по заголовку и по ходу чтения, потому что сервер может ответить без длины. Схемы, кроме http(s), отвергаются — URL приходит с чужого сервера, и `file://` превратил бы «скачай картинку» в «прочитай файл по своему выбору». Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили», но не прячет то, что нашли остальные. 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/` — кэш анимированных превью, тот же ключ плюс число кадров в имени; - `images/` — картинки от источников метаданных: фото актёров, логотипы студий, обложки найденных сцен; ключ — хеш URL; - `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 ```