Documentation
¶
Overview ¶
Package usenet is the delivery-axis plugin: a basic Usenet indexer that crawls the last few days of a set of newsgroups, assembles complete article sets into downloadable NZB files, and serves search / group-list / download through a capability the host's pages consume. It owns the "usenet" Postgres schema and groups its jobs — Crawler, Backfill, Builder, Tag Fill, Prune, Health — under one "Usenet" family.
Staging (the transient article-assembly buffer) is pluggable behind the stagingStore seam: durable Postgres by default (never-lost, the base site's mode), or prod's Redis pipeline lifted verbatim via staging: redis (fast, best-effort) when the host has Redis. See README.md.
Index ¶
- Constants
- Variables
- func DecodeSpotBase64(s string) ([]byte, error)
- func JoinSpotXML(values []string) string
- func ParseSpotKey(header string) (*rsa.PublicKey, error)
- func SpotSignatureBytes(sig string) ([]byte, error)
- func SpotTrust(err error) (string, bool)
- func VerifySpot(pub *rsa.PublicKey, messageID string, signature []byte) error
- type AssemblerStore
- type BackfillStore
- type BlacklistStore
- type BuilderInfo
- type Config
- type Episode
- type ErrorReport
- type GroupStore
- type HealthStore
- type JobReport
- type JunkStore
- type LeaseStore
- type MaintenanceStore
- type PGStore
- type PassReport
- type PendingRelease
- type Plugin
- type ProviderReport
- type ReleaseIndexed
- type ReleaseReader
- type ServerConfig
- type ServerStore
- type SettingStore
- type SinkMode
- type SpotHeader
- type SpotKey
- type SpotXML
- type StagingMode
- type StatusReport
- type Store
- type Tags
- type Tier
- type WorkerReport
- type WorkerStore
Constants ¶
const ( // SpotTrustVerified: signature checked against a key worth checking. SpotTrustVerified = "verified" // SpotTrustWeakKey: the signature is arithmetically valid and proves // nothing, because the key is small enough to forge cheaply. SpotTrustWeakKey = "weak-key" // SpotTrustUnsigned: no key or no signature to check at all. SpotTrustUnsigned = "unsigned" )
Trust labels for a spot, stored on the release as nzbs.origin_trust.
const EventReleaseIndexed = "usenet.release.indexed"
What the crawler announces.
The first SYSTEM event on the site, and the reason core has a kind at all. Nobody did this: a crawler assembled a release out of articles that were already on a news server. There is no member to credit, `UserID` stays zero, and core refuses to let a system event be countable — so no achievement can ever be scored on "releases indexed", which is correct. Rewarding a member for something a machine did is the failure the kind field exists to make impossible rather than merely unwise.
Who wants it: caches (a release page that should stop saying "not found"), stats, and anything showing recent activity. All of which previously had to poll or be told by the host.
const MinSpotKeyBits = 1024
MinSpotKeyBits is the smallest modulus this package will call verified.
This is not theoretical. Of the 12 spots sampled from free.pt, SIX carried 384-bit keys — a size that factors on a laptop in minutes, so anyone can mint signatures for those posters at will. Verifying such a signature and reporting success would be the exact failure this file exists to prevent: a verifier that says yes without proving anything.
1024 is the protocol's own norm rather than a modern recommendation. It is the line between "weak but costly" and "trivially forged", which is the line that matters here. Raising it further would reject most of the live feed.
const SpotGroup = "free.pt"
SpotGroup is the Spotnet index group. A fixed property of the protocol, not operator config: comments live in free.usenet and the NZB and image payloads in alt.binaries.ftd, and a client that reads a different group is not reading Spotnet.
const SpotNZBGroup = "alt.binaries.ftd"
SpotNZBGroup carries the NZB and image payloads a spot points at. A fixed property of the protocol; fetched by message-id, never crawled.
Variables ¶
var ( // ErrNotASpot is returned for a From header that is not in spot form. It // is not a failure worth logging per article: free.pt carries ordinary // posts too, and a listing pass will meet plenty of them. ErrNotASpot = errors.New("spotnet: From header is not a spot") // ErrSpotMalformed means it LOOKED like a spot and was not parseable — // which is worth noticing, because it means either a format change or a // bug here. ErrSpotMalformed = errors.New("spotnet: malformed spot header") )
var ( // ErrSpotBadSignature means the bytes were checked and did not match. ErrSpotBadSignature = errors.New("spotnet: signature does not match the spot's key") // ErrSpotBadKey means the carried key could not be parsed at all. ErrSpotBadKey = errors.New("spotnet: unparseable public key") // ErrSpotWeakKey means the key is too small for a signature over it to mean // anything. Distinct from a bad signature because the spot is not forged — // it is merely unprovable, and a caller may knowingly accept it at lower // trust. ErrSpotWeakKey = errors.New("spotnet: key is too small to be worth verifying") )
var ( // ErrNoSpotXML means the article carried no X-Xml header at all. ErrNoSpotXML = errors.New("spotnet: article has no X-Xml header") // ErrSpotXMLTruncated means the joined document did not parse. Given the // pieces arrive as separate headers, the overwhelmingly likely cause is a // missing or reordered piece rather than a malformed spot. ErrSpotXMLTruncated = errors.New("spotnet: X-Xml document did not parse (missing a piece?)") )
var AllTiers = []Tier{TierCritical, TierNormal, TierLow}
AllTiers is the admin UI's option list, in priority order.
Functions ¶
func DecodeSpotBase64 ¶
DecodeSpotBase64 decodes Spotnet's escaped base64.
It is NOT the URL-safe alphabet, which is the trap: '+' and '/' — the only two non-alphanumeric characters in the standard alphabet — are escaped as the TWO-CHARACTER sequences "-p" and "-s". Treating '-' as a single-character substitution (the URL-safe assumption) leaves a stray 'p' or 's' in the stream, shifting every subsequent byte. That produced signatures one and two bytes LONGER than the modulus, which is arithmetically impossible and read as "these spots are forged" rather than "we decoded them wrongly": 11 of 12 live spots failed that way before the escaping was understood.
Standard base64 never emits '-', so applying this to an unescaped value is a no-op. That is why the same decoder serves both the key (which arrives unescaped, '/' and all) and the signature (which does not) — one function, no per-field guessing about which encoding a value uses.
func JoinSpotXML ¶
JoinSpotXML concatenates the repeated X-Xml header values in the order they were received.
No separator, no trimming of the pieces: the split is at an arbitrary byte offset, so a boundary can fall in the middle of a tag name or an attribute value, and trimming whitespace at the seam would corrupt a document that happened to split on a space inside a description.
func ParseSpotKey ¶
ParseSpotKey turns the X-User-Key header into an rsa.PublicKey.
func SpotSignatureBytes ¶
SpotSignatureBytes decodes the X-User-Signature header.
func SpotTrust ¶
SpotTrust turns a VerifySpot result into the label stored with the release.
This exists so the import path has ONE place that decides what a verification outcome means, rather than each call site inventing its own mapping — the difference between "unprovable" and "forged" is the whole value of the check, and it is exactly the distinction an ad-hoc `if err != nil` at the call site would flatten.
A false second return means DO NOT IMPORT. Note that a weak key is importable: refusing it would drop half the live feed, and the honest treatment is to carry it with a label saying the signature proved nothing.
func VerifySpot ¶
VerifySpot checks a spot's signature against the key it carries.
messageID may arrive with or without its angle brackets; what gets signed is always the bracketed form, because that is what Spotweb signs and a spot verified against the wrong shape would silently fail for every poster.
A nil return means the signature is genuine AND the key is large enough for that to be evidence. ErrSpotWeakKey means the maths was not attempted because the answer would not have meant anything.
Types ¶
type AssemblerStore ¶
type AssemblerStore interface {
// contains filtered or unexported methods
}
AssemblerStore is the staging area the NZB assembler reads + drains.
type BackfillStore ¶
type BackfillStore interface {
// contains filtered or unexported methods
}
BackfillStore drives the backward crawl + its builder view.
type BlacklistStore ¶
type BlacklistStore interface {
// contains filtered or unexported methods
}
BlacklistStore is the operator blacklist + the per-rule filter-hit counters (blacklist_store.go).
type BuilderInfo ¶
type BuilderInfo struct {
StagedArticles int
Releases int
Ready int
Pending []PendingRelease
}
BuilderInfo is the NZB Builder's view of staging: how many articles are staged, how many distinct releases they form, how many are ready to assemble, and the largest still-incomplete releases (with unit progress) — so an admin can see WHY nothing is building (usually huge multi-file releases only partly crawled).
type Config ¶
type Config struct {
// Enabled, ABSENT, is true — the opposite default from the tracker, and
// deliberately so: a tracker answers announces the moment it is reachable
// and must be asked for, while an indexer that vanished because an
// operator upgraded and never added a key would be a catalogue going
// quietly stale. A pointer so absence is distinguishable from an explicit
// false, which is a torrent-flavour host saying it means it: nothing
// crawls, no pages mount, no jobs register.
Enabled *bool `json:"enabled"`
Server ServerConfig `json:"server"`
// RetentionDays is CRAWL DEPTH: how far back to fetch and backfill. It does
// NOT delete anything.
RetentionDays int `json:"retention_days"` // default 6431 (~17.6y, prod parity)
// NZBRetentionDays deletes assembled releases older than N days. 0 = keep
// forever, which is the default and what prod does. Deleting a catalogue is
// not something a default should ever do quietly.
NZBRetentionDays int `json:"nzb_retention_days"` // default 0 = never delete
CrawlIntervalMin int `json:"crawl_interval_min"` // crawl cadence (default 15)
TagFillIntervalMin int `json:"tagfill_interval_min"` // tag-fill + recategorize cadence (default 360)
PruneIntervalMin int `json:"prune_interval_min"` // prune cadence (default 1440)
BuildDrainPerPass int `json:"build_drain_per_pass"` // completed sets assembled per build pass (default 500)
Batch int `json:"batch"` // article-number span per OVER request (default 3000)
MaxGroups int `json:"max_groups"` // cap active groups crawled per run (default 20; 0 = all, no cap)
CrawlMaxBatches int `json:"crawl_max_batches"` // forward-pass batch budget (default 20000) — the catch-up loop rolls the remainder into the next round
// CrawlHeadroom is how many articles below the server's reported high water
// mark the forward crawl stops, leaving the newest articles for the next
// pass. 0 disables it.
//
// Articles do not appear atomically. An article number can exist while its
// overview line is still being written or still propagating between peers,
// so a batch that runs right up to the high water mark comes back short —
// and crawl.go then records the whole requested range as fetched coverage
// anyway. Walk-past eviction reasons FROM that coverage, treating "covered
// and still short" as proof the missing articles are never coming, so a
// frontier fetched too eagerly produces false dead verdicts and salvaged
// BROKEN releases out of content that was merely still arriving.
//
// Nothing is lost by waiting: the next pass picks the articles up, and the
// catch-up loop means "the next pass" is usually seconds away. NNTmux
// leaves a comparable window for the same reason.
CrawlHeadroom int `json:"crawl_headroom"` // articles left below the high water mark (default 2 batches)
MaxArticlesPerGroup int `json:"max_articles_per_group"` // cap the first-pass volume so a busy group can't pull millions (default 20000)
// Connections is the NNTP pool size — how many articles can be fetched in
// parallel. Providers cap concurrent connections per account; the pool keeps
// whatever it can open, so overshooting is safe but pointless.
Connections int `json:"connections"` // default 10
// KeepaliveMin is how often idle pool connections are probed, in minutes.
// 0 disables keepalive.
//
// Providers reap idle connections, and a crawl pass leaves most of the pool
// untouched between runs — so without probing, the steady state is a pool
// full of connections the server already closed, discovered only when the
// next pass leases one. Not a hardcoded constant because the right value is
// the provider's idle timeout, which differs per provider and is rarely
// documented.
KeepaliveMin int `json:"keepalive_min"` // default 2
SkipBackfill bool `json:"skip_backfill"` // "new articles only" — disable the backfill job
CrawlNoCatchup bool `json:"crawl_no_catchup"` // disable the catch-up loop (default off = catch-up ON)
// BackfillNoCatchup disables the backfill's catch-up loop. Same inverted
// sense as the crawl one: the zero value keeps catching up, because a job
// with hundreds of millions of articles outstanding should not sleep.
BackfillNoCatchup bool `json:"backfill_no_catchup"`
// BuildNoCatchup disables the builder's catch-up loop. Same inverted sense:
// a builder holding the backfill's release valve should not nap.
BuildNoCatchup bool `json:"build_no_catchup"`
// BackfillDrainWaitSec is how long the backfill will wait for the builder to
// make room before ending its pass. It waits rather than returning so the
// two jobs run together instead of taking turns — the builder is the only
// thing that can relieve the pressure the backfill is blocked on.
BackfillDrainWaitSec int `json:"backfill_drain_wait_sec"`
// BackfillPressureCeilingPct is the hard stop that applies even when there is
// nothing for the builder to drain. Above the normal high-water mark because
// in that state pausing achieves nothing — but still short of full, because
// at maxmemory Redis EVICTS rather than refusing the write, and the sets it
// evicts are the ones still assembling.
BackfillPressureCeilingPct int `json:"backfill_pressure_ceiling_pct"`
// HoldLowUntilBackfilled stops LOW-tier groups being crawled forward
// while any CRITICAL group still has history to backfill. See
// holdLowTier in provider_state.go for why ordering alone is not enough.
HoldLowUntilBackfilled bool `json:"hold_low_until_backfilled"`
// WalkPastNoEvict disables the walk-past sweep (inverted so the zero value
// sweeps): a set whose whole article span has been fetched and is still
// incomplete can never complete, and every hour it waits for the TTL is an
// hour of staging memory held against the pressure gate.
WalkPastNoEvict bool `json:"walk_past_no_evict"`
// WalkPastGraceMin is how long a set must go without a new article before
// the walk-past sweep may judge it (default 15) — covers retried batches
// and staging latency at the walk edge.
WalkPastGraceMin int `json:"walk_past_grace_min"`
// WalkPastSweepPerRound bounds how many staged sets the walk-past sweep
// examines per build round (default 2000). The cursor persists, so the
// sweep RATE is this budget times the round frequency.
WalkPastSweepPerRound int `json:"walk_past_sweep_per_round"`
// WalkPastNoSalvage disables broken-release salvage (inverted so the zero
// value salvages): walk-past-dead sets holding most of their articles are
// then evicted like the rest instead of being assembled and stored marked
// broken (repairable gaps) or normal (par2-only gaps).
WalkPastNoSalvage bool `json:"walk_past_no_salvage"`
// ReadyReapPerPass bounds the dead-entry sweep of nzb:ready per build
// ROUND (the name predates the round/pass split; the stored key stays for
// compatibility). Default 50000: a full circuit of a multi-million-entry
// queue takes several rounds, which is the point — the sweep must not cost
// more than the round it is clearing the way for. Per round matters: the
// cursor persists, so the sweep RATE is this budget times the call
// frequency, and a catch-up pass has no round cap.
ReadyReapPerPass int `json:"ready_reap_per_pass"`
BackfillBatchesPerRun int `json:"backfill_batches_per_run"` // cap backward batches per backfill pass, across all groups (default 25)
BackfillIntervalMin int `json:"backfill_interval_min"` // backfill cadence (default 5)
// DiagKeepDays is the rolling window for the observe-only diagnostic
// series: staging_census, subject_corpus, set_resolutions.
//
// A knob because their volume is driven by the CRAWLER's behaviour, not by
// ours: set_resolutions took 1,070 rows/minute while the walk-past sweep
// cleared a backlog (settling to ~120), and the next reset will burst
// again. At 195 bytes a row that is the difference between half a gigabyte
// and several, and the fix must not require a deploy. Default 14 days.
DiagKeepDays int `json:"diag_keep_days"`
// Staging backend (README.md). Boot config, not a live knob:
// switching backends at runtime would strand staged data.
Staging StagingMode `json:"staging"` // pg (durable, default) | redis (fast, best-effort)
// Sink is where assembled releases go: SinkInternal (the plugin's own minimal
// nzbs table — standalone installs, the demo) or SinkHost (the host registers
// the ReleaseSink capability and owns the NZB domain — how prod adopts the
// crawler). Boot config: switching sinks live would split the catalogue.
Sink SinkMode `json:"sink"`
StagingMaxRows int `json:"staging_max_rows"` // pg back-pressure denominator: staged rows / this (default 2_000_000)
StagingPruneHours int `json:"staging_prune_hours"` // pg stale-staging horizon in hours (default 6)
StagingTTLHours int `json:"staging_ttl_hours"` // redis staged-key TTL in hours (default 2) — must exceed the gap between passes that stage parts of one release
EvictStaleSecs int `json:"evict_staleness_secs"` // redis inline hopeless-eviction staleness window in seconds (default 300) — must exceed routine staging-pressure pauses or resumed sets are judged abandoned
// Splitting groups between crawlers (assign.go). Membership is fixed for a
// TERM, so a crawler that joins mid-term waits for the next boundary rather
// than changing everyone's share underneath a pass in flight.
AssignTermMin int `json:"assign_term_min"` // default 15
WorkerStaleSec int `json:"worker_stale_sec"` // presence timeout, default 90
// Cross-host coordination (lease.go). How long a claimed lease survives
// without renewal — long enough that a slow pass never loses its own claim,
// short enough that a killed worker's work is picked up promptly.
LeaseTTLMin int `json:"lease_ttl_min"` // default 15
// NZB health checking (health.go). Segments are STATted on idle connections
// only, so these bound how much bookkeeping runs, not how fast it must.
HealthIntervalMin int `json:"health_interval_min"` // sweep cadence (default 60)
HealthBatchSize int `json:"health_batch_size"` // releases per sweep (default 50)
HealthRecheckDays int `json:"health_recheck_days"` // re-check a release this often (default 30)
HealthMinAgeHours int `json:"health_min_age_hours"` // propagation guard: skip releases newer than this (default 24)
HealthStatChunk int `json:"health_stat_chunk"` // segments STATted per connection lease (default 200)
// HealthStatTimeoutSec bounds ONE STAT, as opposed to OpTimeoutSec which
// bounds a whole command exchange and is sized for a 3000-article OVER.
//
// The sweep borrows the crawler's pool and inherited its 60s, so a socket
// the provider had already closed cost a full minute to discover — three
// times per release before the release was abandoned. A measured pass
// spent 19 minutes to check ONE release. A STAT is a single short line:
// if it has not answered in seconds the connection is dead, and the whole
// value of finding that out is finding it out cheaply.
HealthStatTimeoutSec int `json:"health_stat_timeout_sec"` // per-STAT deadline (default 10)
// HealthTransportYield: how many releases in a row may fail on TRANSPORT
// (the provider timed out mid-STAT) before the pass gives up. Not the same
// as the pool being busy, which still yields on the first refusal so the
// crawler keeps priority. This exists because the yield used to be decided
// per release and end the whole pass: against a provider that times out
// routinely the first release tripped it every time, and the sweep checked
// nothing for weeks while logging a plausible "pool busy or failing".
HealthTransportYield int `json:"health_transport_yield"` // consecutive transport-failed releases before yielding (default 5)
// NFO extraction (nfo.go). The first feature built on article bodies --
// the crawler indexes from OVERVIEW lines and has never read one.
//
// NFOEnabled defaults FALSE. Every other job here is bookkeeping against
// data already paid for; this one spends provider bytes, and a block
// account's bytes are finite and metered. An operator should choose to
// spend them rather than discover the choice was made for them by an
// upgrade.
NFOEnabled bool `json:"nfo_enabled"` // read .nfo articles at all (default false)
// Spotnet. The index pass is cheap by design -- one XOVER round trip per
// SpotBatchSize articles -- so its budget is expressed in BATCHES, and a
// full history sweep is a few thousand of them rather than millions.
SpotIntervalMin int `json:"spot_interval_min"` // pass cadence (default 15)
SpotBatchSize int `json:"spot_batch_size"` // articles per XOVER (default 1000)
SpotMaxBatches int `json:"spot_max_batches"` // XOVER round trips per pass, forward + backfill (default 200)
// The fetch pass is the expensive half: TWO article reads per spot (the
// document, then the NZB). Its batch is therefore in SPOTS, not batches,
// and is two orders of magnitude smaller than the index pass's budget.
SpotFetchIntervalMin int `json:"spot_fetch_interval_min"` // pass cadence (default 10)
SpotFetchBatch int `json:"spot_fetch_batch"` // spots per pass (default 200)
NFOIntervalMin int `json:"nfo_interval_min"` // pass cadence (default 60)
NFOBatchSize int `json:"nfo_batch_size"` // releases per pass (default 100)
// NFOBudgetMB caps the bytes ONE PASS may read. The genuinely new control
// this feature needs: providers meter bytes, so unlike connection pressure
// -- which the pool already expresses and TryDo already yields to -- there
// is nothing in the existing machinery that notices bytes being consumed.
// Checked BEFORE each fetch, since a ceiling that one whole article can
// exceed is not a ceiling.
NFOBudgetMB int `json:"nfo_budget_mb"` // per-pass byte ceiling (default 64)
// The junk-recovery probe (junk_probe.go). Off by default for the same
// reason NFO is -- it spends metered bytes -- and additionally because it
// answers a question rather than serving a feature: is the crawler
// discarding real releases on the strength of a scrambled subject? The
// batch is expressed in ARTICLES rather than MB because the wire cost of
// one probe is a whole segment however few bytes we keep.
// The ROT18 title repair (rot18_repair.go). Off by default because it
// REWRITES catalogue titles: the decode is safe on rows a literal marker
// matches, but "safe" is a property of the marker list, and an operator
// should switch that on deliberately rather than find a thousand titles
// changed after an upgrade. It spends no provider bytes.
Rot18RepairEnabled bool `json:"rot18_repair_enabled"` // repair ROT18 titles (default false)
Rot18RepairIntervalMin int `json:"rot18_repair_interval_min"` // pass cadence (default 60)
// Rot18RepairMaxMin bounds ONE pass. The walk is the whole catalogue the
// first time (~1M rows) and nothing after that, so the budget exists to
// keep the first pass from holding the job lease for an unbounded stretch,
// not to ration work.
Rot18RepairMaxMin int `json:"rot18_repair_max_min"` // minutes one pass may run (default 10)
JunkProbeEnabled bool `json:"junk_probe_enabled"` // read dropped-junk bodies at all (default false)
JunkProbeIntervalMin int `json:"junk_probe_interval_min"` // pass cadence (default 360)
JunkProbeBatchSize int `json:"junk_probe_batch_size"` // drops per pass (default 50)
// NFOMaxRetries bounds how many TRANSPORT failures one release may cost
// before it is written off. A 430 is permanent and written off at once;
// a timeout says nothing about the article, so it is counted instead --
// but uncounted it would be retried forever, and a few unreachable
// articles at the head of the queue consume every pass. Newznab bounds
// the same thing by decrementing nfostatus toward a floor. 0 disables the
// ceiling and restores retry-forever.
NFOMaxRetries int `json:"nfo_max_retries"` // transport failures before write-off (default 3)
// Proof-image extraction (image.go), the second body-fetch feature. Same
// default-off reasoning as NFO — it spends metered provider bytes — and a
// bigger per-item cost: a proof JPG spans several whole articles where an
// NFO is one small one.
ImageEnabled bool `json:"image_enabled"` // fetch proof images at all (default false)
ImageIntervalMin int `json:"image_interval_min"` // pass cadence (default 60)
ImageBatchSize int `json:"image_batch_size"` // releases per pass (default 25)
ImageBudgetMB int `json:"image_budget_mb"` // per-pass byte ceiling (default 128)
ImageMaxRetries int `json:"image_max_retries"` // transport failures before write-off (default 3)
// NNTP transport bounds. Per-provider behavior lives on the servers table;
// these are the plugin-wide dial/operation limits every pool is built with.
// DialTimeoutSec bounds one connect+greeting attempt. OpTimeoutSec bounds
// one whole command exchange — one GROUP+OVER round — and interacts with
// `batch`: a bigger batch on a slow provider legitimately takes longer, and
// an OpTimeout below the honest fetch time turns every batch into a
// discarded connection and a reconnect storm.
DialTimeoutSec int `json:"dial_timeout_sec"` // default 30
OpTimeoutSec int `json:"op_timeout_sec"` // default 60
// ProviderDownCooldownMin is how long a provider stays benched after
// failing. Long enough to stop re-dialling a dead server every pass, short
// enough that recovery is noticed the same hour.
ProviderDownCooldownMin int `json:"provider_down_cooldown_min"` // default 10
// Backfill back-pressure thresholds (percent of staging pressure). Backfill
// pauses at high, resumes below low; the forward crawl is never paused.
BackfillPressureHighPct int `json:"backfill_pressure_high_pct"` // default 85
// CrawlPressureHighPct stops the FORWARD crawl staging when the staging
// backend is this full. Higher than the backfill gate on purpose: new
// articles matter more than history, so the forward crawl yields only when
// storing would actively destroy what is already there.
CrawlPressureHighPct int `json:"crawl_pressure_high_pct"` // default 95
BackfillPressureLowPct int `json:"backfill_pressure_low_pct"` // default 70
}
Config is the plugins.usenet section of config.yml. The server here seeds the servers table on first boot if it's empty; after that the wizard owns it. The numeric knobs are DEFAULTS — rows in the plugin's settings table (edited on the host's /admin/settings page) override them at job run time via withOverrides.
type Episode ¶
type Episode struct {
// Series is the show's name as it appeared, cleaned of separators:
// "The.Blacklist" → "The Blacklist".
Series string
// SeriesKey is Series folded for grouping and lookup — lowercase, no
// punctuation, no spaces. It is what "the same show" means, because
// "Marvels.Agents.of.S.H.I.E.L.D." and "Marvel's Agents of SHIELD" are one
// show and no operator should have to reconcile them by hand.
SeriesKey string
Season int
// Episode is 0 for a whole-season pack (S03, S03.COMPLETE), which is a
// real thing to index and a different thing from episode zero.
Episode int
// Pack marks that whole-season release, so a page can group it with the
// season rather than losing it among the episodes.
Pack bool
}
Episode is what a title says about where a release sits in a series.
func ParseEpisode ¶
ParseEpisode reads a title. Zero value when it says nothing usable.
type ErrorReport ¶
type GroupStore ¶
type GroupStore interface {
// contains filtered or unexported methods
}
GroupStore manages the newsgroup catalog.
type HealthStore ¶
type HealthStore interface {
// contains filtered or unexported methods
}
HealthStore is the NZB health surface (health.go).
type JobReport ¶
type JobReport struct {
Name string `json:"name"`
Status string `json:"status"`
Activity string `json:"activity"`
Next string `json:"next_run"`
Running bool `json:"running"`
// DutyPct is the trailing-hour busy percentage — "runs on schedule" and
// "actually works" are different claims, and only this one tells them
// apart from outside.
DutyPct float64 `json:"duty_pct"`
// Logs is the recent job-log tail (jobLogTail lines) — what the Jobs
// tab's per-job panes poll for live logging.
Logs []string `json:"logs,omitempty"`
}
JobReport is one scheduler job's live state (mirrors crawlerJobVM).
type JunkStore ¶
type JunkStore interface {
// contains filtered or unexported methods
}
JunkStore is the tunable junk-rule set (seeded from the embedded TSV, loaded into memory — see junk.go).
type LeaseStore ¶
type LeaseStore interface {
// contains filtered or unexported methods
}
LeaseStore is cross-host coordination (lease.go): who crawls which backbone, and which worker owns the cluster-wide jobs.
type MaintenanceStore ¶
type MaintenanceStore interface {
// contains filtered or unexported methods
}
MaintenanceStore is the nzbs cleanup / retagging surface (off-peak jobs). The staging-side cleanup (prune) moved to stagingStore (staging.go) so it swaps with the backend.
type PGStore ¶
type PGStore struct {
// contains filtered or unexported fields
}
PGStore is the Postgres implementation of Store. Every method runs through the SchemaDB's WithTx, which scopes search_path to "usenet" so unqualified table names resolve into the plugin's own schema.
func NewPGStore ¶
NewPGStore builds the Postgres-backed store over a plugin-scoped SchemaDB.
type PassReport ¶
type PassReport struct {
Running bool `json:"running"`
Groups int `json:"groups"`
// GroupsDone / BatchesTotal / Reading are the legacy dashboard's live
// progress trio: "Group N / M — <what it is reading>" plus the bar's
// denominator (batches/batches_total).
GroupsDone int `json:"groups_done"`
Batches int `json:"batches"`
BatchesTotal int `json:"batches_total"`
// PassBatches/PassBatchesTotal accumulate across the whole pass, and are
// what a consumer should read for "how much did this pass do" — batches/
// batches_total are ROUND-scoped for the live bar, and a completed pass
// always ends on an empty round, so they read ~0 the moment it finishes.
// Additive fields: the round pair keeps its name and meaning.
PassBatches int `json:"pass_batches"`
PassBatchesTotal int `json:"pass_batches_total"`
Reading string `json:"reading"`
Failed int `json:"failed_batches"`
Articles int `json:"articles"`
Staged int `json:"staged"`
WireBytes int64 `json:"wire_bytes"`
DurationSec float64 `json:"duration_seconds"`
ArticlesSec float64 `json:"articles_per_second"`
}
PassReport is one job's current or last pass.
type PendingRelease ¶
PendingRelease is one incomplete staged release. Units are files for multi-file releases, else segments.
func (PendingRelease) Pct ¶
func (p PendingRelease) Pct() int
Pct is the unit-completion percentage (0-100).
type ProviderReport ¶
type ProviderReport struct {
ID int `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Backbone string `json:"backbone"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
// Live dial state, merged from the worker-published fleet stats so the
// dashboard's provider strip can tick without a page reload. Dialled is
// false when the fleet has no entry for the provider yet.
Dialled bool `json:"dialled"`
Down bool `json:"down"`
Open int `json:"open"`
Target int `json:"target"`
Busy int `json:"busy"`
// Fetch volume since worker start, per account. Deltas between polls are
// the per-provider rate — the number that catches a degraded account on a
// shared backbone.
Articles int `json:"articles"`
Staged int `json:"staged"`
WireBytes int64 `json:"wire_bytes"`
FailedBatches int `json:"failed_batches"`
// Resets counts pool rebuilds. It is the signal that separates "the
// provider is slow" from "the pool is thrashing": a climbing Resets with
// steady Open/Target means connections are being torn down and re-dialled
// under the crawl, which is what surfaces to operators as
// "nntp: no usable connection in pool".
Resets int64 `json:"resets"`
}
type ReleaseIndexed ¶
type ReleaseIndexed struct {
Title string
Group string
// Size in bytes of the assembled release.
Size int64
}
ReleaseIndexed is the Data payload of EventReleaseIndexed.
type ReleaseReader ¶
type ReleaseReader interface {
// contains filtered or unexported methods
}
ReleaseReader is the read side: search, browse, feed, detail, raw NZB, stats.
type ServerConfig ¶
type ServerStore ¶
type ServerStore interface {
// contains filtered or unexported methods
}
ServerStore holds the single NNTP server row.
type SettingStore ¶
type SettingStore interface {
// contains filtered or unexported methods
}
SettingStore is the plugin's key/value settings.
type SinkMode ¶
type SinkMode string
SinkMode selects where assembled releases are stored. It drives the catalogue-splitting branch in resolveSink/resolveHealthBackend, so it is a closed type rather than a raw string: a mistyped literal would silently fall through to internal mode and split the catalogue across two tables.
type SpotHeader ¶
type SpotHeader struct {
Poster string // display name, before the angle bracket
PublicKey string // travels WITH the spot — there is no key directory
Signature string // the last dotted field
Category int // 1 video, 2 audio, 3 game (observed; canonical table is Spotweb's)
KeyID int // matches <Key> in the XML document
SubCats []string // "a02", "b00", … letter-prefixed, three characters each
SizeBytes int64 // checked against titles during the spike and consistent
PostedAt int64 // unix seconds
Locale string
// Unknown1 and Unknown2 are the two fields nobody has identified: the
// value after the size and the value after the timestamp. Carried rather
// than dropped, because a parser that silently discards fields it does not
// understand is how a format change becomes invisible.
Unknown1 string
Unknown2 string
}
SpotHeader is what XOVER alone yields: enough to list a spot without a second round trip. Spotnet clients build their whole listing from this, roughly one round trip per thousand spots, which is why they feel fast.
func ParseSpotFrom ¶
func ParseSpotFrom(from string) (*SpotHeader, error)
ParseSpotFrom reads the From header of a spot.
Paaldanser <KEY@27a02b00c08d13z00.3365188124.20.1786812549.1.NL.SIG>
│ ││ └ subcats ┘ └ size ──┘ └┘ └ posted ─┘ │ └ locale
│ │└ key id ? ?
│ └ category
└ public key signature ┘
The address local part is the public key; everything after the @ is a dotted tuple whose FIRST element packs three values with no separator: one digit of category, one digit of key id, then subcategories in three-character groups.
func (*SpotHeader) FullSubCats ¶
func (h *SpotHeader) FullSubCats() []string
FullSubCats renders the subcategories the way the XML document does, with the category prefixed onto each: category 2 + "a02" -> "02a02".
The header and the XML disagree in FORM but not in content, and the XML's form is the one Spotweb's category table is keyed on.
type SpotKey ¶
type SpotKey struct {
XMLName xml.Name `xml:"RSAKeyValue"`
Modulus string `xml:"Modulus"`
Exponent string `xml:"Exponent"`
}
SpotKey is the RSA key a spot carries in X-User-Key.
<RSAKeyValue><Modulus>…base64…</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>
The .NET RSAKeyValue form, because Spotweb and the original client are .NET — big-endian base64 for both components rather than any PEM encoding.
type SpotXML ¶
type SpotXML struct {
XMLName xml.Name `xml:"Spotnet"`
Posting struct {
Key int `xml:"Key"`
Created int64 `xml:"Created"`
Poster string `xml:"Poster"`
Title string `xml:"Title"`
Description string `xml:"Description"`
Size int64 `xml:"Size"`
Image struct {
Width int `xml:"Width,attr"`
Height int `xml:"Height,attr"`
Segment string `xml:"Segment"`
} `xml:"Image"`
Category struct {
// Value is the leading text node ("02"); Subs are the nested
// <Sub> elements ("02a02", …). Mixed content, so the category
// itself is chardata rather than an element of its own.
Value string `xml:",chardata"`
Subs []string `xml:"Sub"`
} `xml:"Category"`
// NZB.Segment is a bare message-id in alt.binaries.ftd. It is the
// whole point of the spot: fetch it and a finished NZB comes back,
// which is why importing skips the crawler's expensive half entirely.
NZB struct {
Segment []string `xml:"Segment"`
} `xml:"NZB"`
} `xml:"Posting"`
}
SpotXML is the document a spot carries. Field names follow the wire.
func ParseSpotXML ¶
ParseSpotXML joins the pieces and parses the result.
Takes the SLICE rather than a joined string on purpose, so a caller cannot accidentally pass the first header and get a plausible-looking answer. The count is reported in the error precisely because "we only had one piece" is the failure that otherwise looks like success.
func (*SpotXML) CategoryValue ¶
CategoryValue is the numeric category as written in the document ("02"), with the surrounding whitespace of the mixed content removed.
chardata on a mixed-content element collects the text around the children too, so the raw value carries the indentation between <Sub> elements.
func (*SpotXML) NZBSegment ¶
NZBSegment is the first segment, kept for the stored column and the "does this spot carry an NZB at all" check. Never use it to FETCH.
func (*SpotXML) NZBSegments ¶
NZBSegments are the message-ids of the articles holding this spot's NZB, in posting order. Empty when the spot points at none.
PLURAL, and that is the whole point. A spot's NZB is one DEFLATE stream cut across as many articles as it takes, so a big release announces several segments and only their concatenation inflates to a document. Reading the first one alone yields a stream that decodes part-way and then stops — which is not a decode failure, it is a shorter NZB that parses far enough to look real. An 89GB release shipped with roughly a tenth of its segments that way, and the only outward sign was the file list failing to load.
The same trap already had a warning on it twelve lines up: ParseSpotXML takes the slice of X-Xml pieces rather than a string precisely "so a caller cannot accidentally pass the first header and get a plausible-looking answer". The NZB pointer needed the same treatment and did not have it.
type StagingMode ¶
type StagingMode string
StagingMode selects the transient article-assembly backend.
const ( StagingPG StagingMode = "pg" // durable Postgres (default) StagingRedis StagingMode = "redis" // prod's Redis pipeline (fast, best-effort) )
type StatusReport ¶
type StatusReport struct {
GeneratedAt time.Time `json:"generated_at"`
Crawl PassReport `json:"crawl"`
Backfill PassReport `json:"backfill"`
Providers []ProviderReport `json:"providers"`
Workers []WorkerReport `json:"workers"`
// Groups counts ACTIVE NEWSGROUPS. Before 2026-07 it was the row count of
// the per-(backbone, group) state join, which double-counted every group a
// second backbone carried — on multi-backbone installs the value dropped
// at that deploy; re-baseline any external monitor thresholds on it.
Groups int `json:"active_groups"`
StagedArticles int `json:"staged_articles"`
TotalNZBs int `json:"total_nzbs"`
BackfillLeft int64 `json:"backfill_remaining"`
// BackfillETASeconds is 0 when there is nothing left or no measured rate.
// A zero here means "unknown", never "done" — check backfill_remaining.
BackfillETASeconds int64 `json:"backfill_eta_seconds"`
// Jobs is the scheduler's view of the plugin's own jobs — status, last
// activity line, and next scheduled run. On a split deployment these come
// from the worker's published telemetry, so the web poll shows the truth.
Jobs []JobReport `json:"jobs"`
// ReadyGroups is redis staging's assembly queue depth (LLEN — O(1)).
// Always 0 in pg mode: the equivalent there is a COUNT scan, which this
// endpoint is forbidden from running per poll.
ReadyGroups int64 `json:"ready_groups"`
// Evicted counts hopeless sets shed by redis staging since worker start.
Evicted int64 `json:"evicted"`
// PendingCount is the size of the last incomplete-sets sample.
PendingCount int `json:"pending_count"`
// WorkerLastSeen is when the worker last published telemetry;
// WorkerStale means that heartbeat has lapsed and every "running"
// claim above is history, not state — the dead-worker case that used
// to render as a crawl whose duration climbed forever.
WorkerLastSeen time.Time `json:"worker_last_seen"`
WorkerStale bool `json:"worker_stale"`
// CrawlStalledPasses: consecutive crawl passes with zero forward
// progress against a large backlog. Non-zero deserves a look; the
// third also lands in the error log.
CrawlStalledPasses int `json:"crawl_stalled_passes"`
RecentErrors []ErrorReport `json:"recent_errors"`
}
StatusReport is the machine-readable crawler status. Exposed as JSON so a run can be watched without scraping the admin HTML — useful for a first live run, for an external monitor, and for the operator's own scripts.
Field names are stable; treat this as an API, not a view model.
type Store ¶
type Store interface {
ReleaseReader
GroupStore
ServerStore
SettingStore
BackfillStore
BlacklistStore
AssemblerStore
MaintenanceStore
JunkStore
HealthStore
LeaseStore
WorkerStore
}
Store is usenet's persistence contract. It's segmented into concern-based interfaces (interface-segregation) so a consumer can depend on only the slice it uses — internalHealth (health.go) takes just HealthStore, and the read tier could one day bind ReleaseReader to a replica. The plugin field holds the union; PGStore is the Postgres impl.
The methods are package-private on purpose: this is an internal contract, so only an in-package impl (PGStore) or test double can satisfy it.
type Tags ¶
type Tags struct {
Resolution string // 2160p / 1080p / 720p / 480p
Source string // BluRay / WEB-DL / WEBRip / HDTV / DVD / Remux
Codec string // x265 / x264 / AV1 / XviD
Audio string // FLAC / AAC / DTS / AC3 / TrueHD / Opus
Language string // English / Japanese / Multi / Dual Audio / …
}
Tags is the quality metadata parsed from a release title.
type Tier ¶
type Tier string
Tier is a group's crawl priority. Closed set, and the crawler branches on it, so it is a type rather than a bare string: a mistyped literal in a comparison takes the wrong branch silently, and the symptom (one group quietly crawled last) is exactly the bug the tier exists to fix.
The schema carries the same constraint (migration 019), so a bad value cannot reach here from the database either.
const ( // TierCritical is crawled before everything else, every pass. For the one // or two groups the content is actually posted to. TierCritical Tier = "critical" // TierNormal is the default. TierNormal Tier = "normal" // TierLow is only crawled with whatever capacity is left after the others. TierLow Tier = "low" )
type WorkerReport ¶
type WorkerStore ¶
type WorkerStore interface {
// contains filtered or unexported methods
}
WorkerStore is crawler presence, used to split groups between hosts (assign.go).
Source Files
¶
- activity.go
- adopt.go
- articleprobe.go
- assemble.go
- assign.go
- backbones.go
- backfill.go
- blacklist.go
- blacklist_store.go
- build_outcomes.go
- config.go
- cp437.go
- crawl.go
- dashboard.go
- duty.go
- episode.go
- events.go
- group_store.go
- grouping_watch.go
- health.go
- health_store.go
- image.go
- junk.go
- junk_order.go
- junk_prefilter.go
- junk_probe.go
- junk_store.go
- lease.go
- maintenance_store.go
- newsgroup_seed.go
- newznab.go
- nfo.go
- nntp.go
- opstats.go
- optimize.go
- passguard.go
- pgsafe.go
- plugin.go
- poster_watch.go
- poster_watch_store.go
- probe.go
- provider_state.go
- provider_store.go
- providers.go
- ranges.go
- redis_staging.go
- release_store.go
- resolutions.go
- rot18.go
- rot18_repair.go
- seed.go
- series_store.go
- service.go
- size_estimate.go
- spot_fetch.go
- spot_header.go
- spot_index.go
- spot_probe.go
- spot_store.go
- spot_verify.go
- spot_xml.go
- staging.go
- staging_census.go
- store.go
- store_iface.go
- stylesheet.go
- subject.go
- subject_mime.go
- tags.go
- telemetry.go
- telemetry_publish.go
- tier.go
- views.go
- views_crawlers.go
- views_diagnostics.go
- views_filters.go
- views_jobs.go
- views_junk_drops.go
- views_nfo.go
- views_settings.go
- views_spots.go
- writegate.go
- xref.go
- yenc.go