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:
@@ -1,73 +1,56 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок.
|
||||
Конвенции репозитория. Читать до правок.
|
||||
|
||||
**Перед правкой подсистемы прочитать её файл — там инварианты, которые ломаются молча:**
|
||||
|
||||
| Правишь | Читай |
|
||||
|---|---|
|
||||
| `Core/Proxies/**`, `Infrastructure/Proxies/**` | [docs/proxies.md](docs/proxies.md) |
|
||||
| `Collecting/**`, `Media/**`, `CollectViewModel`, `GalleryViewModel` | [docs/collecting.md](docs/collecting.md) |
|
||||
| `.axaml`, стили, тестовые проекты, файлы сборки | [docs/avalonia.md](docs/avalonia.md) |
|
||||
|
||||
## Команды
|
||||
|
||||
Полный локальный гейт (формат → сборка → тесты) — то, что нужно прогнать перед коммитом:
|
||||
|
||||
```bash
|
||||
./build.ps1
|
||||
```
|
||||
|
||||
Отдельные шаги, если нужен только один из них:
|
||||
|
||||
```bash
|
||||
dotnet build AvParser.slnx -c Release
|
||||
```
|
||||
```bash
|
||||
dotnet test AvParser.slnx -c Release
|
||||
```
|
||||
```bash
|
||||
dotnet csharpier check .
|
||||
```
|
||||
```bash
|
||||
./run.ps1
|
||||
```
|
||||
|
||||
На Linux/macOS — `./build.sh` и `./run.sh` с теми же шагами.
|
||||
`./build.ps1` — полный гейт (формат → сборка → тесты), прогнать перед коммитом. Отдельные шаги:
|
||||
`dotnet build AvParser.slnx -c Release`, `dotnet test AvParser.slnx -c Release`,
|
||||
`dotnet csharpier check .`, `./run.ps1`. На Linux/macOS — `./build.sh` и `./run.sh`.
|
||||
|
||||
## Слои
|
||||
|
||||
`Core ← Infrastructure ← UI ← Desktop`, строго в одну сторону.
|
||||
|
||||
- **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь
|
||||
по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как
|
||||
только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или
|
||||
`IStorageProvider` внутри источника.
|
||||
- **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не
|
||||
подтягивая `Program.cs`, Serilog и контейнер.
|
||||
- **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает.
|
||||
- **`Core` не ссылается на Avalonia** — домен должен запускаться из CLI, worker-а или бенчмарка. Дай
|
||||
ему Avalonia, и кто-нибудь потянется к `Dispatcher.UIThread` внутри источника.
|
||||
- **`UI` — библиотека, а не exe**: headless-тесты собирают настоящие View без `Program.cs`, Serilog и
|
||||
контейнера.
|
||||
- **`Desktop` — тонкий composition root.** Логика туда не переезжает.
|
||||
|
||||
## Добавить источник
|
||||
|
||||
1. Реализовать `IMediaSource` — в `Core/Collecting/Sources/`, если сети не нужно, иначе в
|
||||
`Infrastructure/Collecting/`.
|
||||
2. Одна строка регистрации: `AddAvParserCore()` для доменного, `AddAvParserCollecting()` для
|
||||
сетевого.
|
||||
3. Два ключа в оба resx: `Source.{id}.Name` и `Source.{id}.Description` (без них источник
|
||||
отрисуется английским текстом из самого класса, а не сломается).
|
||||
2. Одна строка регистрации: `AddAvParserCore()` для доменного, `AddAvParserCollecting()` для сетевого.
|
||||
3. Ключи `Source.{id}.Name` и `Source.{id}.Description` в оба resx (без них отрисуется английский
|
||||
текст самого класса, а не сломается).
|
||||
|
||||
Всё. Каталог, страница «Сбор» и выпадающий список подхватят его сами.
|
||||
Каталог, страница «Сбор» и список источников подхватят его сами.
|
||||
|
||||
**Источник ищет, а не качает.** Он отдаёт `MediaCandidate` — адрес плюс метаданные. Скачиванием,
|
||||
редиректами, тайм-аутами, сниффингом и троттлингом занимается `MediaFetcher`, один на всех. Источник,
|
||||
который сам лезет за байтами, дублирует всё это и почти наверняка неправильно.
|
||||
|
||||
**Сетевой источник обязан переопределить `RequiresNetwork => true`** — иначе он не попадёт под гейт
|
||||
и поедет напрямую в обход настройки.
|
||||
- **Источник ищет, а не качает.** Он отдаёт `MediaCandidate`; скачивание, редиректы, тайм-ауты,
|
||||
сниффинг и троттлинг — на `MediaFetcher`, одном на всех.
|
||||
- **Сетевой обязан переопределить `RequiresNetwork => true`** — иначе поедет напрямую в обход гейта.
|
||||
|
||||
## Добавить страницу
|
||||
|
||||
1. Наследник `PageViewModel` в `UI/ViewModels/XxxViewModel.cs` (`Title`, `IconKey`).
|
||||
2. `UI/Views/XxxView.axaml` — имя обязано соответствовать конвенции `ViewLocator`:
|
||||
`...ViewModels.XxxViewModel` → `...Views.XxxView`.
|
||||
3. Регистрация в `AddAvParserUI()`: конкретным типом **и** как `PageViewModel` — порядок этих
|
||||
регистраций и есть порядок пунктов в рельсе навигации.
|
||||
1. Наследник `PageViewModel` в `UI/ViewModels/XxxViewModel.cs` (`TitleKey`, `IconKey`).
|
||||
2. `UI/Views/XxxView.axaml` — имя по конвенции `ViewLocator`: `...ViewModels.XxxViewModel` →
|
||||
`...Views.XxxView`.
|
||||
3. Регистрация в `AddAvParserUI()` конкретным типом **и** как `PageViewModel`; порядок этих
|
||||
регистраций = порядок пунктов в рельсе навигации.
|
||||
|
||||
## ReactiveUI 24 (дистрибутив Primitives)
|
||||
|
||||
Это не классический ReactiveUI. `System.Reactive` не используется:
|
||||
`System.Reactive` не используется:
|
||||
|
||||
| Классика | Здесь |
|
||||
|---|---|
|
||||
@@ -77,207 +60,49 @@ dotnet csharpier check .
|
||||
| `RxApp.MainThreadScheduler` | `RxSchedulers.MainThreadScheduler` |
|
||||
| `TestScheduler` | `VirtualClock`, `ImmediateSequencer.Instance` |
|
||||
|
||||
Привычные имена операторов (`Select`, `Where`, `Throttle`, `DistinctUntilChanged`,
|
||||
`CombineLatest`) **работают** — Primitives отдаёт оба набора. `using ReactiveUI.Primitives;`
|
||||
нужен ради `Subscribe(Action<T>)`.
|
||||
Привычные операторы (`Select`, `Where`, `Throttle`, `DistinctUntilChanged`, `CombineLatest`)
|
||||
работают; `using ReactiveUI.Primitives;` нужен ради `Subscribe(Action<T>)`.
|
||||
|
||||
**ReactiveUI 24 не инициализируется сама.** Первый `WhenAnyValue` бросит
|
||||
`InvalidOperationException`, пока не отработал builder. В приложении это делает
|
||||
`AppBuilder.UseReactiveUI(...)`; в проекте VM-тестов — module initializer
|
||||
`ReactiveUiBootstrap`. Новый тестовый проект без Avalonia обязан сделать то же самое.
|
||||
**ReactiveUI 24 не инициализируется сама** — первый `WhenAnyValue` бросит
|
||||
`InvalidOperationException`, пока не отработал builder: в приложении `AppBuilder.UseReactiveUI(...)`,
|
||||
в VM-тестах module initializer `ReactiveUiBootstrap`. Новый тестовый проект без Avalonia обязан
|
||||
сделать то же самое.
|
||||
|
||||
## Конвенции ViewModel
|
||||
|
||||
- **Каждая VM принимает `ISequencer? mainThread = null`** и использует его в `outputScheduler:`
|
||||
и `ToProperty(..., scheduler)`. Именно это делает тесты синхронными: они передают
|
||||
`ImmediateSequencer.Instance`. Без этого пришлось бы гонять диспетчер.
|
||||
- У VM с необязательным `ISequencer` регистрация в DI — явная фабрика, а не по типу: иначе
|
||||
выбор конструктора контейнером зависит от порядка регистраций.
|
||||
- **Каждая VM принимает `ISequencer? mainThread = null`** и использует его в `outputScheduler:` и
|
||||
`ToProperty(..., scheduler)`. Это и делает тесты синхронными — они передают `ImmediateSequencer`.
|
||||
- **Регистрация такой VM в DI — явная фабрика**, не по типу: иначе выбор конструктора зависит от
|
||||
порядка регистраций.
|
||||
- `[Reactive]` из `ReactiveUI.SourceGenerators` на partial-свойствах; класс — `partial`.
|
||||
- **VM с производным от языка текстом переопределяет `OnLanguageChanged`** и зовёт `base`, иначе
|
||||
заголовок страницы застрянет на прежнем языке.
|
||||
- **VM, подписанная на синглтон** (пул, каталог, настройки), — `IDisposable` и отписывается.
|
||||
|
||||
## Добавить источник прокси
|
||||
## Строки UI
|
||||
|
||||
1. Реализовать `IProxySource` (или `IMutableProxySource`, если список редактируемый).
|
||||
2. Зарегистрировать как `IProxySource` в `AddAvParserProxies()`. Порядок регистрации = порядок
|
||||
слияния; свой список идёт последним, чтобы пользовательский адрес перебивал фидовый.
|
||||
|
||||
`ProxyPool` при обновлении **переиспользует существующие `ProxyEntry`** по `Endpoint.Key` — иначе
|
||||
перезагрузка списка стирала бы всю накопленную статистику, а публичные фиды переиздаются каждые
|
||||
несколько минут.
|
||||
|
||||
## Инварианты прокси-пула
|
||||
|
||||
- **Доступность определяется карантином, а не `Health`.** `Health` — это «что видели в последний
|
||||
раз». Если исключать всё, что когда-либо падало, окно карантина становится бессмысленным, а
|
||||
прокси теряется навсегда после первой же осечки. Это уже был баг, его ловит
|
||||
`A_failing_proxy_is_quarantined_and_comes_back_later`.
|
||||
- **Проба не трогает `SuccessCount`/`FailureCount`.** Эти счётчики про реальные запросы; свип по
|
||||
паре тысяч прокси перезаписал бы всё, на чём держится взвешенный выбор. Провалившаяся проба
|
||||
выставляет карантин через `RecordProbe(..., quarantineOnFailure:)`.
|
||||
- **Лиза без вердикта нейтральна.** Отменённая операция — не вина прокси; считать это отказом
|
||||
значит карантинить здоровые прокси на каждый Cancel.
|
||||
- **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`.
|
||||
- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт сбора, поэтому
|
||||
«доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться
|
||||
от одного лишь истечения окна.
|
||||
- **Прогрев обрывается по достижении цели, а не проходит список до конца.** `WarmUpAsync` линкует
|
||||
CTS и гасит остаток, как только набралось `MinimumLiveProxies`. Порядок кандидатов —
|
||||
`WarmUpOrder()`, он публичный ровно затем, чтобы порядок проверялся без прогона проб.
|
||||
- **`RestoreState` не выставляет `Health = Alive`.** «Работала вчера» живёт в отдельном
|
||||
`WasAliveOnLastRun` и влияет только на порядок прогрева. Если восстанавливать как `Alive`, пул
|
||||
отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит
|
||||
никого, а гейт сбора откроется по данным недельной давности. Ловит
|
||||
`A_remembered_proxy_is_not_reported_live_until_it_answers_again`.
|
||||
- **`RestoreState` не восстанавливает карантин.** Окно — стенные часы, между запусками могли пройти
|
||||
сутки; перенос окна сажал бы прокси за то, что давно истекло.
|
||||
- **Сохраняются только `HasEverAnswered`** = `SuccessCount > 0 || IsBelievedAlive`, где
|
||||
«believed» = вердикт этой сессии, а без него — прошлой. Мёртвые в фиде исчисляются тысячами и
|
||||
переиздаются каждые пять минут. Важен именно перенос: прогрев обрывается рано, поэтому
|
||||
большинство запомненных заканчивают сессию непроверенными — строгое `Health == Alive` стирало бы
|
||||
накопленный список за пару запусков.
|
||||
|
||||
## Гейт сбора
|
||||
|
||||
`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление источника
|
||||
остаётся однострочным.
|
||||
|
||||
Гейт живёт в `CollectViewModel.RefreshProxyGate()` и складывается из трёх условий: источник сетевой,
|
||||
`AllowDirectConnection` выключен, `LiveCount == 0`. Он пересчитывается по событию пула (с
|
||||
throttle 250 мс — пул дёргается на каждый исход лизы), при смене источника и при смене настроек.
|
||||
|
||||
Второй экземпляр того же правила — в `FetchOptions.RequireProxy`: гейт закрывает кнопку, а фетчер
|
||||
бросает `ProxyUnavailableException` вместо тихого прямого запроса. Одного UI мало — запрос ушёл бы
|
||||
с адреса пользователя ровно тогда, когда он просил этого не делать.
|
||||
|
||||
`CollectView.axaml` держит баннер под `x:Name="ProxyGateBanner"`; `CollectViewTests` рендерит его
|
||||
по-настоящему, потому что мёртвый биндинг `IsVisible` не ломает ни одного VM-теста.
|
||||
|
||||
## Инварианты хранилища медиа
|
||||
|
||||
- **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
|
||||
том же томе и продвигается переименованием. Обрыв оставляет `.part`, который подметает следующий
|
||||
старт, а не обрезанную картинку, неотличимую от настоящей навсегда.
|
||||
- **Тип — по сигнатуре, никогда по URL, расширению или `Content-Type`.** Два из трёх выбирает тот,
|
||||
кто отдаёт файл, и расширение на диске у пользователя не должно зависеть от чужого сервера.
|
||||
- **`GIF89a` не доказывает анимацию**, и APNG не определяется по фиксированному префиксу: нужен обход
|
||||
блоков (второй Image Descriptor) и чанков (`acTL` раньше первого `IDAT`). Тихая ошибка, поэтому
|
||||
обходчики изолированы за `internal static` швами и проверяются на массивах байтов.
|
||||
- **`ref_count` денормализован и пересчитывается, а не инкрементится.** Апсерт `item` может заменить
|
||||
строку, указывавшую на другой blob, и слепой `+1` уехал бы навсегда. Есть
|
||||
`VerifyReferenceCountsAsync`, и он часть замысла, а не отладка.
|
||||
- **Журнал `seen_url` переживает чистку.** Иначе следующий прогон скачает заново ровно то, что
|
||||
пользователь только что удалил. Терминальные исходы отделены от повторяемых: отказ описывает
|
||||
момент, а не ресурс, и считать его окончательным значит терять контент на каждой сетевой икоте.
|
||||
- **Вердикт лизы — про транспорт, а не про ресурс.** 404 и 429 — это успех прокси. Иначе пул
|
||||
карантинил бы рабочие адреса ровно с той частотой, с какой встречаются мёртвые ссылки, а на
|
||||
лимит отвечал бы сменой прокси, то есть обходом лимита.
|
||||
- **Троттл поднимается только сигналами хоста** (429/503 с `Retry-After`) и никогда не приводит к
|
||||
ротации прокси. Флажка «повторить через другую прокси при 429» в настройках быть не должно.
|
||||
- **Жёсткая ссылка — привилегия ФС, а не гарантия.** Откат на копию удваивает расход диска, поэтому
|
||||
достигнутый режим пишется в `showcase_mode` и виден в UI.
|
||||
- **Имя из `SuggestedName` враждебно.** Остаётся только последний сегмент, разделители не
|
||||
переживают, устройства Windows отодвигаются, расширение берётся из типа.
|
||||
|
||||
## Добавить строку в UI
|
||||
|
||||
1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт
|
||||
`LocalizationTests.Russian_translates_every_english_key`).
|
||||
2. В XAML — `{l:Loc Ключ}`, во ViewModel — `Localizer.Instance[...]` / `.Format(...)`.
|
||||
1. Ключ и **оба** перевода — в `Strings.resx` и `Strings.ru.resx`, иначе упадёт
|
||||
`LocalizationTests.Russian_translates_every_english_key`.
|
||||
2. В XAML `{l:Loc Ключ}`, во ViewModel `Localizer.Instance[...]` / `.Format(...)`.
|
||||
|
||||
Никакого хардкода в `Views/` кроме имени продукта и примеров адресов.
|
||||
|
||||
- **Счётчики — только через `Localizer.Plural`** с ключами `.One` / `.Few` / `.Many`. У русского
|
||||
три формы; «{0} records» с приклеенным окончанием непереводимо.
|
||||
- **Перечисления в списках — `LocalizedOption<T>`**, не сырые значения. Конвертер разрешил бы
|
||||
подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы
|
||||
выбор не слетал.
|
||||
- **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и
|
||||
`Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`. Имена источников — так же:
|
||||
`Source.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить источник = одна строка»
|
||||
остаётся в силе.
|
||||
- **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт
|
||||
`base`. Без этого заголовок страницы останется на прежнем языке.
|
||||
|
||||
## Грабли, уже оплаченные
|
||||
|
||||
- **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView`
|
||||
(наследник `ReactiveUserControl<T>`) и молча не делает ничего. Использовать
|
||||
`:is(UserControl).shell`. Голый `.shell` тоже матчит, но тогда XAML-компилятор не может
|
||||
вывести тип для `Setter` и падает с AVLN2200.
|
||||
- **Style-сеттер навсегда проигрывает локальному значению.** Не стилизовать `IsPaneOpen` и
|
||||
`DisplayMode` — они биндятся во ViewModel.
|
||||
- **`IPseudoClasses.Set` требует ведущего `:`**.
|
||||
- **`InvariantGlobalization` обязан быть `false`**: Semi.Avalonia строит `CultureInfo` в
|
||||
статическом конструкторе и падает целиком.
|
||||
- **Превьюер рефлексирует безпараметровый статический `BuildAvaloniaApp()`.** Необязательный
|
||||
параметр ломает его вызов, вторая перегрузка — `AmbiguousMatchException`.
|
||||
- **Compiled bindings включены по умолчанию** (Avalonia 12): `x:DataType` нужен на каждом
|
||||
`UserControl` и каждом `DataTemplate`.
|
||||
- **`Avalonia.Headless.XUnit` 12.x — это xUnit v3**, а не v2. Тестовые проекты — `Exe`.
|
||||
- **Headless: ручной `Measure`/`Arrange` внутри окна бесполезен** — следующий проход layout
|
||||
окна вернёт свой размер. Задавать ширину самому `Window`. Но и без окна нельзя: у
|
||||
открепленного контрола не строится визуальное дерево.
|
||||
- **csharpier — единственный владелец форматирования** (включая `.axaml` и `.csproj`).
|
||||
`IDE0055` понижен до suggestion: два форматтера с `TreatWarningsAsErrors` дерутся насмерть.
|
||||
- **`Condition="'$(Configuration)' == 'Debug'"` на `PackageReference` — ловушка.**
|
||||
`dotnet restore` вычисляется с конфигурацией по умолчанию, поэтому пакет обязан
|
||||
резолвиться, даже если эту конфигурацию никто не собирает. Так тут проехал мёртвый
|
||||
`Avalonia.Diagnostics` (его нет под Avalonia 12): `dotnet build -c Release` работал,
|
||||
а голый `dotnet restore` падал.
|
||||
- **`Execute()` завершился ≠ `IsExecuting` уже false.** Второе публикуется на выходном
|
||||
планировщике. Тест, который сразу после `await` дёргает команду, закрытую по чужому
|
||||
`IsExecuting`, будет мигать под нагрузкой — ждите `CanExecute`, а не предполагайте.
|
||||
- **Проект VM-тестов не параллелится.** `ReactiveUiBootstrap` ставит глобальные планировщики
|
||||
ReactiveUI, то есть тесты делят изменяемое состояние независимо от их желания.
|
||||
- **Инспектора в Avalonia 12 нет из коробки.** `Avalonia.Diagnostics` закончился на 11.3.x;
|
||||
DevTools живут отдельно (`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`), со своей
|
||||
установкой. Зависимость намеренно не добавлена.
|
||||
|
||||
## Качество
|
||||
|
||||
- Центральные версии пакетов — `Directory.Packages.props`. **Никаких `Version=` в csproj**
|
||||
(иначе NU1008 на restore).
|
||||
- `TreatWarningsAsErrors` включён; NuGet-advisory (`NU19xx`) выведены из ошибок, чтобы
|
||||
свежая CVE не роняла сборку кода, который никто не трогал.
|
||||
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
|
||||
|
||||
## Пути
|
||||
|
||||
- **Медиа лежит рядом с exe, настройки — в профиле.** Это осознанное расхождение: конфигурация
|
||||
пользовательская, а коллекция принадлежит установке и переезжает вместе с папкой.
|
||||
- **`DefaultMediaDirectory()` проверяет запись пробным файлом**, а не только созданием каталога:
|
||||
каталог создаётся и там, куда потом нельзя записать. При отказе — откат в профиль.
|
||||
- **Смена каталога требует перезапуска.** `AppPaths` строится до контейнера (медиа-корень читается
|
||||
из settings.json напрямую), так что применить его на лету нельзя без переподключения индекса,
|
||||
blob-хранилища и кэша миниатюр. Настройки честно это говорят и показывают действующий путь.
|
||||
- **Смена каталога не переносит файлы.** Перенос архива — отдельная операция с другой ценой ошибки.
|
||||
|
||||
## Отображение медиа
|
||||
|
||||
- **Миниатюры декодируются в нужную ширину**, а не декодируются целиком и потом масштабируются.
|
||||
На архиве это разница между «работает» и «кончилась память».
|
||||
- **Кэш владеет своими `Bitmap` и удаляет их при вытеснении**, поэтому вызывающий не должен их
|
||||
освобождать — и поэтому ёмкость кэша обязана заметно превышать страницу галереи: вытесненная
|
||||
картинка, которая ещё на экране, освободилась бы под рендерером.
|
||||
- **Видео не декодируется**, и попытка была бы исключением на каждой плитке. `MediaKinds.IsImage`
|
||||
отсекает это до всякого обращения к диску.
|
||||
- **`{DynamicResource}` с несуществующим ключом молча не срабатывает** — свойство остаётся со
|
||||
значением по умолчанию, фон не красится, кисть прозрачная. Это уже дважды доезжало до
|
||||
скриншота; ловит `ResourceKeyTests` в обеих темах.
|
||||
- **`{l:Loc}` с ключом, которого нет в обоих resx, тест паритета не поймает** — файлы согласованы
|
||||
между собой. Ловит `LocalizationCoverageTests`.
|
||||
- **Счётчики — только через `Localizer.Plural`** с ключами `.One` / `.Few` / `.Many`: у русского три
|
||||
формы, «{0} records» с приклеенным окончанием непереводимо.
|
||||
- **Перечисления в списках — `LocalizedOption<T>`**, не сырые значения: конвертер разрешил бы подпись
|
||||
один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не
|
||||
слетал.
|
||||
- **Текст из домена переводится по коду.** `ParseError` несёт `Code` и `Arguments`, UI ищет
|
||||
`Collect.Error.{Code}` с откатом на `Message`; имена источников — `Source.{id}.Name` с откатом на
|
||||
`DisplayName`, поэтому «добавить источник = одна строка» остаётся в силе.
|
||||
|
||||
## Границы, выбранные намеренно
|
||||
|
||||
- **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им
|
||||
вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: **с появлением
|
||||
третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.**
|
||||
- **Перебора идентификаторов нет и не планируется.** Источник перечисляет то, что сайт сам
|
||||
публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов
|
||||
и доступности, а не ради их обхода.
|
||||
- **Возобновления по `Range` нет.** Оборванная загрузка выбрасывается целиком; частичный файл в
|
||||
хранилище дороже, чем повторное скачивание.
|
||||
- **robots.txt не читается.** Источники либо принадлежат пользователю, либо введены им вручную. Это
|
||||
решение, а не забывчивость: **с появлением источника, который ходит по чужому сайту, robots.txt
|
||||
становится обязательным.**
|
||||
- **Возобновления по `Range` нет**: частичный файл в хранилище дороже повторного скачивания.
|
||||
- **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE.
|
||||
BMP/ICO/HEIC/JPEG-XL просто отложены.
|
||||
- **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.
|
||||
- **Кадры из видео не извлекаются.** Это FFmpeg целиком ради одной картинки в плитке.
|
||||
- **Перцептивных хешей нет**: дедуп точный, по SHA-256.
|
||||
- **Кадры из видео не извлекаются** — это FFmpeg целиком ради одной картинки в плитке.
|
||||
|
||||
Reference in New Issue
Block a user