- Added methods to `IMediaStore` for loading, saving, and removing media rules, allowing users to manage rules for media items effectively. - Updated `MediaFetcher` to utilize the new rule management system, integrating rule checks into the fetching process to handle geo-blocks and previously ruled items. - Enhanced `ProxyPool` to support exclusion of proxies from specific countries during acquisition, improving the handling of geo-blocked content. - Adjusted `FetchOptions` to include rules instead of tombstones, streamlining the decision-making process during media fetching. - Updated UI components to support rule editing, providing users with a more interactive experience when managing media rules. These changes improve the overall media collection process by allowing users to define rules for handling media items and enhancing the proxy management system for better content accessibility.
AvParser
Desktop-приложение на Avalonia 12 с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.
Собирает изображения и гифки в локальное хранилище: источник перечисляет адреса, загрузчик их скачивает и проверяет, хранилище дедуплицирует по содержимому и помнит, что уже видело. Запросы идут через пул прокси с ротацией и проверкой живости.
Источников два: список ссылок, который вы вставляете сами, и свой сервис — листинг-эндпоинт сервиса, который вы держите сами. Перебора идентификаторов чужих хостов нет; см. «Границы, выбранные намеренно».
Быстрый старт
Нужен .NET SDK 10.0.100 (закреплён в global.json).
Полный локальный гейт — восстановление, проверка форматирования, сборка, тесты:
./build.ps1
Запуск приложения (по умолчанию Debug):
./run.ps1
На Linux и macOS — ./build.sh и ./run.sh, аргументы те же.
Полезные флаги:
./build.ps1 -Fix -Configuration Debug
-Fix переформатирует код вместо того, чтобы падать на непрошедшей проверке; -SkipTests
собирает без прогона тестов. То же в bash: --fix, -c Debug, --skip-tests.
Если нужны отдельные шаги, скрипты ничего не прячут:
dotnet build AvParser.slnx -c Release
dotnet test AvParser.slnx -c Release
dotnet csharpier check .
Структура
src/
AvParser.Core домен: IMediaSource и каталог, модели медиа, контракт хранилища,
прокси-пул и стратегии. Ноль зависимостей кроме DI.Abstractions —
ни Avalonia, ни HTTP, ни SQLite
AvParser.Infrastructure загрузчик (сниффинг, редиректы, троттл), SQLite-индекс, blob-хранилище,
витрина, источники прокси, AppPaths, JSON-настройки, Serilog
AvParser.UI Avalonia class library: App-независимые View, ViewModel,
ResponsiveLayout, дизайн-токены, навигация
AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root
tests/
AvParser.Core.Tests источники, каталог, модели, пул прокси и стратегии
AvParser.Infrastructure.Tests сигнатуры и анимация, загрузчик против «плохого» сервера,
хранилище и дедуп, фид прокси, настройки
AvParser.UI.Tests ViewModel'и без Avalonia
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
Ссылки идут строго в одну сторону: Core ← Infrastructure ← UI ← Desktop.
UI — библиотека, а не exe, именно чтобы headless-тесты собирали настоящие View, не подтягивая
Program.cs, Serilog и контейнер.
Адаптивный layout
В Avalonia нет AdaptiveTrigger, VisualStateManager и media-queries. Есть три примитива:
наблюдаемый Visual.Bounds, псевдоклассы и SplitView. ResponsiveLayout связывает первое со
вторым — получается CSS-подобная реакция на ширину.
| Брейкпоинт | Ширина окна | Навигация |
|---|---|---|
| Compact | < 720 px | выезжающий drawer поверх контента |
| Medium | 720 – 1100 px | рельс из одних иконок (56 px) |
| Expanded | ≥ 1100 px | полный сайдбар с подписями (248 px) |
Переключение с гистерезисом в 24 px: без неё перетаскивание края окна заставляет layout мигать между двумя состояниями на каждом пикселе дрожания.
Разделение обязанностей, которое важно не сломать:
SplitView.DisplayModeиIsPaneOpenбиндятся во ViewModel. Style-сеттер навсегда проигрывает локальному значению, поэтому первый же клик по гамбургеру заморозил бы любой стиль, который тоже пишет в эти свойства.- Всё чисто визуальное — ширины панели, видимость подписей, паддинги — живёт в
Styles/Shell.axaml.
Селекторы там написаны как :is(UserControl).shell, а не UserControl.shell: селектор типа в
Avalonia матчит точный тип, а ShellView наследуется от ReactiveUserControl<T> — обычная
форма молча не сматчилась бы ни с чем. На это есть тест
(ShellViewTests.The_shell_stylesheet_is_actually_applied).
Прокси
Пул прокси с ротацией — AvParser.Core/Proxies, источники и сетевая часть —
AvParser.Infrastructure/Proxies, управление — страница Proxies.
Источники:
- proxifly/free-proxy-list — публичный список,
обновляется каждые 5 минут. Тянем сводный
all/data.jsonчерез jsDelivr и фильтруем локально: один условный запрос за весь список надёжнее четырёх по протоколам, которые могут разъехаться между собой в момент публикации. Ответ кэшируется на 5 минут, недоступность фида не роняет приложение — остаётся прошлый список. - Свой список —
proxies.custom.jsonрядом с настройками. Вставляется пачкой, по одной на строку; поддерживаютсяscheme://host:port, голыйhost:portиuser:pass@. Непонятые строки не проглатываются молча, а называются в статусе.
Ротация выбирается в настройках:
| Стратегия | Поведение | Когда |
|---|---|---|
| Sticky | одна прокси, смена только по отказу | по умолчанию: не рвёт сессии и cookie |
| RoundRobin | новая на каждый запрос | размазывает рейт-лимиты, но ломает сессии |
| WeightedRandom | случайно, с весом по score и доле успехов | при сильном разбросе качества |
Проверка живости — тоже настройка, два режима: Pool прогоняет весь список параллельно один раз, Lazy проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков рабочих обычно единицы процентов, поэтому без проверки сборщик будет в основном ждать таймауты.
Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но не удаляется навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.
Что запоминается между запусками
Пул грузится и прогревается сам при старте, нажимать «Обновить» не нужно. Прогрев идёт
от известного хорошего: сначала пробуются те, что отвечали в прошлый раз, затем самые быстрые
из них, и проверка обрывается, как только набралось ProxyMinimumLive живых (по умолчанию 10).
Иначе каждый запуск был бы полным свипом по паре тысяч адресов ради десятка рабочих.
Запомненное — это подсказка, а не зачёт: восстановленная прокси идёт первой в очередь на проверку, но живой не считается, пока не ответит в этом запуске. Иначе запуск через неделю открывал бы гейт сбора по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть.
Состояние лежит в proxies.state.json рядом с настройками и пишется после прогрева и на выходе.
Сохраняются только те прокси, что когда-либо отвечали: мёртвых в фиде тысячи, они переиздаются
каждые пять минут, и «было мертво час назад» не говорит почти ничего. Карантин не восстанавливается
— окно отсчитывается по стенным часам, а между запусками могли пройти сутки.
Гейт «без прокси не работаем»
Источник, который объявил RequiresNetwork, не запустится, пока в пуле нет ни одной живой прокси:
кнопка «Собрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies.
Гейт снимается настройкой «Разрешить сетевым источникам работать без прокси».
То же правило продублировано в загрузчике: он бросает ProxyUnavailableException вместо тихого
прямого запроса. Одного UI мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил
этого не делать.
Источники, читающие вставленный пользователем текст, не блокируются никогда — им нечего маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки лежат.
Использование из кода:
var (http, lease) = await clientFactory.CreateFromPoolAsync();
using (http)
using (lease)
{
try { var response = await http.GetAsync(url); lease?.ReportSuccess(); }
catch { lease?.ReportFailure("request failed"); throw; }
}
Отчёт об исходе — не формальность: без него пул ничего не узнаёт о том, какие прокси работают. Освобождение лизы без вердикта нейтрально — отменённая операция не вина прокси.
SOCKS работает штатно: .NET понимает схемы socks4/socks4a/socks5 в WebProxy. Учтите, что
proxifly-запись с "protocol": "https" — это всё равно HTTP-прокси с CONNECT, а не схема https://.
Сбор
Страница Сбор: выбрать источник, дать ему работу, запустить.
- Список ссылок — вставьте адреса, по одному в строке. Пустые строки и строки с
#игнорируются, непонятые называются в списке ошибок, а не проглатываются. - Свой сервис — адрес листинг-эндпоинта. Принимается либо
{"items":[…],"next":"…"}, либо голый массив адресов; элемент может быть строкой или объектом сurl,id,name,published,size,tags. Постранично, пока естьnext.
Что происходит с каждым найденным адресом:
- Журнал. Если прошлый прогон уже закрыл этот адрес — пропуск без единого запроса. Отказы и тайм-ауты закрытыми не считаются: они описывают момент, а не ресурс. Флажок «Скачать всё заново» игнорирует журнал.
- Загрузка. Редиректы разбираются вручную (лимит прыжков, отлов петли, отказ на не-http). Три отдельных тайм-аута: соединение, заголовки и простой между чтениями — один общий был бы либо слишком мал для тридцати мегабайт, либо бесполезен как признак зависания.
- Проверка. Тип определяется по сигнатуре файла, а не по URL, расширению или
Content-Type. Ловятся: страница-ошибка за кодом 200, тело короче заявленного, превышение лимита размера, трекинг-пиксели, известные заглушки мёртвых ссылок. - Хранилище. Файл кладётся по SHA-256 содержимого — один и тот же снимок, перезалитый по десяти адресам, занимает место один раз. Провенанс (откуда, когда, каким прогоном, через какую прокси) пишется отдельно.
Где всё лежит
Собранное — рядом с приложением, в папке media возле исполняемого файла: коллекция и есть
смысл программы, она растёт без предела, и копирование папки должно уносить архив с собой. Каталог
меняется в настройках (кнопка «Выбрать…»), применяется после перезапуска — пути разбираются один
раз, до того как собран контейнер, и писать не туда, куда показывает поле, было бы хуже, чем
попросить перезапуск. Существующие файлы при смене не переносятся.
Если папка рядом с exe недоступна для записи — установка в Program Files, монтирование только для чтения — происходит откат в профиль пользователя. Действующий путь всегда виден в настройках, так что откат заметен, а не загадочен.
Настройки, логи и состояние прокси остаются в профиле (%APPDATA%/AvParser,
~/.config/AvParser): это конфигурация, она пользовательская и лежит там, где положено по правилам ОС.
Витрина
blobs/ab/cd/<sha256>.png не годится для просмотра глазами, поэтому рядом строится
showcase/<источник>/<год>/<месяц>/<день>/0001-имя.png — жёсткими ссылками, то есть без второй
копии байтов.
Жёсткая ссылка — это второе имя того же файла: правка витрины меняет оригинал, а удаление из витрины ничего не освобождает, пока не исчезнет последнее имя. На FAT32, сетевых шарах и между томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и действующий режим виден в настройках.
Галерея
Страница Галерея показывает то, что уже лежит в хранилище, — с фильтрами по источнику, формату и подстроке адреса, постранично по 120 плиток. Клик открывает встроенный просмотр: полный размер плюс откуда, когда, каким форматом и с каким хешем.
Миниатюры декодируются сразу в нужную ширину и кэшируются: полноразмерный JPEG 4000×3000 занимает в памяти около 48 МБ, и пары сотен таких хватило бы, чтобы приложение кончилось раньше, чем пользователь долистает.
Видео не превьюится. Показать первый кадр mp4 или webm нечем без FFmpeg, тащить который в десктопное приложение ради превью несоразмерно; плитка получает значок формата. Гифки показываются первым кадром — Avalonia не анимирует GIF без стороннего пакета.
Чистка
Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и другой источник, остаётся — ровно за этим в индексе счётчик ссылок. Журнал переживает чистку, иначе следующий прогон скачал бы заново только что удалённое; забыть и его — отдельный флажок.
Локализация
Русский и английский, переключение без перезапуска — язык выбирается в настройках («Системный» берёт язык ОС, если для него есть перевод, иначе английский).
Строки лежат в UI/Localization/Strings.resx и Strings.ru.resx; русский собирается в
сателлитную сборку ru/AvParser.UI.resources.dll.
В XAML — разметочное расширение:
<TextBlock Text="{l:Loc Parse.Run}" />
Оно возвращает биндинг через индексатор Localizer, а не готовую строку: смена языка
поднимает PropertyChanged для индексатора, и все такие биндинги перечитываются разом. Строка,
разрешённая один раз при загрузке, потребовала бы перезапуска.
Три места, где локализация упирается в грамматику или в слои:
- Множественные числа. У русского три формы, поэтому счётчики собираются не из «{0} records»
с приклеенным окончанием, а из ключей
.One/.Few/.ManyчерезLocalizer.Plural. «1 запись», «3 записи», «7 записей». - Значения перечислений. Конвертер разрешил бы подпись один раз и не заметил смены языка,
поэтому в списках лежат обёртки
LocalizedOption<T>: идентичность — значение перечисления (выбор не слетает), подпись следует за локализатором. - Текст из домена.
AvParser.Coreо языках не знает. Домен отдаёт английское сообщение и код, а UI переводитCollect.Error.{Code}с откатом на сообщение. Так же и с именами источников:Source.{id}.Nameс откатом наDisplayName, поэтому новый источник работает непереведённым, а не показывает!ключ!.
Оба .resx генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и
отсутствовать в другом; тесты проверяют совпадение ключей, отсутствие пустых переводов и
одинаковый набор плейсхолдеров {0}.
Дизайн-токены
Все цвета, отступы, радиусы и типографика — в Styles/Tokens.axaml, с отдельными словарями
для Light и Dark. В остальном XAML нет ни одного литерального цвета и ни одного «магического»
отступа, так что перекрасить тему или уплотнить интерфейс — это правка одного файла.
Semi.Avalonia даёт темы контролов; токены — это семантический слой приложения поверх них.
Кнопки .primary / .destructive описаны своими стилями, а не классами Semi, чтобы акцентный
цвет не разъезжался между двумя палитрами.
Стек
| Пакет | Версия | Заметка |
|---|---|---|
| Avalonia | 12.1.1 | compiled bindings по умолчанию → x:DataType обязателен |
| ReactiveUI.Avalonia | 12.1.1 | Avalonia.ReactiveUI — deprecated, это его преемник |
| ReactiveUI | 24.1.0 | дистрибутив Primitives: RxVoid вместо Unit, ISequencer вместо IScheduler |
| Semi.Avalonia | 12.1.0.1 | темы контролов |
| xUnit | v3 (3.2.2) | Avalonia.Headless.XUnit 12.x требует именно v3 |
Что проверить руками
- Потянуть окно по ширине — сайдбар проходит путь
полный → только иконки → выезжающий drawer, без мигания на границах. - Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
- На странице Сбор вставить десяток адресов и нажать Собрать: список наполняется, Остановить обрывает на середине и пишет, сколько успело собраться.
- Запустить тот же список повторно — все строки должны прийти как «пропущено», без единого сетевого запроса. Это журнал.
- Заглянуть в
media/showcase— файлы разложены по датам; сверить, что это жёсткие ссылки (fsutil hardlink listв Windows,ls -liв Linux), а не копии. - Нажать чистку — файлы, на которые ссылается только этот источник, исчезают; общие остаются.
В Avalonia 12 инспектора «из коробки» больше нет: Avalonia.Diagnostics остановился на 11.3.x,
а DevTools вынесли в отдельный инструмент со своей установкой
(AvaloniaUI.DiagnosticsSupport + .WithDeveloperTools()). Поэтому F12 здесь ничего не
открывает — зависимость намеренно не добавлена.
Настройки и логи лежат в %APPDATA%/AvParser (Windows) или ~/.config/AvParser (Linux/macOS).