Commit Graph
10 Commits
Author SHA1 Message Date
Leonid Pershin eb5061ee23 Refactor media source handling and update collection options
- Updated `IMediaSourceCatalog` to support user-added media sources, allowing dynamic editing and management of sources.
- Removed the `UrlListSource` class as its functionality is now integrated into the new catalog structure.
- Enhanced `CollectOptions` to default `RequireProxy` to true, ensuring stricter handling of proxy requirements.
- Improved error handling in `ParseError` to include a `Subject` field for better context on failures.
- Adjusted dependency injection to reflect changes in media source management, removing old source registrations.
- Introduced background proxy checks to ensure a more robust proxy pool management during collection processes.

These changes streamline the media collection process and improve the overall user experience by providing clearer error reporting and more flexible source management.
2026-08-15 14:20:06 +03:00
Leonid PershinandClaude Opus 5 a4a0ea9a6b Keep collected media beside the executable, and let it be moved
Two things were wrong with where media lived. It defaulted to the user profile,
which is the wrong home for the thing the application exists to accumulate: the
collection grows without bound and belongs with the installation, so copying
that folder takes the archive with it. And the setting for changing it existed
but had no way to be set - the Settings page showed the path as read-only text.

Media now defaults to a "media" folder next to the executable, and the Settings
page has a box, a folder picker and a reset. Configuration stays in the profile,
because that is genuinely per-user and the OS has an opinion about it.

Writability is probed with a real file, not just a directory creation: creating
a directory can succeed where writing into it does not, which is exactly what an
install under Program Files looks like. On failure it falls back to the profile
rather than refusing to start, and the effective path is shown in Settings so
the fallback is visible instead of mysterious.

A change applies on the next launch and says so. Paths are resolved before the
container exists - the media root is read straight out of settings.json to build
them - so applying it live would mean reconnecting the index, the blob store and
the thumbnail cache underneath a possibly-running collection. Writing somewhere
other than the box claims would be the worse failure. Existing files are not
moved either; relocating an archive is its own operation with its own risks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 07:53:42 +03:00
Leonid PershinandClaude Opus 5 f8744c930a Remove the demo text-parsing domain
The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.

Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.

Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.

Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:23:52 +03:00
Leonid PershinandClaude Opus 5 fe62bcf53f Add collector settings and per-source purge
Every limit the fetcher was using was a constant. They are settings now, and
CollectOptions became the single place policy lives: AppSettings.ToCollectOptions
clamps them, and the HTTP layer's FetchOptions is projected from that. One
clamping site rather than two sets of ceilings drifting apart.

Clamping rather than validating, for the reason the proxy options already do it:
a hand-edited file must not stop the app from starting. A MaxItemBytes edited to
zero would otherwise refuse everything, and a zeroed concurrency would deadlock
the run outright - so both are pulled into range instead. An empty format filter
is read as "everything", because switching every format off is far more likely
to be a slip than an instruction to collect nothing.

The media root has an ordering problem - it is a setting that decides the paths
the container is built from - so the file is read once before the container
exists rather than making every path lazy for one value.

Purge is scoped to a source and lives on the Collect page, where the source is
already chosen. Content another source also holds survives, which is what the
index's reference count was for.

The showcase hint says out loud what a hard link means: editing the browsable
copy edits the original, and deleting it frees nothing until the last name goes.
That is surprising enough to belong in the UI rather than only in the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:52:43 +03:00
Leonid PershinandClaude Opus 5 6909884851 Add media sources and the collect runner, alongside the old parsers
Third step: the collector becomes wireable. Both catalogs coexist for exactly
this one step, so ParseViewModel and every existing test stay green while the
new domain is proven.

IMediaSource reuses the closed-generic trick ITextParser used, and for the same
reason - the container cannot resolve an open generic as IEnumerable<T>, so
adding a source stays a one-line registration. Its input is a MediaQuery rather
than text, because a source that walks a paginated listing needs an endpoint and
a cursor, not a string.

Sources discover; they do not download. That split is why UrlListSource lives in
the domain with no network at all, and why everything hard about fetching lives
in one place instead of once per source.

The catalog takes an explicit default id. Left to alphabetical order the landing
source would be the network one, so the app would open behind the proxy gate
before the user had asked for anything.

The runner decouples discovery from downloading with a bounded channel - a
listing of two hundred thousand items must not materialise because the workers
are slower than the source - and owns its workers, waiting for them even when
cancelled. Without that a stopped run keeps writing to the store after the page
has said it stopped.

The own-service listing is read leniently: the service on the other end is the
user's own and should not have to be rewritten to match a schema we invented, so
both a bare array of addresses and an object with items and a cursor work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:27:59 +03:00
Leonid PershinandClaude Opus 5 1742c094e9 Add the download pipeline: sniffing, redirects, throttling, verdicts
Second half of the collector foundation. Still nothing in the app references
it; the pipeline is tested end to end against a deliberately badly behaved
loopback server before anything depends on it.

Types come from the bytes, never from the URL, the extension or Content-Type -
two of those three are chosen by whoever serves the file, and a host must not
get to pick the extension of a file written to the user's disk. Animation is a
separate question from kind: GIF89a proves nothing without a second image
descriptor, and a PNG is an APNG only if acTL precedes the first IDAT, so both
are walked properly rather than guessed.

