- Introduced a rule editor in the gallery for users to define actions when a media item is found again, allowing for better management of media rules. - Updated `GalleryViewModel` to handle rule actions, including saving, editing, and removing rules, with appropriate UI bindings. - Enhanced UI components in `GalleryView.axaml` to support rule editing, including action selection and source management. - Added localization strings for new rule-related features in both English and Russian. - Improved unit tests to cover new rule management functionalities, ensuring robust behavior during rule creation and editing. These changes significantly enhance the user experience by providing a more interactive and flexible way to manage media items in the gallery.
146 lines
17 KiB
Markdown
146 lines
17 KiB
Markdown
# Сбор и хранилище
|
||
|
||
Читать перед правкой `Collecting/**`, `Media/**`, `CollectViewModel` и `GalleryViewModel`.
|
||
|
||
## Гейт сбора
|
||
|
||
`CollectViewModel.RefreshProxyGate()`: **хотя бы один отмеченный** источник требует прокси
|
||
(`MediaSourceViewModel.NeedsProxy` = сетевой и без своего разрешения на прямое подключение) и
|
||
`LiveCount == 0`. Пересчитывается по событию пула (throttle 250 мс — пул дёргается на каждый исход
|
||
лизы) и при смене отметок. Подсветка в списке для этого не годится: она про то, что редактируют, а не
|
||
про то, что запускают.
|
||
|
||
- **Разрешение работать без прокси — настройка источника, а не приложения**
|
||
(`PatternSourceConfig.AllowDirectConnection`). Один хост может быть своим, где прокси бессмысленна,
|
||
а соседний — чужим, где прямое подключение это ровно то, чего пользователь избегал; общий тумблер
|
||
навязывал обоим разрешающий ответ. По умолчанию `false` — туда же приезжает старый
|
||
`sources.user.json`.
|
||
- **Один закрытый источник в прогоне закрывает весь прогон.** Разрешающий не может поручиться за
|
||
строгого: запрос, которого пользователь не хотел, всё равно ушёл бы с его адреса.
|
||
- **Второй экземпляр того же правила — `FetchOptions.RequireProxy`**, и он строится на источник в
|
||
`ProduceAsync`. Гейт гасит кнопку, а фетчер бросает `ProxyUnavailableException`; одного UI мало.
|
||
`CollectOptions.RequireProxy` по умолчанию `true`: молчание вызывающего — не разрешение.
|
||
- Баннер живёт под `x:Name="ProxyGateBanner"` и рендерится по-настоящему в `CollectViewTests`, потому
|
||
что мёртвый биндинг `IsVisible` не ломает ни одного VM-теста.
|
||
|
||
## Прогон по нескольким источникам
|
||
|
||
- **Отметка ≠ подсветка.** `MediaSourceViewModel.IsSelected` решает, что войдёт в прогон; выделение в
|
||
списке — что правят, удаляют и чистят. Отмеченный набор живёт в `AppSettings.CollectSourceIds`
|
||
строкой, а не списком: список сломал бы сравнение записи, и каждое сохранение выглядело бы
|
||
изменением.
|
||
- **Источники идут одновременно, а не по очереди.** Это не про пропускную способность: при нулевом
|
||
бюджете первый источник не заканчивается никогда, так что последовательный прогон был бы прогоном
|
||
по одному источнику со списком в руках. Слияние — `CollectViewModel.MergeAsync`; канал ограничен, а
|
||
`finally` обязан погасить продюсеров — оставить их писать в канал, который никто не читает, значит
|
||
подвесить их посреди записи в хранилище.
|
||
- **Глобальный лимит закачек делится на число источников, а не умножается**: каждый `CollectRunner`
|
||
поднимает своих воркеров, и пять источников по четыре — это двадцать соединений вместо четырёх.
|
||
- **`Limit = 0` — «пока не остановят».** В безлимитном режиме `PatternMediaSource` сбрасывает
|
||
множество виденных id по `SeenCapacity`: у прогона нет конца, значит и у множества не должно быть
|
||
роста. Настоящий дедуп держит журнал `seen_url`, а не оно.
|
||
|
||
## Источники
|
||
|
||
- **Несколько расширений — это один кандидат с запасными адресами**, а не несколько кандидатов. У id
|
||
одна картинка: три кандидата на три суффикса скачали бы попадание и потом пошли искать его
|
||
несуществующих близнецов, а счётчики посчитали бы одну находку за три. Порядок — инструкция:
|
||
`MediaFetcher.FetchAddressesAsync` идёт по `MediaCandidate.Addresses` до первого ответа.
|
||
- **Дальше по списку двигает только «здесь ничего нет»**: `Gone`, `NotMedia`, `TooSmall`,
|
||
`Placeholder`. Тайм-аут или отказ прокси — это отсутствие вердикта, а не вердикт: сжечь на нём
|
||
остальные суффиксы значит объявить id отсутствующим везде по одному сломанному соединению.
|
||
- **Пустое расширение — это `.jpg`, а не «без суффикса».** В редакторе это плейсхолдер, и
|
||
`PatternSourceConfig.NormaliseExtension` подставляет его в домене, потому что через `TryCreate`
|
||
проходят и форма, и загрузка `sources.user.json`. Голый `/{id}` почти всегда опечатка, которая
|
||
стоит целого прогона из 404.
|
||
|
||
## Живой журнал
|
||
|
||
- **Одна хронологическая лента вместо «результаты + ошибки»**: при переборе id почти всё промахи, и
|
||
смотрят на порядок, а не на две таблицы.
|
||
- **Пакетов мало, нужен ещё и тик.** `FlushInterval` (200 мс) существует потому, что на медленном
|
||
источнике буфер не добирает до `BatchSize` и страница выглядит зависшей. Буферы —
|
||
`ConcurrentQueue`, слив под `_flushGate`, иначе две гонки-выгрузки перемешают строки местами.
|
||
- **Строка хранит ключ и аргументы, а не готовое предложение** — смена языка посреди прогона иначе
|
||
оставит половину журнала по-английски. Литеральная половина (адрес, размер) не переводится никогда.
|
||
- **Прокси попытки живёт в `ParseError.Via`** и печатается в строке как `via {адрес}`: «сайт ответил
|
||
404» через подтверждённую прокси и через ту, с которой никто не разговаривал, — разные диагнозы, а
|
||
без этого поля разница невидима.
|
||
- **Адрес неудачи живёт в `ParseError.Subject`.** В `Message` его нет и быть не может: у шаблона
|
||
перевода фиксированные подстановки. Без него журнал говорит «сайт ответил 404» и не говорит, на
|
||
каком из десяти тысяч id.
|
||
- **Журнал ограничен `MaxLogEntries`** — у безлимитного прогона нет конца, а несрезанный список это
|
||
утечка памяти со скроллбаром.
|
||
- **Строки копируются**: множественное выделение, Ctrl+C и контекстное меню; текст строит
|
||
`CollectLogEntryViewModel.ToString()`, чтобы в буфер попало ровно то, что на экране, а не
|
||
повторный рендер, который тихо разъедется с шаблоном. Ctrl+C без выделения копирует весь журнал.
|
||
В Avalonia 12 `SetTextAsync` — расширение из `Avalonia.Input.Platform`, а не член `IClipboard`.
|
||
|
||
## Правила на картинку
|
||
|
||
Пользователь открывает картинку в галерее и говорит, что делать, когда она попадётся снова:
|
||
`MediaRule` (SHA-256 → действие + список источников), таблица `media_rule`, редактор — оверлей
|
||
`x:Name="RuleEditor"` в `GalleryView`.
|
||
|
||
- **Опознание по байтам, и только.** Ни адрес, ни размер не отличают «удалено» и «недоступно в вашей
|
||
стране» от настоящей картинки — сервис отдаёт и то и другое честным 200. Перцептивных хешей здесь
|
||
нет намеренно, поэтому пережатая версия того же баннера — другое правило; притворяться иначе значит
|
||
тихо терять настоящие находки.
|
||
- **Область — источники.** Одни и те же байты у одного хоста заглушка, у другого обычная картинка.
|
||
Пусто = все источники. Новое правило открывается отмеченным на источнике этой картинки.
|
||
- **`Skip` удаляет и уже собранную копию.** Правило ставят, глядя на мусор в галерее; оставить его на
|
||
диске значит выполнить половину просьбы. Удаление идёт через существующий tombstone — он один умеет
|
||
снимать копии вместе со ссылками showcase.
|
||
- **`RetryElsewhere` перезапрашивает тот же адрес через другую страну**, до `MaxCountryAttempts`, и
|
||
**требует прокси даже у источника, которому разрешено прямое подключение**: смысл в том, чтобы
|
||
прийти откуда-то ещё, а свой адрес — ровно то место, где баннер уже видели.
|
||
- **Некуда идти — ничего не сохраняем, адрес остаётся повторяемым** (`GeoBlocked`, нетерминальный).
|
||
Баннер это не картинка, а записать его как решённый исход значит сжечь id навсегда из-за
|
||
сегодняшнего состава пула. Прокси без страны в фиде исключить не из чего — это тоже конец попыток.
|
||
- **Старые tombstone приезжают в ту же карту как безобластной `Skip`**: у фетчера один поиск на
|
||
скачивание, а правило по тому же хешу перебивает, как более позднее и более конкретное.
|
||
|
||
## Хранилище медиа
|
||
|
||
- **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
|
||
том же томе и продвигается переименованием. Обрыв оставляет `.part`, который подметает следующий
|
||
старт, а не обрезанную картинку, навсегда неотличимую от настоящей.
|
||
- **Тип — по сигнатуре, никогда по URL, расширению или `Content-Type`**: два из трёх выбирает тот,
|
||
кто отдаёт файл, и расширение на диске у пользователя не должно зависеть от чужого сервера.
|
||
- **`GIF89a` не доказывает анимацию**, и APNG не определяется по фиксированному префиксу: нужен обход
|
||
блоков (второй Image Descriptor) и чанков (`acTL` раньше первого `IDAT`). Ошибка тихая, поэтому
|
||
обходчики изолированы за `internal static` швами и проверяются на массивах байтов.
|
||
- **`ref_count` денормализован и пересчитывается, а не инкрементится**: апсерт `item` может заменить
|
||
строку, указывавшую на другой blob, и слепой `+1` уехал бы навсегда. `VerifyReferenceCountsAsync` —
|
||
часть замысла, а не отладка.
|
||
- **Журнал `seen_url` переживает чистку**, иначе следующий прогон скачает заново ровно то, что
|
||
пользователь только что удалил. Терминальные исходы отделены от повторяемых: отказ описывает
|
||
момент, а не ресурс, и считать его окончательным значит терять контент на каждой сетевой икоте.
|
||
- **Троттл поднимается только сигналами хоста** (429/503 с `Retry-After`) и никогда не приводит к
|
||
ротации прокси. Флажка «повторить через другую прокси при 429» в настройках быть не должно.
|
||
- **Жёсткая ссылка — привилегия ФС, а не гарантия.** Откат на копию удваивает расход диска, поэтому
|
||
достигнутый режим пишется в `showcase_mode` и виден в UI.
|
||
- **Имя из `SuggestedName` враждебно**: остаётся только последний сегмент, разделители не переживают,
|
||
устройства Windows отодвигаются, расширение берётся из типа.
|
||
|
||
## Пути
|
||
|
||
- **Медиа лежит рядом с exe, настройки — в профиле.** Расхождение осознанное: конфигурация
|
||
пользовательская, а коллекция принадлежит установке и переезжает вместе с папкой.
|
||
- **`DefaultMediaDirectory()` проверяет запись пробным файлом**, а не только созданием каталога:
|
||
каталог создаётся и там, куда потом нельзя писать. При отказе — откат в профиль.
|
||
- **Смена каталога требует перезапуска и не переносит файлы.** `AppPaths` строится до контейнера
|
||
(медиа-корень читается из settings.json напрямую), так что на лету это не применить без
|
||
переподключения индекса, blob-хранилища и кэша миниатюр. Настройки честно это говорят и показывают
|
||
действующий путь.
|
||
|
||
## Отображение медиа
|
||
|
||
- **Миниатюры декодируются сразу в нужную ширину**, а не декодируются целиком и потом масштабируются.
|
||
На архиве это разница между «работает» и «кончилась память».
|
||
- **Кэш владеет своими `Bitmap` и удаляет их при вытеснении**, поэтому вызывающий не должен их
|
||
освобождать — и поэтому ёмкость кэша обязана заметно превышать страницу галереи: вытесненная
|
||
картинка, которая ещё на экране, освободилась бы под рендерером.
|
||
- **Видео не декодируется**, попытка была бы исключением на каждой плитке: `MediaKinds.IsImage`
|
||
отсекает это до всякого обращения к диску.
|