Refactor media source handling and update collection options
- Updated `IMediaSourceCatalog` to support user-added media sources, allowing dynamic editing and management of sources. - Removed the `UrlListSource` class as its functionality is now integrated into the new catalog structure. - Enhanced `CollectOptions` to default `RequireProxy` to true, ensuring stricter handling of proxy requirements. - Improved error handling in `ParseError` to include a `Subject` field for better context on failures. - Adjusted dependency injection to reflect changes in media source management, removing old source registrations. - Introduced background proxy checks to ensure a more robust proxy pool management during collection processes. These changes streamline the media collection process and improve the overall user experience by providing clearer error reporting and more flexible source management.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Avalonia, тесты и сборка: уже оплаченные грабли
|
||||
|
||||
Читать перед правкой `.axaml`, стилей, тестовых проектов и файлов сборки.
|
||||
|
||||
## XAML и стили
|
||||
|
||||
- **Селектор типа матчит точный тип.** `UserControl.shell` не матчит `ShellView` (наследник
|
||||
`ReactiveUserControl<T>`) и молча не делает ничего — нужен `:is(UserControl).shell`. Голый `.shell`
|
||||
тоже матчит, но тогда XAML-компилятор не выводит тип для `Setter` и падает с AVLN2200.
|
||||
- **Style-сеттер навсегда проигрывает локальному значению.** Не стилизовать `IsPaneOpen` и
|
||||
`DisplayMode` — они биндятся во ViewModel.
|
||||
- **`IPseudoClasses.Set` требует ведущего `:`.**
|
||||
- **Compiled bindings включены по умолчанию** (Avalonia 12): `x:DataType` нужен на каждом
|
||||
`UserControl` и каждом `DataTemplate`.
|
||||
- **`{DynamicResource}` с несуществующим ключом молча не срабатывает** — свойство остаётся со
|
||||
значением по умолчанию, фон не красится, кисть прозрачная. Дважды доезжало до скриншота; ловит
|
||||
`ResourceKeyTests` в обеих темах.
|
||||
- **`{l:Loc}` с ключом, которого нет в обоих resx, тест паритета не поймает** — файлы согласованы
|
||||
между собой. Ловит `LocalizationCoverageTests`.
|
||||
|
||||
## Приложение
|
||||
|
||||
- **`InvariantGlobalization` обязан быть `false`**: Semi.Avalonia строит `CultureInfo` в статическом
|
||||
конструкторе и падает целиком.
|
||||
- **Превьюер рефлексирует безпараметровый статический `BuildAvaloniaApp()`**: необязательный параметр
|
||||
ломает его вызов, вторая перегрузка — `AmbiguousMatchException`.
|
||||
- **Инспектора в Avalonia 12 нет из коробки.** `Avalonia.Diagnostics` закончился на 11.3.x; DevTools
|
||||
живут отдельно (`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`), со своей установкой.
|
||||
Зависимость намеренно не добавлена.
|
||||
|
||||
## Тесты
|
||||
|
||||
- **`Avalonia.Headless.XUnit` 12.x — это xUnit v3**, а не v2. Тестовые проекты — `Exe`.
|
||||
- **Headless: ручной `Measure`/`Arrange` внутри окна бесполезен** — следующий проход layout окна
|
||||
вернёт свой размер. Задавать ширину самому `Window`. Но и без окна нельзя: у открепленного контрола
|
||||
не строится визуальное дерево.
|
||||
- **`Execute()` завершился ≠ `IsExecuting` уже false.** Второе публикуется на выходном планировщике.
|
||||
Тест, который сразу после `await` дёргает команду, закрытую по чужому `IsExecuting`, будет мигать
|
||||
под нагрузкой — ждите `CanExecute`, а не предполагайте.
|
||||
- **Проект VM-тестов не параллелится**: `ReactiveUiBootstrap` ставит глобальные планировщики
|
||||
ReactiveUI, то есть тесты делят изменяемое состояние независимо от их желания.
|
||||
|
||||
## Композиция
|
||||
|
||||
- **`Dispose()` страницы обязан переживать второй вызов.** Каждая страница зарегистрирована дважды —
|
||||
своим типом и как `PageViewModel`, — и контейнер учитывает её как disposable по обеим регистрациям,
|
||||
то есть зовёт `Dispose()` два раза. `GalleryViewModel` отменял на втором вызове уже освобождённый
|
||||
`CancellationTokenSource`, и приложение падало с `ObjectDisposedException` при каждом выходе. Ловят
|
||||
тесты `Disposing_twice_is_safe`.
|
||||
|
||||
## Сборка
|
||||
|
||||
- **csharpier — единственный владелец форматирования** (включая `.axaml` и `.csproj`). `IDE0055`
|
||||
понижен до suggestion: два форматтера с `TreatWarningsAsErrors` дерутся насмерть.
|
||||
- **`Condition="'$(Configuration)' == 'Debug'"` на `PackageReference` — ловушка.** `dotnet restore`
|
||||
вычисляется с конфигурацией по умолчанию, поэтому пакет обязан резолвиться, даже если эту
|
||||
конфигурацию никто не собирает. Так тут проехал мёртвый `Avalonia.Diagnostics`:
|
||||
`dotnet build -c Release` работал, а голый `dotnet restore` падал.
|
||||
- Версии пакетов централизованы в `Directory.Packages.props` — **никаких `Version=` в csproj**
|
||||
(иначе NU1008 на restore).
|
||||
- `TreatWarningsAsErrors` включён; NuGet-advisory (`NU19xx`) выведены из ошибок, чтобы свежая CVE не
|
||||
роняла сборку кода, который никто не трогал.
|
||||
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Сбор и хранилище
|
||||
|
||||
Читать перед правкой `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.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`
|
||||
отсекает это до всякого обращения к диску.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Прокси-пул
|
||||
|
||||
Читать перед правкой `Core/Proxies/**` и `Infrastructure/Proxies/**`.
|
||||
|
||||
## Добавить источник
|
||||
|
||||
1. Реализовать `IProxySource` (или `IMutableProxySource`, если список редактируемый).
|
||||
2. Зарегистрировать в `AddAvParserProxies()`. Порядок регистрации = порядок слияния; свой список идёт
|
||||
последним, чтобы пользовательский адрес перебивал фидовый.
|
||||
|
||||
## Инварианты
|
||||
|
||||
- **`ProxyPool` переиспользует существующие `ProxyEntry` по `Endpoint.Key`** — иначе перезагрузка
|
||||
списка стирала бы статистику, а публичные фиды переиздаются каждые несколько минут.
|
||||
- **Доступность определяется карантином, а не `Health`.** `Health` — «что видели в последний раз»;
|
||||
исключать всё когда-либо упавшее значит потерять прокси после первой осечки. Уже было багом, ловит
|
||||
`A_failing_proxy_is_quarantined_and_comes_back_later`.
|
||||
- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт сбора, поэтому
|
||||
«доступна» (окно истекло) и «живая» намеренно расходятся: гейт не должен открываться от одного лишь
|
||||
истечения окна.
|
||||
- **Проба не трогает `SuccessCount`/`FailureCount`** — они про реальные запросы, а свип по паре тысяч
|
||||
адресов перезаписал бы всё, на чём держится взвешенный выбор. Провал пробы даёт карантин через
|
||||
`RecordProbe(..., quarantineOnFailure:)`.
|
||||
- **Лиза без вердикта нейтральна**: отменённая операция — не вина прокси, иначе каждый Cancel
|
||||
карантинил бы здоровые адреса.
|
||||
- **Вердикт лизы — про транспорт, а не про ресурс.** 404 и 429 — успех прокси; иначе пул карантинил
|
||||
бы рабочие адреса с частотой мёртвых ссылок, а на лимит отвечал бы сменой прокси, то есть обходом
|
||||
лимита.
|
||||
- **Прогрев обрывается по достижении цели**, а не проходит список до конца: `WarmUpAsync` линкует CTS
|
||||
и гасит остаток на `MinimumLiveProxies`. Порядок кандидатов — `WarmUpOrder()`, публичный ровно ради
|
||||
проверки без прогона проб.
|
||||
- **Остаток дочищает `TopUpAsync` в фоне.** Прогрев по построению оставляет тысячи адресов в
|
||||
`Unknown` — это читается как «проверка не запускалась», и, что хуже, сбор получает пул из десяти
|
||||
прокси вместо сотен рабочих. Фоновый проход запускает `ProxyPoolLoader` после прогрева, с
|
||||
четвертью `ProbeConcurrency`: пользовательский свип — это всплеск, за которым смотрят, а этот идёт
|
||||
минутами и, возможно, параллельно с закачкой.
|
||||
- **`TopUpAsync` трогает только `Unknown`.** Перепроверять мёртвых — это и есть свип; перепроверять
|
||||
живых значит посадить в карантин прокси, которой прямо сейчас пользуется прогон.
|
||||
- **`TopUpAsync` не делает `_strategy.Reset()`**, в отличие от свипа: сброс уронил бы липкий выбор
|
||||
посреди сбора, причём ради записей, которых в этом выборе и нет. `Changed` он поднимает раз в
|
||||
`ChangeInterval` проб — иначе страница молчит несколько минут.
|
||||
- **Фоновый проход не идёт при `ProxyHealthCheck.Lazy`**: «проверять в момент выдачи» — это ровно
|
||||
просьба не свипать.
|
||||
- **`SaveStateAsync` сериализован семафором.** Писателей теперь двое (фоновый проход по завершении и
|
||||
выход из приложения), а запись идёт через один и тот же временный файл с переименованием.
|
||||
- **`RestoreState` не выставляет `Health = Alive`** — «работала вчера» живёт в `WasAliveOnLastRun` и
|
||||
влияет только на порядок прогрева. Иначе пул отрапортует живыми тех, с кем не разговаривал: прогрев
|
||||
сочтёт цель достигнутой, а гейт сбора откроется по данным недельной давности. Ловит
|
||||
`A_remembered_proxy_is_not_reported_live_until_it_answers_again`.
|
||||
- **`RestoreState` не восстанавливает карантин**: окно — стенные часы, между запусками могли пройти
|
||||
сутки, и перенос сажал бы прокси за давно истёкшее.
|
||||
- **Сохраняются только `HasEverAnswered`** = `SuccessCount > 0 || IsBelievedAlive` («believed» —
|
||||
вердикт этой сессии, а без него прошлой). Прогрев обрывается рано, поэтому большинство запомненных
|
||||
заканчивают сессию непроверенными — строгое `Health == Alive` стёрло бы накопленный список за пару
|
||||
запусков.
|
||||
- **`Select` и `Next` зарезервированы для CA1716** — метод стратегии называется `Pick`.
|
||||
|
||||
## Статистика на «Обзоре»
|
||||
|
||||
`DashboardViewModel` держит сводку пула и обновляет её по событию `IProxyPool.Changed` с throttle
|
||||
250 мс — событие приходит на каждый исход лизы и на каждую пробу. «Живых» на карточке — это
|
||||
`LiveCount`, то же число, что читает гейт сбора; `RefreshProxyStats()` публичен, чтобы тесты не ждали
|
||||
throttle.
|
||||
Reference in New Issue
Block a user