Timeouts are split three ways because HttpClient.Timeout covers the whole
response: any value large enough for a 30 MB file is also large enough for a
dead connection to hang on. Connect, headers and a per-read idle deadline let
both be strict. Redirects are followed by hand since the shared proxy handler
disables them, which is what allows a hop cap, loop detection and refusing a
jump to a data: URL.

The lease verdict is a pure function, because ProxyLease's constructor is
internal to the domain and no test can fabricate one. Its rule is that the
verdict describes the transport, not the resource: a 404 is a working proxy,
and so is a 429 - blaming the proxy for an origin's rate limit would make the
pool rotate away from a good address in response to being asked to slow down.
Cancellation reports nothing at all.

Throttling exists to be obeyed. It is raised only by the host's own 429 and 503,
and never by rotating to another proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:06:15 +03:00
Leonid PershinandClaude Opus 5 181f974a37 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>
2026-08-13 20:46:21 +03:00
Leonid PershinandClaude Opus 5 44fb0d3a5f Gate network parsers on a working proxy and remember what worked
The pool now warms up from what the previous run learned instead of starting
cold every launch. Startup probes the remembered proxies first, stops as soon
as ProxyMinimumLive of them answer, and writes the survivors to
proxies.state.json after the warm-up and again on shutdown. Only proxies that
ever answered are stored: the feed republishes a few thousand dead addresses
every five minutes, and "was dead an hour ago" says almost nothing.

Remembered state is a hint, not a verdict. A restored proxy sorts first in the
warm-up queue but is not counted live until it answers in this session -
otherwise a launch a week later would report live proxies it had never spoken
to, the warm-up would skip the very entries it exists to re-check, and the
parser gate would open on week-old evidence.

That gate is the other half: a parser declaring RequiresNetwork will not run
while the pool has nothing live. The Parse page disables the run button and
shows a banner that leads to the Proxies page. Parsers that work on pasted text
are never gated - they have nothing to route, and blocking them would make the
app useless whenever the public lists are down. Two new settings cover the
escape hatch and the target: "allow network parsers without a proxy" and how
many live proxies to find at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:11:51 +03:00
Leonid PershinandClaude Opus 5 85656e70b0 Load the proxy pool at startup, and stop old settings files zeroing new defaults
The pool was empty until someone pressed Refresh, which also meant the parser
would have had nothing to work with. A ProxyPoolLoader now fills it once at
launch; startup does not await it, because blocking on a public list being
reachable would be the wrong trade, and it never throws. The proxy page joins
the same operation rather than starting a second download, so it reports the
outcome whether it is opened during the load or long after.

That change surfaced a worse bug underneath. The first run still loaded zero
entries with no error logged at all, which turned out to be the feed source
never being asked: options said UseFeed=False and Protocols=None. Neither is
reachable from the UI — both are default(T).

The cause is that AppSettings kept its defaults on property initialisers, and
the source-generated deserialiser does not run them. Reflection-based
deserialisation of "{}" keeps them; the generated context does not. So a
settings.json written before a setting existed came back with default(T) for it:
the proxy feed switched off, the protocol filter empty, the probe timeout zero
and the probe URL blank — and the app looked like the network had failed.

Defaults now live on primary constructor parameters, which STJ applies for
absent JSON members on both paths, so an older file upgrades cleanly. The
regression test writes a settings file from before the proxy settings existed
and asserts each one comes back at its default. ToProxyOptions also treats an
empty protocol filter as "all", since a hand-edited file that matches nothing is
the least useful possible reading of it.

Also quietens IHttpClientFactory to Warning: four Information lines per request
buried everything the app said, and a proxy sweep makes thousands of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:33:41 +03:00
Leonid PershinandClaude Opus 5 9bf2ea5532 Add a proxy pool with rotation, liveness checks and a management page
The parser will need to move between proxies, so this adds the module it will
sit on: pluggable sources, a pool that hands proxies out and learns from the
outcome, three rotation strategies, and a page to drive it.

Sources are IProxySource implementations. The public proxifly/free-proxy-list
feed is fetched as the combined all/data.json through jsDelivr and filtered
locally — one conditional request beats four per-protocol ones that can disagree
mid-publish — and cached for the five minutes upstream takes to regenerate. A
feed that is down keeps serving its last payload rather than emptying the pool.
The user's own list lives in proxies.custom.json beside the settings, takes a
pasted blob, and names the lines it could not parse instead of quietly dropping
them.

Both knobs the pool exposes are settings, as asked: rotation is Sticky (default,
the only one that keeps site sessions coherent), RoundRobin or WeightedRandom;
liveness is either a parallel sweep of the whole pool or a probe at hand-out
time. Free lists are a few percent alive, so skipping verification entirely
means mostly waiting on timeouts.

Two invariants worth keeping, both of which cost a bug to find:

Availability is decided by the quarantine, never by Health. Excluding everything
that has ever failed made the quarantine window dead code and discarded proxies
permanently on their first hiccup, which is exactly wrong for addresses that
flap constantly. Health only orders the candidates now.

A probe verdict does not touch the success/failure counters. Those are about
real requests, and letting a sweep over a few thousand proxies rewrite them
would drown the evidence weighted selection reads.

SOCKS needs no extra package — .NET resolves socks4/socks4a/socks5 in WebProxy —
but a proxifly record with "protocol": "https" is still an HTTP proxy reached
over http:// with CONNECT, not an https:// scheme.

115 new tests. Also fixes a pre-existing flake: a command gated on another
command's IsExecuting cannot be driven straight after its Execute() completes,
because IsExecuting is published on the output scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:22:30 +03:00