Files
Leonid Pershin 0aa3a7cf11 Implement media rule editor and enhance gallery functionality
- 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.
2026-08-15 16:03:02 +03:00

17 KiB
Raw Permalink Blame History

Сбор и хранилище

Читать перед правкой 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.

Правила на картинку

Пользователь открывает картинку в галерее и говорит, что делать, когда она попадётся снова: MediaRule (SHA-256 → действие + список источников), таблица media_rule, редактор — оверлей x:Name="RuleEditor" в GalleryView.

  • Опознание по байтам, и только. Ни адрес, ни размер не отличают «удалено» и «недоступно в вашей стране» от настоящей картинки — сервис отдаёт и то и другое честным 200. Перцептивных хешей здесь нет намеренно, поэтому пережатая версия того же баннера — другое правило; притворяться иначе значит тихо терять настоящие находки.
  • Область — источники. Одни и те же байты у одного хоста заглушка, у другого обычная картинка. Пусто = все источники. Новое правило открывается отмеченным на источнике этой картинки.
  • Skip удаляет и уже собранную копию. Правило ставят, глядя на мусор в галерее; оставить его на диске значит выполнить половину просьбы. Удаление идёт через существующий tombstone — он один умеет снимать копии вместе со ссылками showcase.
  • RetryElsewhere перезапрашивает тот же адрес через другую страну, до MaxCountryAttempts, и требует прокси даже у источника, которому разрешено прямое подключение: смысл в том, чтобы прийти откуда-то ещё, а свой адрес — ровно то место, где баннер уже видели.
  • Некуда идти — ничего не сохраняем, адрес остаётся повторяемым (GeoBlocked, нетерминальный). Баннер это не картинка, а записать его как решённый исход значит сжечь id навсегда из-за сегодняшнего состава пула. Прокси без страны в фиде исключить не из чего — это тоже конец попыток.
  • Старые tombstone приезжают в ту же карту как безобластной Skip: у фетчера один поиск на скачивание, а правило по тому же хешу перебивает, как более позднее и более конкретное.

Хранилище медиа

  • В 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 отсекает это до всякого обращения к диску.