- Introduced a rule editor in the gallery for users to define actions when a media item is found again, allowing for better management of media rules. - Updated `GalleryViewModel` to handle rule actions, including saving, editing, and removing rules, with appropriate UI bindings. - Enhanced UI components in `GalleryView.axaml` to support rule editing, including action selection and source management. - Added localization strings for new rule-related features in both English and Russian. - Improved unit tests to cover new rule management functionalities, ensuring robust behavior during rule creation and editing. These changes significantly enhance the user experience by providing a more interactive and flexible way to manage media items in the gallery.
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).