- Introduced a `Via` field in `ParseError` to indicate the proxy address used during requests, improving clarity on error contexts. - Updated `ProxyPool` to prioritize confirmed live proxies while available, ensuring more reliable proxy selection and reducing connection timeouts. - Implemented fallback logic to allow the use of unconfirmed proxies when no confirmed ones are available, preventing collection stalls. - Adjusted logging in `CollectLogEntryViewModel` to include proxy details, enhancing error visibility for users. - Added unit tests to verify new proxy selection logic and ensure correct behavior under various conditions. These changes improve the robustness of the proxy management system and enhance the overall user experience by providing clearer error messages and more efficient proxy usage.
115 lines
13 KiB
Markdown
115 lines
13 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`, а не оно.
|
||
|
||
## Источники
|
||
|
||
- **Пустое расширение — это `.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`.
|
||
|
||
## Хранилище медиа
|
||
|
||
- **В `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`
|
||
отсекает это до всякого обращения к диску.
|