Add the media store: content-addressed blobs, SQLite index, showcase
First half of replacing the stub text domain with a media collector. Nothing references this yet - the store is standalone and fully tested before anything depends on it. Blobs are addressed by SHA-256 and sharded two levels deep, so the same picture re-uploaded at a dozen addresses costs one file. Downloads stage in a sibling temp directory on the same volume and are promoted by rename, which is what keeps blobs/ free of truncated files: a crash leaves a stray .part that the next startup sweeps, never a half-image indistinguishable from a real one. The SQLite index holds provenance separately from content, so purging one source leaves blobs another source still references - that is what ref_count buys, and it is recomputed rather than incremented because the item upsert can replace a row pointing at a different blob. The seen_url journal deliberately outlives a purge: without that, the next run downloads again exactly what the user just deleted. Terminal outcomes are split from retryable ones so a flaky network does not permanently lose content. The showcase gives every item a dated, named path via hard links - a second name for one file, not a second file. Hard links are a filesystem privilege rather than a guarantee, so it degrades to copying and records which it achieved; the UI has to be able to admit that. Names suggested by the origin are treated as hostile: only the last path segment survives, Windows device names are pushed aside, and the extension comes from the sniffed kind, never from the remote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
44fb0d3a5f
commit
181f974a37
@@ -0,0 +1,94 @@
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>What happened to one candidate.</summary>
|
||||
public enum CollectStatus
|
||||
{
|
||||
/// <summary>New content; a blob was written.</summary>
|
||||
Stored = 0,
|
||||
|
||||
/// <summary>The bytes were already in the store; only provenance was recorded.</summary>
|
||||
Duplicate = 1,
|
||||
|
||||
/// <summary>Not fetched at all, because a previous run already settled this address.</summary>
|
||||
Skipped = 2,
|
||||
}
|
||||
|
||||
/// <summary>One candidate carried all the way through to the store.</summary>
|
||||
/// <param name="Candidate">What the source found.</param>
|
||||
/// <param name="Blob">The content that came back.</param>
|
||||
/// <param name="Status">Whether it was new, already held, or never fetched.</param>
|
||||
public sealed record CollectedItem(MediaCandidate Candidate, MediaBlob Blob, CollectStatus Status)
|
||||
{
|
||||
/// <summary>How long the fetch took, including redirects.</summary>
|
||||
public TimeSpan Elapsed { get; init; }
|
||||
|
||||
/// <summary>Path of the showcase entry, relative to the showcase root; null when none was made.</summary>
|
||||
public string? ShowcasePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The recorded fate of an address, so a later run need not ask again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split into terminal and retryable by <see cref="SeenOutcomes.IsTerminal"/>. That split is the
|
||||
/// entire value of the journal: without it a re-run either re-downloads everything or gives up on
|
||||
/// addresses that failed once for a transient reason.
|
||||
/// </remarks>
|
||||
public enum SeenOutcome
|
||||
{
|
||||
/// <summary>Downloaded and stored.</summary>
|
||||
Stored = 0,
|
||||
|
||||
/// <summary>Downloaded; the bytes were already held.</summary>
|
||||
Duplicate = 1,
|
||||
|
||||
/// <summary>Content matched a known dead-link placeholder.</summary>
|
||||
Placeholder = 2,
|
||||
|
||||
/// <summary>Bigger than the configured ceiling.</summary>
|
||||
TooLarge = 3,
|
||||
|
||||
/// <summary>Smaller than the configured floor — tracking pixels and spacers.</summary>
|
||||
TooSmall = 4,
|
||||
|
||||
/// <summary>A recognised format that the user excluded.</summary>
|
||||
UnsupportedType = 5,
|
||||
|
||||
/// <summary>The bytes matched no known signature.</summary>
|
||||
NotMedia = 6,
|
||||
|
||||
/// <summary>The origin said it is permanently gone.</summary>
|
||||
Gone = 7,
|
||||
|
||||
/// <summary>Failed for a reason that may not recur.</summary>
|
||||
Failed = 8,
|
||||
|
||||
/// <summary>Deferred by the origin's own rate limiting.</summary>
|
||||
RateLimited = 9,
|
||||
|
||||
/// <summary>Timed out.</summary>
|
||||
Timeout = 10,
|
||||
}
|
||||
|
||||
/// <summary>Helpers over <see cref="SeenOutcome"/>.</summary>
|
||||
public static class SeenOutcomes
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the outcome settles the address for good, so a re-run can skip it without asking.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately excludes <see cref="SeenOutcome.Failed"/>, <see cref="SeenOutcome.RateLimited"/>
|
||||
/// and <see cref="SeenOutcome.Timeout"/>: those describe the moment, not the resource, and a
|
||||
/// journal that treated them as final would make a flaky network permanently lose content.
|
||||
/// </remarks>
|
||||
public static bool IsTerminal(SeenOutcome outcome) =>
|
||||
outcome
|
||||
is SeenOutcome.Stored
|
||||
or SeenOutcome.Duplicate
|
||||
or SeenOutcome.Placeholder
|
||||
or SeenOutcome.TooLarge
|
||||
or SeenOutcome.TooSmall
|
||||
or SeenOutcome.UnsupportedType
|
||||
or SeenOutcome.NotMedia
|
||||
or SeenOutcome.Gone;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>A finished download waiting to be taken into the store.</summary>
|
||||
/// <param name="Candidate">What the source found.</param>
|
||||
/// <param name="Blob">Hash, kind and size of the content that came back.</param>
|
||||
/// <param name="TempFilePath">
|
||||
/// Complete, verified file to take ownership of. The store moves or deletes it and the caller must
|
||||
/// not touch it afterwards.
|
||||
/// </param>
|
||||
/// <param name="RunId">Run this belongs to.</param>
|
||||
/// <param name="FinalUrl">Address after redirects; <see cref="MediaCandidate.Url"/> is the one asked for.</param>
|
||||
/// <param name="HttpStatus">Status of the final response.</param>
|
||||
public sealed record MediaStoreRequest(
|
||||
MediaCandidate Candidate,
|
||||
MediaBlob Blob,
|
||||
string TempFilePath,
|
||||
string RunId,
|
||||
Uri FinalUrl,
|
||||
int HttpStatus
|
||||
)
|
||||
{
|
||||
/// <summary>What the origin claimed the type was. Kept precisely to catch the ones that lie.</summary>
|
||||
public string? ContentType { get; init; }
|
||||
|
||||
/// <summary>Proxy the content came through, or null when the connection was direct.</summary>
|
||||
public string? ProxyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>What to remove when purging.</summary>
|
||||
/// <param name="SourceId">Only content attributed to this source is considered.</param>
|
||||
public sealed record PurgeOptions(string SourceId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Also forget every address this source ever visited.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Off by default, and the default is the interesting one: the journal outliving a purge is
|
||||
/// what stops the next run from downloading again exactly what the user just deleted.
|
||||
/// </remarks>
|
||||
public bool ForgetSeenUrls { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a purge.</summary>
|
||||
/// <param name="ItemsRemoved">Provenance rows deleted.</param>
|
||||
/// <param name="BlobsRemoved">Blobs that lost their last reference and were deleted.</param>
|
||||
/// <param name="BytesFreed">Bytes reclaimed on disk.</param>
|
||||
public readonly record struct PurgeResult(int ItemsRemoved, int BlobsRemoved, long BytesFreed);
|
||||
|
||||
/// <summary>Totals for the dashboard.</summary>
|
||||
/// <param name="BlobCount">Distinct byte sequences held.</param>
|
||||
/// <param name="ItemCount">Provenance rows across every source.</param>
|
||||
/// <param name="TotalBytes">Sum of blob sizes.</param>
|
||||
/// <param name="SourceCount">Distinct sources that contributed.</param>
|
||||
public readonly record struct MediaStoreStats(int BlobCount, int ItemCount, long TotalBytes, int SourceCount);
|
||||
|
||||
/// <summary>How a run ended.</summary>
|
||||
/// <param name="Discovered">Candidates the source produced.</param>
|
||||
/// <param name="Stored">Candidates that produced new content.</param>
|
||||
/// <param name="Duplicates">Candidates whose bytes were already held.</param>
|
||||
/// <param name="Failed">Candidates that produced an error.</param>
|
||||
/// <param name="Bytes">Bytes newly written.</param>
|
||||
/// <param name="Cancelled">Whether the user stopped it.</param>
|
||||
public readonly record struct RunSummary(
|
||||
int Discovered,
|
||||
int Stored,
|
||||
int Duplicates,
|
||||
int Failed,
|
||||
long Bytes,
|
||||
bool Cancelled
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Everything the collector persists: the blobs, their provenance, and the journal of addresses
|
||||
/// already visited.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The abstraction lives in the domain and the implementation in the infrastructure, the same way
|
||||
/// <c>ISettingsService</c> and <c>IProxySource</c> do, so that the domain stays runnable from a
|
||||
/// CLI or a test without dragging in a database.
|
||||
/// </remarks>
|
||||
public interface IMediaStore
|
||||
{
|
||||
/// <summary>Creates or upgrades the schema. Safe to call repeatedly.</summary>
|
||||
Task InitialiseAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Opens a run and returns its id.</summary>
|
||||
Task<string> BeginRunAsync(string sourceId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Closes a run with its totals.</summary>
|
||||
Task CompleteRunAsync(string runId, RunSummary summary, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up what previous runs made of these addresses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Batched deliberately: a listing of a hundred thousand addresses must cost a handful of
|
||||
/// queries, not a hundred thousand of them.
|
||||
/// </remarks>
|
||||
Task<IReadOnlyDictionary<string, SeenOutcome>> GetSeenAsync(
|
||||
string sourceId,
|
||||
IReadOnlyCollection<string> urls,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>Records the fate of an address that produced no content.</summary>
|
||||
Task RecordSeenAsync(
|
||||
string sourceId,
|
||||
string url,
|
||||
SeenOutcome outcome,
|
||||
int? httpStatus = null,
|
||||
string? errorCode = null,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>Reads every hash known to be a dead-link placeholder.</summary>
|
||||
/// <remarks>Loaded once per run: the set is tiny and the check runs on every download.</remarks>
|
||||
Task<IReadOnlySet<string>> LoadTombstonesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a hash as a placeholder and removes every copy already held.
|
||||
/// </summary>
|
||||
/// <returns>How many stored items were removed.</returns>
|
||||
Task<int> TombstoneAsync(string sha256, string? reason, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Takes ownership of a completed download and records it.</summary>
|
||||
Task<CollectedItem> StoreAsync(MediaStoreRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Removes everything attributed to one source.</summary>
|
||||
Task<PurgeResult> PurgeAsync(PurgeOptions options, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Rebuilds the browsable showcase for a source from the recorded provenance.</summary>
|
||||
/// <returns>How many entries were linked.</returns>
|
||||
Task<int> RebuildShowcaseAsync(string sourceId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Totals across the whole store.</summary>
|
||||
Task<MediaStoreStats> GetStatsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>
|
||||
/// One distinct byte sequence in the store, identified by its content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Identity is the hash, not the URL: the same picture is re-uploaded and re-hosted constantly,
|
||||
/// and deduplicating by address would store it once per address. Because the proxy handler
|
||||
/// decompresses transparently, the hash is over the <b>decoded</b> bytes — so the same image
|
||||
/// served gzipped at one URL and plain at another still collapses to a single blob.
|
||||
/// </remarks>
|
||||
/// <param name="Sha256">Lowercase hex SHA-256 of the content, 64 characters.</param>
|
||||
/// <param name="Kind">What the signature said it is.</param>
|
||||
/// <param name="Extension">Extension derived from <paramref name="Kind"/>, leading dot included.</param>
|
||||
/// <param name="Length">Size in bytes of the decoded content.</param>
|
||||
public sealed record MediaBlob(string Sha256, MediaKind Kind, string Extension, long Length)
|
||||
{
|
||||
/// <summary>Pixel width, when it was cheap to read from the header.</summary>
|
||||
public int? Width { get; init; }
|
||||
|
||||
/// <summary>Pixel height, when it was cheap to read from the header.</summary>
|
||||
public int? Height { get; init; }
|
||||
|
||||
/// <summary>Whether the content has more than one frame.</summary>
|
||||
public bool IsAnimated { get; init; }
|
||||
|
||||
/// <summary>Creates a blob, deriving the extension from the kind.</summary>
|
||||
public static MediaBlob Create(string sha256, MediaKind kind, long length) =>
|
||||
new(sha256, kind, MediaKinds.ExtensionFor(kind), length);
|
||||
|
||||
/// <summary>
|
||||
/// Path of this blob relative to the blob root, using two levels of two hex characters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 65 536 leaf directories keeps any one of them to a few hundred entries at a million blobs,
|
||||
/// which matters because some filesystems and most file managers degrade badly on flat
|
||||
/// directories of that size.
|
||||
/// </remarks>
|
||||
public string RelativePath => Path.Combine(Sha256[..2], Sha256.Substring(2, 2), string.Concat(Sha256, Extension));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>
|
||||
/// Something a source found and thinks is worth downloading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A discovery, not the bytes. Keeping the two apart is the whole shape of the collector: a source
|
||||
/// only has to enumerate addresses, so it stays cheap and mostly testable without a network, while
|
||||
/// everything hard about downloading — redirects, timeouts, sniffing, throttling — lives in one
|
||||
/// place instead of being reimplemented per source.
|
||||
/// </remarks>
|
||||
/// <param name="Url">Where to fetch it from.</param>
|
||||
public sealed record MediaCandidate(Uri Url)
|
||||
{
|
||||
/// <summary>Id of the source that found it. Stamped by the runner when a source leaves it blank.</summary>
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Page to send as <c>Referer</c>; some origins refuse a bare request.</summary>
|
||||
public Uri? Referer { get; init; }
|
||||
|
||||
/// <summary>Identifier in the originating service, when it has one.</summary>
|
||||
public string? ExternalId { get; init; }
|
||||
|
||||
/// <summary>Name to prefer in the showcase, before sanitising.</summary>
|
||||
public string? SuggestedName { get; init; }
|
||||
|
||||
/// <summary>When the origin says it was published.</summary>
|
||||
public DateTimeOffset? PublishedUtc { get; init; }
|
||||
|
||||
/// <summary>Size the listing claimed, when it said. Advisory only — never trusted for limits.</summary>
|
||||
public long? ExpectedLength { get; init; }
|
||||
|
||||
/// <summary>Tags carried over from the source.</summary>
|
||||
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>1-based position within the listing, used to report failures against something.</summary>
|
||||
public int Ordinal { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>What a downloaded byte sequence actually turned out to be.</summary>
|
||||
/// <remarks>
|
||||
/// Decided by the file's own signature, never by its URL, extension or <c>Content-Type</c> — all
|
||||
/// three are routinely wrong, and two of them are attacker-controlled.
|
||||
/// </remarks>
|
||||
public enum MediaKind
|
||||
{
|
||||
/// <summary>Nothing recognised. Never stored.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>JPEG image.</summary>
|
||||
Jpeg = 1,
|
||||
|
||||
/// <summary>PNG image, possibly animated (APNG).</summary>
|
||||
Png = 2,
|
||||
|
||||
/// <summary>GIF image, possibly animated.</summary>
|
||||
Gif = 3,
|
||||
|
||||
/// <summary>WebP image, possibly animated.</summary>
|
||||
WebP = 4,
|
||||
|
||||
/// <summary>AVIF image.</summary>
|
||||
Avif = 5,
|
||||
|
||||
/// <summary>MP4 video. What most sites serve when they say "GIF".</summary>
|
||||
Mp4 = 6,
|
||||
|
||||
/// <summary>WebM video.</summary>
|
||||
WebM = 7,
|
||||
}
|
||||
|
||||
/// <summary>Helpers over <see cref="MediaKind"/>.</summary>
|
||||
public static class MediaKinds
|
||||
{
|
||||
/// <summary>File extension for a kind, leading dot included.</summary>
|
||||
/// <remarks>
|
||||
/// The single source of truth for what a stored file is called. Deriving it from the URL would
|
||||
/// mean a host could dictate the extension of a file written to the user's disk.
|
||||
/// </remarks>
|
||||
public static string ExtensionFor(MediaKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
MediaKind.Jpeg => ".jpg",
|
||||
MediaKind.Png => ".png",
|
||||
MediaKind.Gif => ".gif",
|
||||
MediaKind.WebP => ".webp",
|
||||
MediaKind.Avif => ".avif",
|
||||
MediaKind.Mp4 => ".mp4",
|
||||
MediaKind.WebM => ".webm",
|
||||
_ => ".bin",
|
||||
};
|
||||
|
||||
/// <summary>Whether the kind is a still or animated picture rather than a video container.</summary>
|
||||
public static bool IsImage(MediaKind kind) =>
|
||||
kind is MediaKind.Jpeg or MediaKind.Png or MediaKind.Gif or MediaKind.WebP or MediaKind.Avif;
|
||||
|
||||
/// <summary>Whether the kind is a video container.</summary>
|
||||
public static bool IsVideo(MediaKind kind) => kind is MediaKind.Mp4 or MediaKind.WebM;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>
|
||||
/// What to ask a source for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The collector's equivalent of the text that used to be pasted into the parse page. A record
|
||||
/// rather than a bare string because sources differ in what they need — one reads a pasted list,
|
||||
/// another walks a paginated listing — and widening a record beats a second interface per source.
|
||||
/// </remarks>
|
||||
/// <param name="Text">Free text, typically a pasted list of addresses, one per line.</param>
|
||||
/// <param name="Endpoint">Listing endpoint, for sources that ask a service what it holds.</param>
|
||||
/// <param name="Limit">Stop after this many candidates; 0 means no cap.</param>
|
||||
/// <param name="Cursor">Where to resume a paginated listing.</param>
|
||||
public sealed record MediaQuery(string Text = "", Uri? Endpoint = null, int Limit = 0, string? Cursor = null)
|
||||
{
|
||||
/// <summary>Source-specific extras, so a new source needs no change to this type.</summary>
|
||||
public IReadOnlyDictionary<string, string> Options { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Whether the caller asked for a bounded number of candidates.</summary>
|
||||
public bool HasLimit => Limit > 0;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>
|
||||
/// How a showcase entry points at the blob it shows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Doubles as the user's preference and as the record of what was actually achieved for one entry.
|
||||
/// They differ more often than you would like: hard links are a filesystem privilege, not a
|
||||
/// guarantee, and a media root on exFAT, on a network share or across volumes silently has to fall
|
||||
/// back to copying. Storing the achieved mode per entry is what lets the UI admit that.
|
||||
/// </remarks>
|
||||
public enum ShowcaseMode
|
||||
{
|
||||
/// <summary>No browsable entry; only the content-addressed blob exists.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>A second directory entry for the same content. Costs no extra space.</summary>
|
||||
HardLink = 1,
|
||||
|
||||
/// <summary>A symbolic link. Needs Developer Mode or elevation on Windows, so it is opt-in.</summary>
|
||||
SymbolicLink = 2,
|
||||
|
||||
/// <summary>An independent copy. Doubles the space used.</summary>
|
||||
Copy = 3,
|
||||
}
|
||||
Reference in New Issue
Block a user