namespace AvParser.Core.Collecting; /// What to do when a collected item turns out to be a particular known picture. public enum MediaRuleAction { /// Never keep it: drop the bytes and settle the address without storing anything. Skip = 0, /// Fetch the same address again through a proxy in another country. RetryElsewhere = 1, } /// /// A standing instruction about one exact picture, recognised by its content hash. /// /// /// /// Born from what a scan actually produces: services answer a missing id with a placeholder and a /// geo-block with a banner, and both arrive as a perfectly valid image with a 200. Nothing about the /// address distinguishes them — only the bytes do. So the user points at one in the gallery and says /// what it means, and every future copy is treated the same way. /// /// /// Matching is by SHA-256, which is exact: a re-encoded or differently sized variant of the same /// banner is a different rule. That is the same deliberate line the store draws for de-duplication — /// there is no perceptual hashing here, and pretending otherwise would silently drop real finds. /// /// /// Content hash the rule recognises. /// What to do with a match. public sealed record MediaRule(string Sha256, MediaRuleAction Action) { /// Separates ids inside . public const char SourceSeparator = ';'; /// /// Sources the rule covers, separated by ;; or empty means all of them. /// /// /// A joined string rather than a list so the record keeps value equality and the row stays one /// column. Scoped because the same bytes mean different things to different services: one host's /// "removed" placeholder is another host's perfectly ordinary picture. /// public string? SourceIds { get; init; } /// Why the user made this rule, for the list they will read months later. public string? Reason { get; init; } /// When it was created. public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; /// Whether the rule covers . public bool AppliesTo(string? sourceId) { if (string.IsNullOrWhiteSpace(SourceIds)) { return true; } if (string.IsNullOrEmpty(sourceId)) { return false; } foreach (var id in Split(SourceIds)) { if (string.Equals(id, sourceId, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } /// The ids this rule is scoped to; empty means every source. public IReadOnlyList Sources => Split(SourceIds); /// Joins ids for storage in . public static string? JoinSources(IEnumerable sourceIds) { ArgumentNullException.ThrowIfNull(sourceIds); var joined = string.Join(SourceSeparator, sourceIds); return joined.Length == 0 ? null : joined; } private static string[] Split(string? ids) => string.IsNullOrWhiteSpace(ids) ? [] : ids.Split(SourceSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); }