- 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.
17 KiB
17 KiB
Сбор и хранилище
Читать перед правкой 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 12SetTextAsync— расширение из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отсекает это до всякого обращения к диску.