Files
av-parser/docs/collecting.md
T
Leonid Pershin 0aa3a7cf11 Implement media rule editor and enhance gallery functionality
- 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.
2026-08-15 16:03:02 +03:00

146 lines
17 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.
# Сбор и хранилище
Читать перед правкой `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`
отсекает это до всякого обращения к диску.