diff --git a/docs/collecting.md b/docs/collecting.md index 4810287..a02f7e2 100644 --- a/docs/collecting.md +++ b/docs/collecting.md @@ -76,6 +76,30 @@ повторный рендер, который тихо разъедется с шаблоном. 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/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на diff --git a/docs/proxies.md b/docs/proxies.md index 33af5eb..3d4a105 100644 --- a/docs/proxies.md +++ b/docs/proxies.md @@ -24,6 +24,9 @@ всего набора — то есть из пары тысяч непроверенных адресов, и почти каждый запрос платил полный connect-таймаут, чтобы это выяснить. Теперь «живых N» на странице и то, куда реально ходит сбор, — одно и то же число. +- **`AcquireAsync(excludedCountries)` — для правила «попробовать из другой страны».** Записи, у + которых фид не назвал страну, остаются в игре: неизвестная страна — не совпадение, и отбрасывать их + значит сужать пул из-за пустого поля. - **Фолбэк на всё доступное, когда живых нет.** Непроверенная ≠ мёртвая: на холодном пуле строгий фильтр не выдал бы ничего и сбор просто встал бы, не попробовав. Ответившая на этом пути прокси сама себя переводит в живые. diff --git a/src/AvParser.Core/Proxies/IProxyPool.cs b/src/AvParser.Core/Proxies/IProxyPool.cs index 1db4783..d91a927 100644 --- a/src/AvParser.Core/Proxies/IProxyPool.cs +++ b/src/AvParser.Core/Proxies/IProxyPool.cs @@ -44,7 +44,10 @@ public interface IProxyPool /// stated stay eligible: an unknown country is not a known match, and refusing them would turn a /// missing field into a smaller pool. /// - Task AcquireAsync(IReadOnlySet? excludedCountries, CancellationToken cancellationToken = default); + Task AcquireAsync( + IReadOnlySet? excludedCountries, + CancellationToken cancellationToken = default + ); /// Probes every entry in parallel and updates their health. /// How many answered. diff --git a/src/AvParser.Infrastructure/Collecting/MediaFetcher.cs b/src/AvParser.Infrastructure/Collecting/MediaFetcher.cs index 5fee0a6..f086156 100644 --- a/src/AvParser.Infrastructure/Collecting/MediaFetcher.cs +++ b/src/AvParser.Infrastructure/Collecting/MediaFetcher.cs @@ -140,8 +140,7 @@ public sealed class MediaFetcher( foreach (var address in candidate.Addresses) { var attempt = address == candidate.Url ? candidate : candidate with { Url = address, Alternatives = [] }; - var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken) - .ConfigureAwait(false); + var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken).ConfigureAwait(false); if (!IsMissing(result.Outcome)) { @@ -224,13 +223,7 @@ public sealed class MediaFetcher( /// The verdict when the picture is a known geo-block and there is nowhere else to be. private static FetchResult GeoBlocked(Uri url, Stopwatch stopwatch, HashSet tried) => - Failure( - url, - SeenOutcome.Failed, - "GeoBlocked", - tried.Count == 0 ? null : string.Join(", ", tried), - stopwatch - ); + Failure(url, SeenOutcome.Failed, "GeoBlocked", tried.Count == 0 ? null : string.Join(", ", tried), stopwatch); /// Whether the address simply held nothing, which is the only reason to try the next one. private static bool IsMissing(SeenOutcome outcome) => @@ -601,7 +594,15 @@ public sealed class MediaFetcher( var outcome = rule == MediaRuleAction.Skip ? Failure(url, SeenOutcome.Placeholder, "Placeholder", null, stopwatch, leased.ProxyKey, status) - : Failure(url, SeenOutcome.Failed, RetryElsewhereCode, null, stopwatch, leased.ProxyKey, status); + : Failure( + url, + SeenOutcome.Failed, + RetryElsewhereCode, + null, + stopwatch, + leased.ProxyKey, + status + ); return (outcome with { ProxyCountry = leased.ProxyCountry }, true, null); } diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index c310a60..6358b63 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -63,6 +63,7 @@ public static class UiServiceCollectionExtensions )); services.AddSingleton(static sp => new GalleryViewModel( sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>() )); diff --git a/src/AvParser.UI/Localization/Strings.resx b/src/AvParser.UI/Localization/Strings.resx index ba9b367..3b0f84b 100644 --- a/src/AvParser.UI/Localization/Strings.resx +++ b/src/AvParser.UI/Localization/Strings.resx @@ -814,6 +814,45 @@ CONTENT HASH + + When found again… + + + When this picture turns up again + + + Matched by content, byte for byte: a re-encoded or resized version of the same picture is a different one. + + + ACTION + + + SOURCES + + + Nothing ticked means every source. The same bytes can be a placeholder on one host and an ordinary picture on another. + + + Save + + + Cancel + + + Remove the rule + + + Rule saved: {0}. Removed {1} copy(ies). + + + Rule removed. What it deleted does not come back. + + + Skip: never keep this picture + + + Try again through another country + Close diff --git a/src/AvParser.UI/Localization/Strings.ru.resx b/src/AvParser.UI/Localization/Strings.ru.resx index 1b34c49..1a84451 100644 --- a/src/AvParser.UI/Localization/Strings.ru.resx +++ b/src/AvParser.UI/Localization/Strings.ru.resx @@ -814,6 +814,45 @@ ХЕШ СОДЕРЖИМОГО + + Действие при находке… + + + Когда эта картинка попадётся снова + + + Сравнение по содержимому, байт в байт: пережатая или изменённая в размере версия той же картинки — уже другая. + + + ДЕЙСТВИЕ + + + ИСТОЧНИКИ + + + Ничего не отмечено — значит для всех источников. Одни и те же байты у одного хоста заглушка, у другого обычная картинка. + + + Сохранить + + + Отмена + + + Удалить правило + + + Правило сохранено: {0}. Удалено копий: {1}. + + + Правило удалено. То, что оно стёрло, не возвращается. + + + Пропускать: никогда не сохранять + + + Пробовать снова через другую страну + Закрыть diff --git a/src/AvParser.UI/ViewModels/GalleryViewModel.cs b/src/AvParser.UI/ViewModels/GalleryViewModel.cs index 70f4141..ca022f1 100644 --- a/src/AvParser.UI/ViewModels/GalleryViewModel.cs +++ b/src/AvParser.UI/ViewModels/GalleryViewModel.cs @@ -94,6 +94,7 @@ public partial class GalleryViewModel : PageViewModel, IDisposable /// Creates the page. /// Where the content is. + /// Sources a rule can be scoped to. /// Decodes and caches tile bitmaps. /// Diagnostics. /// Scheduler for UI-affine updates; tests pass an immediate one. @@ -148,7 +149,14 @@ public partial class GalleryViewModel : PageViewModel, IDisposable this.WhenAnyValue(x => x.HasRule), _mainThread ); - CancelRuleCommand = ReactiveCommand.Create(() => IsRuleEditorOpen = false, editing, _mainThread); + CancelRuleCommand = ReactiveCommand.Create( + () => + { + IsRuleEditorOpen = false; + }, + editing, + _mainThread + ); EditRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed); SaveRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed); @@ -218,6 +226,31 @@ public partial class GalleryViewModel : PageViewModel, IDisposable /// Closes the viewer. public ReactiveCommand CloseViewerCommand { get; } + /// Opens the "what to do when this turns up again" panel for the open picture. + public ReactiveCommand EditRuleCommand { get; } + + /// Saves the rule for the open picture. + public ReactiveCommand SaveRuleCommand { get; } + + /// Drops the rule for the open picture. + public ReactiveCommand RemoveRuleCommand { get; } + + /// Closes the panel without saving. + public ReactiveCommand CancelRuleCommand { get; } + + /// Actions the editor offers. + public IReadOnlyList> RuleActions { get; } + + /// + /// Sources the rule may cover, each with its own tick. + /// + /// + /// Ticked rather than a single choice because the same bytes mean different things per service: + /// one host's "removed" placeholder is another host's ordinary picture. Nothing ticked means + /// every source, which is what a user who does not care about the distinction will leave it as. + /// + public ObservableCollection RuleSources { get; } + /// Whether the viewer is open. public bool IsViewerOpen => Selected is not null; @@ -339,6 +372,114 @@ public partial class GalleryViewModel : PageViewModel, IDisposable private static bool HasNextPage(int page, int total, bool loading) => !loading && (page + 1) * PageSize < total; + /// Fills the editor from whatever rule the open picture already has. + private async Task OpenRuleEditorAsync() + { + if (Selected is not { } item) + { + return; + } + + var rules = await _store.LoadRulesAsync().ConfigureAwait(false); + var existing = rules.FirstOrDefault(rule => + string.Equals(rule.Sha256, item.Media.Sha256, StringComparison.Ordinal) + ); + + var scoped = new HashSet(existing?.Sources ?? [], StringComparer.OrdinalIgnoreCase); + + OnUi(() => + { + foreach (var source in RuleSources) + { + source.Dispose(); + } + + RuleSources.Clear(); + + foreach (var source in _catalog.Sources) + { + RuleSources.Add(new MediaSourceViewModel(source) { IsSelected = scoped.Contains(source.Id) }); + } + + // A new rule starts on the source this picture came from: it is the one the user is + // looking at, and scoping to it is nearly always what they mean. + if (existing is null) + { + foreach (var source in RuleSources.Where(source => source.Id == item.Media.SourceId)) + { + source.IsSelected = true; + } + } + + RuleAction = RuleActions.First(option => option.Value == (existing?.Action ?? MediaRuleAction.Skip)); + HasRule = existing is not null; + IsRuleEditorOpen = true; + }); + } + + private async Task SaveRuleAsync() + { + if (Selected is not { } item) + { + return; + } + + var rule = new MediaRule(item.Media.Sha256, RuleAction.Value) + { + SourceIds = MediaRule.JoinSources( + RuleSources.Where(source => source.IsSelected).Select(source => source.Id) + ), + }; + + var removed = await _store.SaveRuleAsync(rule).ConfigureAwait(false); + var message = Localizer.Instance.Format("Gallery.Rule.Saved", RuleAction.Label, removed); + + OnUi(() => + { + IsRuleEditorOpen = false; + HasRule = true; + + // A skip rule deletes the copy that prompted it, so the page behind the viewer is now + // showing something that is gone. + if (removed > 0) + { + Selected = null; + } + }); + + if (removed > 0) + { + await LoadAsync(PageIndex).ConfigureAwait(false); + } + + // After the reload, not before: reading a page rewrites the status line, so saying it first + // would leave the user with an empty toolbar and no sign anything happened. + OnUi(() => StatusMessage = message); + } + + private async Task RemoveRuleAsync() + { + if (Selected is not { } item) + { + return; + } + + await _store.RemoveRuleAsync(item.Media.Sha256).ConfigureAwait(false); + + OnUi(() => + { + HasRule = false; + IsRuleEditorOpen = false; + StatusMessage = Localizer.Instance["Gallery.Rule.Removed"]; + }); + } + + private void OnRuleFailed(Exception exception) + { + _logger.LogError(exception, "The gallery rule could not be applied"); + OnUi(() => StatusMessage = Localizer.Instance.Format("Gallery.Failed", exception.Message)); + } + /// protected override void OnLanguageChanged() { @@ -371,6 +512,11 @@ public partial class GalleryViewModel : PageViewModel, IDisposable loading.Dispose(); } + foreach (var source in RuleSources) + { + source.Dispose(); + } + GC.SuppressFinalize(this); } diff --git a/src/AvParser.UI/Views/GalleryView.axaml b/src/AvParser.UI/Views/GalleryView.axaml index e00ca9b..e449e20 100644 --- a/src/AvParser.UI/Views/GalleryView.axaml +++ b/src/AvParser.UI/Views/GalleryView.axaml @@ -198,12 +198,78 @@ Margin="0,0,32,32" Spacing="8" > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/AvParser.UI.Tests/GalleryViewModelTests.cs b/tests/AvParser.UI.Tests/GalleryViewModelTests.cs index 810a07a..e1b8dbb 100644 --- a/tests/AvParser.UI.Tests/GalleryViewModelTests.cs +++ b/tests/AvParser.UI.Tests/GalleryViewModelTests.cs @@ -1,4 +1,5 @@ using AvParser.Core.Collecting; +using AvParser.Core.Collecting.Sources; using AvParser.UI.Tests.Fakes; using AvParser.UI.ViewModels; using Microsoft.Extensions.Logging.Abstractions; @@ -25,6 +26,49 @@ public class GalleryViewModelTests IsAnimated = animated, }; + /// An in-memory catalog, so a rule can be scoped to a source in tests. + private sealed class FakeUserSourceStore(IEnumerable seed) : IUserSourceStore + { + private readonly List _configs = [.. seed]; + + public event EventHandler? Changed + { + add { } + remove { } + } + + public IReadOnlyList List() => [.. _configs]; + + public Task AddAsync( + PatternSourceConfig config, + CancellationToken cancellationToken = default + ) => Task.FromResult(config); + + public Task UpdateAsync(PatternSourceConfig config, CancellationToken cancellationToken = default) => + Task.FromResult(false); + + public Task RemoveAsync(string id, CancellationToken cancellationToken = default) => + Task.FromResult(false); + } + + private static PatternSourceConfig Config(string id) + { + PatternSourceConfig.TryCreate( + id, + "https://imgtest.example/x/", + 6, + 8, + IdAlphabet.Alphanumeric, + null, + ".jpg", + allowDirectConnection: false, + out var config, + id + ); + + return config!; + } + private static (GalleryViewModel Page, FakeMediaStore Store, FakeThumbnailCache Thumbnails) Build( params StoredMedia[] items ) @@ -36,6 +80,7 @@ public class GalleryViewModelTests var thumbnails = new FakeThumbnailCache(); var page = new GalleryViewModel( store, + new MediaSourceCatalog(new FakeUserSourceStore([Config("url-list"), Config("other")])), thumbnails, NullLogger.Instance, ImmediateSequencer.Instance @@ -171,6 +216,90 @@ public class GalleryViewModelTests page.IsLoading.ShouldBeFalse(); } + [Fact] + public async Task A_new_rule_starts_scoped_to_the_source_the_picture_came_from() + { + var (page, _, _) = Build(Media("https://a.test/1.png")); + await page.LoadAsync(0, TestContext.Current.CancellationToken); + page.Selected = page.Items[0]; + + await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); + + page.IsRuleEditorOpen.ShouldBeTrue(); + page.HasRule.ShouldBeFalse(); + page.RuleSources.Single(source => source.IsSelected).Id.ShouldBe("url-list"); + page.RuleAction.Value.ShouldBe(MediaRuleAction.Skip); + } + + [Fact] + public async Task Saving_a_skip_rule_records_it_and_drops_the_copy_that_prompted_it() + { + var (page, store, _) = Build(Media("https://a.test/1.png")); + await page.LoadAsync(0, TestContext.Current.CancellationToken); + page.Selected = page.Items[0]; + var hash = page.Selected.Media.Sha256; + + await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); + await page.SaveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); + + var rule = store.Rules.ShouldHaveSingleItem(); + rule.Sha256.ShouldBe(hash); + rule.Action.ShouldBe(MediaRuleAction.Skip); + rule.AppliesTo("url-list").ShouldBeTrue(); + rule.AppliesTo("other").ShouldBeFalse(); + + // The thing the rule was made about is gone, so the viewer cannot keep showing it. + page.IsRuleEditorOpen.ShouldBeFalse(); + page.Selected.ShouldBeNull(); + page.StatusMessage.ShouldNotBeNull().ShouldContain("Rule saved"); + } + + [Fact] + public async Task A_rule_with_no_source_ticked_covers_every_source() + { + var (page, store, _) = Build(Media("https://a.test/1.png")); + await page.LoadAsync(0, TestContext.Current.CancellationToken); + page.Selected = page.Items[0]; + + await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); + + foreach (var source in page.RuleSources) + { + source.IsSelected = false; + } + + page.RuleAction = page.RuleActions.Single(option => option.Value == MediaRuleAction.RetryElsewhere); + await page.SaveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); + + var rule = store.Rules.ShouldHaveSingleItem(); + rule.SourceIds.ShouldBeNull(); + rule.AppliesTo("anything-at-all").ShouldBeTrue(); + rule.Action.ShouldBe(MediaRuleAction.RetryElsewhere); + } + + [Fact] + public async Task An_existing_rule_comes_back_into_the_editor() + { + var (page, store, _) = Build(Media("https://a.test/1.png")); + await page.LoadAsync(0, TestContext.Current.CancellationToken); + page.Selected = page.Items[0]; + + store.Rules.Add( + new MediaRule(page.Selected.Media.Sha256, MediaRuleAction.RetryElsewhere) { SourceIds = "other" } + ); + + await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); + + page.HasRule.ShouldBeTrue(); + page.RuleAction.Value.ShouldBe(MediaRuleAction.RetryElsewhere); + page.RuleSources.Single(source => source.IsSelected).Id.ShouldBe("other"); + + await page.RemoveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); + + store.Rules.ShouldBeEmpty(); + page.HasRule.ShouldBeFalse(); + } + [Fact] public async Task Disposing_twice_is_safe() {