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