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);
}