Author SHA1 Message Date
Daan MeijerandClaude Opus 5.5 ef09c07d6e files.id migration in one transaction
Test and deploy / test (push) Successful in 1m51s
Test and deploy / deploy (push) Successful in 49s
A failure after the old parent is dropped would leave files half-converted;
in one transaction it rolls back completely. Tested on a fresh Postgres 17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:48:11 +02:00
Daan MeijerandClaude Opus 5.5 9201e1200e files.id to 64 bits
Test and deploy / test (push) Successful in 1m52s
Test and deploy / deploy (push) Skipped
Seeding adds ~14M files a day; 32-bit ids run out at 2.1 billion in about
five months. Postgres can't change a partition key's type, so the
migration detaches the partitions, rewrites each as a plain table (a CHECK
added first spares the attach scan), and attaches them to a new 64-bit
parent with the same indexes, foreign keys and triggers. binaries.file_id
and the id sequence become 64-bit too. Meant to run with the writers
stopped and Postgres on wal_level=minimal (compose: PG_WAL_LEVEL,
PG_MAX_WAL_SENDERS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:46:53 +02:00
Daan MeijerandClaude Opus 5.5 f1773744c0 Partition files by age (id)
Test and deploy / test (push) Successful in 54s
Test and deploy / deploy (push) Successful in 7m42s
New rows went into free space scattered through the 9.6 GB heap on the HDD
(left behind by inspection's updates): one random HDD read per insert, and
seed batches took a median 13 s to store. The existing table becomes the
first partition as it is (a CHECK scan, no rewrite; indexes, its foreign
key and the triggers are taken over by the partitioned parent); new
partitions of 4M ids go in the same tablespace and stay in memory while
they fill.

- Every partition has a unique file_key index; FileStore.insert looks keys
  up in all partitions first (a key can't be unique across partitions).
- FilePartitions.ensure() (gc:scheduler) keeps two partitions ahead.
- Stats count files from the partitions; seed:node waits ~20 minutes for
  the API before giving a range up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:57:51 +02:00
Daan MeijerandClaude Opus 5.5 41859636d3 Seed relay: nodes get their headers through the seed API
Test and deploy / test (push) Successful in 1m28s
Test and deploy / deploy (push) Successful in 2m5s
The seed API keeps a pool of NNTP connections (SEED_NNTP_CONNECTIONS) and
serves GET /api/v1/seed/ranges/:id/chunk?from=&to= for leased ranges:
the gzip stream the news server sent, passed on unopened. seed:node fetches
that way by default (SEED_FETCH=relay), so a node needs only a token and
SEED_CONNECTIONS; SEED_FETCH=direct keeps its own NNTP connections. All
fetching then comes from the indexer's IP (the account allows two).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:39 +02:00
Daan MeijerandClaude Opus 5.5 3d5cfb2527 Seeding nodes lease first, connect for the lease, close when idle
Test and deploy / test (push) Successful in 1m36s
Test and deploy / deploy (push) Successful in 1m58s
A failed NNTP login gives the range back uncounted (no attempt used), and a
node with nothing to lease holds no connection. Replaces log-in-before-lease,
which kept idle connections and logged in again every minute while idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:18:38 +02:00
Daan MeijerandClaude Opus 5.5 70036847cf Seeding nodes log in before leasing; TLS through tunnels
Test and deploy / test (push) Successful in 1m29s
Test and deploy / deploy (push) Successful in 1m59s
- seed:node logs in to NNTP before taking a range and keeps the connection
  across ranges (replaced after 2 minutes idle or a failed range). A node
  that can't reach its news server takes no range: two ranges were set
  aside as failed while an office node's tunnel was being set up
  (ECONNREFUSED 127.0.0.1:1563, five leases each). After an IP-address
  limit refusal it waits 5 minutes.
- NNTP_TLS_SERVERNAME: the hostname the TLS certificate is checked
  against when NNTP_HOST is a tunnel ("Hostname/IP does not match
  certificate's altnames: Host: localhost").
- seed-node/README: the IP-address limit and tunnelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:14:15 +02:00
Daan MeijerandClaude Opus 5.5 04f945055f XoverClient times replies only, not idle time between commands
Test and deploy / test (push) Successful in 1m12s
Test and deploy / deploy (push) Successful in 1m37s
A seeding node that waited over two minutes for the seed API (restarting)
had its idle NNTP connection closed by its own timeout, and gave its range
back. Now the timeout runs only while a reply is awaited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:40:15 +02:00
Daan MeijerandClaude Opus 5.5 00f6187b8f Seed leases respect the backfill's disk limits
Test and deploy / test (push) Successful in 1m8s
Test and deploy / deploy (push) Successful in 1m37s
No new range while the database's NVMe part or the hdd tablespace is over
BACKFILL_MAX_NVME_GB / BACKFILL_MAX_HDD_GB; nodes ask again a minute later.
seed:status says when leasing is paused. The NVMe part was at 47 GB of 50
with a plan for all of a.b.ath queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:30:46 +02:00
Daan MeijerandClaude Opus 5.5 f9e9295d3e seed-node: step-by-step guide for running a node on a remote server
Test and deploy / test (push) Successful in 1m12s
Test and deploy / deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:11:17 +02:00
Daan MeijerandClaude Opus 5.5 2b14b0dca5 seed:node retries 404 from the proxy (seed API restarting)
Test and deploy / test (push) Successful in 1m0s
Test and deploy / deploy (push) Successful in 1m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:17:54 +02:00
Daan MeijerandClaude Opus 5.5 e2acfae98d seed:node posts batches in order beside fetching
Test and deploy / test (push) Successful in 57s
Test and deploy / deploy (push) Successful in 1m44s
At most two posts waiting; a failed post stops the chain, so nothing is
stored past a missing batch. The first remote node did 7k headers/s while
waiting for every post.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:09:16 +02:00
Daan MeijerandClaude Opus 5.5 8c36744088 Seed orchestrator: remote nodes lease ranges and post results over HTTPS
Test and deploy / test (push) Successful in 1m4s
Test and deploy / deploy (push) Successful in 1m37s
- seed_nodes (hashed bearer tokens) and seed_ranges (planned ranges with
  lease, resume point and counts); migration …140.
- SeedOrchestrator: lease (oldest plan first, FOR UPDATE SKIP LOCKED; a
  lease without a batch for 15 min passes to the next node and resumes at
  next_id), batch (stores files and fragments, moves next_id), finish,
  release, plan (from below live indexing / the backfill / earlier plans
  down to N days back; takes over the group's backfill), status.
- Seed API under /api/v1/seed (lease, ranges/:id/batches|done|release,
  status) behind a node-token middleware; JSON bodies up to 64 MB after
  gzip.
- seed:node runs a node: no database or Redis, outgoing HTTPS only, N
  connections, gzip batches, retries for network errors and 5xx, drops a
  range on 409, gives its ranges back on SIGTERM. seed-node/ has a compose
  file that builds the image from this repository.
- seed:token, seed:plan, seed:status for the operator. Header parsing moved
  to lib/ingest and the range loop to lib/seed/runner, shared by seed:range
  and seed:node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:02:01 +02:00
Daan MeijerandClaude Opus 5.5 97bdf8e982 Seeding node: gzip overviews, batched SFV lookups, fragments beside fetching
Test and deploy / test (push) Successful in 1m0s
Test and deploy / deploy (push) Successful in 1m43s
- lib/nntp/xover_client: a minimal one-connection client for seed:range
  with XFEATURE COMPRESS GZIP (a zlib stream after "224 … [COMPRESS=GZIP]",
  then a "." line; 420 for an empty range). About 6x less header traffic.
  Overviews come out with the field names nntp-js uses.
- SfvService.claimAll: one sfv_entries lookup per stored batch.
- Fragments go through the regular ingest path beside fetching (at most
  two batches in flight); a failure fails the run at the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:11:41 +02:00
Daan MeijerandClaude Opus 5.5 68eb9a44fd Edge aggregation: seed:range indexes history one row per file
Test and deploy / test (push) Successful in 1m36s
Test and deploy / deploy (push) Successful in 3m37s
A seeding node fetches a range over one NNTP connection, parses headers
with the live rules, groups them into files in memory (lib/aggregate) and
stores per complete file its release, the file and a completed binary
(SeedService), instead of one parts row per article. What doesn't complete
within the window goes through the regular ingest path as fragments.
`--dry-run` only fetches and aggregates, to measure a node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:41:18 +02:00
Daan MeijerandClaude Opus 5.5 7ca338d4f3 Extension filter via a releases.extensions column
Test and deploy / test (push) Successful in 1m28s
Test and deploy / deploy (push) Successful in 58m9s
The first version matched extensions in search_text, which was wrong and
slow: file names enter search_text as stems without extension (so plainly
posted .mkv files were missed), and matching the TOASTed text took 3-4 s
for common types.

- Migration …130: releases.extensions text[] (two to four characters with
  a letter; .7z.001 and .r00 count as 7z and rar), kept current by triggers
  on files (name, release) and on releases (archive listing), filled with
  one sequential pass over files, with a GIN index.
- Search: ext matches with `extensions && ?`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:54:38 +02:00
Daan MeijerandClaude Opus 5.5 e1392608d8 Search filters for size (MB) and file extensions
Test and deploy / test (push) Successful in 1m27s
Test and deploy / deploy (push) Successful in 2m9s
- API: min_size and max_size in MB, ext as a comma-separated list. An
  extension matches as a whole word in search_text (file and entry names,
  dots as spaces), so "iso" finds .iso files but not "ISOLATION", which a
  plain search word matched as a substring. The trigram index serves it.
- Web UI: a size range and an extension field in the filter row, kept in
  the URL like the other filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:43:55 +02:00
Daan MeijerandClaude Opus 5.5 95f1d427df Inspect fewer files: skip later volumes under random stems
Test and deploy / test (push) Successful in 1m25s
Test and deploy / deploy (push) Successful in 2m58s
Of 5,000 waiting inspection jobs, 3,212 were queued only because their
stem looked random, and 2,044 of those were later RAR volumes
(xyz.part47.rar, .r00) or par2 recovery volumes (.vol03+04.par2). Their
first segment adds nothing: the set is listed from its first volume and
named from its index .par2, as for readable names already. Each one cost a
~700 KB download; inspection pulled ~85 MB/s and fell behind the backfill.

- isCandidate() rejects later volumes (isLaterVolume) before the random-
  name rule.
- worker:file only queues files that are candidates, so the ~30% that
  inspection skips anyway no longer take queue slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:32:40 +02:00
Daan MeijerandClaude Opus 5.5 2178847513 Stop refilling parts of binaries that are already a file
Test and deploy / test (push) Successful in 52s
Test and deploy / deploy (push) Successful in 1m23s
Crossposts, reposts and backfill ranges keep bringing articles of files
that worker:file already created. Ingest inserted their header parts again,
and GC's copied-parts pass (which searched all binaries for parts on every
batch) removed only 150k-450k per sweep: parts grew to 68M rows, 56M of
them belonging to finished files, and the 23 GB table paused the backfill.

- Ingest skips header parts (no per-part CRC) for binaries with a file_id.
- GC drops copied parts with a cursor over binaries, 2000 per statement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:53:11 +02:00
Daan MeijerandClaude Opus 5.5 d3e774a489 Add Gitea Actions: test every push, deploy master to homelab
Test and deploy / test (push) Successful in 4m20s
Test and deploy / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:04:48 +02:00
Daan MeijerandClaude Opus 5.5 be8a3d4580 Spotnet import, header backfill, staging and counter grouping, faster search
Spotnet (CONCEPT D29):
- Index free.pt fully and import spots with their descriptions, categories
  and NZBs; a spot renames its release, since Spotnet names are more
  trustworthy than names derived from subjects.
- Find passwords in spot descriptions too, and tell "needs a password we
  have" from "needs a password we don't have".

Content inspection:
- List the contents of 7z (.7z.001) archives, SFV sets, nicovideo uploads
  and other archive-like files; mark yEncBin .bin containers as encrypted.
- Take real names from yEnc headers for randomly named files, and merge
  uploads that were split across posts.

Storage (D31, D32):
- Store segment lists zstd-compressed behind the FileStore accessor.
- group:backfill indexes older headers at low priority and pauses when
  the NVMe or HDD part of the database gets too large.

Grouping (D35, D36):
- Lone header segments wait in staged_segments until a second segment of
  their file arrives, which drops the per-article junk binaries.
- Counter grouping joins random-subject uploads by poster, [i/M] counter,
  part count and day.

Search and maintenance (D33, D34):
- A trigram-indexed search_text column, smaller indexes, estimated stats.
- Passes that clean up nonsense names; weekly index maintenance; GC budgets
  per kind.
- Web UI: grey out stale results while a search runs and cancel superseded
  requests. Count only up to 1,000 matches for the page, which made filter
  clicks take seconds (Newznab keeps the 10,000 cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:59:23 +02:00
131 changed files with 7537 additions and 388 deletions

No files matched your search

+1
View File
@@ -9,4 +9,5 @@ export const controllers = {
Newznab: () => import('#controllers/newznab_controller'),
Profile: () => import('#controllers/profile_controller'),
Releases: () => import('#controllers/releases_controller'),
Seed: () => import('#controllers/seed_controller'),
}
+81
View File
@@ -0,0 +1,81 @@
# Tests every push; a push to master that passes is deployed to homelab.
#
# Deploying: the job SSHes to homelab with DEPLOY_SSH_KEY. That key may only
# run ~/bin/deploy-usenet-indexer (a forced command in authorized_keys),
# which fetches the commit from this repo, builds the image, runs the
# migrations and recreates the app services (never postgres or redis).
name: Test and deploy
on:
push:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: indexer
POSTGRES_PASSWORD: indexer
POSTGRES_DB: usenet_indexer
options: >-
--health-cmd "pg_isready -U indexer -d usenet_indexer"
--health-interval 5s
--health-retries 20
redis:
image: redis:8
env:
TZ: UTC
PORT: 3333
HOST: 0.0.0.0
NODE_ENV: test
LOG_LEVEL: warn
APP_KEY: ci-only-app-key-not-a-secret-000000
APP_URL: http://localhost:3333
SESSION_DRIVER: memory
DB_CONNECTION: pg
DB_HOST: postgres
DB_PORT: 5432
DB_USER: indexer
DB_PASSWORD: indexer
DB_DATABASE: usenet_indexer
REDIS_HOST: redis
REDIS_PORT: 6379
# Tests never connect to a news server.
NNTP_HOST: localhost
NNTP_PORT: 563
NNTP_USER: ci
NNTP_PASSWORD: ci
NNTP_SECURE: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Migrate
run: node ace migration:run --force
- name: Test
run: node ace test
deploy:
needs: test
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Deploy to homelab
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes \
daan@10.206.0.175 "deploy ${{ github.sha }}"
+3 -1
View File
@@ -101,7 +101,9 @@ export default class NewznabController {
postedAt: r.postedAt ? new Date(r.postedAt) : null,
poster: r.poster,
groups: r.groups ?? [],
password: Boolean(r.encrypted),
// 1 only when a password is needed that we can't give; a known one is in our NZB.
password: r.passwordState === 'missing',
description: r.spotDescription ?? undefined,
season: tv ? `S${tv[1].padStart(2, '0')}` : undefined,
episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined,
}
+22 -1
View File
@@ -3,8 +3,14 @@ import SearchService, { type SortOrder } from '#services/SearchService'
const flag = (value: unknown) => value === '1' || value === 'true' || value === true
/** A size in MB from the query string, as bytes. */
function megabytes(value: unknown): number | undefined {
const mb = Number(value)
return value !== undefined && value !== '' && Number.isFinite(mb) && mb >= 0 ? Math.round(mb * 1024 * 1024) : undefined
}
export default class ReleasesController {
/** GET /api/v1/releases?q=&readable=1&complete=1&hide_encrypted=1&source=&poster=&sort=&page=&per_page= */
/** GET /api/v1/releases?q=&readable=1&complete=1&hide_encrypted=1&min_size=&max_size=(MB)&ext=mkv,flac&source=&poster=&sort=&page=&per_page= */
async index({ request }: HttpContext) {
const qs = request.qs()
return SearchService.search({
@@ -15,9 +21,14 @@ export default class ReleasesController {
includePar2Only: flag(qs.par2_only),
source: typeof qs.source === 'string' && qs.source ? qs.source : undefined,
poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : undefined,
minSize: megabytes(qs.min_size),
maxSize: megabytes(qs.max_size),
extensions: typeof qs.ext === 'string' && qs.ext ? qs.ext.split(/[\s,]+/) : undefined,
sort: qs.sort as SortOrder | undefined,
page: Number(qs.page) || 1,
perPage: Number(qs.per_page) || 50,
// The page shows "1,000+"; counting further made filter clicks take seconds.
countLimit: 1000,
})
}
@@ -37,6 +48,16 @@ export default class ReleasesController {
return response.send(nzb.xml)
}
/** GET /api/v1/releases/:id/image: the image of the release's spot, read from Usenet. */
async image({ params, response }: HttpContext) {
const image = await SearchService.spotImage(Number(params.id))
if (!image) return response.notFound({ error: 'No image' })
response.header('Content-Type', image.type)
// A spot's image never changes.
response.header('Cache-Control', 'public, max-age=604800, immutable')
return response.send(image.data)
}
/** GET /api/v1/stats */
async stats() {
return SearchService.stats()
+88
View File
@@ -0,0 +1,88 @@
import type { HttpContext } from '@adonisjs/core/http'
import SeedOrchestrator, { LeaseLost } from '#services/SeedOrchestrator'
import SeedFetchPool from '#services/SeedFetchPool'
import type { SeedBatch } from '#lib/seed/wire'
/**
* The seed API (CONCEPT.md D38). Nodes make outgoing HTTPS requests only:
* lease a range, post batches of what they found, then finish or release
* the range. Bodies may be gzip-compressed (Content-Encoding: gzip).
*/
export default class SeedController {
/** POST /api/v1/seed/lease → 200 { range } or 204 when there's nothing to do. */
async lease({ seedNode, response }: HttpContext) {
const range = await SeedOrchestrator.lease(seedNode.id)
return range ? { range } : response.noContent()
}
/** POST /api/v1/seed/ranges/:id/batches with a SeedBatch. */
async batch({ seedNode, params, request, response }: HttpContext) {
const batch = request.body() as SeedBatch
if (!Number.isSafeInteger(batch?.next) || !Array.isArray(batch.files) || !Array.isArray(batch.fragments)) {
return response.badRequest({ error: 'Expected { next, headers, files[], fragments[] }' })
}
return this.leased(response, async () => {
const stored = await SeedOrchestrator.batch(seedNode.id, Number(params.id), {
...batch,
headers: Number(batch.headers) || 0,
obfuscated: Number(batch.obfuscated) || 0,
deferred: Number(batch.deferred) || 0,
})
return { stored }
})
}
/**
* GET /api/v1/seed/ranges/:id/chunk?from=&to= → the overviews of those
* articles as a zlib/gzip stream (relay nodes fetch through the indexer).
*/
async chunk({ seedNode, params, request, response }: HttpContext) {
if (SeedFetchPool.size === 0) return response.serviceUnavailable({ error: 'Relay is off (SEED_NNTP_CONNECTIONS=0); fetch directly' })
const from = Number(request.input('from'))
const to = Number(request.input('to'))
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) return response.badRequest({ error: 'Expected ?from=&to=' })
return this.leased(response, async () => {
try {
const raw = await SeedOrchestrator.chunk(seedNode.id, Number(params.id), from, to)
response.header('content-type', 'application/octet-stream')
return response.send(raw)
} catch (error) {
if (error instanceof RangeError) return response.badRequest({ error: error.message })
throw error
}
})
}
/** POST /api/v1/seed/ranges/:id/done */
async done({ seedNode, params, response }: HttpContext) {
return this.leased(response, async () => {
await SeedOrchestrator.finish(seedNode.id, Number(params.id))
return { ok: true }
})
}
/** POST /api/v1/seed/ranges/:id/release { error?, uncounted? } */
async release({ seedNode, params, request, response }: HttpContext) {
const error = request.input('error')
const uncounted = request.input('uncounted') === true
return this.leased(response, async () => {
await SeedOrchestrator.release(seedNode.id, Number(params.id), typeof error === 'string' ? error : undefined, uncounted)
return { ok: true }
})
}
/** GET /api/v1/seed/status */
async status() {
return SeedOrchestrator.status()
}
/** 409 when the range was given to another node meanwhile; the node should drop it. */
private async leased(response: HttpContext['response'], action: () => Promise<unknown>) {
try {
return await action()
} catch (error) {
if (error instanceof LeaseLost) return response.conflict({ error: error.message })
throw error
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import SeedOrchestrator from '#services/SeedOrchestrator'
declare module '@adonisjs/core/http' {
interface HttpContext {
/** The seeding node that made this request (CONCEPT.md D38). */
seedNode: { id: number; name: string }
}
}
/**
* Seed API requests carry a node's bearer token (`node ace seed:token
* <name>`); anything else gets 401. The seed API is the only part of the app
* reachable from the internet (traefik routes only /api/v1/seed there).
*/
export default class SeedNodeMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const header = ctx.request.header('authorization') ?? ''
const token = header.startsWith('Bearer ') ? header.slice(7).trim() : ''
const node = token ? await SeedOrchestrator.nodeForToken(token) : null
if (!node) return ctx.response.unauthorized({ error: 'Unknown or disabled seed node token' })
ctx.seedNode = node
return next()
}
}
+8 -2
View File
@@ -1,6 +1,8 @@
import { DateTime } from 'luxon'
import { column } from '@adonisjs/lucid/orm'
import BaseModel from '#models/base_model'
import { loadedSegments, storedSegments } from '#services/FileStore'
import type { Segment } from '#lib/nzb/index'
export default class File extends BaseModel {
@column({ isPrimary: true })
@@ -21,8 +23,12 @@ export default class File extends BaseModel {
@column()
declare parts: number
@column({ columnName: 'message_ids' })
declare messageIds: Record<string, any>
/** Stored compressed (see FileStore); the model sees the list. */
@column({ prepare: (value: Segment[]) => storedSegments(value), consume: (value: Buffer | null) => loadedSegments(value) })
declare segments: Segment[]
@column({ columnName: 'first_message_id' })
declare firstMessageId: string | null
@column()
declare groups: string[]
+22 -4
View File
@@ -7,7 +7,7 @@ import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import IngestService from '#services/IngestService'
import { storedEntryData, type RarEntry, type RarInfo } from '#lib/rar/index'
import { fileStem, rarReleaseIdentity } from '#lib/release/index'
import { fileStem, isGenericName, rarReleaseIdentity } from '#lib/release/index'
/** Stored per file and release; a listing of thousands of files is not useful. */
const MAX_ENTRIES = 500
@@ -59,7 +59,9 @@ class ArchiveService {
update.encrypted = true
}
const better = releaseNameFromContents(info)
const renamed = release && better && (looksRandom(release.name) || release.source === 'post') ? better : undefined
// A spot's title is kept (CONCEPT.md D29).
const renamed =
release && better && release.source !== 'spot' && (looksRandom(release.name) || release.source === 'post') ? better : undefined
if (renamed) update.name = renamed
if (Object.keys(update).length > 0) await db.from('releases').where('id', releaseId).update(update)
@@ -138,7 +140,7 @@ export async function renameFromStoredContents(): Promise<number> {
.select('id', 'name', 'source', 'contents')
let renamed = 0
for (const release of releases) {
if (!looksRandom(release.name) && release.source !== 'post') continue
if (release.source === 'spot' || (!looksRandom(release.name) && release.source !== 'post')) continue
const better = releaseNameFromContents({ entries: release.contents })
if (!better || better === release.name) continue
await db.from('releases').where('id', release.id).update({ name: better })
@@ -147,11 +149,27 @@ export async function renameFromStoredContents(): Promise<number> {
return renamed
}
/** Obfuscated names: one long token of mixed letters and digits, no separators. */
/**
* Obfuscated names: one long token of mixed letters and digits, no
* separators, and the other generated shapes seen on Usenet (LOG.md
* 2026-10-01 (3)). Kept in step with SQL's is_readable_name().
*/
export function looksRandom(name: string): boolean {
// An unknown short extension (e.g. ".bin") says nothing about the name.
name = name.replace(/\.[A-Za-z0-9]{1,4}$/, '')
// VIDEO_TS, Sample, CD1: real words, but no name.
if (isGenericName(name)) return true
if (/^[0-9a-f]{16,}$/i.test(name)) return true
// 6327d9fa2d6acf3e9a61fb053359d5e7.1790574045 (hex and a Unix time)
if (/^[0-9a-f]{16,}\.\d{9,}$/i.test(name)) return true
// UUIDs
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name)) return true
// 1650777167 (yEncBin), "83111763 n" (rclone uploads)
if (/^\d{6,}( n)?$/.test(name)) return true
// prvjr7hapd0i1ny14pz4, and rclone's long base32 names
if (/^[a-z0-9]{12,}$/.test(name) && (name.match(/\d/g)?.length ?? 0) >= 2 && (name.match(/[a-z]/g)?.length ?? 0) >= 4) return true
// wHJ7s71sAGE: short, but capitals all over the token, not only at word starts
if (/^[A-Za-z0-9]{8,15}$/.test(name) && /\d/.test(name) && /[a-z]/.test(name) && (name.slice(1).match(/[A-Z]/g)?.length ?? 0) >= 3) return true
if (!/^[A-Za-z0-9]{16,}$/.test(name) || !/[a-z]/.test(name) || !/[A-Z]/.test(name)) return false
// Digits, or many capitals without separators (e.g. RmISakytFwXSCEzjVEpaKk).
return /\d/.test(name) || (name.match(/[A-Z]/g) ?? []).length >= 6
+112
View File
@@ -0,0 +1,112 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import indexerConfig from '#config/indexer'
import NntpService from '#services/NntpService'
import FetchRangeService from '#services/FetchRangeService'
import { articleAtDate } from '#lib/nntp/date_search'
/** Articles XOVERed to find a posting date near an article number. */
const PROBE = 200n
/**
* Header backfill (CONCEPT.md D32). `start` gives a group a cursor from
* where live indexing began back to the article posted N days ago; `tick`
* (every scheduler run) queues low-priority, header-only fetch ranges from
* that cursor downwards, newest first, while few are waiting and the
* database stays within its size limits.
*/
class BackfillService {
/** Sets the group's backfill to reach back `days` days. Returns its range. */
async start(groupName: string, days: number): Promise<{ from: bigint; to: bigint } | null> {
const group = await db.from('groups').where('name', groupName).first()
if (!group) throw new Error(`Unknown group ${groupName}`)
const conn = await NntpService.acquire()
let failed = false
try {
const { first, last } = await NntpService.timed(NntpService.selectGroup(conn, groupName), `GROUP ${groupName}`)
const dateOf = async (n: bigint) => {
const { overviews } = await NntpService.timed<any>(conn.xover(n, n + PROBE), `XOVER ${groupName} ${n}`)
const header = overviews.find(([number, h]: [number | null, any]) => number !== null && h?.date)?.[1]
const date = header ? Date.parse(header.date) : Number.NaN
return Number.isNaN(date) ? null : date
}
const until = await articleAtDate(first, last, Date.now() - days * 24 * 60 * 60 * 1000, dateOf)
// Live indexing began a little before the group was added: continue from there.
const began = await articleAtDate(first, last, new Date(group.created_at).getTime(), dateOf)
const next = group.backfill_next_id !== null && BigInt(group.backfill_next_id) < began ? BigInt(group.backfill_next_id) : began
await db.from('groups').where('id', group.id).update({ backfill_until_id: until.toString(), backfill_next_id: next.toString() })
return next > until ? { from: until, to: next } : null
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
}
/** Stops a group's backfill (queued ranges still run). */
async stop(groupName: string) {
await db.from('groups').where('name', groupName).update({ backfill_until_id: null, backfill_next_id: null })
}
/** Queues the next backfill ranges. Returns how many. */
async tick(): Promise<number> {
const { maxQueued, maxDefaultGb, maxHddGb } = indexerConfig.backfill
const groups = await db
.from('groups')
.where('active', true)
.whereNotNull('backfill_until_id')
.whereRaw('backfill_next_id > backfill_until_id')
.orderBy('id')
if (groups.length === 0) return 0
const room = await this.room(maxDefaultGb, maxHddGb)
if (room !== true) {
logger.warn(`Backfill paused: ${room}.`)
return 0
}
const [{ count }] = await db.from('fetch_ranges').where('backfill', true).where('status', 'queued').count('* as count')
let free = maxQueued - Number(count)
let queued = 0
// Round-robin over the groups, one range each per pass.
while (free > 0 && groups.length > 0) {
for (const group of [...groups]) {
if (free <= 0) break
const next = BigInt(group.backfill_next_id)
const until = BigInt(group.backfill_until_id)
const start = next - indexerConfig.batchSize + 1n > until ? next - indexerConfig.batchSize + 1n : until + 1n
await FetchRangeService.queue(group, start, next, { backfill: true })
group.backfill_next_id = (start - 1n).toString()
await db.from('groups').where('id', group.id).update({ backfill_next_id: group.backfill_next_id })
free--
queued++
if (start - 1n <= until) {
groups.splice(groups.indexOf(group), 1)
logger.info(`${group.name}: backfill fully queued.`)
}
}
}
return queued
}
/** True when both parts of the database are under their limits, else why not. */
/**
* Whether the database has room for more history: the default tablespace
* (NVMe) and tablespace `hdd` against BACKFILL_MAX_NVME_GB/_HDD_GB. Seed
* leases (CONCEPT.md D38) check the same limits.
*/
async room(
maxDefaultGb = indexerConfig.backfill.maxDefaultGb,
maxHddGb = indexerConfig.backfill.maxHddGb
): Promise<true | string> {
const { rows } = await db.rawQuery(
`SELECT spcname, pg_tablespace_size(oid) AS bytes FROM pg_tablespace WHERE spcname IN ('pg_default', 'hdd')`
)
const gb = (name: string) => Number(rows.find((r: any) => r.spcname === name)?.bytes ?? 0) / 1024 ** 3
if (gb('pg_default') > maxDefaultGb) return `database on the NVMe is ${gb('pg_default').toFixed(1)} GB (limit ${maxDefaultGb})`
if (gb('hdd') > maxHddGb) return `files table on the HDD is ${gb('hdd').toFixed(1)} GB (limit ${maxHddGb})`
return true
}
}
export default new BackfillService()
+108 -12
View File
@@ -6,10 +6,26 @@ import ArchiveService, { extractEntry, looksRandom, type ArchiveResult } from '#
import NameService from '#services/NameService'
import { decodeSegment, YencCrcError, YencError, YencFile } from '#lib/yenc/index'
import NzbImportService, { type NzbImportResult } from '#services/NzbImportService'
import { sniffContentType, type ContentType } from '#lib/content/index'
import {
isArchiveStart,
isLaterVolume,
NICOVIDEO_FILE,
NICOVIDEO_RELEASE,
nicovideoInfo,
nicovideoReleaseName,
sniffContentType,
zstdPrefix,
type ContentType,
type NicovideoInfo,
} from '#lib/content/index'
import { parseRarHeaders, type RarEntry, type RarInfo } from '#lib/rar/index'
import { fileStem } from '#lib/release/index'
import { fileStem, releaseIdentity } from '#lib/release/index'
import IngestService from '#services/IngestService'
import { looksLikeFilename } from '#lib/subject/index'
import { sevenZipVolume } from '#lib/sevenzip/index'
import SevenZipService from '#services/SevenZipService'
import SfvService from '#services/SfvService'
import { segmentsOf } from '#services/FileStore'
const FIRST_CHUNK = 16 * 1024
@@ -35,6 +51,12 @@ export interface ContentResult {
nzb?: NzbImportResult
/** NZBs read from inside an archive. */
archiveNzbs?: NzbImportResult[]
/** Files an SFV's listing moved into its release. */
sfvMoved?: number
/** A niconico capture's release name from its API response (D34). */
nicovideo?: string
/** The real name from the yEnc header, for a file posted under a random subject (D36). */
yencName?: string
}
/**
@@ -47,15 +69,68 @@ export interface ContentResult {
class ContentService {
/** Random or missing names, and named first RAR volumes (for contents and password flag). */
isCandidate(name: string) {
// Even under a random stem: the first volume and the index .par2 are inspected instead.
if (isLaterVolume(name)) return false
if (!looksLikeFilename(name) || looksRandom(fileStem(name))) return true
if (isArchiveStart(name)) return true
if (/\.nzb$/i.test(name)) return true
// SFVs name the files of their release; niconico captures carry their title (D34).
if (/\.sfv$/i.test(name) || NICOVIDEO_FILE.test(name)) return true
// Every 7z volume: the set is listed when its last volume is in (see inspect).
if (sevenZipVolume(name) !== null) return true
return /\.part0*1\.rar$/i.test(name) || (/\.rar$/i.test(name) && !/\.part\d+\.rar$/i.test(name))
}
/**
* Uploads that randomise the subject per article often keep the real file
* name in the yEnc header inside the body (`Deep.Strike.S01E16.1080p….part01.rar`;
* LOG.md 2026-10-02 (2)). A file with a random name takes that name, and
* leaves its weak one-file release for the release its name gives, where
* the other files of the upload gather. Returns the name, or undefined.
*/
private async nameFromYenc(file: any, binary: any, yencName: string): Promise<string | undefined> {
const current = String(file.real_name ?? file.filename)
if (file.real_name || !looksRandom(fileStem(current))) return undefined
if (!looksLikeFilename(yencName) || looksRandom(fileStem(yencName)) || yencName === current) return undefined
const release = file.release_id ? await db.from('releases').where('id', file.release_id).first() : null
const weak = !release || (['stem', 'weak-stem'].includes(release.source) && Number(release.file_count) <= 1)
let releaseId = file.release_id
if (weak) {
const identity = releaseIdentity({ poster: file.poster, subject: '', name: yencName, weak: true })
if (identity) {
const ids = await IngestService.upsertReleases(db.connection().getWriteClient(), [
{ release: identity, poster: file.poster, groups: file.groups ?? [], postedAt: file.date ? new Date(Number(file.date)) : undefined, weakIdentity: true },
])
releaseId = ids.get(identity.key) ?? releaseId
}
}
await db.from('files').where('id', file.id).update({ real_name: yencName, release_id: releaseId })
if (binary) await db.from('binaries').where('id', binary.id).update({ real_name: yencName, release_id: releaseId })
return yencName
}
/**
* Names a niconico capture's release after the video, while it still has
* the archiver's `<time>_nicovideo_jp_watch_sm<id>` name; its tags join
* the search text.
*/
private async nameNicovideo(file: any, info: NicovideoInfo, id: string): Promise<string | undefined> {
if (!file.release_id) return undefined
const release = await db.from('releases').where('id', file.release_id).first()
if (!release || !NICOVIDEO_RELEASE.test(release.name)) return undefined
const name = nicovideoReleaseName(info, id)
await db
.from('releases')
.where('id', release.id)
.update({ name, search_files: info.tags.join(' ').slice(0, 4000) || null })
return name
}
/** Downloads a whole (small) file whose first segment is already decoded. */
private async readWhole(first: Buffer, messageIds: Record<string, { id: string; size: number }>): Promise<Buffer | null> {
const parts = Object.entries(messageIds).sort(([a], [b]) => Number(a) - Number(b))
const bytes = parts.reduce((sum, [, p]) => sum + (p.size ?? 0), 0)
private async readWhole(first: Buffer, file: any): Promise<Buffer | null> {
const parts = segmentsOf(file)
const bytes = parts.reduce((sum, p) => sum + (p.size ?? 0), 0)
if (parts.length > WHOLE_MAX_PARTS || bytes > WHOLE_MAX_BYTES) return null
if (parts.length === 1) return first
@@ -63,7 +138,7 @@ class ContentService {
const conn = await NntpService.acquire()
let failed = false
try {
for (const [, part] of parts) {
for (const part of parts) {
const segment = decodeSegment(await NntpService.fetchBody(conn, part.id), { lenientSize: true })
assembled ??= YencFile.fromSegment(segment)
assembled.add(segment)
@@ -83,7 +158,7 @@ class ContentService {
* uploads by other posters.
*/
private async importArchiveNzbs(file: any, first: Buffer): Promise<NzbImportResult[]> {
const archive = await this.readWhole(first, file.message_ids)
const archive = await this.readWhole(first, file)
const info = archive && parseRarHeaders(archive)
if (!archive || !info) return []
const entries = info.entries.filter(isNzbEntry)
@@ -104,7 +179,13 @@ class ContentService {
const file = await db.from('files').where('id', fileId).first()
const binary = await db.from('binaries').where('file_id', fileId).first()
if (!file || !this.isCandidate(file.filename)) return null
const first = file.message_ids?.['1']
// A later 7z volume: list the set from its first volume once all volumes are in.
if ((sevenZipVolume(String(file.real_name ?? file.filename)) ?? 0) > 1) {
const volumes = await SevenZipService.volumes(file)
if (!volumes || volumes[0].archive) return null
return this.inspect(Number(volumes[0].id))
}
const first = segmentsOf(file)[0]
if (!first?.id) return null
const conn = await NntpService.acquire()
@@ -142,8 +223,9 @@ class ContentService {
}
const result: ContentResult = { named: 0, contentType }
if (contentType === 'nzbe' || contentType === 'rclone') {
// An encrypted NZB, or rclone crypt (names and data encrypted): nothing
result.yencName = await this.nameFromYenc(file, binary, segment.meta.begin.name)
if (contentType === 'nzbe' || contentType === 'rclone' || contentType === 'yencbin') {
// An encrypted NZB, rclone crypt (names and data encrypted) or yEncBin's container: nothing
// to read, and useless without the key.
const releaseId = file.release_id ?? binary?.release_id
if (releaseId) {
@@ -151,14 +233,28 @@ class ContentService {
}
return result
}
const nicovideo = NICOVIDEO_FILE.exec(String(file.real_name ?? file.filename))
if (nicovideo) {
const info = nicovideoInfo(await zstdPrefix(segment.data))
if (info) result.nicovideo = await this.nameNicovideo(file, info, nicovideo[1])
return result
}
if (/\.sfv$/i.test(String(file.real_name ?? file.filename))) {
const text = await this.readWhole(segment.data, file)
if (text) result.sfvMoved = (await SfvService.attach(file, text.toString('latin1'))) ?? undefined
return result
}
if (contentType === 'nzb') {
const xml = await this.readWhole(segment.data, file.message_ids)
const xml = await this.readWhole(segment.data, file)
if (xml) result.nzb = (await NzbImportService.import(file, xml.toString('utf8'))) ?? undefined
return result
}
const set = await Par2Service.read(segment.data, file.message_ids)
const set = await Par2Service.read(segment.data, segmentsOf(file))
if (set && binary) {
result.par2 = await Par2Service.attach(binary, set)
} else if (contentType === '7z') {
const info = await SevenZipService.list(file, segment.data)
if (info) result.archive = await ArchiveService.record(file, binary, info)
} else {
const info = parseRarHeaders(segment.data)
if (info) {
+13 -10
View File
@@ -1,6 +1,7 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import QueueService from '#services/QueueService'
import indexerConfig from '#config/indexer'
/** A failed range is retried after this long... */
const REQUEUE_AFTER_MS = 5 * 60 * 1000
@@ -20,21 +21,22 @@ interface Group {
* retried instead of silently skipped. See CONCEPT.md (D18).
*/
class FetchRangeService {
async queue(group: Group, startId: bigint, endId: bigint) {
/** Queues a range. A backfill range is header-only and waits behind live ones (CONCEPT.md D32). */
async queue(group: Group, startId: bigint, endId: bigint, { backfill = false } = {}) {
const now = new Date()
const [row] = await db
.table('fetch_ranges')
.insert({ group_id: group.id, start_id: startId.toString(), end_id: endId.toString(), created_at: now, updated_at: now })
.insert({ group_id: group.id, start_id: startId.toString(), end_id: endId.toString(), backfill, created_at: now, updated_at: now })
.returning(['id', 'requeues'])
await this.addJob(group.name, Number(row.id), 0, startId, endId)
await this.addJob(group.name, Number(row.id), 0, startId, endId, backfill)
}
private async addJob(groupName: string, rangeId: number, requeues: number, startId: bigint, endId: bigint) {
private async addJob(groupName: string, rangeId: number, requeues: number, startId: bigint, endId: bigint, backfill: boolean) {
await QueueService.nntpFetchQueue.add(
'fetch-headers',
{ groupName, startId: startId.toString(), endId: endId.toString(), rangeId },
{ groupName, startId: startId.toString(), endId: endId.toString(), rangeId, ...(backfill ? { backfill: true } : {}) },
// The job id changes per attempt: BullMQ ignores an add() for an id it still knows.
{ jobId: this.jobId(rangeId, requeues) }
{ jobId: this.jobId(rangeId, requeues), ...(backfill ? { priority: indexerConfig.backfill.priority } : {}) }
)
}
@@ -75,7 +77,7 @@ class FetchRangeService {
.from('fetch_ranges')
.where('id', range.id)
.update({ status: 'queued', requeues: range.requeues + 1, updated_at: new Date() })
await this.addJob(group.name, Number(range.id), range.requeues + 1, BigInt(range.start_id), BigInt(range.end_id))
await this.addJob(group.name, Number(range.id), range.requeues + 1, BigInt(range.start_id), BigInt(range.end_id), Boolean(range.backfill))
requeued++
logger.warn(
`Re-queued ${group.name} ${range.start_id}-${range.end_id} (attempt ${range.requeues + 2}): ${range.error ?? 'job lost'}`
@@ -85,13 +87,14 @@ class FetchRangeService {
}
/**
* Moves `groups.last_indexed_id` up to just below the first range that is
* not done yet, and forgets done ranges. Returns the new watermark.
* Moves `groups.last_indexed_id` up to just below the first live range
* that is not done yet (backfill ranges lie below it and don't count), and
* forgets done ranges. Returns the new watermark.
*/
async advanceWatermark(group: Group): Promise<bigint> {
const { rows } = await db.rawQuery(
`UPDATE groups g SET last_indexed_id = greatest(g.last_indexed_id, coalesce(
(SELECT min(start_id) - 1 FROM fetch_ranges WHERE group_id = g.id AND status <> 'done'),
(SELECT min(start_id) - 1 FROM fetch_ranges WHERE group_id = g.id AND status <> 'done' AND NOT backfill),
g.last_queued_id))
WHERE g.id = ? RETURNING last_indexed_id`,
[group.id]
+39
View File
@@ -0,0 +1,39 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
/** Ids per partition (as the migration that partitioned files: …150). */
export const FILE_PARTITION_SIZE = 4_000_000
/** Ids kept covered beyond the newest file: two partitions. */
const AHEAD = 2 * FILE_PARTITION_SIZE
/**
* `files` is partitioned by id (CONCEPT.md D39). An insert with an id no
* partition covers fails, so gc:scheduler calls ensure() every sweep to keep
* two partitions ready ahead of the newest id. Each new partition gets its
* own unique index on file_key (FileStore.insert checks the older ones),
* in the parent's tablespace (the HDD on homelab).
*/
class FilePartitions {
async ensure(): Promise<string[]> {
const created: string[] = []
while (true) {
const { rows } = await db.rawQuery(
`SELECT (SELECT coalesce(max(id), 0) FROM files) AS max_id,
-- ''{0,1}, not ''?: knex reads ? as a binding.
max((regexp_match(pg_get_expr(c.relpartbound, c.oid), 'TO \\(''{0,1}(\\d+)''{0,1}\\)'))[1]::bigint) AS upper
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'files'::regclass`
)
const maxId = Number(rows[0].max_id)
const upper = Number(rows[0].upper)
if (upper - maxId >= AHEAD) return created
const name = `files_p${upper}`
await db.rawQuery(`CREATE TABLE ${name} PARTITION OF files FOR VALUES FROM (${upper}) TO (${upper + FILE_PARTITION_SIZE})`)
await db.rawQuery(`ALTER TABLE ${name} ADD CONSTRAINT ${name}_file_key_unique UNIQUE (file_key)`)
logger.info(`files: partition ${name} created (ids ${upper}–${upper + FILE_PARTITION_SIZE - 1}).`)
created.push(name)
}
}
}
export default new FilePartitions()
+74
View File
@@ -0,0 +1,74 @@
import db from '@adonisjs/lucid/services/db'
import type { QueryClientContract } from '@adonisjs/lucid/types/database'
import { packSegments, unpackSegments, type Segment } from '#lib/nzb/segments'
/**
* The only code that knows how `files` keeps its segment lists (CONCEPT.md
* D31): compressed in `files.segments`, with the first segment's message-id
* in the indexed `first_message_id` for lookups. Read them with
* `segmentsOf(file)`; write new files with `FileStore.insert`.
*/
/** A new `files` row, with its segments as a list. */
export type NewFile = Record<string, unknown> & { segments: Segment[] }
const unpacked = new WeakMap<object, Segment[]>()
/** The segments of a `files` row (a row from `db.from('files')`), in order. */
export function segmentsOf(file: { segments?: Buffer | Segment[] | null }): Segment[] {
const { segments } = file
if (!segments) return []
if (Array.isArray(segments)) return segments
let list = unpacked.get(file)
if (!list) {
list = unpackSegments(segments)
unpacked.set(file, list)
}
return list
}
/** Stored form of a segment list, for the File model's column. */
export function storedSegments(segments: Segment[]) {
return packSegments(segments)
}
export function loadedSegments(stored: Buffer | null): Segment[] {
return stored ? unpackSegments(stored) : []
}
class FileStore {
/**
* Inserts files, skipping those whose `file_key` exists. Returns the rows
* that were inserted (id and filename). `files` is partitioned by id
* (CONCEPT.md D39) and each partition has its own unique file_key index:
* keys already in any partition are looked up first; two writers adding
* the same new key at once meet in the newest partition's index.
*/
async insert(files: NewFile[], client: Pick<QueryClientContract, 'knexQuery'> = db): Promise<{ id: number; filename: string }[]> {
if (files.length === 0) return []
const keys = [...new Set(files.map((file) => file.file_key).filter((key): key is string => typeof key === 'string'))]
const known = new Set<string>()
for (let i = 0; i < keys.length; i += 5000) {
const found = await client.knexQuery().from('files').whereIn('file_key', keys.slice(i, i + 5000)).select('file_key')
for (const row of found) known.add(row.file_key)
}
const seen = new Set<string>()
const rows = files
.filter((file) => {
const key = file.file_key as string | null | undefined
if (!key) return true
if (known.has(key) || seen.has(key)) return false
seen.add(key)
return true
})
.map(({ segments, ...file }) => {
const sorted = [...segments].sort((a, b) => a.number - b.number)
return { ...file, segments: packSegments(sorted), first_message_id: sorted[0]?.id ?? null }
})
if (rows.length === 0) return []
const inserted = await client.knexQuery().table('files').insert(rows).onConflict().ignore().returning(['id', 'filename'])
return inserted.map((row: any) => ({ id: Number(row.id), filename: row.filename }))
}
}
export default new FileStore()
+85
View File
@@ -0,0 +1,85 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
/** Only indexes at least this big are worth checking. */
const MIN_BYTES = 50 * 1024 * 1024
/** Leaf pages less full than this (percent) mean the index is mostly empty space. */
const MAX_LEAF_DENSITY = 50
export interface Rebuilt {
index: string
density: number
before: number
after: number
}
/**
* GC deletes millions of rows (single-article binaries, copied parts, empty
* releases). Vacuum makes their index entries reusable but doesn't compact
* the index, so B-trees on the churning tables end up 10–20% full (LOG.md
* 2026-10-01). Rebuilding them online brings them back to size (CONCEPT.md
* D33). Needs the pgstattuple extension.
*/
class IndexMaintenance {
async rebuildBloated({ dryRun = false } = {}): Promise<Rebuilt[]> {
const { rows: extension } = await db.rawQuery(`SELECT 1 FROM pg_extension WHERE extname = 'pgstattuple'`)
if (extension.length === 0) {
logger.warn('Index maintenance skipped: the pgstattuple extension is not installed.')
return []
}
if (!dryRun) await this.dropLeftovers()
const { rows: candidates } = await db.rawQuery(
`SELECT c.relname AS index, pg_relation_size(c.oid) AS bytes
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_am am ON am.oid = c.relam
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE am.amname = 'btree' AND n.nspname = 'public' AND i.indisvalid AND pg_relation_size(c.oid) >= ?
ORDER BY 2 DESC`,
[MIN_BYTES]
)
const rebuilt: Rebuilt[] = []
for (const { index, bytes } of candidates) {
const { rows } = await db.rawQuery(`SELECT avg_leaf_density FROM pgstatindex(?)`, [index])
const density = Number(rows[0].avg_leaf_density)
if (density >= MAX_LEAF_DENSITY) continue
if (!dryRun && !(await this.reindex(index))) continue
const { rows: after } = await db.rawQuery(`SELECT pg_relation_size(?::regclass) AS bytes`, [index])
const result = { index, density, before: Number(bytes), after: Number(after[0].bytes) }
rebuilt.push(result)
logger.info(`Index ${index}: ${density.toFixed(0)}% full, ${mb(result.before)} → ${mb(result.after)}${dryRun ? ' (dry run)' : ''}.`)
}
return rebuilt
}
/**
* REINDEX CONCURRENTLY can lose a deadlock against busy writers (seen on
* binaries during a backfill). Its half-built copy is dropped and it is
* tried once more; a second failure skips the index until the next run.
*/
/** Invalid copies an interrupted REINDEX CONCURRENTLY left behind (`<index>_ccnew`, `_ccnew1`, …). */
private async dropLeftovers(index = '') {
const { rows } = await db.rawQuery(
`SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid AND c.relname LIKE ? || '%\\_ccnew%'`,
[index]
)
for (const { relname } of rows) await db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS "${relname}"`)
}
private async reindex(index: string): Promise<boolean> {
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await db.rawQuery(`REINDEX INDEX CONCURRENTLY "${index}"`)
return true
} catch (error: any) {
logger.warn(`Rebuilding index ${index} failed (attempt ${attempt}): ${error.message}`)
await this.dropLeftovers(index)
}
}
return false
}
}
const mb = (bytes: number) => `${Math.round(bytes / 1024 / 1024)} MB`
export default new IndexMaintenance()
+19 -107
View File
@@ -4,43 +4,10 @@ import type { QueryClientContract } from '@adonisjs/lucid/types/database'
import type { Knex } from 'knex'
import QueueService from '#services/QueueService'
import NameService from '#services/NameService'
import { parseXrefGroups } from '#lib/nntp/xref'
import { fileKey, parseSubject, weakFileKey } from '#lib/subject/index'
import { releaseIdentity, type ReleaseIdentity } from '#lib/release/index'
import { inferTotalParts, type YencMeta } from '#lib/yenc/index'
import type { IngestSegment } from '#lib/ingest/segment'
/** One segment, with the identity of the file it belongs to already resolved. */
export interface IngestSegment {
fileKey: string
poster: string
/** Subject without segment counter. */
subject: string
name: string
totalParts: number
number: number
/** Without angle brackets. */
messageId: string
bytes: number
articleNumber?: bigint | number
postedAt?: Date
groups: string[]
/**
* The identity comes from the yEnc name only (obfuscated posts). Such names
* can be shared by several equal-sized files of one post, so a second
* article for the same part number marks the binary as conflicted.
*/
weakIdentity?: boolean
/** Release (download) this file belongs to, when it can be determined. */
release?: ReleaseIdentity | null
/** Exact file size from =ybegin (body-derived segments only). */
size?: number
/** yEnc CRCs (body-derived segments only), to verify split volumes. */
pcrc32?: string
partSize?: number
fileCrc32?: string
/** Part 1 only: MD5 of the first 16 KiB of the file, as par2 records it. */
md5_16k?: string
}
// Header parsing is pure and lives in lib, so seeding nodes need no database (CONCEPT.md D37).
export { segmentFromHeader, segmentFromYenc, type IngestSegment, type OverviewHeader } from '#lib/ingest/segment'
export interface CompletedBinary {
id: number
@@ -134,74 +101,6 @@ function validDate(date?: Date) {
return date && !Number.isNaN(date.getTime()) ? date : null
}
/** An XOVER overview entry as returned by nntp-js. */
export type OverviewHeader = Record<string, string>
function common(header: OverviewHeader, groupName: string, articleNumber?: bigint | number) {
const xrefGroups = parseXrefGroups(header.xref)
return {
poster: header.from ?? '',
messageId: (header['message-id'] ?? '').replace(/^<|>$/g, ''),
bytes: Number.parseInt(header[':bytes'] ?? '0', 10) || 0,
articleNumber,
postedAt: header.date ? new Date(header.date) : undefined,
groups: xrefGroups.length > 0 ? xrefGroups : [groupName],
}
}
/** Builds a segment from the subject line; null when the subject is obfuscated. */
export function segmentFromHeader(
header: OverviewHeader,
groupName: string,
articleNumber?: bigint | number
): IngestSegment | null {
const parsed = header.subject ? parseSubject(header.subject) : null
if (!parsed || !header['message-id']) return null
const base = common(header, groupName, articleNumber)
return {
...base,
fileKey: fileKey(base.poster, parsed.base, parsed.totalSegments),
subject: parsed.base,
name: parsed.name,
totalParts: parsed.totalSegments,
number: parsed.segment,
release: releaseIdentity({ poster: base.poster, subject: parsed.base, name: parsed.name, weak: false }),
}
}
/**
* Builds a segment for an obfuscated post from the yEnc header in its body.
* Subject and poster can be random per article, so the identity comes from
* the yEnc header only (see weakFileKey).
*/
export function segmentFromYenc(
header: OverviewHeader,
groupName: string,
meta: Pick<YencMeta, 'begin' | 'part'> & Partial<Pick<YencMeta, 'end'>>,
articleNumber?: bigint | number
): IngestSegment | null {
const base = common(header, groupName, articleNumber)
const totalParts = inferTotalParts(meta)
const number = meta.begin.part ?? 1
if (!base.messageId || number < 1 || number > totalParts) return null
const subject = `"${meta.begin.name}" yEnc`
return {
...base,
fileKey: weakFileKey(meta.begin.name, meta.begin.size, totalParts),
subject,
name: meta.begin.name,
totalParts,
number,
weakIdentity: true,
release: releaseIdentity({ poster: base.poster, subject, name: meta.begin.name, weak: true }),
size: meta.begin.size,
// Single-part posts only carry crc32, which is then also the part's CRC.
pcrc32: meta.end?.pcrc32 ?? (meta.part ? undefined : meta.end?.crc32),
partSize: meta.part ? meta.part.end - meta.part.begin + 1 : meta.begin.size,
fileCrc32: meta.end?.crc32,
}
}
/**
* Stores segments in `binaries`/`parts` and queues every binary that became
* complete exactly once. Idempotent: ingesting the same segments again is a
@@ -260,6 +159,17 @@ class IngestService {
for (const [key, id] of ids) idsByKey.set(key, id)
}
// A binary that is a file already has its segment list in `files`; its
// header parts were dropped then. Crossposts in other groups, reposts
// and backfill ranges keep bringing the same articles, and inserting
// them again grew `parts` to 56M rows nobody reads (LOG.md 2026-10-04).
const done = new Set<number>()
const ids = [...idsByKey.values()]
for (const chunk of chunks(ids, BINARY_CHUNK)) {
const { rows } = await knex.raw('SELECT id FROM binaries WHERE id = ANY(?) AND file_id IS NOT NULL', [chunk])
for (const row of rows) done.add(Number(row.id))
}
const partRows = segments
.map((segment) => ({
binary_id: idsByKey.get(segment.fileKey)!,
@@ -272,10 +182,12 @@ class IngestService {
file_crc32: segment.fileCrc32 ?? null,
md5_16k: segment.md5_16k ?? null,
}))
const weakRows = partRows.filter((_, i) => segments[i].weakIdentity)
const weakRows = partRows.filter((row, i) => segments[i].weakIdentity && !(done.has(row.binary_id) && row.pcrc32 === null))
// Body-derived parts (with a per-part CRC) are kept, as worker:file does.
const newParts = partRows.filter((row) => !(done.has(row.binary_id) && row.pcrc32 === null))
partRows.sort((a, b) => a.binary_id - b.binary_id || a.number - b.number)
for (const chunk of chunks(partRows, PART_CHUNK)) {
newParts.sort((a, b) => a.binary_id - b.binary_id || a.number - b.number)
for (const chunk of chunks(newParts, PART_CHUNK)) {
await knex('parts').insert(chunk).onConflict(['binary_id', 'number']).ignore()
}
+1 -1
View File
@@ -112,7 +112,7 @@ export async function regroupAndRenameFromPar2(): Promise<{ regrouped: number; r
const { par2ReleaseName } = await import('#lib/release/index')
const listings = await db.rawQuery(
`SELECT r.id, r.name, array_agg(rf.name) AS names FROM releases r
JOIN release_files rf ON rf.release_id = r.id GROUP BY r.id, r.name`
JOIN release_files rf ON rf.release_id = r.id WHERE r.source <> 'spot' GROUP BY r.id, r.name`
)
let renamed = 0
for (const release of listings.rows) {
+15 -1
View File
@@ -1,5 +1,5 @@
import nntpConfig from '#config/nntp'
import { NNTP } from "nntp-js";
import { NNTP } from "#lib/nntp/nntp_js";
import { parseGroupResponse, type GroupInfo } from '#lib/nntp/group'
class NntpService {
@@ -94,6 +94,20 @@ class NntpService {
return parseGroupResponse(resp)
}
/**
* Rejects when `promise` hasn't settled within `ms`. nntp-js has no
* timeouts: a connection the server stops answering (still ESTABLISHED,
* nothing in flight) leaves its command waiting forever. The caller must
* then release the connection as failed, so it's destroyed.
*/
public timed<T>(promise: Promise<T>, what: string, ms = nntpConfig.commandTimeoutMs): Promise<T> {
let timer: NodeJS.Timeout
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`NNTP ${what}: no answer within ${ms / 1000} s`)), ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
/** Fetches a raw (still dot-stuffed) article body as a Buffer. */
public async fetchBody(conn: any, messageId: string): Promise<Buffer> {
const id = messageId.startsWith('<') ? messageId : `<${messageId}>`
+83 -20
View File
@@ -7,6 +7,9 @@ import { parseNzb, type ParsedNzb } from '#lib/nzb/index'
import { parseSubject } from '#lib/subject/index'
import { fileStem } from '#lib/release/index'
import { looksRandom } from '#services/ArchiveService'
import QueueService from '#services/QueueService'
import FileStore from '#services/FileStore'
import { isArchiveStart } from '#lib/content/index'
export interface NzbImportResult {
releaseId: number
@@ -20,8 +23,29 @@ export interface NzbImportResult {
/** Filename of an NZB entry, from its subject (tolerating odd quoting). */
export function nzbFileName(subject: string): string {
const parsed = parseSubject(subject)
const name = parsed?.name ?? /"([^"]+)"/.exec(subject)?.[1] ?? subject
return name.trim().replace(/^"+|"+$/g, '').trim() || subject.trim()
const quoted = parsed?.name ?? /"([^"]+)"/.exec(subject)?.[1]
if (quoted) return quoted.trim().replace(/^"+|"+$/g, '').trim() || subject.trim()
return unquotedFileName(subject)
}
/**
* A filename from a subject without quotes, as some spot NZBs have:
* `Adventure.part0055.rar - [056/175] (1/1)`, `x.vol007+008.par2 (1/0) (1/0)`,
* `06 - Song.flac Artist - Album (2020) (01/35)`. Counters, "yEnc" and a
* trailing size are dropped; then the name ends at the first extension
* followed by a space.
*/
export function unquotedFileName(subject: string): string {
const cleaned = subject
.replace(/\(\d+\/\d+\)|\[\d+\/\d+\]|\byEnc\b/gi, ' ')
.replace(/\s\d+\s*$/, ' ')
.replace(/(\s+-)+\s*$/, '')
.replace(/^\s*-\s+/, '')
.replace(/\s+/g, ' ')
.trim()
const end = /\.(?:[A-Za-z][A-Za-z0-9]{1,4}|\d{3})(?=\s)/.exec(cleaned)
const name = end ? cleaned.slice(0, end.index + end[0].length) : cleaned
return name.replace(/\s+-$/, '').trim() || subject.trim()
}
export interface NzbImportOptions {
@@ -36,6 +60,13 @@ export interface NzbImportOptions {
name?: string
/** Release source for new releases (default `nzb`). */
source?: ReleaseIdentity['source']
/**
* The NZB is authoritative for which files form the release and what it is
* called (Spotnet spots): its files get the NZB's own release, named
* `name`, also when name rules had put them in another release. Files
* another trusted NZB claimed first stay where they are.
*/
trusted?: boolean
}
/**
@@ -55,21 +86,39 @@ class NzbImportService {
firstId: f.segments[0].id,
}))
return db.transaction(async (trx) => {
const added: { id: number; filename: string }[] = []
const result = await db.transaction(async (trx) => {
const known = await trx
.from('files')
.whereRaw(`(message_ids->'1'->>'id') = ANY(?)`, [entries.map((e) => e.firstId)])
.select('id', 'release_id', `message_ids`)
const knownIds = new Set(known.map((f) => f.message_ids?.['1']?.id))
.whereIn('first_message_id', entries.map((e) => e.firstId))
.select('id', 'release_id', 'first_message_id')
const knownIds = new Set(known.map((f) => f.first_message_id))
// Join the release the listed files are already in, else the posted NZB's own, else a new one.
const counts = new Map<number, number>()
for (const f of known) if (f.release_id) counts.set(Number(f.release_id), (counts.get(Number(f.release_id)) ?? 0) + 1)
let releaseId: number | undefined = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0]
const title = options.name?.trim() || this.title(nzb, nzbFile, entries.map((e) => e.name), options.entryName)
const origin = `nzb\n${nzbFile.first_message_id ?? nzbFile.id}`
const key = createHash('sha1').update(options.entryName ? `${origin}\n${options.entryName}` : origin).digest('hex')
// Releases of other trusted NZBs: their files are not taken over.
let claimed = new Set<number>()
if (options.trusted) {
// Only while that NZB still stands: a disposed spot's files go to its correction.
const holders = await trx
.from('releases as r')
.whereIn('r.id', [...counts.keys()])
.where('r.source', 'spot')
.whereNot('r.release_key', key)
.whereNotExists((q) => q.from('spots as s').whereRaw('s.release_id = r.id').whereIn('s.status', ['removed', 'dummy']))
.select('r.id')
claimed = new Set(holders.map((r) => Number(r.id)))
// Join another spot's release when it holds most of these files (a repost); else use our own.
if (releaseId && !claimed.has(releaseId)) releaseId = undefined
if (releaseId) claimed.delete(releaseId)
}
if (!releaseId) {
const origin = `nzb\n${nzbFile.message_ids?.['1']?.id ?? nzbFile.id}`
const key = createHash('sha1').update(options.entryName ? `${origin}\n${options.entryName}` : origin).digest('hex')
const ids = await IngestService.upsertReleases(trx.knexClient, [
{
release: { key, name: title, source: options.source ?? 'nzb' },
@@ -84,9 +133,8 @@ class NzbImportService {
const now = new Date()
const missing = entries.filter((e) => !knownIds.has(e.firstId))
for (let i = 0; i < missing.length; i += 500) {
await trx
.knexClient('files')
.insert(
added.push(
...(await FileStore.insert(
missing.slice(i, i + 500).map(({ file, name, firstId }) => ({
file_key: createHash('sha1').update(`nzb-file\n${firstId}`).digest('hex'),
filename: name,
@@ -94,22 +142,23 @@ class NzbImportService {
date: (file.date || Math.floor(Number(nzbFile.date) / 1000)) * 1000,
parts: file.segments.length,
size: file.segments.reduce((sum, s) => sum + s.bytes, 0),
message_ids: JSON.stringify(Object.fromEntries(file.segments.map((s) => [s.number, { id: s.id, size: s.bytes }]))),
segments: file.segments.map((s) => ({ number: s.number, id: s.id, size: s.bytes })),
groups: JSON.stringify(file.groups),
release_id: releaseId,
created_at: now,
updated_at: now,
}))
)
.onConflict('file_key')
.ignore()
})),
trx
))
)
}
// The poster's NZB says which files form the post: known files join this
// release, also when name rules had split them over several.
const previous = [...new Set(known.map((f) => f.release_id).filter((id) => id && Number(id) !== releaseId))]
const moving = known.filter((f) => Number(f.release_id) !== releaseId && !claimed.has(Number(f.release_id)))
const previous = [...new Set(moving.map((f) => f.release_id).filter(Boolean))]
await trx
.from('files')
.whereIn('id', known.filter((f) => Number(f.release_id) !== releaseId).map((f) => f.id))
.whereIn('id', moving.map((f) => f.id))
.update({ release_id: releaseId })
if (previous.length) {
await trx.rawQuery(
@@ -130,9 +179,15 @@ class NzbImportService {
const release = await trx.from('releases').where('id', releaseId).first()
const update: Record<string, unknown> = {}
if (nzb.meta.password) update.password = nzb.meta.password
if (nzb.meta.password) Object.assign(update, { password: nzb.meta.password, password_source: 'nzb' })
if (!release.file_total) update.file_total = entries.length
if ((looksRandom(release.name) || release.source === 'post') && !looksRandom(title)) update.name = title
if (options.trusted) {
// Only its own release: a repost joining another spot's release keeps that one's title.
if (release.release_key === key && release.name !== title) update.name = title
if (release.release_key === key && options.source && release.source !== options.source) update.source = options.source
} else if ((looksRandom(release.name) || release.source === 'post') && !looksRandom(title)) {
update.name = title
}
if (Object.keys(update).length) await trx.from('releases').where('id', releaseId).update(update)
const result = {
@@ -149,6 +204,14 @@ class NzbImportService {
)
return result
})
// Their first segment tells what the archives hold (contents, password); nothing else reads it for files from an NZB.
const archives = added.filter((f) => isArchiveStart(f.filename))
if (archives.length) {
await QueueService.collectionQueue.addBulk(
archives.map((f) => ({ name: 'process-collection', data: { fileId: f.id }, opts: { jobId: `file-${f.id}` } }))
)
}
return result
}
/** Release name: the NZB's meta name, else the NZB's (entry or file) name, else its largest file's stem. */
+7 -6
View File
@@ -1,4 +1,5 @@
import db from '@adonisjs/lucid/services/db'
import type { Segment } from '#lib/nzb/index'
import logger from '@adonisjs/core/services/logger'
import NntpService from '#services/NntpService'
import IngestService, { PAR2_MATCH_WINDOW_SECONDS } from '#services/IngestService'
@@ -35,11 +36,9 @@ class Par2Service {
* Reads a par2 file whose first segment is already decoded, downloading the
* remaining parts if there are any. Returns null when it is not par2.
*/
async read(first: Buffer, messageIds: Record<string, { id: string }>): Promise<Par2Set | null> {
async read(first: Buffer, segments: Segment[]): Promise<Par2Set | null> {
if (!isPar2(first)) return null
const ids = Object.entries(messageIds)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, part]) => part.id)
const ids = segments.map((part) => part.id)
if (ids.length > MAX_PARTS) return null
if (ids.length === 1) return parsePar2(first)
@@ -143,9 +142,11 @@ class Par2Service {
)
// The set may have joined a release named after one random filename.
let releaseName = (await trx.from('releases').where('id', releaseId).first())?.name
const release = await trx.from('releases').where('id', releaseId).first()
let releaseName = release?.name
const better = par2ReleaseName(set.files.map((f) => f.name))
if (releaseName && better && isBetterName(releaseName, better)) {
// A spot's title is kept (CONCEPT.md D29).
if (releaseName && better && release.source !== 'spot' && isBetterName(releaseName, better)) {
await trx.from('releases').where('id', releaseId).update({ name: better })
releaseName = better
}
+42
View File
@@ -0,0 +1,42 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
/**
* Uploads split per poster (CONCEPT.md D35). Some uploaders post every
* volume of a RAR set under a different poster; the post-id grouping then
* makes one release per volume, all with the same name
* (`Masters.of.the.Universe.1987…`: 191 one-file releases of one 199-volume
* set). Such releases are one upload when all their files share one stem and
* no file name repeats (a repost would repeat them, another upload would
* bring another stem); their files move into the oldest of them, and GC
* deletes the emptied ones.
*/
class ReleaseMergeService {
async mergeSplitPosts({ dryRun = false } = {}): Promise<{ names: number; releases: number }> {
const { rows } = await db.rawQuery(`
WITH dup AS (
SELECT name FROM releases
WHERE file_count > 0 AND source = 'post' AND is_readable_name(name)
GROUP BY name HAVING count(*) > 1
)
SELECT r.name, array_agg(DISTINCT r.id ORDER BY r.id) AS ids
FROM dup JOIN releases r ON r.name = dup.name AND r.file_count > 0 AND r.source = 'post'
JOIN files f ON f.release_id = r.id
GROUP BY r.name
HAVING count(DISTINCT name_stem(coalesce(f.real_name, f.filename))) = 1
AND count(f.id) = count(DISTINCT coalesce(f.real_name, f.filename))
AND max(r.posted_at) - min(r.posted_at) < interval '30 days'`)
let releases = 0
for (const { name, ids } of rows as { name: string; ids: (string | number)[] }[]) {
const [target, ...others] = ids.map(Number)
releases += others.length
if (dryRun) continue
await db.from('files').whereIn('release_id', others).update({ release_id: target })
await db.from('binaries').whereIn('release_id', others).update({ release_id: target })
logger.info(`Merged ${others.length + 1} releases of one upload split per poster into ${target} "${name}".`)
}
return { names: rows.length, releases }
}
}
export default new ReleaseMergeService()
+169 -32
View File
@@ -1,6 +1,20 @@
import db from '@adonisjs/lucid/services/db'
import { buildNzb } from '#lib/nzb/index'
import SpotService from '#services/SpotService'
import { segmentsOf } from '#services/FileStore'
import { spotCategoryLabels } from '#lib/spotnet/index'
/**
* `known`: we have the password (it's in our NZB, so SABnzbd/NZBGet unpack
* without asking); `missing`: the archive is encrypted and we don't have
* it; `none`: no password needed as far as we know.
*/
export type PasswordState = 'known' | 'missing' | 'none'
export function passwordState(encrypted: boolean | null, hasPassword: boolean): PasswordState {
if (hasPassword) return 'known'
return encrypted ? 'missing' : 'none'
}
export type SortOrder = 'newest' | 'oldest' | 'largest' | 'files' | 'name'
@@ -10,7 +24,7 @@ export interface SearchParams {
readable?: boolean
/** Only releases that have all their files. */
complete?: boolean
/** Hide password-protected releases. */
/** Hide releases that need a password we don't have. */
hideEncrypted?: boolean
source?: string
poster?: string
@@ -31,6 +45,13 @@ export interface SearchParams {
hideContainers?: boolean
/** Include incomplete releases that consist of par2 files only (hidden by default). */
includePar2Only?: boolean
/** Size bounds in bytes (releases without a known size never match). */
minSize?: number
maxSize?: number
/** File extensions (without the dot); a release matches if a file or archive entry has one of them (`releases.extensions`). */
extensions?: string[]
/** Count matches up to this many past the page (default COUNT_LIMIT); `total` is capped there. */
countLimit?: number
}
const SORTS: Record<SortOrder, string> = {
@@ -58,6 +79,24 @@ function pattern(word: string): string {
return `%${escaped.replace(/[._\- ]+/g, '_')}%`
}
/**
* As SQL's search_normalize(): lowercase, and dots, dashes, underscores and
* spaces as one space. `The.Block.AU` and `the block au` search the same.
*/
export function searchNormalize(text: string): string {
return text.toLowerCase().replace(/[._\s-]+/g, ' ').trim()
}
/** How long the web UI's totals are reused. */
const STATS_TTL_MS = 60 * 1000
/**
* Counting stops here: a broad search needn't count every match. Each
* counted row is a random heap read, so the web UI counts less (it only
* shows "1,000+"); Newznab clients page by this total and keep the default.
*/
const COUNT_LIMIT = 10_000
/**
* Searching and browsing releases for the web interface (CONCEPT.md D22).
*/
@@ -66,21 +105,33 @@ class SearchService {
const where: string[] = [
VISIBLE_SQL,
// Retracted by its poster or a Spotnet moderator (CONCEPT.md D29).
`NOT EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id AND s.status = 'removed')`,
`NOT EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id AND s.status IN ('removed', 'dummy'))`,
]
const bindings: unknown[] = []
const words = (params.q ?? '').trim().split(/\s+/).filter(Boolean).slice(0, 8)
// Every word must occur in the release's search text: its name, archive
// listing and file names (CONCEPT.md D33), one trigram index for all.
const words = (params.q ?? '').trim().split(/\s+/).map(searchNormalize).filter(Boolean).slice(0, 8)
for (const word of words) {
const p = pattern(word)
// Every word must match the release name, a file name, or the archive contents.
// One trigram-indexed lookup per table, not a subquery per release.
where.push(`(r.name ILIKE ? OR r.contents::text ILIKE ?
OR r.id IN (SELECT f.release_id FROM files f WHERE f.filename ILIKE ? OR f.real_name ILIKE ?))`)
bindings.push(p, p, p, p)
where.push(`r.search_text LIKE ?`)
bindings.push(`%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`)
}
if (params.readable) where.push('is_readable_name(r.name)')
if (params.hideEncrypted) where.push('r.encrypted IS NOT TRUE')
if (params.minSize !== undefined) {
where.push('r.size >= ?')
bindings.push(params.minSize)
}
if (params.maxSize !== undefined) {
where.push('r.size <= ?')
bindings.push(params.maxSize)
}
// File types from file names and archive listings (migration …130), via their GIN index.
const extensions = (params.extensions ?? []).map((e) => e.toLowerCase().replace(/^\./, '')).filter((e) => /^[a-z0-9]{1,8}$/.test(e)).slice(0, 8)
if (extensions.length > 0) {
where.push('r.extensions && ?::text[]')
bindings.push(extensions)
}
if (params.hideEncrypted) where.push('(r.encrypted IS NOT TRUE OR r.password IS NOT NULL)')
if (params.hideContainers) where.push('r.container IS NULL')
// par2 files whose data never arrived (e.g. uploads spread over groups we can't fetch).
if (!params.includePar2Only) where.push(`(${PAR2_ONLY_SQL}) IS NOT TRUE`)
@@ -121,6 +172,7 @@ class SearchService {
const offset = params.offset ?? (page - 1) * perPage
const order = SORTS[params.sort ?? 'newest'] ?? SORTS.newest
const whereSql = where.join(' AND ')
const countLimit = offset + (params.countLimit ?? COUNT_LIMIT)
const [{ rows }, { rows: countRows }] = await Promise.all([
db.rawQuery(
@@ -131,19 +183,22 @@ class SearchService {
SELECT 1 FROM files f JOIN binaries b ON b.file_id = f.id
WHERE f.release_id = r.id AND (b.size = rf.size OR f.real_name = rf.name))) AS listed_present,
jsonb_array_length(coalesce(r.contents, '[]'::jsonb)) AS archive_entries,
r.complete, ${PAR2_ONLY_SQL} AS par2_only
r.complete, ${PAR2_ONLY_SQL} AS par2_only, r.password IS NOT NULL AS has_password,
(SELECT s.description FROM spots s WHERE s.release_id = r.id AND s.description IS NOT NULL LIMIT 1) AS spot_description
FROM releases r WHERE ${whereSql}
ORDER BY ${order} LIMIT ? OFFSET ?`,
[...bindings, perPage, offset]
),
db.rawQuery(`SELECT count(*) AS total FROM releases r WHERE ${whereSql}`, bindings),
db.rawQuery(`SELECT count(*) AS total FROM (SELECT 1 FROM releases r WHERE ${whereSql} LIMIT ${countLimit + 1}) x`, bindings),
])
return {
page,
perPage,
offset,
total: Number(countRows[0].total),
total: Math.min(Number(countRows[0].total), countLimit),
/** More than `total` match (the count stops at `countLimit`). */
totalCapped: Number(countRows[0].total) > countLimit,
releases: rows.map((r: any) => ({
id: Number(r.id),
name: r.name,
@@ -163,6 +218,10 @@ class SearchService {
category: r.category,
container: r.container,
par2Only: r.par2_only,
listingOnly: r.source === 'spot' && Number(r.file_count) === 0,
hasPassword: r.has_password,
passwordState: passwordState(r.encrypted, r.has_password),
spotDescription: r.spot_description,
})),
}
}
@@ -193,14 +252,32 @@ class SearchService {
),
])
const spot = release.source === 'spot'
? await db.from('spots').where('release_id', id).select('tag', 'poster', 'category', 'website').first()
: null
const spotRow = await db.from('spots').where('release_id', id).whereNotIn('status', ['removed', 'dummy']).orderBy('posted_at', 'desc').first()
const spot = spotRow ? await SpotService.withDetails(spotRow) : null
return {
id: Number(release.id),
name: release.name,
source: release.source,
spot: spot ? { tag: spot.tag, poster: spot.poster, category: spot.category, website: spot.website } : null,
spot: spot
? {
messageId: spot.message_id,
title: spot.title,
tag: spot.tag,
poster: spot.poster,
postedAt: spot.posted_at,
website: spot.website,
description: spot.description,
image: spot.image?.segments
? { url: `/api/v1/releases/${id}/image`, width: spot.image.width ?? null, height: spot.image.height ?? null }
: spot.image?.url
? { external: spot.image.url }
: null,
categories: spotCategoryLabels(spot.category, spot.subcats),
}
: null,
password: release.password ?? null,
passwordSource: release.password_source ?? null,
passwordState: passwordState(release.encrypted, release.password !== null),
listingOnly: release.source === 'spot' && Number(release.file_count) === 0,
poster: release.poster,
groups: release.groups,
@@ -230,6 +307,22 @@ class SearchService {
}
}
/** The image of the spot behind a release: bytes and a media type sniffed from them, or null. */
async spotImage(id: number): Promise<{ data: Buffer; type: string } | null> {
const spot = await db.from('spots').where('release_id', id).whereNotNull('image').orderBy('posted_at', 'desc').first()
if (!spot) return null
const data = await SpotService.image(spot)
if (!data) return null
const type =
data[0] === 0xff && data[1] === 0xd8 ? 'image/jpeg'
: data.subarray(0, 4).toString('latin1') === '\x89PNG' ? 'image/png'
: data.subarray(0, 3).toString('latin1') === 'GIF' ? 'image/gif'
: data.subarray(0, 2).toString('latin1') === 'BM' ? 'image/bmp'
: data.subarray(8, 12).toString('latin1') === 'WEBP' ? 'image/webp'
: null
return type ? { data, type } : null
}
async nzb(id: number): Promise<{ name: string; xml: string } | null> {
let release = await db.from('releases').where('id', id).first()
if (!release) return null
@@ -244,39 +337,83 @@ class SearchService {
.from('files')
.where('release_id', id)
.orderByRaw('coalesce(real_name, filename)')
.select('filename', 'real_name', 'poster', 'date', 'groups', 'message_ids')
.select('filename', 'real_name', 'poster', 'date', 'groups', 'segments')
const xml = buildNzb(
files.map((f) => ({
name: f.real_name ?? f.filename,
poster: f.poster,
date: Number(f.date),
groups: f.groups?.length ? f.groups : ['alt.binaries.test'],
segments: f.message_ids,
segments: segmentsOf(f),
})),
{ title: release.name, password: release.password ?? undefined }
)
return { name: release.name, xml }
}
/**
* Totals for the web UI header. They count millions of rows (minutes with
* the backfill running), so they are computed at most once per
* STATS_TTL_MS and never twice at a time; callers in between share it.
*/
async stats() {
if (!this.statsCache || Date.now() - this.statsCache.at > STATS_TTL_MS) {
this.statsRefresh ??= this.computeStats()
.then((value) => {
this.statsCache = { at: Date.now(), value }
})
// A failed background refresh keeps the previous totals; only a caller that waits sees the error.
.catch((error) => {
if (!this.statsCache) throw error
})
.finally(() => {
this.statsRefresh = undefined
})
// Only the very first caller waits; later ones get the previous totals meanwhile.
if (!this.statsCache) await this.statsRefresh
}
return this.statsCache!.value
}
private statsCache?: { at: number; value: Awaited<ReturnType<SearchService['computeStats']>> }
private statsRefresh?: Promise<void>
/**
* Estimates, in milliseconds instead of minutes (CONCEPT.md D33): counts
* from Postgres's statistics (visible releases = the entries of an index
* that covers exactly them), ratios and sizes from a 0.5% sample of the
* releases table. Within a few percent once autovacuum has analysed.
*/
private async computeStats() {
const { rows } = await db.rawQuery(`
SELECT (SELECT count(*) FROM releases r WHERE ${VISIBLE_SQL}) AS releases,
(SELECT count(*) FROM releases r WHERE ${VISIBLE_SQL} AND is_readable_name(name)) AS readable_releases,
(SELECT count(*) FROM files) AS files,
(SELECT coalesce(sum(size), 0) FROM files) AS size,
(SELECT count(*) FROM releases WHERE encrypted) AS encrypted_releases,
WITH sample AS (
SELECT r.name, r.size, r.file_count, r.encrypted, coalesce(r.source, 'unknown') AS source
FROM releases r TABLESAMPLE SYSTEM (0.5) WHERE ${VISIBLE_SQL})
SELECT (SELECT reltuples FROM pg_class WHERE relname = 'releases_category_idx') AS releases,
-- files is partitioned (D39): its partitions hold the counts.
(SELECT sum(c.reltuples) FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'files'::regclass) AS files,
(SELECT count(*) FROM sample) AS sampled,
(SELECT count(*) FROM sample WHERE is_readable_name(name)) AS sampled_readable,
-- Indexed data only: a spot listing's size is what the spot announces.
(SELECT coalesce(sum(size), 0) FROM sample WHERE file_count > 0) AS sampled_size,
(SELECT count(*) FROM sample WHERE encrypted) AS sampled_encrypted,
(SELECT max(updated_at) FROM groups) AS last_indexed_at,
(SELECT json_agg(json_build_object('source', source, 'releases', n) ORDER BY n DESC)
FROM (SELECT coalesce(source, 'unknown') AS source, count(*) AS n FROM releases r WHERE ${VISIBLE_SQL} GROUP BY 1) s) AS sources`)
(SELECT json_agg(json_build_object('source', source, 'n', n) ORDER BY n DESC)
FROM (SELECT source, count(*) AS n FROM sample GROUP BY 1) s) AS sources`)
const r = rows[0]
const releases = Math.max(0, Number(r.releases))
const sampled = Number(r.sampled)
// Sample counts scaled to all visible releases.
const scale = (n: unknown) => (sampled > 0 ? Math.round((Number(n) / sampled) * releases) : 0)
return {
releases: Number(r.releases),
readableReleases: Number(r.readable_releases),
files: Number(r.files),
size: Number(r.size),
encryptedReleases: Number(r.encrypted_releases),
approximate: true,
releases,
readableReleases: scale(r.sampled_readable),
files: Math.max(0, Number(r.files)),
size: scale(r.sampled_size),
encryptedReleases: scale(r.sampled_encrypted),
lastIndexedAt: r.last_indexed_at,
sources: r.sources ?? [],
sources: (r.sources ?? []).map((s: any) => ({ source: s.source, releases: scale(s.n) })),
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import nntpConfig from '#config/nntp'
import indexerConfig from '#config/indexer'
import { XoverClient } from '#lib/nntp/xover_client'
interface Slot {
client: XoverClient
group?: string
usedAt: number
}
/** A pooled connection unused this long is replaced: the news server may have dropped it. */
const IDLE_MS = 2 * 60 * 1000
/**
* NNTP connections in the seed API for relay nodes (CONCEPT.md D38): the
* indexer fetches overview chunks and passes the gzip stream on unopened, so
* nodes need no news server account and every connection comes from this
* machine's IP. SEED_NNTP_CONNECTIONS (0: relay off).
*/
class SeedFetchPool {
private idle: Slot[] = []
private open = 0
private waiters: (() => void)[] = []
get size() {
return indexerConfig.seed.nntpConnections
}
async fetch(group: string, from: number, to: number): Promise<Buffer> {
const slot = await this.acquire()
let ok = false
try {
if (slot.group !== group) {
await slot.client.group(group)
slot.group = group
}
const raw = await slot.client.xoverRaw(from, to)
ok = true
return raw
} finally {
this.release(slot, ok)
}
}
private async acquire(): Promise<Slot> {
while (true) {
const slot = this.idle.pop()
if (slot) {
if (Date.now() - slot.usedAt < IDLE_MS) return slot
slot.client.close()
this.open--
continue
}
if (this.open < this.size) {
this.open++
try {
const client = await XoverClient.connect({
host: nntpConfig.host,
port: nntpConfig.port,
secure: nntpConfig.secure,
servername: nntpConfig.tlsServername,
user: nntpConfig.user,
password: nntpConfig.password?.release(),
timeoutMs: nntpConfig.commandTimeoutMs,
compress: true,
})
return { client, usedAt: Date.now() }
} catch (error) {
this.open--
this.waiters.shift()?.()
throw error
}
}
await new Promise<void>((resolve) => this.waiters.push(resolve))
}
}
private release(slot: Slot, ok: boolean) {
if (ok) {
slot.usedAt = Date.now()
this.idle.push(slot)
} else {
// A failed command can leave the connection mid-reply: start afresh.
slot.client.close()
this.open--
}
this.waiters.shift()?.()
}
}
export default new SeedFetchPool()
+214
View File
@@ -0,0 +1,214 @@
import { createHash, randomBytes } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import NntpService from '#services/NntpService'
import SeedService from '#services/SeedService'
import BackfillService from '#services/BackfillService'
import SeedFetchPool from '#services/SeedFetchPool'
import { articleAtDate } from '#lib/nntp/date_search'
import { normalizeSeedFile, type SeedBatch } from '#lib/seed/wire'
/** A lease without a batch for this long is given to another node; the range resumes at next_id. */
export const LEASE_TTL_SECONDS = 15 * 60
/** Leases per range before it counts as failed (a range that keeps breaking nodes). */
const MAX_ATTEMPTS = 5
/** Articles per XOVER probe when looking for an article by date. */
const PROBE = 50
export interface SeedLease {
id: number
group: string
/** Resume point: first article not reported yet. */
start: number
end: number
}
export class LeaseLost extends Error {}
const hash = (token: string) => createHash('sha256').update(token).digest('hex')
/**
* The seed orchestrator (CONCEPT.md D38): seeding nodes lease article
* ranges over HTTPS, report what they aggregated in batches (D37), and
* finish or give back the range. Nodes only make outgoing requests; a lease
* that goes quiet for LEASE_TTL_SECONDS passes to the next node that asks.
*/
class SeedOrchestrator {
/** Creates a node, or gives an existing one a new token. Returns the token (stored hashed only). */
async issueToken(name: string): Promise<string> {
const token = `sn_${randomBytes(24).toString('base64url')}`
await db
.table('seed_nodes')
.insert({ name, token_hash: hash(token), disabled: false })
.onConflict('name')
.merge({ token_hash: hash(token), disabled: false })
return token
}
async nodeForToken(token: string): Promise<{ id: number; name: string } | null> {
const node = await db.from('seed_nodes').where('token_hash', hash(token)).where('disabled', false).first()
if (!node) return null
if (!node.last_seen_at || Date.now() - new Date(node.last_seen_at).getTime() > 60_000) {
await db.from('seed_nodes').where('id', node.id).update({ last_seen_at: new Date() })
}
return { id: Number(node.id), name: node.name }
}
/**
* The next range for this node, oldest plan first; a quiet lease is taken
* over. Nothing while the database is over the backfill's disk limits: the
* node asks again a minute later.
*/
async lease(nodeId: number): Promise<SeedLease | null> {
if ((await BackfillService.room()) !== true) return null
// Ranges that broke node after node stop being handed out.
await db.rawQuery(
`UPDATE seed_ranges SET status = 'failed', error = coalesce(error, 'lease expired too often')
WHERE status = 'leased' AND heartbeat_at < now() - make_interval(secs => ?) AND attempts >= ?`,
[LEASE_TTL_SECONDS, MAX_ATTEMPTS]
)
const { rows } = await db.rawQuery(
`UPDATE seed_ranges SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1
WHERE id = (
SELECT id FROM seed_ranges
WHERE status = 'queued' OR (status = 'leased' AND heartbeat_at < now() - make_interval(secs => ?))
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING id, group_name, next_id, end_id`,
[nodeId, LEASE_TTL_SECONDS]
)
const row = rows[0]
return row ? { id: Number(row.id), group: row.group_name, start: Number(row.next_id), end: Number(row.end_id) } : null
}
/** Stores a node's batch and moves the range's resume point; LeaseLost when the range is no longer this node's. */
async batch(nodeId: number, rangeId: number, batch: SeedBatch) {
const range = await this.leased(nodeId, rangeId)
const stored = await SeedService.store(batch.files.map(normalizeSeedFile))
await SeedService.storeFragments(range.group_name, batch.fragments)
await db.rawQuery(
`UPDATE seed_ranges SET next_id = greatest(next_id, least(?, end_id + 1)), heartbeat_at = now(),
headers = headers + ?, files = files + ?, fragments = fragments + ?
WHERE id = ?`,
[batch.next, batch.headers, batch.files.length, batch.fragments.length, rangeId]
)
return stored
}
/**
* Relay: overviews of `from`..`to` of a leased range, fetched here and handed
* on as the gzip stream the news server sent. Keeps the lease alive.
*/
async chunk(nodeId: number, rangeId: number, from: number, to: number): Promise<Buffer> {
const range = await this.leased(nodeId, rangeId)
const start = Math.max(from, Number(range.start_id))
const end = Math.min(to, Number(range.end_id), start + 49_999)
if (end < start) throw new RangeError(`Articles ${from}–${to} are outside range ${rangeId}`)
await db.from('seed_ranges').where('id', rangeId).update({ heartbeat_at: new Date() })
return SeedFetchPool.fetch(range.group_name, start, end)
}
async finish(nodeId: number, rangeId: number) {
await this.leased(nodeId, rangeId)
await db.from('seed_ranges').where('id', rangeId).update({ status: 'done', done_at: new Date(), heartbeat_at: new Date(), error: null })
}
/**
* A node gives a range back (it failed, or is shutting down); it's queued
* again unless it failed too often. `uncounted`: the node never started on
* it (couldn't reach its news server), so the lease doesn't count as an attempt.
*/
async release(nodeId: number, rangeId: number, error?: string, uncounted = false) {
const range = await this.leased(nodeId, rangeId)
const attempts = uncounted ? Math.max(0, range.attempts - 1) : range.attempts
await db
.from('seed_ranges')
.where('id', rangeId)
.update({ status: attempts >= MAX_ATTEMPTS ? 'failed' : 'queued', node_id: null, attempts, error: error?.slice(0, 2000) ?? null })
}
private async leased(nodeId: number, rangeId: number) {
const range = await db.from('seed_ranges').where('id', rangeId).first()
if (!range || range.status !== 'leased' || Number(range.node_id) !== nodeId) throw new LeaseLost(`Range ${rangeId} is not leased to this node`)
return range
}
/**
* Plans ranges for groups: from below what live indexing and the backfill
* cover (or below earlier seed ranges) down to `days` back, or to the
* server's oldest article. A group's backfill stops where its seed plan
* begins. Ranges are interleaved across groups, newest first.
*/
async plan(groups: string[], options: { days?: number; size: number }): Promise<{ group: string; from: number; to: number; ranges: number }[]> {
const perGroup: { group: string; ranges: [number, number][] }[] = []
const summary: { group: string; from: number; to: number; ranges: number }[] = []
const conn = await NntpService.acquire()
let failed = false
try {
for (const name of groups) {
const group = await db.from('groups').where('name', name).first()
if (!group) throw new Error(`Unknown group ${name}`)
const { first, last } = await NntpService.timed(NntpService.selectGroup(conn, name), `GROUP ${name}`)
const dateOf = async (n: bigint) => {
const { overviews } = await NntpService.timed<any>(conn.xover(n, n + BigInt(PROBE)), `XOVER ${name} ${n}`)
const header = overviews.find(([number, h]: [number | null, any]) => number !== null && h?.date)?.[1]
const date = header ? Date.parse(header.date) : Number.NaN
return Number.isNaN(date) ? null : date
}
// Top: where live indexing began, or the backfill's position, or below earlier seed ranges.
let top = Number(await articleAtDate(first, last, new Date(group.created_at).getTime(), dateOf))
const backfilling = group.backfill_next_id !== null && group.backfill_until_id !== null && Number(group.backfill_next_id) > Number(group.backfill_until_id)
if (backfilling) top = Math.min(top, Number(group.backfill_next_id))
const seeded = await db.from('seed_ranges').where('group_name', name).min('start_id as low').first()
if (seeded?.low !== null && seeded?.low !== undefined) top = Math.min(top, Number(seeded.low))
const bottom = options.days
? Number(await articleAtDate(first, last, Date.now() - options.days * 24 * 60 * 60 * 1000, dateOf))
: Number(first)
const ranges: [number, number][] = []
for (let end = top - 1; end >= bottom; end -= options.size) ranges.push([Math.max(bottom, end - options.size + 1), end])
perGroup.push({ group: name, ranges })
summary.push({ group: name, from: bottom, to: top - 1, ranges: ranges.length })
// The seed plan takes over the rest of this group's backfill.
if (backfilling && ranges.length > 0) {
await db.from('groups').where('id', group.id).update({ backfill_until_id: group.backfill_next_id })
}
}
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
// Round-robin over groups, each newest first, so every group fills in from the present backwards.
const rows: Record<string, unknown>[] = []
for (let i = 0; perGroup.some((g) => i < g.ranges.length); i++) {
for (const g of perGroup) {
const range = g.ranges[i]
if (range) rows.push({ group_name: g.group, start_id: range[0], end_id: range[1], next_id: range[0] })
}
}
for (let i = 0; i < rows.length; i += 5000) await db.table('seed_ranges').multiInsert(rows.slice(i, i + 5000))
return summary
}
async status() {
const { rows: ranges } = await db.rawQuery(
`SELECT status, count(*)::int AS ranges, sum(end_id - start_id + 1)::bigint AS articles,
sum(next_id - start_id)::bigint AS reached, sum(headers)::bigint AS headers, sum(files)::bigint AS files
FROM seed_ranges GROUP BY status ORDER BY status`
)
const { rows: nodes } = await db.rawQuery(
`SELECT n.name, n.last_seen_at, n.disabled,
(SELECT count(*)::int FROM seed_ranges r WHERE r.node_id = n.id AND r.status = 'leased') AS leases,
(SELECT coalesce(sum(headers), 0)::bigint FROM seed_ranges r WHERE r.node_id = n.id) AS headers
FROM seed_nodes n ORDER BY n.name`
)
const { rows: recent } = await db.rawQuery(
`SELECT coalesce(sum(headers), 0)::bigint AS headers FROM seed_ranges WHERE done_at > now() - interval '1 hour'`
)
const room = await BackfillService.room()
return { ranges, nodes, headersDoneLastHour: Number(recent[0].headers), paused: room === true ? null : room }
}
}
export default new SeedOrchestrator()
+110
View File
@@ -0,0 +1,110 @@
import db from '@adonisjs/lucid/services/db'
import IngestService from '#services/IngestService'
import FileStore from '#services/FileStore'
import SfvService from '#services/SfvService'
import ContentService from '#services/ContentService'
import QueueService from '#services/QueueService'
import StagingService from '#services/StagingService'
import { fromFragment, type SeedFile, type SeedFragment } from '#lib/seed/wire'
export interface SeedStoreResult {
files: number
/** Files that existed already (same file_key, e.g. from live indexing or a crosspost). */
existing: number
releases: number
inspections: number
}
/**
* Stores what seeding nodes found (CONCEPT.md D37): per complete file its
* release, the file with its segment list, and one completed binary, as
* worker:file would have left it, so par2 naming, inspection and GC treat
* it like any other file; fragments take the regular ingest path. A batch
* is a few statements, not one per article.
*/
class SeedService {
async store(batch: SeedFile[]): Promise<SeedStoreResult> {
const result: SeedStoreResult = { files: 0, existing: 0, releases: 0, inspections: 0 }
// A repost can complete the same file twice in one batch; ON CONFLICT DO UPDATE takes each row once.
const files = [...new Map(batch.map((file) => [file.fileKey, file])).values()]
if (files.length === 0) return result
const knex = db.connection().getWriteClient()
const now = new Date()
const postedOf = (file: SeedFile) => (file.postedAt ? new Date(file.postedAt) : undefined)
const releaseIds = await IngestService.upsertReleases(
knex,
files.map((file) => ({ release: file.release, poster: file.poster, groups: file.groups, postedAt: postedOf(file) })),
now
)
result.releases = releaseIds.size
const rows = files.map((file) => ({
file_key: file.fileKey,
filename: file.name,
poster: file.poster,
date: (postedOf(file) ?? now).getTime(),
parts: file.segments.length,
segments: file.segments.map(([number, id, size]) => ({ number, id, size })),
groups: JSON.stringify(file.groups),
release_id: file.release ? (releaseIds.get(file.release.key) ?? null) : null,
size: file.segments.reduce((sum, [, , size]) => sum + size, 0),
created_at: now,
updated_at: now,
}))
const inserted = await FileStore.insert(rows)
result.files = inserted.length
result.existing = rows.length - inserted.length
const fileIds = new Map<string, number>()
for (const row of await db.from('files').whereIn('file_key', rows.map((r) => r.file_key)).select('id', 'file_key')) {
fileIds.set(row.file_key, Number(row.id))
}
// The completed binary worker:file would have left behind; an existing one just learns its file.
const binaries = files.map((file, i) => ({
file_key: file.fileKey,
poster: file.poster,
subject: file.subject,
name: file.name,
total_parts: file.totalParts,
groups: rows[i].groups,
posted_at: postedOf(file) ?? null,
first_seen_at: now,
last_seen_at: now,
completed_at: now,
release_id: rows[i].release_id,
file_id: fileIds.get(file.fileKey) ?? null,
}))
binaries.sort((a, b) => a.file_key.localeCompare(b.file_key))
await knex('binaries')
.insert(binaries)
.onConflict('file_key')
.merge({ file_id: knex.raw('coalesce(binaries.file_id, excluded.file_id)') as any })
// As worker:file does for a new file: an SFV posted earlier may list it (D34), then inspection.
const rowById = new Map(rows.map((row) => [fileIds.get(row.file_key), row]))
const added = inserted.flatMap((file) => {
const row = rowById.get(file.id)
return row ? [{ id: file.id, name: row.filename, poster: row.poster, date: row.date, release_id: row.release_id }] : []
})
await SfvService.claimAll(added)
const candidates = added
.filter((file) => ContentService.isCandidate(file.name))
.map((file) => ({ name: 'process-collection', data: { fileId: file.id }, opts: { jobId: `file-${file.id}` } }))
if (candidates.length > 0) await QueueService.collectionQueue.addBulk(candidates)
result.inspections = candidates.length
return result
}
/** Segments of files that didn't complete on a node: staging (D35) for lone ones, binaries and parts for the rest. */
async storeFragments(group: string, fragments: SeedFragment[]): Promise<void> {
if (fragments.length === 0) return
const segments = fragments.map(fromFragment)
const { ingest } = StagingService.enabled(group)
? await StagingService.filter(segments, { counters: StagingService.countersEnabled(group) })
: { ingest: segments }
await IngestService.ingest(ingest)
}
}
export default new SeedService()
+202
View File
@@ -0,0 +1,202 @@
import { execFile } from 'node:child_process'
import { mkdtemp, open, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import NntpService from '#services/NntpService'
import { segmentsOf } from '#services/FileStore'
import { decodeSegment, type DecodedSegment } from '#lib/yenc/index'
import type { RarInfo } from '#lib/rar/index'
import {
checkHeader,
listingStart,
parseBsdtarListing,
parseSevenZipStart,
sevenZipVolume,
type SevenZipStart,
} from '#lib/sevenzip/index'
const run = promisify(execFile)
/** The tail read for a listing: header plus packed header; more is not a header. */
const MAX_TAIL_BYTES = 16 * 1024 * 1024
/**
* Lists 7z archives, also split ones (.7z.001, .002, …), from the first
* segment of the first volume and the last segment(s) of the last one
* (CONCEPT.md D19). Those bytes are put at their place in a sparse file the
* size of the whole archive, which bsdtar (libarchive) lists: it only reads
* the start and the header at the end.
*/
class SevenZipService {
/**
* The volumes of the archive `file` belongs to, in order, when all of them
* up to the highest number seen are there; null otherwise. An unsplit (or
* unnamed) archive is its own only volume.
*/
async volumes(file: any): Promise<any[] | null> {
const name = String(file.real_name ?? file.filename)
const volume = sevenZipVolume(name)
if (!volume) return [file]
if (!file.release_id) return null
const base = name.replace(/\.\d+$/, '').toLowerCase()
const rows = await db
.from('files')
.where('release_id', file.release_id)
.whereRaw(`lower(coalesce(real_name, filename)) LIKE ? ESCAPE '\\'`, [`${base.replace(/[\\%_]/g, '\\$&')}.%`])
const byNumber = new Map<number, any>()
for (const row of rows) {
const n = sevenZipVolume(String(row.real_name ?? row.filename))
if (n && !byNumber.has(n)) byNumber.set(n, row)
}
const max = Math.max(0, ...byNumber.keys())
const ordered = Array.from({ length: max }, (_, i) => byNumber.get(i + 1))
return max > 0 && ordered.every(Boolean) ? ordered : null
}
/**
* Lists the archive whose first volume starts with `firstData` (a decoded
* first segment). Null when it isn't a 7z start, when volumes are missing,
* or when the tail doesn't hold the header its start points to.
*/
async list(firstFile: any, firstData: Buffer): Promise<RarInfo | null> {
const start = parseSevenZipStart(firstData)
if (!start) return null
const volumes = await this.volumes(firstFile)
if (!volumes) return null
if (start.headerSize === 0) return this.info([], false, false, volumes.length > 1)
const tail = await this.readTail(volumes[volumes.length - 1], start)
if (!tail) return null
const dir = await mkdtemp(path.join(tmpdir(), '7z-'))
try {
const archive = path.join(dir, 'archive.7z')
const handle = await open(archive, 'w')
try {
await handle.write(firstData, 0, Math.min(firstData.length, start.archiveSize), 0)
await handle.write(tail.data, 0, tail.data.length, tail.from)
await handle.truncate(start.archiveSize)
} finally {
await handle.close()
}
return await this.bsdtar(archive, volumes.length > 1)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
/** Lists the sparse archive; a trial extraction of one entry tells whether its data is encrypted. */
private async bsdtar(archive: string, split: boolean): Promise<RarInfo | null> {
let stdout = ''
try {
stdout = (await run('bsdtar', ['-tvf', archive], { timeout: 60_000, maxBuffer: 32 * 1024 * 1024 })).stdout
} catch (error: any) {
if (/header is encrypted/i.test(String(error.stderr))) return this.info([], true, true, split)
logger.warn(`bsdtar could not list a 7z archive: ${String(error.stderr || error.message).trim()}`)
return null
}
const entries = parseBsdtarListing(stdout)
const sample = entries.filter((e) => !e.directory).sort((a, b) => a.size - b.size)[0]
let encrypted = false
if (sample) {
try {
await run('bsdtar', ['-xOf', archive, sample.name], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024, encoding: 'buffer' })
} catch (error: any) {
// Without the data (it's all zeros here), anything else fails too: only this message counts.
encrypted = /content is encrypted/i.test(String(error.stderr))
}
}
return this.info(entries, false, encrypted, split)
}
private info(entries: { name: string; size: number; directory: boolean }[], headersEncrypted: boolean, encrypted: boolean, split: boolean): RarInfo {
return {
format: '7z',
volume: split,
firstVolume: true,
solid: false,
headersEncrypted,
truncated: false,
entries: entries.map((e) => ({
name: e.name,
size: e.size,
packedSize: 0,
directory: e.directory,
encrypted: encrypted && !e.directory,
splitBefore: false,
splitAfter: false,
stored: false,
dataOffset: 0,
})),
}
}
/**
* The archive's tail from the last volume: the header (checked against its
* CRC) and, for an encoded header, the packed stream before it. `from` is
* its position in the whole archive.
*/
private async readTail(last: any, start: SevenZipStart): Promise<{ from: number; data: Buffer } | null> {
const ids = new Map(segmentsOf(last).map((s) => [s.number, s]))
const numbers = [...ids.keys()]
if (numbers.length === 0) return null
const segments = new Map<number, DecodedSegment>()
const segment = async (n: number) => {
if (!segments.has(n)) {
const id = ids.get(n)?.id
if (!id) throw new Error(`part ${n} of ${last.filename} is missing`)
segments.set(n, decodeSegment(await this.body(id), { lenientSize: true }))
}
return segments.get(n)!
}
const lastNumber = numbers[numbers.length - 1]
const lastSegment = await segment(lastNumber)
const volumeSize = lastSegment.meta.begin.size
const volumeStart = start.archiveSize - volumeSize
const partBegin = (lastSegment.meta.part?.begin ?? 1) - 1
const partSize = lastNumber > 1 ? partBegin / (lastNumber - 1) : volumeSize
if (volumeStart < 0 || !Number.isInteger(partSize) || partSize <= 0) return null
// Bytes [from, to) of the whole archive, from the last volume's parts.
const read = async (from: number, to: number): Promise<Buffer | null> => {
if (from < volumeStart) return null
const a = from - volumeStart
const b = to - volumeStart
const pieces: Buffer[] = []
for (let n = Math.floor(a / partSize) + 1; n <= Math.floor((b - 1) / partSize) + 1; n++) {
const s = await segment(n)
const offset = (s.meta.part?.begin ?? 1) - 1
if (offset !== (n - 1) * partSize) return null
pieces.push(s.data.subarray(Math.max(0, a - offset), Math.max(0, Math.min(s.data.length, b - offset))))
}
const data = Buffer.concat(pieces)
return data.length === b - a ? data : null
}
const header = await read(start.headerStart, start.archiveSize)
if (!header || !checkHeader(start, header)) return null
const from = Math.min(listingStart(start, header), start.headerStart)
if (start.archiveSize - from > MAX_TAIL_BYTES) return null
const data = await read(from, start.archiveSize)
return data ? { from, data } : null
}
private async body(messageId: string): Promise<Buffer> {
const conn = await NntpService.acquire()
let failed = false
try {
return await NntpService.timed(NntpService.fetchBody(conn, messageId), `BODY ${messageId}`)
} catch (error: any) {
failed = !/^4\d\d/.test(String(error.message))
throw error
} finally {
NntpService.release(conn, failed)
}
}
}
export default new SevenZipService()
+95
View File
@@ -0,0 +1,95 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { parseSfv, sfvReleaseName } from '#lib/content/index'
/** Releases made by name rules alone; an SFV's listing outranks them. */
const WEAK_SOURCES = ['stem', 'weak-stem', 'subject']
/** Files further apart than this from the SFV in posting time aren't taken. */
const WINDOW_MS = 2 * 24 * 60 * 60 * 1000
/**
* SFV grouping (CONCEPT.md D34). An uploader that posts every track of an
* album as its own file (13.5k such releases from one poster on homelab)
* still posts the album's SFV, which names them all.
*/
class SfvService {
/**
* Records an SFV file's listing on its release, moves the listed files that
* sit in weak releases into it, and names the release after the album when
* it's still named after a track or the SFV itself. Returns how many files
* moved, or null when it isn't an SFV.
*/
async attach(sfvFile: any, text: string): Promise<number | null> {
const entries = parseSfv(text)
if (entries.length === 0 || !sfvFile.release_id) return null
const releaseId = Number(sfvFile.release_id)
const postedAt = sfvFile.date ? new Date(Number(sfvFile.date)) : null
await db
.knexQuery()
.table('sfv_entries')
.insert(entries.map((e) => ({ release_id: releaseId, name: e.name, crc: e.crc, poster: sfvFile.poster, posted_at: postedAt })))
.onConflict(['release_id', 'name'])
.ignore()
await db.from('files').where('id', sfvFile.id).update({ content_type: 'sfv' })
const candidates = await db
.from('files as f')
.join('releases as r', 'r.id', 'f.release_id')
.whereIn(db.raw('coalesce(f.real_name, f.filename)') as any, entries.map((e) => e.name))
.whereNot('f.release_id', releaseId)
.whereIn('r.source', WEAK_SOURCES)
.select('f.id', 'f.poster', 'f.date')
const moving = candidates.filter((f) => this.sameUpload(f, sfvFile.poster, postedAt))
await this.move(moving.map((f) => Number(f.id)), releaseId)
const release = await db.from('releases').where('id', releaseId).first()
const album = sfvReleaseName(String(sfvFile.real_name ?? sfvFile.filename))
if (release && WEAK_SOURCES.includes(release.source) && album !== release.name && /^\d{1,3}-/.test(release.name)) {
await db.from('releases').where('id', releaseId).update({ name: album })
}
logger.info(`SFV "${sfvFile.filename}": ${entries.length} files listed, ${moving.length} moved into release ${releaseId} "${album}".`)
return moving.length
}
/** A new file an SFV already listed joins that SFV's release. Returns the release, or null. */
/**
* claim() for a batch of new files: one lookup for all their names, the
* per-file work only for the few an SFV lists (seeding stores thousands
* of files per batch; CONCEPT.md D37).
*/
async claimAll(files: { id: number; name: string; poster?: string | null; date?: number | null; release_id?: number | null }[]): Promise<number> {
if (files.length === 0) return 0
const listed = new Set(
(await db.from('sfv_entries').whereIn('name', [...new Set(files.map((f) => f.name))]).select('name')).map((e) => e.name as string)
)
let moved = 0
for (const file of files) if (listed.has(file.name) && (await this.claim(file)) !== null) moved++
return moved
}
async claim(file: { id: number; name: string; poster?: string | null; date?: number | null; release_id?: number | null }): Promise<number | null> {
const entries = await db.from('sfv_entries').where('name', file.name)
const entry = entries.find((e) => this.sameUpload(file, e.poster, e.posted_at ? new Date(e.posted_at) : null))
if (!entry || Number(entry.release_id) === Number(file.release_id)) return null
if (file.release_id) {
const current = await db.from('releases').where('id', file.release_id).first()
if (current && !WEAK_SOURCES.includes(current.source)) return null
}
await this.move([file.id], Number(entry.release_id))
return Number(entry.release_id)
}
private sameUpload(file: { poster?: string | null; date?: number | string | null }, poster: string | null, postedAt: Date | null) {
if (poster && file.poster && file.poster !== poster) return false
if (postedAt && file.date && Math.abs(Number(file.date) - postedAt.getTime()) > WINDOW_MS) return false
return true
}
private async move(fileIds: number[], releaseId: number) {
if (fileIds.length === 0) return
await db.from('files').whereIn('id', fileIds).update({ release_id: releaseId })
await db.from('binaries').whereIn('file_id', fileIds).update({ release_id: releaseId })
}
}
export default new SfvService()
+270 -53
View File
@@ -1,28 +1,55 @@
import { createHash } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import indexerConfig from '#config/indexer'
import NntpService from '#services/NntpService'
import NzbImportService from '#services/NzbImportService'
import { type OverviewHeader } from '#services/IngestService'
import IngestService, { type OverviewHeader } from '#services/IngestService'
import { parseNzb } from '#lib/nzb/index'
import {
moderationTarget,
passwordFromSpot,
spotterId,
decodeSpotBinary,
decodeSpotText,
parseSpotArticleHeaders,
parseSpotHeader,
parseSpotXml,
verifySpotHeader,
type SpotHeader,
type SpotXml,
} from '#lib/spotnet/index'
/** Spots whose NZB is larger than this are skipped (Spotweb skips >50 KB of XML too). */
const MAX_NZB_SEGMENTS = 20
/** A personal dispose only counts within this long after the spot (Spotweb: 5 days). */
const DISPOSE_WINDOW_SECONDS = 5 * 24 * 60 * 60
/** Rows per INSERT: 14 columns each, under Postgres' 65,535 bind parameters. */
const INSERT_CHUNK = 2000
/** Spot images larger than this many articles aren't fetched. */
const MAX_IMAGE_SEGMENTS = 10
/** A claimed spot whose worker died is taken again after this long. */
const RECLAIM_AFTER = '1 hour'
/**
* A spot's title: from its XML, which is decoded properly (the Subject of a
* raw header is read as ISO-8859-1), else from the header.
*/
function spotTitle(head: { header: SpotHeader; xml: SpotXml | null }): string {
return head.xml?.title || head.header.title
}
/**
* A placeholder NZB (e.g. Dummy.rar + Dummy.nzb, 2.6 KB, shared by many
* spots): less than 1% of the size the spot announces (the real NZB is
* handed out elsewhere), or under 16 KB in all (text-only spots: requests,
* announcements). Its files are not imported, and the spot is hidden.
*/
export function isDummyNzb(nzb: { files: { segments: { bytes: number }[] }[] }, spotSize: number): boolean {
const bytes = nzb.files.reduce((sum, f) => sum + f.segments.reduce((s, x) => s + x.bytes, 0), 0)
return bytes < 16 * 1024 || (spotSize > 0 && bytes < spotSize / 100)
}
export function isSpotGroup(name: string): boolean {
return indexerConfig.spotGroups.includes(name)
}
@@ -55,74 +82,115 @@ class SpotService {
posted_at: spot.stamp ? new Date(spot.stamp * 1000) : null,
key_id: spot.keyId,
spotter_id: spotterId(spot.selfSignedPubKey) || null,
// From the overview; titles with accents may fail here (UTF-8 decoding)
// and are verified again on their raw header by worker:spots.
verified: verifySpotHeader(spot),
created_at: now,
})
}
if (rows.length === 0) return 0
await db.knexQuery().table('spots').insert(rows).onConflict('message_id').ignore()
for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
await db.knexQuery().table('spots').insert(rows.slice(i, i + INSERT_CHUNK)).onConflict('message_id').ignore()
}
return rows.length
}
/** Takes up to `limit` pending spots, oldest first; they are marked `processing`. */
/**
* Takes up to `limit` pending spots, marked `processing`: recent ones
* (to be imported) first, then the history in posting order, so live spots
* don't wait behind a backfill and moderation follows its targets.
*/
async claim(limit: number): Promise<any[]> {
const { rows } = await db.rawQuery(
`UPDATE spots SET status = 'processing', processed_at = now()
WHERE message_id IN (
SELECT message_id FROM spots
WHERE status = 'pending' OR (status = 'processing' AND processed_at < now() - interval '${RECLAIM_AFTER}')
ORDER BY created_at, article_number LIMIT ? FOR UPDATE SKIP LOCKED)
RETURNING *`,
[limit]
)
const claim = async (filter: string, bindings: unknown[], n: number) =>
(
await db.rawQuery(
`UPDATE spots SET status = 'processing', processed_at = now()
WHERE message_id IN (
SELECT message_id FROM spots WHERE ${filter}
ORDER BY article_number LIMIT ? FOR UPDATE SKIP LOCKED)
RETURNING *`,
[...bindings, n]
)
).rows as any[]
const rows = await claim(`status = 'pending' AND posted_at >= ?`, [this.importCutoff()], limit)
if (rows.length < limit) rows.push(...(await claim(`status = 'pending'`, [], limit - rows.length)))
if (rows.length < limit) {
rows.push(...(await claim(`status = 'processing' AND processed_at < now() - interval '${RECLAIM_AFTER}'`, [], limit - rows.length)))
}
return rows
}
/** Verifies the spot on its raw header, then reads and imports its NZB. Returns the new status. */
/** Spots posted before this are listed, not imported: their NZB is read on first download. */
importCutoff(): Date {
return new Date(Date.now() - indexerConfig.spotImportDays * 24 * 60 * 60 * 1000)
}
/** Old, verified spots that aren't moderation: listed in bulk, without reading anything more. */
isListable(spot: any): boolean {
return (
spot.verified === true &&
spot.posted_at !== null &&
new Date(spot.posted_at) < this.importCutoff() &&
!moderationTarget({ keyId: spot.key_id, title: spot.title })
)
}
/**
* Creates the listing releases (source `spot`, no files) for these spots.
* The release key is the one an import of the spot's NZB uses, so the
* import on first download fills this release.
*/
async list(spots: any[]): Promise<number> {
if (spots.length === 0) return 0
const keyOf = (spot: any) => createHash('sha1').update(`nzb\n${spot.message_id}`).digest('hex')
const ids = await IngestService.upsertReleases(
db.connection().getWriteClient(),
spots.map((spot) => ({
release: { key: keyOf(spot), name: spot.title, source: 'spot' as const },
poster: spot.poster,
groups: [spot.group_name],
postedAt: spot.posted_at ? new Date(spot.posted_at) : undefined,
}))
)
const rows = spots.map((spot) => ({ message_id: spot.message_id, release_id: ids.get(keyOf(spot)), size: spot.size }))
await db.rawQuery(
`UPDATE releases r SET size = v.size FROM jsonb_to_recordset(?::jsonb) AS v(release_id bigint, size bigint)
WHERE r.id = v.release_id AND r.file_count = 0 AND v.size IS NOT NULL`,
[JSON.stringify(rows)]
)
await db.rawQuery(
`UPDATE spots s SET status = 'listed', release_id = v.release_id, processed_at = now()
FROM jsonb_to_recordset(?::jsonb) AS v(message_id text, release_id bigint)
WHERE s.message_id = v.message_id`,
[JSON.stringify(rows)]
)
return rows.length
}
/**
* Verifies the spot on its raw header (the signature covers ISO-8859-1
* bytes, which the overview, read as UTF-8, may have mangled), then
* applies moderation, lists an old spot or imports a recent one's NZB.
* Returns the new status.
*/
async process(spot: any): Promise<string> {
const update: Record<string, unknown> = { processed_at: new Date(), error: null }
try {
const raw = await this.fetch(`HEAD <${spot.message_id}>`)
const { headers, xml } = parseSpotArticleHeaders(raw.toString('latin1'))
// The signature covers the ISO-8859-1 bytes, which the overview (read as UTF-8) may have mangled.
const header = parseSpotHeader(headers.from ?? '', headers.subject ?? '', spot.message_id, headers.date)
if (!header || !verifySpotHeader(header)) {
const head = await this.readHead(spot.message_id)
if (!head) {
update.status = 'unverified'
} else if (moderationTarget(header)) {
} else if (moderationTarget(head.header)) {
update.status = 'moderation'
await this.moderate(header, moderationTarget(header)!)
await this.moderate(head.header, moderationTarget(head.header)!)
} else {
const parsed = parseSpotXml(xml)
update.title = header.title
update.poster = header.poster
update.spotter_id = spotterId(header.selfSignedPubKey) || null
if (parsed?.website) update.website = parsed.website
const segments = parsed?.nzbSegments ?? []
if (segments.length === 0 || segments.length > MAX_NZB_SEGMENTS) {
update.status = 'no-nzb'
update.title = spotTitle(head)
update.poster = head.header.poster
update.spotter_id = spotterId(head.header.selfSignedPubKey) || null
Object.assign(update, this.details(head.xml))
if (new Date(head.header.stamp * 1000) < this.importCutoff()) {
await this.list([{ ...spot, title: spotTitle(head), poster: head.header.poster }])
update.status = 'listed'
} else {
const bodies = []
for (const id of segments) bodies.push(await this.fetch(`BODY <${id.replace(/^<|>$/g, '')}>`))
const nzbXml = decodeSpotBinary(bodies).toString('utf8')
const nzb = parseNzb(nzbXml)
if (!nzb || nzb.files.length === 0) {
update.status = 'no-nzb'
} else {
const groups = [...new Set(nzb.files.flatMap((f) => f.groups))]
const result = await NzbImportService.import(
{
id: null,
filename: `${header.title}.nzb`,
poster: header.poster,
groups,
date: header.stamp * 1000,
message_ids: { '1': { id: spot.message_id } },
},
nzbXml,
{ name: header.title, source: 'spot', moveContainer: false }
)
update.status = result ? 'imported' : 'no-nzb'
if (result) update.release_id = result.releaseId
}
Object.assign(update, await this.importNzb(spot.message_id, head))
}
}
} catch (error: any) {
@@ -131,9 +199,149 @@ class SpotService {
logger.warn(`Spot ${spot.message_id} "${spot.title}": ${error.message}`)
}
await db.from('spots').where('message_id', spot.message_id).update(update)
if (update.status === 'imported' || update.status === 'listed') await this.applyPassword(spot.message_id)
return update.status as string
}
/**
* The NZB of a listing, on first download: reads and imports it. Returns
* the release that now holds its files (the listing's own, unless the NZB
* joined files already known elsewhere), or null.
*/
async importListing(releaseId: number): Promise<number | null> {
const spot = await db.from('spots').where('release_id', releaseId).where('status', 'listed').first()
if (!spot) return null
return this.reimport(spot)
}
/**
* Reads a spot's NZB again and imports it, so its files end up in the
* spot's own release under the spot's title. Returns that release, or null.
*/
async reimport(spot: any): Promise<number | null> {
const head = await this.readHead(spot.message_id)
if (!head) return null
const result = await this.importNzb(spot.message_id, head)
await db
.from('spots')
.where('message_id', spot.message_id)
.update({ ...result, ...this.details(head.xml), processed_at: new Date() })
await this.applyPassword(spot.message_id)
return (result.release_id as number | undefined) ?? null
}
/**
* A password the spotter wrote in the spot's title or description (not in
* the NZB) goes on its release, unless the release has one already.
*/
async applyPassword(messageId: string): Promise<string | null> {
const spot = await db.from('spots').where('message_id', messageId).whereNotNull('release_id').first()
const password = spot ? passwordFromSpot(spot.title, spot.description) : null
if (!password) return null
await db
.from('releases')
.where('id', spot.release_id)
.whereNull('password')
.update({ password, password_source: 'spot' })
return password
}
/** Columns for what the spot's XML adds to its overview: description, image, website. */
private details(xml: SpotXml | null): Record<string, unknown> {
return {
description: xml?.description || null,
image: xml?.image ? JSON.stringify(xml.image) : null,
...(xml?.website ? { website: xml.website } : {}),
details_at: new Date(),
}
}
/**
* A spot row with its description and image, reading its full header
* first when that hasn't happened yet (old spots are listed from their
* overview only). Returns the row as it is when Usenet can't be reached.
*/
async withDetails(spot: any): Promise<any> {
if (spot.details_at) return spot
try {
const head = await this.readHead(spot.message_id)
const update = head ? this.details(head.xml) : { details_at: new Date() }
await db.from('spots').where('message_id', spot.message_id).update(update)
await this.applyPassword(spot.message_id)
return { ...spot, ...update, image: head?.xml?.image ?? null }
} catch (error: any) {
logger.warn(`Spot ${spot.message_id} details: ${error.message}`)
// The article is gone (430): nothing to read later either.
if (/^4\d\d/.test(String(error.message))) {
await db.from('spots').where('message_id', spot.message_id).update({ details_at: new Date() })
}
return spot
}
}
/**
* Reads the details of up to `limit` imported spots that don't have them
* (imported before details were kept), newest first. Returns how many.
* Listed spots are left to their first view: there are millions.
* Returns how many were read (0 when Usenet can't be reached).
*/
async fillDetails(limit: number): Promise<number> {
const spots = await db
.from('spots')
.where('status', 'imported')
.whereNull('details_at')
.orderBy('posted_at', 'desc')
.limit(limit)
let read = 0
for (const spot of spots) if ((await this.withDetails(spot)).details_at) read++
return read
}
/** The spot's image as posted on Usenet (Spotnet posts them uncompressed), or null. */
async image(spot: any): Promise<Buffer | null> {
const segments: string[] = spot.image?.segments ?? []
if (segments.length === 0 || segments.length > MAX_IMAGE_SEGMENTS) return null
const bodies = []
for (const id of segments) bodies.push(await this.fetch(`BODY <${id.replace(/^<|>$/g, '')}>`))
return decodeSpotBinary(bodies, false)
}
/** The spot's raw header, verified; null when it doesn't verify. */
private async readHead(messageId: string): Promise<{ header: SpotHeader; xml: SpotXml | null } | null> {
const raw = await this.fetch(`HEAD <${messageId}>`)
const { headers, xml } = parseSpotArticleHeaders(raw.toString('latin1'))
const header = parseSpotHeader(headers.from ?? '', headers.subject ?? '', messageId, headers.date)
if (!header || !verifySpotHeader(header)) return null
return { header, xml: parseSpotXml(decodeSpotText(xml)) }
}
/** Downloads the spot's compressed NZB and imports it as a release (D26). */
private async importNzb(messageId: string, head: { header: SpotHeader; xml: SpotXml | null }): Promise<Record<string, unknown>> {
const segments = head.xml?.nzbSegments ?? []
if (segments.length === 0 || segments.length > MAX_NZB_SEGMENTS) return { status: 'no-nzb' }
const bodies = []
for (const id of segments) bodies.push(await this.fetch(`BODY <${id.replace(/^<|>$/g, '')}>`))
const nzbXml = decodeSpotBinary(bodies).toString('utf8')
const nzb = parseNzb(nzbXml)
if (!nzb || nzb.files.length === 0) return { status: 'no-nzb' }
const { header } = head
if (isDummyNzb(nzb, header.size)) return { status: 'dummy' }
const title = spotTitle(head)
const result = await NzbImportService.import(
{
id: null,
filename: `${title}.nzb`,
poster: header.poster,
groups: [...new Set(nzb.files.flatMap((f) => f.groups))],
date: header.stamp * 1000,
first_message_id: messageId,
},
nzbXml,
{ name: title, source: 'spot', moveContainer: false, trusted: true }
)
return result ? { status: 'imported', release_id: result.releaseId } : { status: 'no-nzb' }
}
/**
* Applies a delete/dispose/remove: the target spot is marked `removed`
* (its release is then hidden from search). As in Spotweb, a personal
@@ -150,6 +358,15 @@ class SpotService {
}
await db.from('spots').where('message_id', targetId).update({ status: 'removed' })
logger.info(`Spot "${target.title}" removed by ${personal ? 'its poster' : 'a moderator'}.`)
// Spots that joined its release (a correction, a repost) take the files over now.
if (target.release_id) {
const sharing = await db
.from('spots')
.where('release_id', target.release_id)
.where('status', 'imported')
.whereNot('message_id', targetId)
for (const spot of sharing) await this.reimport(spot)
}
}
/** One HEAD or BODY, as raw bytes. */
@@ -157,7 +374,7 @@ class SpotService {
const conn = await NntpService.acquire()
let failed = false
try {
const { data } = await conn._artcmd(command)
const { data } = await NntpService.timed<any>(conn._artcmd(command), command)
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
} catch (error: any) {
// 430 (no such article) is an answer, not a broken connection.
+252
View File
@@ -0,0 +1,252 @@
import { createHash } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import indexerConfig from '#config/indexer'
import IngestService, { type IngestSegment } from '#services/IngestService'
import { counterFileKey } from '#lib/subject/index'
import { fileStem } from '#lib/release/index'
/** Rows per statement (a key and a JSON array each). */
const CHUNK = 5000
/**
* Staging of lone header segments (CONCEPT.md D35). Uploads that give every
* article its own random subject turned each article into a binary, a part
* and a release, which GC deleted two hours later: ~3.9M binaries an hour
* against ~123k real files. A segment whose file is new and alone in its
* fetch batch now waits in `staged_segments` (unlogged, one index) until a
* second segment of that file arrives; only then do both go into the real
* tables. GC drops what waited longer than STAGING_TTL_SECONDS (30 min).
*
* Counter grouping (D36): those random-subject uploads still keep poster,
* file counter `[i/M]` and part count per file. Staged segments also carry
* that key (`counter_key`); once two staged segments from different subjects
* share it, they become one file under that key, and later segments with it
* join that file directly.
*/
class StagingService {
enabled(groupName: string): boolean {
const groups = indexerConfig.stagingGroups
return groups.includes('*') || groups.includes(groupName)
}
/** Whether counter grouping (D36) applies to this group. */
countersEnabled(groupName: string): boolean {
const groups = indexerConfig.counterGroupingGroups
return groups.includes('*') || groups.includes(groupName)
}
/**
* The segments of a header batch that should be ingested now: the batch's
* own, minus lone segments of new files (staged instead), plus staged
* segments whose file this batch shows to be real. `staged` counts the
* segments that went to staging.
*/
async filter(segments: IngestSegment[], { counters = false } = {}): Promise<{ ingest: IngestSegment[]; staged: number }> {
const byKey = new Map<string, IngestSegment[]>()
for (const segment of segments) {
const list = byKey.get(segment.fileKey)
if (list) list.push(segment)
else byKey.set(segment.fileKey, [segment])
}
const ingest: IngestSegment[] = []
const lone: IngestSegment[] = []
for (const list of byKey.values()) {
// Single-segment files are complete; two different articles of a file in one batch make it real.
// (One article twice is a crosspost, LOG.md 2026-10-02.)
const articles = new Set(list.map((s) => s.messageId)).size
if (list[0].totalParts <= 1 || articles >= 2 || list[0].weakIdentity || list[0].size != null) ingest.push(...list)
else lone.push(list[0])
}
const counterOf = new Map<IngestSegment, string>()
if (counters) {
for (const segment of lone) {
const key = counterFileKey(segment)
if (key) counterOf.set(segment, key)
}
}
// Files we already have a binary for (by subject, or by counters) take their segments directly.
const known = new Set<string>()
const lookup = [...new Set([...lone.map((s) => s.fileKey), ...counterOf.values()])]
for (let i = 0; i < lookup.length; i += CHUNK) {
const rows = await db.from('binaries').whereIn('file_key', lookup.slice(i, i + CHUNK)).select('file_key')
for (const row of rows) known.add(row.file_key)
}
const toStage: IngestSegment[] = []
const knownCounters = new Set<string>()
for (const segment of lone) {
const counter = counterOf.get(segment)
if (known.has(segment.fileKey)) {
ingest.push(segment)
} else if (counter && known.has(counter)) {
ingest.push(asCounterFile(segment, counter))
knownCounters.add(counter)
} else {
toStage.push(segment)
}
}
// Stage the rest; a file that now has two staged segments comes out again.
const promoted: string[] = []
// Key order, so concurrent jobs lock rows in the same order.
toStage.sort((a, b) => a.fileKey.localeCompare(b.fileKey))
for (let i = 0; i < toStage.length; i += CHUNK) {
const rows = toStage.slice(i, i + CHUNK).map((s) => ({
file_key: s.fileKey,
counter_key: counterOf.get(s) ?? null,
segments: [serialize(s)],
staged_at: new Date(),
}))
const { rows: result } = await retryDeadlock(() => db.rawQuery(
`INSERT INTO staged_segments (file_key, counter_key, segments, staged_at)
SELECT file_key, counter_key, segments, staged_at
FROM jsonb_to_recordset(?::jsonb) AS v(file_key text, counter_key text, segments jsonb, staged_at timestamptz)
ON CONFLICT (file_key) DO UPDATE SET segments = CASE
-- The same article again (a crosspost, or a re-fetched range) is no second segment.
WHEN EXISTS (SELECT 1 FROM jsonb_array_elements(staged_segments.segments) e
WHERE e->>'messageId' = EXCLUDED.segments->0->>'messageId') THEN staged_segments.segments
ELSE staged_segments.segments || EXCLUDED.segments END
RETURNING file_key, jsonb_array_length(segments) AS n`,
[JSON.stringify(rows)]
))
for (const row of result) if (Number(row.n) >= 2) promoted.push(row.file_key)
}
// Counter files: two staged rows (two subjects) with one counter key make a file.
const stagedCounters = [...new Set(toStage.map((s) => counterOf.get(s)).filter((k): k is string => Boolean(k)))]
const readyCounters: string[] = []
for (let i = 0; i < stagedCounters.length; i += CHUNK) {
const { rows } = await db.rawQuery(
`SELECT counter_key FROM staged_segments WHERE counter_key = ANY(?) GROUP BY counter_key HAVING count(*) >= 2`,
[stagedCounters.slice(i, i + CHUNK)]
)
for (const row of rows) readyCounters.push(row.counter_key)
}
// Promoted files, staged segments of files this batch ingests anyway, and ready counter files.
const fileKeys = [...new Set([...promoted, ...ingest.map((s) => s.fileKey)])]
const counterKeys = [...new Set([...readyCounters, ...knownCounters])]
const seen = new Set(ingest.map((s) => s.messageId))
for (const segment of await this.take(fileKeys, counterKeys)) {
if (!seen.has(segment.messageId)) ingest.push(segment)
seen.add(segment.messageId)
}
const staged = toStage.filter((s) => !seen.has(s.messageId)).length
return { ingest, staged }
}
/**
* GC: drops staged segments that waited longer than `maxAgeSeconds`, then
* ingests those whose file got a binary meanwhile (two fetch jobs racing).
* Expiry comes first so the table stays bounded whatever the budget; the
* adoption starts from recently seen binaries (index on last_seen_at,
* which moves in 10-minute steps) rather than joining every staged row.
*/
async sweep(maxAgeSeconds: number, budget: number): Promise<{ adopted: number; expired: number }> {
let adopted = 0
let expired = 0
while (Date.now() < budget) {
const result = await db.rawQuery(
`DELETE FROM staged_segments WHERE ctid IN (
SELECT ctid FROM staged_segments WHERE staged_at < now() - make_interval(secs => ?) LIMIT 20000)`,
[maxAgeSeconds]
)
expired += result.rowCount
if (result.rowCount < 20000) break
}
let lastId = 0
while (Date.now() < budget) {
const recent = await db
.from('binaries')
.where('last_seen_at', '>', new Date(Date.now() - (maxAgeSeconds + 15 * 60) * 1000))
.where('id', '>', lastId)
.orderBy('id')
.limit(CHUNK)
.select('id', 'file_key')
if (recent.length === 0) break
lastId = Number(recent[recent.length - 1].id)
const keys = recent.map((b) => b.file_key as string)
// A binary's key is a subject key or a counter key; staged rows are looked up by both.
const segments = await this.take(keys, keys)
if (segments.length > 0) await IngestService.ingest(segments)
adopted += segments.length
}
return { adopted, expired }
}
/**
* Removes and returns the staged segments of these files: by subject key,
* or by counter key (returned as segments of the counter file).
*/
private async take(fileKeys: string[], counterKeys: string[] = []): Promise<IngestSegment[]> {
const taken: IngestSegment[] = []
for (let i = 0; i < fileKeys.length; i += CHUNK) {
const { rows } = await retryDeadlock(() =>
db.rawQuery(`DELETE FROM staged_segments WHERE file_key = ANY(?) RETURNING segments`, [fileKeys.slice(i, i + CHUNK)])
)
for (const row of rows) taken.push(...(row.segments as any[]).map(deserialize))
}
for (let i = 0; i < counterKeys.length; i += CHUNK) {
const { rows } = await retryDeadlock(() =>
db.rawQuery(`DELETE FROM staged_segments WHERE counter_key = ANY(?) RETURNING counter_key, segments`, [counterKeys.slice(i, i + CHUNK)])
)
for (const row of rows) taken.push(...(row.segments as any[]).map((s) => asCounterFile(deserialize(s), row.counter_key)))
}
return taken
}
}
/**
* A segment as part of its counter file (D36): the counter key is its file
* key, it is a weak identity (a repeated part number from another upload
* marks the binary conflicted), and its release is a weak one of its own,
* which the upload's par2 set can gather later.
*/
function asCounterFile(segment: IngestSegment, counterKey: string): IngestSegment {
return {
...segment,
fileKey: counterKey,
weakIdentity: true,
release: {
key: createHash('sha1').update(`counter-release\n${counterKey}`).digest('hex'),
name: fileStem(segment.name),
source: 'weak-stem',
},
}
}
/**
* Runs a staging statement again when it lost a deadlock: concurrent fetch
* jobs insert by file key and delete by counter key, so their row locks can
* cross. Postgres rolled the statement back, and each one is safe to repeat.
*/
async function retryDeadlock<T>(statement: () => Promise<T>, attempts = 5): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await statement()
} catch (error: any) {
if (error?.code !== '40P01' || attempt >= attempts) throw error
await new Promise((resolve) => setTimeout(resolve, 50 * attempt + Math.random() * 100))
}
}
}
function serialize(segment: IngestSegment) {
return {
...segment,
postedAt: segment.postedAt?.toISOString(),
articleNumber: segment.articleNumber?.toString(),
}
}
function deserialize(stored: any): IngestSegment {
return {
...stored,
postedAt: stored.postedAt ? new Date(stored.postedAt) : undefined,
articleNumber: stored.articleNumber ?? undefined,
}
}
export default new StagingService()
+33 -7
View File
@@ -3,6 +3,7 @@ import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import QueueService from '#services/QueueService'
import ContentService from '#services/ContentService'
import { isArchiveStart } from '#lib/content/index'
const BATCH = 1000
@@ -28,17 +29,37 @@ export default class ContentBackfill extends BaseCommand {
@flags.boolean({ description: 'Only archives whose listing contains NZBs, to import those (CONCEPT.md D28)' })
declare archiveNzbs: boolean
@flags.boolean({ description: 'Only unlisted 7z archives (first volumes: .7z, .7z.001)' })
declare sevenZip: boolean
@flags.boolean({ description: 'Archive starts never inspected (RAR first volumes, 7z, .bin), also files from NZBs/spots' })
declare archives: boolean
@flags.boolean({ description: 'SFV files not read yet (their listings group the files they name, D34)' })
declare sfv: boolean
@flags.boolean({ description: 'niconico captures not read yet (their releases get the video title, D34)' })
declare nicovideo: boolean
public async run() {
let lastId = 0
let queued = 0
try {
while (!this.limit || queued < this.limit) {
// Files never inspected: no fingerprint on their binary and no archive listing.
const query = db
.from('files as f')
.join('binaries as b', 'b.file_id', 'f.id')
.where('f.id', '>', lastId)
if (this.archiveNzbs) query.whereRaw(`f.archive::text ~* '\\.nzb"'`)
const query = db.from('files as f').where('f.id', '>', lastId)
// Files from NZBs (spots) have no binary.
if (this.sevenZip || this.archives || this.sfv || this.nicovideo) query.leftJoin('binaries as b', 'b.file_id', 'f.id')
else query.join('binaries as b', 'b.file_id', 'f.id')
if (this.nicovideo) {
query.whereNull('f.content_type').whereRaw(`f.filename ~* '^\\d{9,11}_nicovideo_jp_watch_sm\\d+\\.tar\\.zst$'`)
} else if (this.sfv) {
query.whereNull('f.content_type').whereRaw(`coalesce(f.real_name, f.filename) ~* '\\.sfv$'`)
} else if (this.archives) {
query.whereNull('f.content_type').whereNull('f.archive').whereRaw(`coalesce(f.real_name, f.filename) ~* '\\.(rar|7z|7z\\.0*1|bin)$'`)
} else if (this.sevenZip) {
query.whereNull('f.archive').whereRaw(`coalesce(f.real_name, f.filename) ~* '\\.7z(\\.0*1){0,1}$'`)
} else if (this.archiveNzbs) query.whereRaw(`f.archive::text ~* '\\.nzb"'`)
else if (!this.all) query.whereNull('b.md5_16k').whereNull('f.archive')
const files = await query
.orderBy('f.id')
@@ -48,14 +69,19 @@ export default class ContentBackfill extends BaseCommand {
lastId = Number(files.at(-1).id)
const jobs = files
.filter((f) => ContentService.isCandidate(f.filename))
.filter((f) => (this.archives ? isArchiveStart(f.filename) : this.sfv || this.nicovideo || ContentService.isCandidate(f.filename)))
.slice(0, this.limit ? this.limit - queued : undefined)
.map((f) => ({
name: 'process-collection',
data: { fileId: Number(f.id) },
// A new id per run: BullMQ ignores an add() for an id it still knows.
// Targeted backfills go ahead of whatever is waiting.
opts: { jobId: `content-${f.id}-${Date.now()}`, lifo: this.archiveNzbs },
opts: {
jobId: `content-${f.id}-${Date.now()}`,
lifo: this.archiveNzbs || this.sevenZip || this.archives || this.sfv,
// niconico captures are many (~190k) and only cosmetic: behind everything else.
...(this.nicovideo ? { priority: 10 } : {}),
},
}))
if (jobs.length > 0) await QueueService.collectionQueue.addBulk(jobs)
queued += jobs.length
+22
View File
@@ -0,0 +1,22 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import IndexMaintenance from '#services/IndexMaintenance'
/** Rebuilds bloated indexes online (gc:scheduler also does this weekly). */
export default class DbReindex extends BaseCommand {
public static commandName = 'db:reindex'
public static description = 'Rebuilds B-tree indexes that are mostly empty space (online, REINDEX CONCURRENTLY).'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only show which indexes would be rebuilt' })
declare dryRun: boolean
public async run() {
const rebuilt = await IndexMaintenance.rebuildBloated({ dryRun: this.dryRun })
const saved = rebuilt.reduce((sum, r) => sum + r.before - r.after, 0)
this.logger.info(`${rebuilt.length} indexes ${this.dryRun ? 'would be rebuilt' : `rebuilt, ${Math.round(saved / 1024 / 1024)} MB freed`}.`)
}
}
+35
View File
@@ -0,0 +1,35 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import { unquotedFileName } from '#services/NzbImportService'
/**
* Files named after a whole NZB subject without quotes (spot NZBs), e.g.
* `Adventure.part0055.rar - [056/175] (1/1)`: renamed to the filename in it.
*/
export default class FilesCleanNames extends BaseCommand {
public static commandName = 'files:clean-names'
public static description = 'Strips subject leftovers (counters, "yEnc") from file names that came from NZBs.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only show what would change' })
declare dryRun: boolean
public async run() {
const files = await db
.from('files')
.whereRaw(`filename ~* '(\\(\\d+/\\d+\\)|yEnc|\\[\\d+/\\d+\\])\\s*$'`)
.select('id', 'filename')
const changes = files
.map((f) => ({ id: Number(f.id), from: f.filename as string, to: unquotedFileName(f.filename) }))
.filter((c) => c.to !== c.from)
for (const c of changes.slice(0, 5)) this.logger.info(`"${c.from}" → "${c.to}"`)
if (!this.dryRun) {
for (const c of changes) await db.from('files').where('id', c.id).update({ filename: c.to })
}
this.logger.info(`${changes.length} of ${files.length} file names ${this.dryRun ? 'would change' : 'cleaned'}.`)
}
}
+84 -16
View File
@@ -5,10 +5,16 @@ import db from '@adonisjs/lucid/services/db'
import gcConfig from '#config/gc'
import QueueService from '#services/QueueService'
import RedisService from '#services/RedisService'
import IndexMaintenance from '#services/IndexMaintenance'
import ReleaseMergeService from '#services/ReleaseMergeService'
import StagingService from '#services/StagingService'
import FilePartitions from '#services/FilePartitions'
import Shutdown from '#services/Shutdown'
const CLEAN_BATCH = 1000
const SCAN_COUNT = 1000
/** Binaries per statement when dropping copied parts (a few hundred parts each at most). */
const COPIED_PARTS_BATCH = 2000
export default class GcScheduler extends BaseCommand {
public static commandName = 'gc:scheduler'
@@ -56,6 +62,9 @@ export default class GcScheduler extends BaseCommand {
}
}
// Inserts fail without a partition for their id (D39).
if (!this.dryRun) await FilePartitions.ensure()
const trimmed = await this.trimBodyQueue()
if (trimmed) this.logger.info(`Body queue: ${this.verb('drop', 'dropped')} ${trimmed} oldest waiting jobs.`)
@@ -63,13 +72,20 @@ export default class GcScheduler extends BaseCommand {
this.logger.info(`Binaries: ${this.verb('delete', 'deleted')} ${binaries} stale.`)
if (!this.dryRun) {
const budget = Date.now() + gcConfig.deleteBudgetMs
const parts = await this.dropCopiedParts(budget)
const single = await this.deleteSingleArticleBinaries(budget)
const releases = await this.deleteEmptyReleases(budget)
if (parts || single || releases) {
// Each kind gets its own time budget; empty releases last, as deleting binaries empties them.
const budget = () => Date.now() + gcConfig.deleteBudgetMs
const parts = await this.dropCopiedParts(budget())
const single = await this.deleteSingleArticleBinaries(budget())
const unfingerprinted = await this.deleteUnfingerprintedBinaries(budget())
const releases = await this.deleteEmptyReleases(budget())
const staging = await StagingService.sweep(gcConfig.stagingTtlSeconds, budget())
if (staging.adopted || staging.expired) {
this.logger.info(`Staging: ${staging.adopted} segments adopted by their file, ${staging.expired} expired.`)
}
if (parts || single || unfingerprinted || releases) {
this.logger.info(
`Space: ${parts} copied parts, ${single} single-article binaries, ${releases} empty releases deleted.`
`Space: ${parts} copied parts, ${single} single-article binaries, ${unfingerprinted} unfingerprinted completed binaries, ` +
`${releases} empty releases deleted.`
)
}
}
@@ -83,6 +99,23 @@ export default class GcScheduler extends BaseCommand {
`Legacy pending files: scanned ${scanned}, ${this.verb('set a TTL on', 'set a TTL on')} ${expired} without expiry.`
)
}
// Uploads split per poster (CONCEPT.md D35); the releases this empties go in a later sweep.
const merged = await ReleaseMergeService.mergeSplitPosts({ dryRun: this.dryRun })
if (merged.releases) this.logger.info(`Split uploads: ${this.verb('merge', 'merged')} ${merged.releases} releases into ${merged.names}.`)
await this.rebuildIndexes()
}
/** Rebuilds bloated indexes, at most every gcConfig.reindexIntervalMs (CONCEPT.md D33). */
private async rebuildIndexes() {
const key = 'gc:reindex:last'
const last = Number((await RedisService.client.get(key)) ?? 0)
if (Date.now() - last < gcConfig.reindexIntervalMs) return
const rebuilt = await IndexMaintenance.rebuildBloated({ dryRun: this.dryRun })
if (!this.dryRun) await RedisService.client.set(key, String(Date.now()))
const saved = rebuilt.reduce((sum, r) => sum + r.before - r.after, 0)
this.logger.info(`Indexes: ${this.verb('rebuild', 'rebuilt')} ${rebuilt.length}, ${Math.round(saved / 1024 / 1024)} MB freed.`)
}
/**
@@ -146,20 +179,32 @@ export default class GcScheduler extends BaseCommand {
private singleCursor = 0
private releaseCursor = 0
/** Parts of header-derived binaries that are a file already (worker:file now drops them itself). */
/**
* Header parts of binaries that are a file already. worker:file drops them
* when it creates the file, and ingest no longer adds them afterwards; this
* catches what slipped through. A cursor over binaries (as for single
* articles) visits each one once per pass, instead of searching all of
* them for parts on every batch (LOG.md 2026-10-04).
*/
private copiedCursor = 0
private async dropCopiedParts(budget: number): Promise<number> {
let total = 0
while (Date.now() < budget) {
const result = await db.rawQuery(
`DELETE FROM parts WHERE binary_id IN (
SELECT b.id FROM binaries b
WHERE b.file_id IS NOT NULL AND b.size IS NULL
AND EXISTS (SELECT 1 FROM parts p WHERE p.binary_id = b.id)
LIMIT ?)`,
[500]
const { rows } = await db.rawQuery(
`WITH batch AS (
SELECT id FROM binaries WHERE id > ? AND file_id IS NOT NULL ORDER BY id LIMIT ?
), gone AS (
DELETE FROM parts p USING batch WHERE p.binary_id = batch.id AND p.pcrc32 IS NULL
RETURNING 1)
SELECT (SELECT max(id) FROM batch) AS last, (SELECT count(*) FROM gone) AS deleted`,
[this.copiedCursor, COPIED_PARTS_BATCH]
)
total += result.rowCount ?? 0
if (!result.rowCount) break
total += Number(rows[0].deleted)
if (rows[0].last === null) {
this.copiedCursor = 0
break
}
this.copiedCursor = Number(rows[0].last)
}
return total
}
@@ -190,6 +235,29 @@ export default class GcScheduler extends BaseCommand {
return total
}
/**
* Completed header-derived binaries without a fingerprint, some hours after
* completion: their file exists, their parts are gone, and par2 naming needs
* a size and MD5 they don't have. Oldest first, via the last_seen_at index.
*/
private async deleteUnfingerprintedBinaries(budget: number): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.binaries.unfingerprintedAfterSeconds * 1000)
let total = 0
while (Date.now() < budget) {
const result = await db.rawQuery(
`DELETE FROM binaries WHERE id IN (
SELECT id FROM binaries
WHERE last_seen_at < ? AND file_id IS NOT NULL AND completed_at IS NOT NULL
AND size IS NULL AND md5_16k IS NULL AND conflicted_at IS NULL
ORDER BY last_seen_at LIMIT ?)`,
[cutoff, gcConfig.deleteBatch]
)
total += result.rowCount
if (result.rowCount < gcConfig.deleteBatch) break
}
return total
}
/** Releases nothing refers to any more: no files, binaries, par2 listing or spot. */
private async deleteEmptyReleases(budget: number): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.emptyReleaseAfterSeconds * 1000)
+14 -7
View File
@@ -33,6 +33,9 @@ export default class GroupAdd extends BaseCommand {
@flags.boolean({ description: 'With --backfill: also for groups indexed before, skipping what was missed since' })
declare reset: boolean
@flags.boolean({ description: "Start at the server's oldest article: the group's full history (also for groups indexed before)" })
declare fromStart: boolean
public async run() {
for (const name of this.names) {
if (!/^[a-z0-9][a-z0-9.+_-]*$/i.test(name)) {
@@ -42,26 +45,30 @@ export default class GroupAdd extends BaseCommand {
const group = await Group.firstOrCreate({ name }, { name, active: !this.disable, fetchBodies: this.bodies })
group.active = !this.disable
if (this.bodies) group.fetchBodies = true
if (this.backfill !== undefined && (this.reset || BigInt(group.lastQueuedId ?? 0) === 0n)) {
// The scheduler continues after last_queued_id.
const last = await this.lastArticle(name)
if (this.fromStart) {
// The scheduler continues after last_queued_id; 0 would mean "new group" to it.
const { first } = await this.groupInfo(name)
group.lastQueuedId = first > 1n ? first - 1n : 1n
group.lastIndexedId = group.lastQueuedId
} else if (this.backfill !== undefined && (this.reset || BigInt(group.lastQueuedId ?? 0) === 0n)) {
const { last } = await this.groupInfo(name)
group.lastQueuedId = (last > BigInt(this.backfill) ? last - BigInt(this.backfill) : 0n)
group.lastIndexedId = group.lastQueuedId
}
await group.save()
this.logger.info(
`${name}: ${group.active ? 'active' : 'inactive'}, bodies ${group.fetchBodies ? 'on' : 'off'}` +
(this.backfill !== undefined ? `, starting after article ${group.lastQueuedId}` : '')
(this.backfill !== undefined || this.fromStart ? `, starting after article ${group.lastQueuedId}` : '')
)
}
if (this.backfill !== undefined) await NntpService.shutdown()
if (this.backfill !== undefined || this.fromStart) await NntpService.shutdown()
}
private async lastArticle(name: string): Promise<bigint> {
private async groupInfo(name: string) {
const conn = await NntpService.acquire()
let failed = false
try {
return parseGroupResponse((await conn.group(name)).resp).last
return parseGroupResponse((await conn.group(name)).resp)
} catch (error) {
failed = true
throw error
+52
View File
@@ -0,0 +1,52 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import NntpService from '#services/NntpService'
import BackfillService from '#services/BackfillService'
import { isSpotGroup } from '#services/SpotService'
/**
* Indexes a group's older headers (CONCEPT.md D32): from where live
* indexing began back to N days ago, header-only and behind live indexing.
* index:scheduler queues the ranges; this only sets where to go.
*/
export default class GroupBackfill extends BaseCommand {
public static commandName = 'group:backfill'
public static description = "Indexes groups' older headers (header-only, behind live indexing)."
public static options: CommandOptions = {
startApp: true,
}
@args.spread({ description: 'Groups (default: every active binary group)', required: false })
declare names?: string[]
@flags.number({ description: 'How many days back', default: 21 })
declare days: number
@flags.boolean({ description: 'Stop backfilling these groups (ranges already queued still run)' })
declare stop: boolean
public async run() {
const names = this.names?.length
? this.names
: (await db.from('groups').where('active', true).orderBy('name').select('name')).map((g) => g.name as string).filter((n) => !isSpotGroup(n))
try {
for (const name of names) {
if (this.stop) {
await BackfillService.stop(name)
this.logger.info(`${name}: backfill stopped.`)
continue
}
const range = await BackfillService.start(name, this.days)
this.logger.info(
range
? `${name}: backfilling articles ${range.from}–${range.to} (${(range.to - range.from).toLocaleString('en')} articles, ${this.days} days).`
: `${name}: nothing to backfill.`
)
}
} finally {
await NntpService.shutdown()
}
}
}
+11
View File
@@ -7,6 +7,7 @@ import indexerConfig from '#config/indexer'
import Shutdown from '#services/Shutdown'
import FetchRangeService from '#services/FetchRangeService'
import SplitService from '#services/SplitService'
import BackfillService from '#services/BackfillService'
export default class IndexScheduler extends BaseCommand {
public static commandName = 'index:scheduler'
@@ -107,9 +108,19 @@ export default class IndexScheduler extends BaseCommand {
}
}
const backfill = async () => {
try {
const queued = await BackfillService.tick()
if (queued > 0) this.logger.info(`Queued ${queued} backfill range(s).`)
} catch (err: any) {
this.logger.error(`Error queuing backfill: ${err.message}`)
}
}
// Run immediately, then wait a full interval after each run so runs never overlap.
while (!Shutdown.requested) {
await schedule()
await backfill()
await split()
await Shutdown.sleep(indexerConfig.intervalMs)
}
+21
View File
@@ -0,0 +1,21 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import ReleaseMergeService from '#services/ReleaseMergeService'
/** Merges uploads split per poster (gc:scheduler also does this every sweep). */
export default class ReleaseMergeSplit extends BaseCommand {
public static commandName = 'release:merge-split'
public static description = 'Merges releases of one upload that was posted under a different poster per file.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only count them' })
declare dryRun: boolean
public async run() {
const { names, releases } = await ReleaseMergeService.mergeSplitPosts({ dryRun: this.dryRun })
this.logger.info(`${names} split uploads; ${releases} releases ${this.dryRun ? 'would be' : ''} merged into their first.`)
}
}
+309
View File
@@ -0,0 +1,309 @@
import { gzipSync, unzipSync } from 'node:zlib'
import { setTimeout as sleep } from 'node:timers/promises'
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import nntpConfig from '#config/nntp'
import indexerConfig from '#config/indexer'
import { XoverClient, parseOverviews, type Overview } from '#lib/nntp/xover_client'
import { runRange, type OverviewSource } from '#lib/seed/runner'
import type { SeedBatch } from '#lib/seed/wire'
import type { OverviewHeader } from '#lib/ingest/segment'
class LeaseLost extends Error {}
class Stopping extends Error {}
interface Lease {
id: number
group: string
start: number
end: number
}
/**
* A seeding node (CONCEPT.md D38): leases article ranges from the seed API
* over HTTPS, fetches and aggregates their headers over its own NNTP
* connection(s) (D37), and posts the results back in gzip-compressed
* batches. Outgoing requests only: no ports, database or Redis needed.
*
* SEED_URL=https://seed.example SEED_TOKEN=sn_… NNTP_HOST=… node ace seed:node --connections 4
*/
export default class SeedNode extends BaseCommand {
public static commandName = 'seed:node'
public static description =
'Runs a seeding node: leases ranges from the seed API and submits what it aggregated.'
public static options: CommandOptions = {
startApp: false,
staysAlive: true,
}
@flags.string({ description: 'Seed API base URL (default: SEED_URL)' })
declare url?: string
@flags.string({ description: 'Node token (default: SEED_TOKEN)' })
declare token?: string
@flags.number({
description:
'Ranges worked on at once, one NNTP connection each (default: SEED_CONNECTIONS or 1)',
default: Number(process.env.SEED_CONNECTIONS) || 1,
})
declare connections: number
@flags.number({ description: 'Articles per XOVER', default: 20_000 })
declare chunk: number
@flags.number({ description: 'XOVER chunks per batch posted to the API', default: 5 })
declare submitEvery: number
@flags.string({
description:
'relay: headers come through the seed API (no NNTP account needed); direct: own NNTP connections (default: SEED_FETCH or relay)',
default: process.env.SEED_FETCH || 'relay',
})
declare fetch: string
@flags.boolean({ description: 'Stop when there is nothing to lease instead of waiting' })
declare once: boolean
private stopping = false
public async run() {
const base = (this.url ?? process.env.SEED_URL ?? '').replace(/\/+$/, '')
const token = this.token ?? process.env.SEED_TOKEN ?? ''
if (!base || !token) {
this.logger.error('Set --url and --token (or SEED_URL and SEED_TOKEN).')
this.exitCode = 1
return
}
const stop = () => {
if (!this.stopping) this.logger.info('Stopping: giving back the ranges in progress…')
this.stopping = true
}
process.on('SIGTERM', stop)
process.on('SIGINT', stop)
const api = new SeedApi(base, token, (message) => this.logger.warning(message))
this.logger.info(
`Seeding node for ${base}: ${this.connections} range(s) at once, ${this.fetch === 'direct' ? 'own NNTP connections' : 'headers through the seed API'}.`
)
await Promise.all(Array.from({ length: this.connections }, (_, i) => this.work(api, i + 1)))
this.logger.info('Seeding node stopped.')
}
private async work(api: SeedApi, worker: number) {
const log = (message: string) => this.logger.info(`[${worker}] ${message}`)
// The NNTP connection is opened for a lease and kept while leases keep coming; with
// nothing to lease it's closed, so an idle node holds no connections.
let client: XoverClient | undefined
try {
while (!this.stopping) {
const lease = await this.lease(api, log)
if (!lease) {
client?.close()
client = undefined
if (this.once) return
await this.pause(60_000)
continue
}
if (this.fetch !== 'direct') {
await this.workRange(api, new RelaySource(api, lease.id), lease, log)
continue
}
if (!client) {
try {
client = await this.connect()
} catch (error: any) {
// Never started: the lease doesn't count as one of the range's attempts.
const ipLimit = /simultaneous IP/i.test(error.message)
log(`NNTP login failed: ${error.message}; trying again in ${ipLimit ? 5 : 1} min`)
await api
.post(`/api/v1/seed/ranges/${lease.id}/release`, {
error: `NNTP login: ${error.message}`,
uncounted: true,
})
.catch(() => {})
await this.pause(ipLimit ? 300_000 : 60_000)
continue
}
}
if (!(await this.workRange(api, client, lease, log))) {
client.close()
client = undefined
}
}
} finally {
client?.close()
}
}
private connect() {
return XoverClient.connect({
host: nntpConfig.host,
port: nntpConfig.port,
secure: nntpConfig.secure,
servername: nntpConfig.tlsServername,
user: nntpConfig.user,
password: nntpConfig.password?.release(),
timeoutMs: nntpConfig.commandTimeoutMs,
compress: true,
})
}
/** The next range, or null when there is none (or the API can't be reached; logged). */
private async lease(api: SeedApi, log: (message: string) => void): Promise<Lease | null> {
try {
return ((await api.post('/api/v1/seed/lease')) as { range: Lease } | null)?.range ?? null
} catch (error: any) {
if (error instanceof Unauthorized) throw error
log(`Lease failed: ${error.message}`)
return null
}
}
/** Works one leased range; false when the NNTP connection should be replaced. */
private async workRange(
api: SeedApi,
client: OverviewSource,
lease: Lease,
log: (message: string) => void
): Promise<boolean> {
log(`${lease.group} ${lease.start}–${lease.end}`)
const started = Date.now()
let headers = 0
let chain: Promise<void> = Promise.resolve()
const posting = new Set<Promise<void>>()
// Throws once a post has failed, so the range stops fetching.
const failedSoFar = () => Promise.race([chain, Promise.resolve()])
try {
await runRange({
client,
group: lease.group,
start: lease.start,
end: lease.end,
chunk: this.chunk,
submitEvery: this.submitEvery,
isDeferred: (header: OverviewHeader) =>
indexerConfig.deferBodyMessageIds.some((re) => re.test(header['message-id'] ?? '')),
// Batches are posted in order, one after the other, while fetching goes on; with two
// waiting, fetching waits too. A failed post stops the chain, so no batch is stored
// past a missing one (the range would resume beyond it).
sink: async (batch: SeedBatch) => {
if (this.stopping) throw new Stopping()
while (posting.size >= 2) await Promise.race(posting)
const post = chain.then(async () => {
await api.post(`/api/v1/seed/ranges/${lease.id}/batches`, batch)
headers += batch.headers
})
chain = post
posting.add(post)
post.catch(() => {}).finally(() => posting.delete(post))
await failedSoFar()
},
})
await chain
await api.post(`/api/v1/seed/ranges/${lease.id}/done`)
const seconds = (Date.now() - started) / 1000
log(
`done: ${headers.toLocaleString('en')} headers in ${seconds.toFixed(0)} s (${Math.round(headers / seconds).toLocaleString('en')}/s)`
)
return true
} catch (error: any) {
if (error instanceof Unauthorized) throw error
// An XOVER may still be in flight: the connection is replaced either way.
if (error instanceof LeaseLost) {
log(`Range ${lease.id} passed to another node; dropping it.`)
return false
}
const reason = error instanceof Stopping ? 'node stopping' : error.message
if (!(error instanceof Stopping)) log(`Range ${lease.id} failed: ${reason}`)
// Let posts in flight finish (or fail) first, so the range resumes after what was stored.
await chain.catch(() => {})
await api.post(`/api/v1/seed/ranges/${lease.id}/release`, { error: reason }).catch(() => {})
if (!this.stopping) await this.pause(30_000)
return false
}
}
/** Sleeps, but wakes up early when the node is stopping. */
private async pause(ms: number) {
for (let waited = 0; waited < ms && !this.stopping; waited += 1000) await sleep(1000)
}
}
class Unauthorized extends Error {}
/** The seed API client: JSON over HTTPS, gzip request bodies, retries for network errors and 5xx. */
class SeedApi {
constructor(
private base: string,
private token: string,
private warn: (message: string) => void
) {}
async post(path: string, body?: unknown): Promise<unknown> {
const payload = body === undefined ? undefined : gzipSync(JSON.stringify(body))
const result = await this.request('POST', path, payload)
if (result === null) return null
const text = result.toString('utf8')
return text ? JSON.parse(text) : null
}
/** A binary GET (a relay chunk). */
async get(path: string): Promise<Buffer> {
return (await this.request('GET', path)) ?? Buffer.alloc(0)
}
private async request(method: string, path: string, payload?: Buffer): Promise<Buffer | null> {
for (let attempt = 1; ; attempt++) {
let status = 0
let data = Buffer.alloc(0)
try {
const response = await fetch(this.base + path, {
method,
headers: {
authorization: `Bearer ${this.token}`,
...(payload ? { 'content-type': 'application/json', 'content-encoding': 'gzip' } : {}),
},
body: payload,
signal: AbortSignal.timeout(10 * 60 * 1000),
})
status = response.status
data = Buffer.from(await response.arrayBuffer())
} catch (error: any) {
data = Buffer.from(error.message)
}
const text = () => data.toString('utf8').slice(0, 300)
if (status === 204) return null
if (status >= 200 && status < 300) return data
if (status === 401) throw new Unauthorized('The seed API rejected this node token')
if (status === 409) throw new LeaseLost(text())
// 404 comes from the proxy while the seed API restarts (a deploy): this API has no 404s of its own.
if (status >= 400 && status < 500 && status !== 404)
throw new Error(`Seed API ${path}: ${status} ${text()}`)
// Network errors, 404 and 5xx: storing a batch twice is harmless, so try again.
// ~20 minutes in all, longer than a lease lasts: a restart or maintenance of the API is waited out.
if (attempt >= 14) throw new Error(`Seed API ${path}: ${status || 'no response'} ${text()}`)
const wait = Math.min(120_000, 2000 * 2 ** (attempt - 1))
this.warn(`Seed API ${path}: ${status || text()}; retrying in ${wait / 1000} s`)
await sleep(wait)
}
}
}
/** A range's overviews through the seed API's relay (CONCEPT.md D38): no news server account on the node. */
class RelaySource implements OverviewSource {
constructor(
private api: SeedApi,
private rangeId: number
) {}
async group() {}
async xover(from: number, to: number): Promise<Overview[]> {
const raw = await this.api.get(
`/api/v1/seed/ranges/${this.rangeId}/chunk?from=${from}&to=${to}`
)
return raw.length > 0 ? parseOverviews(unzipSync(raw).toString('utf8')) : []
}
}
+41
View File
@@ -0,0 +1,41 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import NntpService from '#services/NntpService'
import SeedOrchestrator from '#services/SeedOrchestrator'
/**
* Plans seed ranges (CONCEPT.md D38) for groups, from below what live
* indexing, the backfill and earlier plans cover down to `--days` back (or
* the server's oldest article). A group's backfill stops where its plan
* starts. Ranges are interleaved across groups, newest first.
*
* node ace seed:plan alt.binaries.ath alt.binaries.mom --days 365
*/
export default class SeedPlan extends BaseCommand {
public static commandName = 'seed:plan'
public static description = 'Plans article ranges of groups for seeding nodes to lease.'
public static options: CommandOptions = {
startApp: true,
}
@args.spread({ description: 'Groups' })
declare groups: string[]
@flags.number({ description: 'How many days back (default: everything the server has)' })
declare days?: number
@flags.number({ description: 'Articles per range', default: 2_000_000 })
declare size: number
public async run() {
try {
const summary = await SeedOrchestrator.plan(this.groups, { days: this.days, size: this.size })
for (const s of summary) {
this.logger.info(`${s.group}: ${s.ranges.toLocaleString('en')} ranges, articles ${s.from.toLocaleString('en')}–${s.to.toLocaleString('en')}.`)
}
} finally {
await NntpService.shutdown()
}
}
}
+125
View File
@@ -0,0 +1,125 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import nntpConfig from '#config/nntp'
import SeedService from '#services/SeedService'
import QueueService from '#services/QueueService'
import { isDeferred } from '#services/DeferredService'
import { XoverClient } from '#lib/nntp/xover_client'
import { runRange } from '#lib/seed/runner'
import type { SeedBatch } from '#lib/seed/wire'
/**
* Edge aggregation (CONCEPT.md D37) on this machine: indexes a range of a
* group's history over one NNTP connection (gzip-compressed overviews when
* the server has them), grouping headers into files in memory and storing
* one row per file directly. seed:node does the same for a remote node,
* through the seed API. Header-only, like the backfill.
*
* node ace seed:range alt.binaries.ath --start 46000000000 --count 2000000
* node ace seed:range alt.binaries.ath --start 46000000000 --count 2000000 --dry-run
*/
export default class SeedRange extends BaseCommand {
public static commandName = 'seed:range'
public static description = "Indexes a range of a group's headers by aggregating them into files on this node."
public static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Group' })
declare group: string
@flags.string({ description: 'First article number' })
declare start: string
@flags.number({ description: 'Articles to index', default: 1_000_000 })
declare count: number
@flags.number({ description: 'Articles per XOVER', default: 20_000 })
declare chunk: number
@flags.number({ description: 'Batches without a new part before an open file is given up as fragments', default: 5 })
declare idle: number
@flags.boolean({ description: 'Fetch and aggregate only; store nothing' })
declare dryRun: boolean
@flags.boolean({ description: 'Ask the server for gzip-compressed overviews', default: true })
declare compress: boolean
public async run() {
const first = Number(this.start)
if (!Number.isSafeInteger(first) || first < 1) throw new Error(`--start must be an article number, got ${this.start}`)
const last = first + this.count - 1
const stats = { headers: 0, obfuscated: 0, deferred: 0, complete: 0, files: 0, existing: 0, fragments: 0, inspections: 0 }
let storeMs = 0
const cpuStart = process.cpuUsage()
const started = Date.now()
const client = await XoverClient.connect({
host: nntpConfig.host,
port: nntpConfig.port,
secure: nntpConfig.secure,
servername: nntpConfig.tlsServername,
user: nntpConfig.user,
password: nntpConfig.password?.release(),
timeoutMs: nntpConfig.commandTimeoutMs,
compress: this.compress,
})
this.logger.info(`Connected${client.compressed ? ', overviews gzip-compressed' : ''}.`)
try {
await runRange({
client,
group: this.group,
start: first,
end: last,
chunk: this.chunk,
idle: this.idle,
submitEvery: 1,
isDeferred,
sink: async (batch: SeedBatch) => {
stats.headers += batch.headers
stats.obfuscated += batch.obfuscated
stats.deferred += batch.deferred
stats.complete += batch.files.length
stats.fragments += batch.fragments.length
if (!this.dryRun) {
const t = Date.now()
const stored = await SeedService.store(batch.files)
await SeedService.storeFragments(this.group, batch.fragments)
storeMs += Date.now() - t
stats.files += stored.files
stats.existing += stored.existing
stats.inspections += stored.inspections
}
this.progress(stats, started)
},
})
} finally {
client.close()
await QueueService.closeAll()
}
const seconds = (Date.now() - started) / 1000
const cpu = process.cpuUsage(cpuStart)
const n = (x: number) => x.toLocaleString('en')
this.logger.info(
`${this.group} ${first}-${last}: ${n(stats.headers)} headers in ${seconds.toFixed(0)} s (${n(Math.round(stats.headers / seconds))}/s). ` +
`${n(stats.obfuscated)} obfuscated, ${n(stats.deferred)} deferred. ` +
`${n(stats.complete)} complete files (${n(stats.files)} new, ${n(stats.existing)} known), ` +
`${n(stats.fragments)} fragment segments, ${n(stats.inspections)} queued for inspection.`
)
this.logger.info(`Store ${(storeMs / 1000).toFixed(0)} s; node CPU ${((cpu.user + cpu.system) / 1e6).toFixed(0)} s.`)
}
private lastReport = 0
private progress(stats: Record<string, number>, started: number) {
if (Date.now() - this.lastReport < 30_000) return
this.lastReport = Date.now()
const seconds = (Date.now() - started) / 1000
this.logger.info(
`${stats.headers.toLocaleString('en')} headers (${Math.round(stats.headers / seconds).toLocaleString('en')}/s), ` +
`${stats.complete.toLocaleString('en')} files, ${stats.fragments.toLocaleString('en')} fragment segments.`
)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import SeedOrchestrator from '#services/SeedOrchestrator'
/** Seeding progress per range status and per node (CONCEPT.md D38). */
export default class SeedStatus extends BaseCommand {
public static commandName = 'seed:status'
public static description = 'Shows seeding progress per range status and per node.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const status = await SeedOrchestrator.status()
const n = (x: unknown) => Number(x ?? 0).toLocaleString('en')
for (const r of status.ranges) {
this.logger.info(`${r.status}: ${n(r.ranges)} ranges, ${n(r.reached)} of ${n(r.articles)} articles reached, ${n(r.headers)} headers, ${n(r.files)} files`)
}
for (const node of status.nodes) {
this.logger.info(`node ${node.name}${node.disabled ? ' (disabled)' : ''}: ${node.leases} leases, ${n(node.headers)} headers, last seen ${node.last_seen_at ?? 'never'}`)
}
this.logger.info(`Headers in ranges finished in the last hour: ${n(status.headersDoneLastHour)}`)
if (status.paused) this.logger.warning(`No new leases: ${status.paused} (BACKFILL_MAX_NVME_GB / BACKFILL_MAX_HDD_GB).`)
}
}
+37
View File
@@ -0,0 +1,37 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import SeedOrchestrator from '#services/SeedOrchestrator'
/**
* Creates a seeding node, or gives it a new token (the old one stops
* working), and prints the token once (CONCEPT.md D38).
*
* node ace seed:token hetzner-1
* node ace seed:token hetzner-1 --disable
*/
export default class SeedToken extends BaseCommand {
public static commandName = 'seed:token'
public static description = 'Creates a seeding node or replaces its token; prints the token once.'
public static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Node name' })
declare name: string
@flags.boolean({ description: 'Disable the node instead (its leases pass to others after they expire)' })
declare disable: boolean
public async run() {
if (this.disable) {
const n = await db.from('seed_nodes').where('name', this.name).update({ disabled: true })
this.logger.info(n ? `${this.name}: disabled.` : `${this.name}: no such node.`)
return
}
const token = await SeedOrchestrator.issueToken(this.name)
this.logger.info(`${this.name}: token (shown once, keep it on the node only):`)
console.log(token)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import SpotService from '#services/SpotService'
/** Passwords from the titles and descriptions of spots read so far, onto their releases. */
export default class SpotPasswords extends BaseCommand {
public static commandName = 'spot:passwords'
public static description = "Takes archive passwords from spots' titles and descriptions onto their releases."
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const spots = await db
.from('spots')
.whereNotNull('release_id')
.where((q) => q.whereNotNull('description').orWhere('title', 'like', '%{{%}}%'))
.select('message_id')
let found = 0
for (const spot of spots) if (await SpotService.applyPassword(spot.message_id)) found++
this.logger.info(`${found} of ${spots.length} spots name a password in their title or description.`)
}
}
+51
View File
@@ -0,0 +1,51 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import NntpService from '#services/NntpService'
import SpotService from '#services/SpotService'
/**
* Imported spots whose files sit in a release that isn't the spot's own
* (another source, or another name): their NZB is read again, which moves
* the files into the spot's release under its title (CONCEPT.md D29).
*/
export default class SpotReimport extends BaseCommand {
public static commandName = 'spot:reimport'
public static description = "Moves imported spots' files into their own release, named after the spot."
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only count them' })
declare dryRun: boolean
public async run() {
const spots = await db
.from('spots as s')
.join('releases as r', 'r.id', 's.release_id')
.where('s.status', 'imported')
.where((q) => q.whereNot('r.source', 'spot').orWhereRaw('r.name <> s.title'))
.orderBy('s.posted_at', 'desc')
.select('s.*', 'r.name as release_name')
this.logger.info(`${spots.length} imported spots are in a release that isn't their own.`)
if (this.dryRun) return
let moved = 0
let failed = 0
try {
for (const spot of spots) {
try {
const releaseId = await SpotService.reimport(spot)
if (releaseId) moved++
else failed++
} catch (error: any) {
failed++
this.logger.warning(`${spot.title}: ${error.message}`)
}
}
} finally {
await NntpService.shutdown()
}
this.logger.info(`${moved} spots re-imported, ${failed} without a usable NZB (dummy, gone) or failed.`)
}
}
+4
View File
@@ -49,6 +49,10 @@ export default class CollectionWorker extends BaseCommand {
if (content?.named) this.logger.info(`"${file.filename}" got its real name from par2.`)
if (content?.contentType === 'nzbe') this.logger.info(`"${file.filename}" is an encrypted NZB container.`)
if (content?.contentType === 'rclone') this.logger.info(`"${file.filename}" is rclone-encrypted.`)
if (content?.yencName) this.logger.info(`"${file.filename}" is "${content.yencName}" (yEnc name).`)
if (content?.nicovideo) this.logger.info(`"${file.filename}" → "${content.nicovideo}".`)
if (content?.sfvMoved) this.logger.info(`SFV "${file.filename}" moved ${content.sfvMoved} files into its release.`)
if (content?.contentType === 'yencbin') this.logger.info(`"${file.filename}" is a yEncBin encrypted container.`)
if (content?.nzb) {
const n = content.nzb
this.logger.info(`NZB "${file.filename}": ${n.listed} files (${n.known} known, ${n.added} added) → "${n.releaseName}".`)
+13 -5
View File
@@ -12,6 +12,7 @@ import FetchRangeService from '#services/FetchRangeService'
import IngestService, { segmentFromHeader, type IngestSegment } from '#services/IngestService'
import DeferredService, { bodyJob, isDeferred, type DeferredArticle } from '#services/DeferredService'
import SpotService, { isSpotGroup } from '#services/SpotService'
import StagingService from '#services/StagingService'
export default class FetchWorker extends BaseCommand {
public static commandName = 'worker:fetch'
@@ -36,8 +37,10 @@ export default class FetchWorker extends BaseCommand {
let failed = false
try {
conn = await pool.acquire()
await conn.group(groupName)
overviews = (await conn.xover(startId, endId)).overviews
await pool.timed(conn.group(groupName), `GROUP ${groupName}`)
const reply = await pool.timed<any>(conn.xover(startId, endId), `XOVER ${groupName} ${startId}-${endId}`)
// An empty reply parses as one line without fields ([null, {}]).
overviews = reply.overviews.filter(([number, header]: [number | null, Record<string, string>]) => number !== null && header?.['message-id'])
} catch (error: any) {
failed = true
this.logger.error(`Error fetching headers for ${groupName}: ${error.message}`)
@@ -56,7 +59,8 @@ export default class FetchWorker extends BaseCommand {
}
const group = await db.from('groups').where('name', groupName).first()
const fetchBodies = Boolean(group?.fetch_bodies)
// Backfill is header-only: old bodies would be far more than the link carries (CONCEPT.md D32).
const fetchBodies = Boolean(group?.fetch_bodies) && !job.data.backfill
const segments: IngestSegment[] = []
const obfuscated: { articleNumber: number; header: Record<string, string> }[] = []
@@ -72,7 +76,11 @@ export default class FetchWorker extends BaseCommand {
else obfuscated.push({ articleNumber, header })
}
const completed = await IngestService.ingest(segments)
// Lone segments of new files wait in staging until their file proves real (D35).
const { ingest, staged } = StagingService.enabled(groupName)
? await StagingService.filter(segments, { counters: StagingService.countersEnabled(groupName) })
: { ingest: segments, staged: 0 }
const completed = await IngestService.ingest(ingest)
const deferredFiles = await DeferredService.record(groupName, deferred)
if (obfuscated.length > 0) {
@@ -82,7 +90,7 @@ export default class FetchWorker extends BaseCommand {
if (job.data.rangeId) await FetchRangeService.markDone(job.data.rangeId)
this.logger.info(
`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${segments.length} stored, ` +
`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${ingest.length} stored${staged ? `, ${staged} staged` : ''}, ` +
`${obfuscated.length} sent to body-queue${skipped ? ` (${skipped} skipped)` : ''}` +
`${deferred.length ? `, ${deferred.length} deferred (${deferredFiles} files)` : ''}, ${completed.length} files completed.`
)
+38 -19
View File
@@ -2,9 +2,13 @@ import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import FileStore from '#services/FileStore'
import SfvService from '#services/SfvService'
import queueConfig from '#config/queue'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import ContentService from '#services/ContentService'
export default class FileWorker extends BaseCommand {
public static commandName = 'worker:file'
@@ -42,15 +46,10 @@ export default class FileWorker extends BaseCommand {
throw new Error(`Binary ${binaryId} has ${parts.length}/${binary.total_parts} parts.`)
}
const messageIds = Object.fromEntries(
parts.map((part) => [part.number, { id: part.message_id, size: part.bytes }])
)
const segments = parts.map((part) => ({ number: Number(part.number), id: part.message_id, size: Number(part.bytes) }))
// Already known as a file, e.g. added from a posted NZB before its own articles completed.
const existing = await db
.from('files')
.whereRaw(`(message_ids->'1'->>'id') = ?`, [messageIds['1']?.id ?? ''])
.first()
const existing = await db.from('files').where('first_message_id', segments[0]?.id ?? '').first()
if (existing) {
await db.from('binaries').where('id', binaryId).update({ file_id: existing.id })
await dropCopiedParts(binary)
@@ -59,26 +58,23 @@ export default class FileWorker extends BaseCommand {
}
// file_key is unique: a second completion of the same upload inserts nothing.
const knex = db.connection().getWriteClient()
const now = new Date()
const [inserted] = await knex('files')
.insert({
const [inserted] = await FileStore.insert([
{
file_key: binary.file_key,
filename: binary.name,
poster: binary.poster,
date: binary.posted_at ? new Date(binary.posted_at).getTime() : now.getTime(),
parts: parts.length,
message_ids: JSON.stringify(messageIds),
segments,
groups: JSON.stringify(binary.groups),
release_id: binary.release_id,
real_name: binary.real_name,
size: parts.reduce((sum, part) => sum + Number(part.bytes), 0),
created_at: now,
updated_at: now,
})
.onConflict('file_key')
.ignore()
.returning('id')
},
])
const fileId =
inserted?.id ?? (await db.from('files').where('file_key', binary.file_key).first())?.id
@@ -91,8 +87,24 @@ export default class FileWorker extends BaseCommand {
}
this.logger.info(`Saved file "${binary.name}" (${parts.length} parts) as ID ${fileId}.`)
await collectionQueue.add('process-collection', { fileId }, { jobId: `file-${fileId}` })
}, { connection: queueConfig.connection })
// An SFV posted earlier may already list it (D34).
await SfvService.claim({
id: Number(fileId),
name: binary.real_name ?? binary.name,
poster: binary.poster,
date: binary.posted_at ? new Date(binary.posted_at).getTime() : null,
release_id: binary.release_id,
})
// Files inspection would skip anyway (later volumes, readable media names) don't take a queue slot.
if (ContentService.isCandidate(binary.name)) {
await collectionQueue.add('process-collection', { fileId }, { jobId: `file-${fileId}` })
}
}, {
connection: queueConfig.connection,
// One job at a time could not keep up with the backfill (130k waiting); jobs are per binary and
// the file insert ignores duplicates, so they can run side by side.
concurrency: env.get('FILE_CONCURRENCY', 8),
})
worker.on('failed', (job, err) => {
this.logger.error(`File job ${job?.id} failed: ${err.message}`)
@@ -112,6 +124,13 @@ export default class FileWorker extends BaseCommand {
* ones keep them, so articles of an equal-sized next volume are recognised
* as a conflict and split off (CONCEPT.md D20). LOG.md 2026-09-28 (17).
*/
async function dropCopiedParts(binary: { id: number; size: number | null }) {
if (binary.size === null) await db.from('parts').where('binary_id', binary.id).delete()
/**
* The file holds the segment list now. Header-derived parts (no per-part
* CRC) aren't needed any more; body-derived ones are kept for split-volume
* verification (D20). Going by the CRC, not the binary's size: content
* inspection gives header-derived binaries a size too, which kept ~20M
* parts around (LOG.md 2026-10-02).
*/
async function dropCopiedParts(binary: { id: number }) {
await db.from('parts').where('binary_id', binary.id).whereNull('pcrc32').delete()
}
+13 -4
View File
@@ -6,6 +6,10 @@ import NntpService from '#services/NntpService'
import SpotService from '#services/SpotService'
const IDLE_MS = 30_000
/** Spots claimed at a time; most of a history backfill is listed in bulk. */
const BATCH = 500
/** Imported spots whose details are read per idle round. */
const DETAILS_BATCH = 50
/**
* Works through queued Spotnet spots (CONCEPT.md D29): verifies each one,
@@ -29,17 +33,22 @@ export default class SpotsWorker extends BaseCommand {
let done = 0
try {
while (!Shutdown.requested && (!this.limit || done < this.limit)) {
const batch = await SpotService.claim(Math.min(concurrency * 4, this.limit ? this.limit - done : Infinity))
const batch = await SpotService.claim(Math.min(BATCH, this.limit ? this.limit - done : Infinity))
if (batch.length === 0) {
if (this.limit) break
await Shutdown.sleep(IDLE_MS)
// Idle: read the details of imported spots that don't have them yet (newest first).
if ((await SpotService.fillDetails(DETAILS_BATCH)) === 0) await Shutdown.sleep(IDLE_MS)
continue
}
// Old, verified spots are listed in bulk; the rest need their header (and maybe NZB) read.
const listable = batch.filter((spot) => SpotService.isListable(spot))
const single = batch.filter((spot) => !SpotService.isListable(spot))
counts.listed = (counts.listed ?? 0) + (await SpotService.list(listable))
let next = 0
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (next < batch.length) {
const status = await SpotService.process(batch[next++])
while (next < single.length) {
const status = await SpotService.process(single[next++])
counts[status] = (counts[status] ?? 0) + 1
}
})
+5
View File
@@ -32,6 +32,11 @@ const bodyParserConfig = defineConfig({
*/
convertEmptyStringsToNull: true,
/**
* Seeding nodes post batches of a few MB (CONCEPT.md D38); the limit applies after gzip.
*/
limit: '64mb',
/**
* Content types handled by the JSON parser.
*/
+23 -3
View File
@@ -5,7 +5,7 @@ import env from '#start/env'
*/
export default {
/** How often `gc:scheduler` sweeps. */
intervalMs: env.get('GC_INTERVAL_MS', 10 * 60 * 1000),
intervalMs: env.get('GC_INTERVAL_MS', 3 * 60 * 1000),
jobs: {
/** Completed jobs are only kept for inspection; keep them briefly. */
@@ -34,14 +34,34 @@ export default {
* complete from headers. A real file gets its next articles in minutes.
*/
singleArticleAfterSeconds: env.get('GC_SINGLE_ARTICLE_SECONDS', 2 * 60 * 60),
/**
* Completed header-derived binaries without a fingerprint (size, MD5 of
* the first 16 KiB): their file exists and no par2 set can name them
* later, so they go sooner than staleAfterSeconds.
*/
unfingerprintedAfterSeconds: env.get('GC_UNFINGERPRINTED_SECONDS', 6 * 60 * 60),
},
/**
* Staged lone segments (CONCEPT.md D35) are dropped after this long. A
* real file's next segment comes in the same or the next fetch range,
* seconds to minutes later; at backfill speed two hours of waiting
* segments were 12M rows, 10 GB.
*/
stagingTtlSeconds: env.get('STAGING_TTL_SECONDS', 30 * 60),
/** Releases without files or binaries (their binaries were collected) are deleted after this long. */
emptyReleaseAfterSeconds: env.get('GC_EMPTY_RELEASE_SECONDS', 2 * 60 * 60),
/** Bulk deletes run in batches of this size, for at most this long per sweep. */
/**
* Bulk deletes run in batches of this size, each kind (copied parts,
* single-article and unfingerprinted binaries, empty releases) for at most
* this long per sweep, so one backlog can't starve the others.
*/
deleteBatch: 20_000,
deleteBudgetMs: env.get('GC_DELETE_BUDGET_MS', 3 * 60 * 1000),
deleteBudgetMs: env.get('GC_DELETE_BUDGET_MS', 60 * 1000),
/** Bloated indexes are rebuilt at most this often (IndexMaintenance). */
reindexIntervalMs: env.get('GC_REINDEX_INTERVAL_MS', 7 * 24 * 60 * 60 * 1000),
/**
* Deferred files (CONCEPT.md D28) are deleted this long after they were
+42
View File
@@ -39,6 +39,48 @@ export default {
* binaries. Add one with `group:add free.pt --backfill 5000`.
*/
spotGroups: ['free.pt'],
/**
* Spots posted within this many days are imported (their NZB read and its
* files added). Older spots are listed: title, size and date, with the NZB
* read on first download.
*/
spotImportDays: env.get('SPOT_IMPORT_DAYS', 30),
/**
* Groups whose lone header segments wait in `staged_segments` until their
* file shows a second one (CONCEPT.md D35). Comma-separated; `*` for all.
*/
stagingGroups: (env.get('STAGING_GROUPS', '') ?? '').split(',').map((g) => g.trim()).filter(Boolean),
/**
* Groups whose staged segments also group by poster, file counter `[i/M]`
* and part count (CONCEPT.md D36), for uploads with a random subject per
* article. Needs staging. Comma-separated; `*` for all.
*/
counterGroupingGroups: (env.get('COUNTER_GROUPING_GROUPS', '') ?? '').split(',').map((g) => g.trim()).filter(Boolean),
/**
* Header backfill (`group:backfill`, CONCEPT.md D32): low-priority,
* header-only fetch ranges walking back through a group's history.
*/
seed: {
/** NNTP connections the seed API fetches with for relay nodes (CONCEPT.md D38); 0: relay off. */
nntpConnections: env.get('SEED_NNTP_CONNECTIONS', 0),
},
backfill: {
/** Backfill ranges waiting or running at a time, over all groups. */
maxQueued: env.get('BACKFILL_MAX_QUEUED', 40),
/** BullMQ priority (higher runs later; live ranges have none, so they go first). */
priority: 10,
/**
* No new backfill ranges while the database's NVMe part (default
* tablespace) or its HDD part (tablespace `hdd`, the files table) is
* larger than this. Checked every scheduler tick.
*/
maxDefaultGb: env.get('BACKFILL_MAX_NVME_GB', 45),
maxHddGb: env.get('BACKFILL_MAX_HDD_GB', 150),
},
/** worker:deferred leaves a file alone until it has had no new articles for this long. */
deferredSettleMs: env.get('DEFERRED_SETTLE_MS', 15 * 60 * 1000),
+4
View File
@@ -8,4 +8,8 @@ export default {
secure: env.get('NNTP_SECURE', false),
/** Connections per process. Mind the provider's limit across all processes. */
poolSize: env.get('NNTP_POOL_SIZE', 10),
/** A command without an answer this long counts as a dead connection (XOVER of 20k headers takes ~0.2 s). */
commandTimeoutMs: env.get('NNTP_COMMAND_TIMEOUT_MS', 120_000),
/** TLS certificate hostname when NNTP_HOST is a tunnel (seeding nodes; lib/nntp/xover_client). */
tlsServername: env.get('NNTP_TLS_SERVERNAME'),
}
@@ -22,11 +22,16 @@ export default class extends BaseSchema {
async up() {
await this.db.rawQuery(`ALTER TABLE spots ADD COLUMN IF NOT EXISTS verified boolean`)
// worker:spots claims recent spots first, then the history in posting order.
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_article_idx ON spots (status, article_number)`)
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_posted_idx ON spots (status, posted_at)`)
await this.swap(VISIBLE)
}
async down() {
await this.swap('file_count > 0')
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS spots_status_article_idx`)
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS spots_status_posted_idx`)
await this.db.rawQuery(`ALTER TABLE spots DROP COLUMN IF EXISTS verified`)
}
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Spot details (CONCEPT.md D29): the description and image from the spot's
* full header. Stored whenever the header is read (recent spots, moderation,
* downloads); for other old spots on first view. `details_at` marks the read.
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('spots', (table) => {
table.text('description')
table.jsonb('image')
table.timestamp('details_at', { useTz: true })
})
}
async down() {
this.schema.alterTable('spots', (table) => {
table.dropColumn('description')
table.dropColumn('image')
table.dropColumn('details_at')
})
}
}
@@ -0,0 +1,16 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Where a release's archive password came from: `nzb` (the NZB's
* `<meta type="password">`) or `spot` (the spot's title or description).
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('releases', (table) => table.string('password_source', 8))
this.schema.raw(`UPDATE releases SET password_source = 'nzb' WHERE password IS NOT NULL`)
}
async down() {
this.schema.alterTable('releases', (table) => table.dropColumn('password_source'))
}
}
@@ -0,0 +1,63 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import { packSegments, unpackSegments } from '#lib/nzb/segments'
const BATCH = 5000
/**
* Segment lists compressed (CONCEPT.md D31): `files.message_ids` (jsonb,
* ~38 bytes per segment after pglz) becomes `files.segments` (zstd, ~20),
* plus `first_message_id` for the lookups the old expression index served.
* Run with the workers stopped; reclaim the space with VACUUM FULL files.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
await this.db.rawQuery(`ALTER TABLE files ADD COLUMN IF NOT EXISTS segments bytea, ADD COLUMN IF NOT EXISTS first_message_id text`)
let last = 0
let done = 0
while (true) {
const { rows } = await this.db.rawQuery(
`SELECT id, message_ids FROM files WHERE id > ? AND segments IS NULL ORDER BY id LIMIT ?`,
[last, BATCH]
)
if (rows.length === 0) break
const ids: number[] = []
const packed: Buffer[] = []
const firsts: (string | null)[] = []
for (const row of rows) {
const segments = Object.entries((row.message_ids ?? {}) as Record<string, { id: string; size?: number }>)
.map(([number, s]) => ({ number: Number(number), id: s.id, size: Number(s.size ?? 0) }))
.sort((a, b) => a.number - b.number)
ids.push(Number(row.id))
packed.push(packSegments(segments))
firsts.push(segments[0]?.id ?? null)
}
await this.db.rawQuery(
`UPDATE files f SET segments = v.segments, first_message_id = v.first
FROM unnest(?::bigint[], ?::bytea[], ?::text[]) AS v(id, segments, first) WHERE f.id = v.id`,
[ids, packed, firsts] as any
)
last = ids[ids.length - 1]
done += rows.length
if (done % 100_000 < BATCH) console.log(`Packed the segments of ${done} files.`)
}
await this.db.rawQuery(`ALTER TABLE files ALTER COLUMN segments SET NOT NULL`)
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_first_message_id_idx ON files (first_message_id)`)
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS files_first_segment_idx`)
await this.db.rawQuery(`ALTER TABLE files DROP COLUMN message_ids`)
}
async down() {
await this.db.rawQuery(`ALTER TABLE files ADD COLUMN IF NOT EXISTS message_ids jsonb`)
const { rows } = await this.db.rawQuery(`SELECT id, segments FROM files`)
for (const row of rows) {
const json = Object.fromEntries(unpackSegments(row.segments).map((s) => [s.number, { id: s.id, size: s.size }]))
await this.db.rawQuery(`UPDATE files SET message_ids = ? WHERE id = ?`, [JSON.stringify(json), row.id])
}
await this.db.rawQuery(`ALTER TABLE files ALTER COLUMN message_ids SET NOT NULL`)
await this.db.rawQuery(`CREATE INDEX IF NOT EXISTS files_first_segment_idx ON files ((message_ids->'1'->>'id'))`)
await this.db.rawQuery(`DROP INDEX IF EXISTS files_first_message_id_idx`)
await this.db.rawQuery(`ALTER TABLE files DROP COLUMN segments, DROP COLUMN first_message_id`)
}
}
@@ -0,0 +1,26 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Header backfill (CONCEPT.md D32): a group walks backwards from
* `backfill_next_id` down to `backfill_until_id` in low-priority,
* header-only fetch ranges (`fetch_ranges.backfill`).
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('groups', (table) => {
table.bigInteger('backfill_until_id')
table.bigInteger('backfill_next_id')
})
this.schema.alterTable('fetch_ranges', (table) => {
table.boolean('backfill').notNullable().defaultTo(false)
})
}
async down() {
this.schema.alterTable('groups', (table) => {
table.dropColumn('backfill_until_id')
table.dropColumn('backfill_next_id')
})
this.schema.alterTable('fetch_ranges', (table) => table.dropColumn('backfill'))
}
}
@@ -0,0 +1,144 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
const VISIBLE = `(file_count > 0 OR source = 'spot')`
const BATCH = 20_000
/**
* Search and index cleanup (CONCEPT.md D33).
* - `releases.search_text`: name, archive listing and readable file-name
* stems, normalised (lowercase; dots, dashes, underscores and spaces as
* one space), kept by triggers; one trigram index replaces the four the
* old per-table OR search could hardly use.
* - Spot status indexes become partial indexes over the few rows the worker
* and search look for.
* - Unused release indexes go (size: never scanned; complete: a boolean).
* Runs online: indexes are built and dropped concurrently.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
const q = (sql: string, bindings: unknown[] = []) => this.db.rawQuery(sql, bindings as any)
// For IndexMaintenance; needs a superuser (it is one in the compose setup).
await q(`CREATE EXTENSION IF NOT EXISTS pgstattuple`).catch((error) => console.warn(`pgstattuple not installed: ${error.message}`))
// Spots: the worker only looks for pending/processing and un-detailed imported spots, search for hidden ones.
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_todo_idx ON spots (article_number) WHERE status IN ('pending', 'processing')`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_hidden_release_idx ON spots (release_id) WHERE status IN ('removed', 'dummy')`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_details_todo_idx ON spots (posted_at DESC) WHERE status = 'imported' AND details_at IS NULL`)
for (const index of ['spots_status_posted_idx', 'spots_status_article_idx', 'spots_status_created_at_index', 'spots_status_release_id_index']) {
await q(`DROP INDEX CONCURRENTLY IF EXISTS ${index}`)
}
for (const index of ['releases_size_index', 'releases_complete_idx']) {
await q(`DROP INDEX CONCURRENTLY IF EXISTS ${index}`)
}
// Search text.
await q(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS search_files text, ADD COLUMN IF NOT EXISTS search_text text`)
await q(`
CREATE OR REPLACE FUNCTION search_normalize(t text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
SELECT btrim(lower(regexp_replace(coalesce(t, ''), '[._[:space:]-]+', ' ', 'g')))
$$`)
await q(`
CREATE OR REPLACE FUNCTION release_search_text(name text, contents jsonb, files text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
SELECT left(concat_ws(' ',
search_normalize(name),
nullif(search_normalize((SELECT string_agg(e->>'name', ' ') FROM (
SELECT e FROM jsonb_array_elements(CASE WHEN jsonb_typeof(contents) = 'array' THEN contents ELSE '[]'::jsonb END) e LIMIT 200) x)), ''),
nullif(files, '')), 8000)
$$`)
// Visible releases only; computed when one becomes visible or its name, listing or file names change.
await q(`
CREATE OR REPLACE FUNCTION releases_search_text() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT (NEW.file_count > 0 OR NEW.source = 'spot') THEN
NEW.search_text := NULL;
ELSIF TG_OP = 'INSERT' OR NEW.search_text IS NULL
OR NEW.name IS DISTINCT FROM OLD.name OR NEW.contents IS DISTINCT FROM OLD.contents
OR NEW.search_files IS DISTINCT FROM OLD.search_files THEN
NEW.search_text := release_search_text(NEW.name, NEW.contents, NEW.search_files);
END IF;
RETURN NEW;
END $$`)
await q(`DROP TRIGGER IF EXISTS releases_search_text ON releases`)
await q(`CREATE TRIGGER releases_search_text BEFORE INSERT OR UPDATE ON releases FOR EACH ROW EXECUTE FUNCTION releases_search_text()`)
// A file's readable stem joins its release's search text when it adds something.
await q(`
CREATE OR REPLACE FUNCTION files_release_search() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
n text := coalesce(NEW.real_name, NEW.filename);
s text;
BEGIN
IF NEW.release_id IS NULL OR NOT is_readable_name(n) THEN RETURN NULL; END IF;
s := search_normalize(name_stem(n));
IF s = '' THEN RETURN NULL; END IF;
UPDATE releases SET search_files = left(concat_ws(' ', search_files, s), 4000)
WHERE id = NEW.release_id AND position(s IN coalesce(search_text, search_normalize(name))) = 0
AND length(coalesce(search_files, '')) < 4000;
RETURN NULL;
END $$`)
await q(`DROP TRIGGER IF EXISTS files_release_search ON files`)
await q(`CREATE TRIGGER files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`)
// Fill: readable file stems per release (one pass over files), then every visible release in batches.
await q(`DROP TABLE IF EXISTS search_stems_tmp`)
await q(`
CREATE UNLOGGED TABLE search_stems_tmp AS
SELECT release_id, left(string_agg(DISTINCT s, ' '), 4000) AS stems FROM (
SELECT f.release_id, search_normalize(name_stem(coalesce(f.real_name, f.filename))) AS s
FROM files f WHERE f.release_id IS NOT NULL AND is_readable_name(coalesce(f.real_name, f.filename))) x
WHERE s <> '' GROUP BY release_id`)
await q(`CREATE UNIQUE INDEX ON search_stems_tmp (release_id)`)
let last = 0
let done = 0
while (true) {
const { rows } = await q(`SELECT id FROM releases WHERE id > ? AND ${VISIBLE} ORDER BY id LIMIT ?`, [last, BATCH])
if (rows.length === 0) break
const to = Number(rows[rows.length - 1].id)
await q(
`UPDATE releases r SET search_files = (
SELECT nullif(array_to_string(array(
SELECT w FROM unnest(string_to_array(t.stems, ' ')) w
WHERE position(w IN search_normalize(r.name)) = 0), ' '), '')
FROM search_stems_tmp t WHERE t.release_id = r.id),
search_text = NULL
WHERE r.id > ? AND r.id <= ? AND ${VISIBLE.replaceAll('file_count', 'r.file_count').replace('source', 'r.source')}`,
[last, to]
)
last = to
done += rows.length
if (done % 200_000 < BATCH) console.log(`Search text for ${done} releases.`)
}
await q(`DROP TABLE search_stems_tmp`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_search_trgm ON releases USING gin (search_text gin_trgm_ops) WHERE ${VISIBLE}`)
for (const index of ['releases_name_trgm', 'releases_contents_trgm', 'files_filename_trgm', 'files_real_name_trgm']) {
await q(`DROP INDEX CONCURRENTLY IF EXISTS ${index}`)
}
}
async down() {
const q = (sql: string) => this.db.rawQuery(sql)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_name_trgm ON releases USING gin (name gin_trgm_ops) WHERE ${VISIBLE}`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_contents_trgm ON releases USING gin ((contents::text) gin_trgm_ops) WHERE ${VISIBLE}`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_filename_trgm ON files USING gin (filename gin_trgm_ops)`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_real_name_trgm ON files USING gin (real_name gin_trgm_ops)`)
await q(`DROP INDEX CONCURRENTLY IF EXISTS releases_search_trgm`)
await q(`DROP TRIGGER IF EXISTS files_release_search ON files`)
await q(`DROP TRIGGER IF EXISTS releases_search_text ON releases`)
await q(`DROP FUNCTION IF EXISTS files_release_search()`)
await q(`DROP FUNCTION IF EXISTS releases_search_text()`)
await q(`DROP FUNCTION IF EXISTS release_search_text(text, jsonb, text)`)
await q(`DROP FUNCTION IF EXISTS search_normalize(text)`)
await q(`ALTER TABLE releases DROP COLUMN IF EXISTS search_files, DROP COLUMN IF EXISTS search_text`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_size_index ON releases (size) WHERE ${VISIBLE}`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_complete_idx ON releases (complete) WHERE ${VISIBLE}`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_posted_idx ON spots (status, posted_at)`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_article_idx ON spots (status, article_number)`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_created_at_index ON spots (status, created_at)`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_release_id_index ON spots (status, release_id)`)
await q(`DROP INDEX CONCURRENTLY IF EXISTS spots_todo_idx`)
await q(`DROP INDEX CONCURRENTLY IF EXISTS spots_hidden_release_idx`)
await q(`DROP INDEX CONCURRENTLY IF EXISTS spots_details_todo_idx`)
}
}
@@ -0,0 +1,43 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* is_readable_name(): also hex with a Unix time, UUIDs, digit-only names
* (and rclone's "83111763 n"), lowercase base32/36 tokens and short tokens
* with capitals all over. The same rules as looksRandom() in ArchiveService.
*/
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
await db.rawQuery(`
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
WITH s AS (SELECT regexp_replace(name_stem(name), '\\.[A-Za-z0-9]{1,4}$', '') AS stem)
SELECT name IS NOT NULL AND NOT (
(stem ~ '^[A-Za-z0-9]{16,}$' AND stem ~ '[a-z]' AND stem ~ '[A-Z]'
AND (stem ~ '\\d' OR length(regexp_replace(stem, '[^A-Z]', '', 'g')) >= 6))
OR stem ~* '^[0-9a-f]{16,}$'
OR stem ~* '^[0-9a-f]{16,}\\.\\d{9,}$'
OR stem ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
OR stem ~ '^\\d{6,}( n){0,1}$'
OR (stem ~ '^[a-z0-9]{12,}$' AND length(regexp_replace(stem, '\\D', '', 'g')) >= 2
AND length(regexp_replace(stem, '[^a-z]', '', 'g')) >= 4)
OR (stem ~ '^[A-Za-z0-9]{8,15}$' AND stem ~ '\\d' AND stem ~ '[a-z]'
AND length(regexp_replace(substr(stem, 2), '[^A-Z]', '', 'g')) >= 3))
FROM s
$$`)
})
}
async down() {
this.defer(async (db) => {
await db.rawQuery(`
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
WITH s AS (SELECT regexp_replace(name_stem(name), '\\.[A-Za-z0-9]{1,4}$', '') AS stem)
SELECT name IS NOT NULL AND NOT (
(stem ~ '^[A-Za-z0-9]{16,}$' AND stem ~ '[a-z]' AND stem ~ '[A-Z]'
AND (stem ~ '\\d' OR length(regexp_replace(stem, '[^A-Z]', '', 'g')) >= 6))
OR stem ~* '^[0-9a-f]{16,}$')
FROM s
$$`)
})
}
}
@@ -0,0 +1,23 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* SFV listings (CONCEPT.md D34): the files an SFV names belong to its
* release; files arriving later are matched by name.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('sfv_entries', (table) => {
table.bigInteger('release_id').notNullable().references('releases.id').onDelete('CASCADE')
table.text('name').notNullable()
table.string('crc', 8).notNullable()
table.text('poster')
table.timestamp('posted_at', { useTz: true })
table.primary(['release_id', 'name'])
table.index(['name'])
})
}
async down() {
this.schema.dropTable('sfv_entries')
}
}
@@ -0,0 +1,15 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** SFV grouping (CONCEPT.md D34) looks files up by their name. */
export default class extends BaseSchema {
static disableTransactions = true
async up() {
// On the NVMe (default tablespace), like the files table's other indexes.
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_name_idx ON files ((coalesce(real_name, filename)))`)
}
async down() {
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS files_name_idx`)
}
}
@@ -0,0 +1,37 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* is_readable_name(): generic folder/file names (VIDEO_TS, Sample, CD1) are
* no name either. Same list as GENERIC_NAMES in lib/release/release_key.ts.
*/
const FUNCTION = (generic: boolean) => `
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
WITH s AS (SELECT regexp_replace(name_stem(name), '\\.[A-Za-z0-9]{1,4}$', '') AS stem)
SELECT name IS NOT NULL AND NOT (
(stem ~ '^[A-Za-z0-9]{16,}$' AND stem ~ '[a-z]' AND stem ~ '[A-Z]'
AND (stem ~ '\\d' OR length(regexp_replace(stem, '[^A-Z]', '', 'g')) >= 6))
OR stem ~* '^[0-9a-f]{16,}$'
OR stem ~* '^[0-9a-f]{16,}\\.\\d{9,}$'
OR stem ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
OR stem ~ '^\\d{6,}( n){0,1}$'
OR (stem ~ '^[a-z0-9]{12,}$' AND length(regexp_replace(stem, '\\D', '', 'g')) >= 2
AND length(regexp_replace(stem, '[^a-z]', '', 'g')) >= 4)
OR (stem ~ '^[A-Za-z0-9]{8,15}$' AND stem ~ '\\d' AND stem ~ '[a-z]'
AND length(regexp_replace(substr(stem, 2), '[^A-Z]', '', 'g')) >= 3)
${generic ? `OR btrim(stem) ~* '^(video_ts|audio_ts|bdmv|certificate|hvdvd_ts|samples{0,1}|subs{0,1}|subtitles{0,1}|proof|cover|covers|extras{0,1}|featurettes{0,1}|(cd|dvd|disc|disk)[ ._-]{0,1}\\d{1,2})$'` : ''})
FROM s
$$`
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
await db.rawQuery(FUNCTION(true))
})
}
async down() {
this.defer(async (db) => {
await db.rawQuery(FUNCTION(false))
})
}
}
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Staged segments (CONCEPT.md D35): a header segment whose file is new and
* alone in its batch waits here until a second segment of the file shows
* up; ~97% never do (uploads with a random subject per article). Unlogged:
* no WAL, emptied by a crash, which only costs those waiting segments.
*/
export default class extends BaseSchema {
async up() {
await this.db.rawQuery(`
CREATE UNLOGGED TABLE staged_segments (
file_key text PRIMARY KEY,
segments jsonb NOT NULL,
staged_at timestamptz NOT NULL
)`)
// Rows arrive in time order, so a BRIN index serves the expiry cheaply.
await this.db.rawQuery(`CREATE INDEX staged_segments_staged_at_brin ON staged_segments USING brin (staged_at)`)
}
async down() {
await this.db.rawQuery(`DROP TABLE staged_segments`)
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Counter grouping (CONCEPT.md D36): staged segments also carry the key of
* the file they'd belong to by poster, file counter `[i/M]`, part count and
* posting day, so uploads with a random subject per article become files.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
await this.db.rawQuery(`ALTER TABLE staged_segments ADD COLUMN IF NOT EXISTS counter_key text`)
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS staged_segments_counter_key_idx ON staged_segments (counter_key) WHERE counter_key IS NOT NULL`)
}
async down() {
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS staged_segments_counter_key_idx`)
await this.db.rawQuery(`ALTER TABLE staged_segments DROP COLUMN IF EXISTS counter_key`)
}
}
@@ -0,0 +1,109 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
const VISIBLE = `(file_count > 0 OR source = 'spot')`
const BATCH = 20_000
/**
* `releases.extensions`: the file types in a release (`{mkv,nfo,par2}`), from
* its files' names and its archive listing, for the search's extension
* filter (LOG.md 2026-10-04 (3)). search_text can't serve it: file names
* enter it as stems without extension, and matching the long, TOASTed text
* took seconds per search.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
const q = (sql: string, bindings: unknown[] = []) => this.db.rawQuery(sql, bindings as any)
await q(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS extensions text[]`)
// Two to four characters with a letter (mkv, flac, 7z, par2); split
// archive volumes count as their archive type (.7z.001, .r00).
await q(`
CREATE OR REPLACE FUNCTION name_extension(n text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
SELECT CASE
WHEN lower(n) ~ '\\.(7z|zip)\\.[0-9]{3}$' THEN substring(lower(n) from '\\.(7z|zip)\\.[0-9]{3}$')
WHEN lower(n) ~ '\\.r[0-9]{2,3}$' THEN 'rar'
ELSE substring(lower(n) from '\\.([a-z0-9]*[a-z][a-z0-9]*)$')
END
$$`)
await q(`
CREATE OR REPLACE FUNCTION listing_extensions(contents jsonb) RETURNS text[] IMMUTABLE LANGUAGE sql AS $$
SELECT array(SELECT DISTINCT e FROM (
SELECT name_extension(c->>'name') AS e
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(contents) = 'array' THEN contents ELSE '[]'::jsonb END) c) x
WHERE e IS NOT NULL AND length(e) BETWEEN 2 AND 4 ORDER BY e)
$$`)
// A new archive listing adds its entries' types.
await q(`
CREATE OR REPLACE FUNCTION releases_extensions() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' OR NEW.contents IS DISTINCT FROM OLD.contents THEN
NEW.extensions := nullif(array(
SELECT DISTINCT e FROM unnest(coalesce(NEW.extensions, '{}') || listing_extensions(NEW.contents)) e ORDER BY e), '{}');
END IF;
RETURN NEW;
END $$`)
await q(`DROP TRIGGER IF EXISTS releases_extensions ON releases`)
await q(`CREATE TRIGGER releases_extensions BEFORE INSERT OR UPDATE OF contents ON releases FOR EACH ROW EXECUTE FUNCTION releases_extensions()`)
// A file adds its type to its release (once: most files of a release share it).
await q(`
CREATE OR REPLACE FUNCTION files_release_extension() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
e text := name_extension(coalesce(NEW.real_name, NEW.filename));
BEGIN
IF NEW.release_id IS NULL OR e IS NULL OR length(e) NOT BETWEEN 2 AND 4 THEN RETURN NULL; END IF;
UPDATE releases SET extensions = array(SELECT DISTINCT x FROM unnest(coalesce(extensions, '{}') || e) x ORDER BY x)
WHERE id = NEW.release_id AND NOT (coalesce(extensions, '{}') @> ARRAY[e]);
RETURN NULL;
END $$`)
await q(`DROP TRIGGER IF EXISTS files_release_extension ON files`)
await q(`CREATE TRIGGER files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`)
// Fill: file types per release in one sequential pass over files (random
// reads per release would take over an hour on the HDD), then the
// visible releases in batches by id, adding their listing's types.
await q(`DROP TABLE IF EXISTS file_extensions_tmp`)
await q(`
CREATE UNLOGGED TABLE file_extensions_tmp AS
SELECT release_id, array_agg(DISTINCT e) AS exts FROM (
SELECT release_id, name_extension(coalesce(real_name, filename)) AS e FROM files WHERE release_id IS NOT NULL) x
WHERE e IS NOT NULL AND length(e) BETWEEN 2 AND 4 GROUP BY release_id`)
await q(`CREATE UNIQUE INDEX ON file_extensions_tmp (release_id)`)
let last = 0
let done = 0
while (true) {
const { rows } = await q(`SELECT id FROM releases WHERE id > ? AND ${VISIBLE} ORDER BY id LIMIT ?`, [last, BATCH])
if (rows.length === 0) break
const to = Number(rows[rows.length - 1].id)
await q(
`UPDATE releases r SET extensions = x.exts FROM (
SELECT r2.id, array(SELECT DISTINCT e FROM unnest(coalesce(t.exts, '{}') || listing_extensions(r2.contents)) e ORDER BY e) AS exts
FROM releases r2 LEFT JOIN file_extensions_tmp t ON t.release_id = r2.id
WHERE r2.id > ? AND r2.id <= ? AND (r2.file_count > 0 OR r2.source = 'spot')) x
WHERE r.id = x.id AND cardinality(x.exts) > 0 AND r.extensions IS DISTINCT FROM x.exts`,
[last, to]
)
last = to
done += rows.length
if (done % 500_000 < BATCH) console.log(`Extensions for ${done} releases.`)
}
await q(`DROP TABLE file_extensions_tmp`)
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_extensions_gin ON releases USING gin (extensions) WHERE ${VISIBLE}`)
}
async down() {
const q = (sql: string) => this.db.rawQuery(sql)
await q(`DROP INDEX CONCURRENTLY IF EXISTS releases_extensions_gin`)
await q(`DROP TRIGGER IF EXISTS files_release_extension ON files`)
await q(`DROP TRIGGER IF EXISTS releases_extensions ON releases`)
await q(`DROP FUNCTION IF EXISTS files_release_extension()`)
await q(`DROP FUNCTION IF EXISTS releases_extensions()`)
await q(`DROP FUNCTION IF EXISTS listing_extensions(jsonb)`)
await q(`DROP FUNCTION IF EXISTS name_extension(text)`)
await q(`ALTER TABLE releases DROP COLUMN IF EXISTS extensions`)
}
}
@@ -0,0 +1,47 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The seed orchestrator (CONCEPT.md D38): nodes that lease article ranges
* over HTTPS and submit what they aggregated (D37), and the ranges planned
* for them. A range's id is its priority: plans insert newest first.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('seed_nodes', (table) => {
table.increments('id')
table.text('name').notNullable().unique()
// sha256 of the bearer token; the token itself is shown once, when it's made.
table.string('token_hash', 64).notNullable().unique()
table.boolean('disabled').notNullable().defaultTo(false)
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
table.timestamp('last_seen_at', { useTz: true }).nullable()
})
this.schema.createTable('seed_ranges', (table) => {
table.bigIncrements('id')
table.text('group_name').notNullable()
table.bigInteger('start_id').notNullable()
table.bigInteger('end_id').notNullable()
// First article not reported yet; a re-leased range resumes here.
table.bigInteger('next_id').notNullable()
table.string('status', 8).notNullable().defaultTo('queued')
table.integer('node_id').nullable().references('id').inTable('seed_nodes').onDelete('SET NULL')
table.integer('attempts').notNullable().defaultTo(0)
table.timestamp('leased_at', { useTz: true }).nullable()
table.timestamp('heartbeat_at', { useTz: true }).nullable()
table.timestamp('done_at', { useTz: true }).nullable()
table.bigInteger('headers').notNullable().defaultTo(0)
table.bigInteger('files').notNullable().defaultTo(0)
table.bigInteger('fragments').notNullable().defaultTo(0)
table.text('error').nullable()
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
table.index(['group_name', 'start_id'])
})
this.schema.raw(`CREATE INDEX seed_ranges_open_idx ON seed_ranges (id) WHERE status IN ('queued', 'leased')`)
}
async down() {
this.schema.dropTable('seed_ranges')
this.schema.dropTable('seed_nodes')
}
}
@@ -0,0 +1,77 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** Ids per partition: 4M files is a few days of indexing, ~2 GB of heap (FilePartitions uses the same). */
const FILE_PARTITION_SIZE = 4_000_000
/**
* `files` partitioned by id, i.e. by age (CONCEPT.md D39). New rows went into
* free space scattered through the 9.6 GB heap on the HDD (left by
* inspection's updates), one random read per insert; now they go into a
* small recent partition that stays in memory. The existing table becomes
* the first partition as it is: only a CHECK scan, no rewrite.
*
* Postgres can't enforce a unique file_key across partitions: every
* partition has its own unique index, and FileStore.insert looks keys up in
* the older ones. Run with the writers stopped (a few minutes).
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
const q = (sql: string) => this.db.rawQuery(sql)
const { rows } = await q(`SELECT coalesce(max(id), 0) AS max FROM files`)
// The first new partition starts at the next million.
const boundary = (Math.floor(Number(rows[0].max) / 1_000_000) + 1) * 1_000_000
// The HDD tablespace on homelab (D32), the default elsewhere; new partitions inherit the parent's.
const { rows: ts } = await q(`SELECT t.spcname FROM pg_class c JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE c.oid = 'files'::regclass`)
const tablespace = ts[0]?.spcname ? ` TABLESPACE ${ts[0].spcname}` : ''
// The old table, renamed; its constraint and index names get a files_p0 prefix.
await q(`ALTER TABLE files ADD CONSTRAINT files_p0_bound CHECK (id < ${boundary}) NOT VALID`)
await q(`ALTER TABLE files VALIDATE CONSTRAINT files_p0_bound`)
await q(`ALTER TABLE files RENAME TO files_p0`)
await q(`ALTER TABLE files_p0 RENAME CONSTRAINT files_pkey TO files_p0_pkey`)
await q(`ALTER TABLE files_p0 RENAME CONSTRAINT files_file_key_unique TO files_p0_file_key_unique`)
await q(`ALTER TABLE files_p0 RENAME CONSTRAINT files_release_id_foreign TO files_p0_release_id_foreign`)
for (const index of ['files_content_type_index', 'files_first_message_id_idx', 'files_name_idx', 'files_release_id_index']) {
await q(`ALTER INDEX ${index} RENAME TO ${index.replace(/^files_/, 'files_p0_')}`)
}
// Recreated on the parent, which clones them to every partition.
for (const trigger of ['files_release_extension', 'files_release_search', 'files_release_totals', 'files_zz_release_complete']) {
await q(`DROP TRIGGER ${trigger} ON files_p0`)
}
// The parent: same columns and defaults (the id sequence), partitioned by id, in the old table's tablespace.
await q(`CREATE TABLE files (LIKE files_p0 INCLUDING DEFAULTS) PARTITION BY RANGE (id)${tablespace}`)
await q(`ALTER SEQUENCE files_id_seq OWNED BY files.id`)
await q(`ALTER TABLE files ADD CONSTRAINT files_pkey PRIMARY KEY (id)`)
await q(`ALTER TABLE files ADD CONSTRAINT files_release_id_foreign FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE SET NULL`)
await q(`CREATE INDEX files_content_type_index ON files (content_type)`)
await q(`CREATE INDEX files_first_message_id_idx ON files (first_message_id)`)
await q(`CREATE INDEX files_name_idx ON files ((coalesce(real_name, filename::text)))`)
await q(`CREATE INDEX files_release_id_index ON files (release_id)`)
// Matching indexes and the foreign key of files_p0 are taken over, not rebuilt; the CHECK spares the scan.
await q(`ALTER TABLE files ATTACH PARTITION files_p0 FOR VALUES FROM (MINVALUE) TO (${boundary})`)
await q(`ALTER TABLE files_p0 DROP CONSTRAINT files_p0_bound`)
await q(`CREATE TRIGGER files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`)
await q(`CREATE TRIGGER files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`)
await q(`CREATE TRIGGER files_release_totals AFTER INSERT OR DELETE OR UPDATE OF release_id, size ON files FOR EACH ROW EXECUTE FUNCTION files_release_totals()`)
await q(`CREATE TRIGGER files_zz_release_complete AFTER INSERT OR DELETE OR UPDATE OF release_id, size, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_complete()`)
// binaries.file_id followed the rename to files_p0; point it at the parent (no scan needed).
await q(`ALTER TABLE binaries DROP CONSTRAINT binaries_file_id_foreign`)
await q(`ALTER TABLE binaries ADD CONSTRAINT binaries_file_id_foreign FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL NOT VALID`)
// The next two partitions; FilePartitions.ensure() (gc:scheduler) keeps two ahead from now on.
for (const start of [boundary, boundary + FILE_PARTITION_SIZE]) {
const name = `files_p${start}`
await q(`CREATE TABLE ${name} PARTITION OF files FOR VALUES FROM (${start}) TO (${start + FILE_PARTITION_SIZE})`)
await q(`ALTER TABLE ${name} ADD CONSTRAINT ${name}_file_key_unique UNIQUE (file_key)`)
}
}
async down() {
throw new Error('Not reversible: merging partitions back means rewriting the files table.')
}
}
@@ -0,0 +1,89 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
const TRIGGERS = [
`files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`,
`files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`,
`files_release_totals AFTER INSERT OR DELETE OR UPDATE OF release_id, size ON files FOR EACH ROW EXECUTE FUNCTION files_release_totals()`,
`files_zz_release_complete AFTER INSERT OR DELETE OR UPDATE OF release_id, size, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_complete()`,
]
/**
* files.id (and binaries.file_id, the id sequence) to 64 bits (CONCEPT.md
* D39): seeding adds ~14M files a day, and 32 bits run out at 2.1 billion.
* Postgres can't change the type of a partition key, so the partitions are
* detached, rewritten one by one as plain tables (files_p0: ~100 GB with its
* segment lists, 1–2 hours on homelab's HDD) and attached to a new 64-bit
* parent. A CHECK per partition, added before its rewrite, spares the attach
* scan. Run with the writers stopped and Postgres on wal_level=minimal, or the
* rewrite also goes through the WAL (LOG.md 2026-10-05 (8)).
*/
export default class extends BaseSchema {
// One transaction: a failure anywhere leaves files as it was (it drops and recreates the parent).
async up() {
const q = (sql: string) => this.db.rawQuery(sql)
const { rows: partitions } = await q(
`SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'files'::regclass ORDER BY c.relname`
)
const { rows: ts } = await q(`SELECT t.spcname FROM pg_class c JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE c.oid = 'files'::regclass`)
const tablespace = ts[0]?.spcname ? ` TABLESPACE ${ts[0].spcname}` : ''
// The sequence belongs to the old parent: keep it when that goes.
await q(`ALTER SEQUENCE files_id_seq OWNED BY NONE`)
await q(`ALTER SEQUENCE files_id_seq AS bigint`)
await q(`ALTER TABLE binaries DROP CONSTRAINT IF EXISTS binaries_file_id_foreign`)
await q(`ALTER TABLE binaries ALTER COLUMN file_id TYPE bigint`)
for (const p of partitions) {
await q(`ALTER TABLE files DETACH PARTITION ${p.name}`)
// Triggers cloned from the parent may stay behind on a detached partition; the new parent clones them again.
for (const t of TRIGGERS) await q(`DROP TRIGGER IF EXISTS ${t.split(' ')[0]} ON ${p.name}`)
const [, from, to] = /FROM \((.+)\) TO \((.+)\)/.exec(p.bound.replaceAll("'", ''))!
const conditions = [from === 'MINVALUE' ? null : `id >= ${from}`, to === 'MAXVALUE' ? null : `id < ${to}`].filter(Boolean)
if (conditions.length > 0) await q(`ALTER TABLE ${p.name} ADD CONSTRAINT ${p.name}_bound CHECK (${conditions.join(' AND ')})`)
}
await q(`DROP TABLE files`)
for (const p of partitions) {
await q(`ALTER TABLE ${p.name} ALTER COLUMN id TYPE bigint`)
}
await q(`
CREATE TABLE files (
id bigint NOT NULL DEFAULT nextval('files_id_seq'),
filename varchar(255) NOT NULL,
poster varchar(255) NOT NULL,
date bigint NOT NULL,
parts integer NOT NULL,
groups jsonb,
created_at timestamptz,
updated_at timestamptz,
file_key varchar(40),
release_id bigint,
archive jsonb,
real_name text,
size bigint,
content_type varchar(16),
segments bytea NOT NULL,
first_message_id text
) PARTITION BY RANGE (id)${tablespace}`)
await q(`ALTER SEQUENCE files_id_seq OWNED BY files.id`)
await q(`ALTER TABLE files ADD CONSTRAINT files_pkey PRIMARY KEY (id)`)
await q(`ALTER TABLE files ADD CONSTRAINT files_release_id_foreign FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE SET NULL`)
await q(`CREATE INDEX files_content_type_index ON files (content_type)`)
await q(`CREATE INDEX files_first_message_id_idx ON files (first_message_id)`)
await q(`CREATE INDEX files_name_idx ON files ((coalesce(real_name, filename::text)))`)
await q(`CREATE INDEX files_release_id_index ON files (release_id)`)
for (const p of partitions) {
await q(`ALTER TABLE files ATTACH PARTITION ${p.name} ${p.bound}`)
await q(`ALTER TABLE ${p.name} DROP CONSTRAINT IF EXISTS ${p.name}_bound`)
}
for (const t of TRIGGERS) await q(`CREATE TRIGGER ${t}`)
await q(`ALTER TABLE binaries ADD CONSTRAINT binaries_file_id_foreign FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL NOT VALID`)
}
async down() {
throw new Error('Not reversible: ids above 2^31 may exist.')
}
}
+226 -9
View File
@@ -40,7 +40,7 @@ export class BinarySchema extends BaseModel {
@column.dateTime()
declare conflictedAt: DateTime | null
@column()
declare fileId: number | null
declare fileId: bigint | number | null
@column()
declare fileKey: string
@column.dateTime()
@@ -148,8 +148,10 @@ export class DeferredRangeSchema extends BaseModel {
}
export class FetchRangeSchema extends BaseModel {
static $columns = ['createdAt', 'endId', 'error', 'groupId', 'id', 'requeues', 'startId', 'status', 'updatedAt'] as const
static $columns = ['backfill', 'createdAt', 'endId', 'error', 'groupId', 'id', 'requeues', 'startId', 'status', 'updatedAt'] as const
$columns = FetchRangeSchema.$columns
@column()
declare backfill: boolean
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
@@ -171,7 +173,7 @@ export class FetchRangeSchema extends BaseModel {
}
export class FileSchema extends BaseModel {
static $columns = ['archive', 'contentType', 'createdAt', 'date', 'fileKey', 'filename', 'groups', 'id', 'messageIds', 'parts', 'poster', 'realName', 'releaseId', 'size', 'updatedAt'] as const
static $columns = ['archive', 'contentType', 'createdAt', 'date', 'fileKey', 'filename', 'firstMessageId', 'groups', 'id', 'parts', 'poster', 'realName', 'releaseId', 'segments', 'size', 'updatedAt'] as const
$columns = FileSchema.$columns
@column()
declare archive: any | null
@@ -186,11 +188,11 @@ export class FileSchema extends BaseModel {
@column()
declare filename: string
@column()
declare firstMessageId: string | null
@column()
declare groups: any | null
@column({ isPrimary: true })
declare id: number
@column()
declare messageIds: any
declare id: bigint | number
@column()
declare parts: number
@column()
@@ -200,16 +202,133 @@ export class FileSchema extends BaseModel {
@column()
declare releaseId: bigint | number | null
@column()
declare segments: Buffer
@column()
declare size: bigint | number | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class FilesP0Schema extends BaseModel {
static $columns = ['archive', 'contentType', 'createdAt', 'date', 'fileKey', 'filename', 'firstMessageId', 'groups', 'id', 'parts', 'poster', 'realName', 'releaseId', 'segments', 'size', 'updatedAt'] as const
$columns = FilesP0Schema.$columns
@column()
declare archive: any | null
@column()
declare contentType: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column()
declare date: bigint | number
@column()
declare fileKey: string | null
@column()
declare filename: string
@column()
declare firstMessageId: string | null
@column()
declare groups: any | null
@column({ isPrimary: true })
declare id: bigint | number
@column()
declare parts: number
@column()
declare poster: string
@column()
declare realName: string | null
@column()
declare releaseId: bigint | number | null
@column()
declare segments: Buffer
@column()
declare size: bigint | number | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class FilesP1000000Schema extends BaseModel {
static $columns = ['archive', 'contentType', 'createdAt', 'date', 'fileKey', 'filename', 'firstMessageId', 'groups', 'id', 'parts', 'poster', 'realName', 'releaseId', 'segments', 'size', 'updatedAt'] as const
$columns = FilesP1000000Schema.$columns
@column()
declare archive: any | null
@column()
declare contentType: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column()
declare date: bigint | number
@column()
declare fileKey: string | null
@column()
declare filename: string
@column()
declare firstMessageId: string | null
@column()
declare groups: any | null
@column({ isPrimary: true })
declare id: bigint | number
@column()
declare parts: number
@column()
declare poster: string
@column()
declare realName: string | null
@column()
declare releaseId: bigint | number | null
@column()
declare segments: Buffer
@column()
declare size: bigint | number | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class FilesP5000000Schema extends BaseModel {
static $columns = ['archive', 'contentType', 'createdAt', 'date', 'fileKey', 'filename', 'firstMessageId', 'groups', 'id', 'parts', 'poster', 'realName', 'releaseId', 'segments', 'size', 'updatedAt'] as const
$columns = FilesP5000000Schema.$columns
@column()
declare archive: any | null
@column()
declare contentType: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column()
declare date: bigint | number
@column()
declare fileKey: string | null
@column()
declare filename: string
@column()
declare firstMessageId: string | null
@column()
declare groups: any | null
@column({ isPrimary: true })
declare id: bigint | number
@column()
declare parts: number
@column()
declare poster: string
@column()
declare realName: string | null
@column()
declare releaseId: bigint | number | null
@column()
declare segments: Buffer
@column()
declare size: bigint | number | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class GroupSchema extends BaseModel {
static $columns = ['active', 'createdAt', 'fetchBodies', 'id', 'lastIndexedId', 'lastQueuedId', 'name', 'updatedAt'] as const
static $columns = ['active', 'backfillNextId', 'backfillUntilId', 'createdAt', 'fetchBodies', 'id', 'lastIndexedId', 'lastQueuedId', 'name', 'updatedAt'] as const
$columns = GroupSchema.$columns
@column()
declare active: boolean
@column()
declare backfillNextId: bigint | number | null
@column()
declare backfillUntilId: bigint | number | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column()
@@ -292,7 +411,7 @@ export class ReleaseFileSchema extends BaseModel {
}
export class ReleaseSchema extends BaseModel {
static $columns = ['category', 'complete', 'container', 'contents', 'encrypted', 'fileCount', 'fileTotal', 'firstSeenAt', 'groups', 'id', 'lastSeenAt', 'name', 'password', 'postedAt', 'poster', 'releaseKey', 'size', 'source'] as const
static $columns = ['category', 'complete', 'container', 'contents', 'encrypted', 'extensions', 'fileCount', 'fileTotal', 'firstSeenAt', 'groups', 'id', 'lastSeenAt', 'name', 'password', 'passwordSource', 'postedAt', 'poster', 'releaseKey', 'searchFiles', 'searchText', 'size', 'source'] as const
$columns = ReleaseSchema.$columns
@column()
declare category: number | null
@@ -305,6 +424,8 @@ export class ReleaseSchema extends BaseModel {
@column()
declare encrypted: boolean | null
@column()
declare extensions: any | null
@column()
declare fileCount: number
@column()
declare fileTotal: number | null
@@ -320,6 +441,8 @@ export class ReleaseSchema extends BaseModel {
declare name: string
@column({ serializeAs: null })
declare password: string | null
@column()
declare passwordSource: string | null
@column.dateTime()
declare postedAt: DateTime | null
@column()
@@ -327,13 +450,86 @@ export class ReleaseSchema extends BaseModel {
@column()
declare releaseKey: string
@column()
declare searchFiles: string | null
@column()
declare searchText: string | null
@column()
declare size: bigint | number
@column()
declare source: string | null
}
export class SeedNodeSchema extends BaseModel {
static $columns = ['createdAt', 'disabled', 'id', 'lastSeenAt', 'name', 'tokenHash'] as const
$columns = SeedNodeSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare disabled: boolean
@column({ isPrimary: true })
declare id: number
@column.dateTime()
declare lastSeenAt: DateTime | null
@column()
declare name: string
@column()
declare tokenHash: string
}
export class SeedRangeSchema extends BaseModel {
static $columns = ['attempts', 'createdAt', 'doneAt', 'endId', 'error', 'files', 'fragments', 'groupName', 'headers', 'heartbeatAt', 'id', 'leasedAt', 'nextId', 'nodeId', 'startId', 'status'] as const
$columns = SeedRangeSchema.$columns
@column()
declare attempts: number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime()
declare doneAt: DateTime | null
@column()
declare endId: bigint | number
@column()
declare error: string | null
@column()
declare files: bigint | number
@column()
declare fragments: bigint | number
@column()
declare groupName: string
@column()
declare headers: bigint | number
@column.dateTime()
declare heartbeatAt: DateTime | null
@column({ isPrimary: true })
declare id: bigint | number
@column.dateTime()
declare leasedAt: DateTime | null
@column()
declare nextId: bigint | number
@column()
declare nodeId: number | null
@column()
declare startId: bigint | number
@column()
declare status: string
}
export class SfvEntrySchema extends BaseModel {
static $columns = ['crc', 'name', 'postedAt', 'poster', 'releaseId'] as const
$columns = SfvEntrySchema.$columns
@column()
declare crc: string
@column()
declare name: string
@column.dateTime()
declare postedAt: DateTime | null
@column()
declare poster: string | null
@column({ isPrimary: true })
declare releaseId: bigint | number
}
export class SpotSchema extends BaseModel {
static $columns = ['articleNumber', 'category', 'createdAt', 'error', 'groupName', 'keyId', 'messageId', 'postedAt', 'poster', 'processedAt', 'releaseId', 'size', 'spotterId', 'status', 'subcats', 'tag', 'title', 'website'] as const
static $columns = ['articleNumber', 'category', 'createdAt', 'description', 'detailsAt', 'error', 'groupName', 'image', 'keyId', 'messageId', 'postedAt', 'poster', 'processedAt', 'releaseId', 'size', 'spotterId', 'status', 'subcats', 'tag', 'title', 'verified', 'website'] as const
$columns = SpotSchema.$columns
@column()
declare articleNumber: bigint | number | null
@@ -342,10 +538,16 @@ export class SpotSchema extends BaseModel {
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare description: string | null
@column.dateTime()
declare detailsAt: DateTime | null
@column()
declare error: string | null
@column()
declare groupName: string
@column()
declare image: any | null
@column()
declare keyId: number | null
@column({ isPrimary: true })
declare messageId: string
@@ -370,9 +572,24 @@ export class SpotSchema extends BaseModel {
@column()
declare title: string
@column()
declare verified: boolean | null
@column()
declare website: string | null
}
export class StagedSegmentSchema extends BaseModel {
static $columns = ['counterKey', 'fileKey', 'segments', 'stagedAt'] as const
$columns = StagedSegmentSchema.$columns
@column()
declare counterKey: string | null
@column({ isPrimary: true })
declare fileKey: string
@column()
declare segments: any
@column.dateTime()
declare stagedAt: DateTime
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const
$columns = UserSchema.$columns
+86
View File
@@ -0,0 +1,86 @@
/**
* Edge aggregation (CONCEPT.md D37): groups header segments into files in
* memory, so a seeding node writes one row per file instead of one per
* article. A file is done once every part number from 1 to its total has
* an article; what never completes within the node's window (an upload cut
* by the range edges, a random subject per article) leaves as fragments
* for the regular ingest path, where it can meet its other parts.
*/
/** What the aggregator needs of a segment (IngestSegment has all of it). */
export interface AggregatableSegment {
fileKey: string
number: number
totalParts: number
messageId: string
bytes: number
}
export interface AggregatedFile<S extends AggregatableSegment> {
fileKey: string
/** One segment per part number, in part order; the first article seen wins (a later one is a repost). */
segments: S[]
}
interface OpenFile<S> {
parts: Map<number, S>
totalParts: number
/** Batch in which a segment last arrived. */
touched: number
}
export class FileAggregator<S extends AggregatableSegment> {
private open = new Map<string, OpenFile<S>>()
private batch = 0
/** Segments with a part number outside 1..total: never part of a complete file. */
private stray: S[] = []
/** Adds one batch of segments; returns the files they completed. */
add(segments: S[]): AggregatedFile<S>[] {
this.batch++
const touched = new Set<string>()
for (const segment of segments) {
if (segment.number < 1 || segment.number > segment.totalParts) {
this.stray.push(segment)
continue
}
let file = this.open.get(segment.fileKey)
if (!file) {
file = { parts: new Map(), totalParts: segment.totalParts, touched: this.batch }
this.open.set(segment.fileKey, file)
}
if (!file.parts.has(segment.number)) file.parts.set(segment.number, segment)
file.touched = this.batch
touched.add(segment.fileKey)
}
const complete: AggregatedFile<S>[] = []
for (const key of touched) {
const file = this.open.get(key)!
if (file.parts.size < file.totalParts) continue
this.open.delete(key)
complete.push({ fileKey: key, segments: [...file.parts.values()].sort((a, b) => a.number - b.number) })
}
return complete
}
/**
* Gives up on files that got no segment in the last `idleBatches` batches
* (all open files when 0) and returns their segments, plus stray ones.
*/
evict(idleBatches = 0): S[] {
const out = this.stray
this.stray = []
for (const [key, file] of this.open) {
if (idleBatches > 0 && this.batch - file.touched < idleBatches) continue
this.open.delete(key)
out.push(...file.parts.values())
}
return out
}
/** Files still waiting for parts. */
get openFiles(): number {
return this.open.size
}
}
+25
View File
@@ -0,0 +1,25 @@
import { sevenZipVolume } from '../sevenzip/index.js'
/**
* Files whose first segment says what an archive holds: named first RAR
* volumes (.part01.rar, .rar), 7z (.7z, .7z.001) and `.bin`, an extension
* uploaders use for archive volumes too (sniffed by content).
*/
export function isArchiveStart(name: string): boolean {
if (/\.bin$/i.test(name)) return true
const volume = sevenZipVolume(name)
if (volume === 0 || volume === 1) return true
return /\.part0*1\.rar$/i.test(name) || (/\.rar$/i.test(name) && !/\.part\d+\.rar$/i.test(name))
}
/**
* A later RAR volume (.part02.rar, .r00) or a par2 recovery volume
* (.vol03+04.par2): the volume number is readable even under a random stem.
* The set is listed from its first volume and named from its index .par2,
* so the first segment of these says nothing new (LOG.md 2026-10-04).
*/
export function isLaterVolume(name: string): boolean {
const part = /\.part(\d+)\.rar$/i.exec(name)
if (part) return Number(part[1]) > 1
return /\.r\d{2,3}$/i.test(name) || /\.vol\d+[+-]\d+\.par2$/i.test(name)
}
+3
View File
@@ -2,3 +2,6 @@
* Content recognition by magic bytes, independent of AdonisJS.
*/
export { sniffContentType, type ContentType } from './sniff.js'
export { isArchiveStart, isLaterVolume } from './archive_names.js'
export { parseSfv, sfvReleaseName, type SfvEntry } from './sfv.js'
export { NICOVIDEO_FILE, NICOVIDEO_RELEASE, nicovideoInfo, nicovideoReleaseName, zstdPrefix, tarEntries, type NicovideoInfo } from './nicovideo.js'
+76
View File
@@ -0,0 +1,76 @@
import { createZstdDecompress } from 'node:zlib'
/**
* Captures of niconico videos (`<unixtime>_nicovideo_jp_watch_sm<id>.tar.zst`,
* one poster, ~190k files on homelab). The tar's first entry after its
* directory is `server_response.metadata`: the site's API response for the
* watch page, 30–70 KB, so the first article of the file holds it.
*/
export const NICOVIDEO_FILE = /^\d{9,11}_nicovideo_jp_watch_(sm\d+)\.tar\.zst$/i
export const NICOVIDEO_RELEASE = /^\d{9,11}_nicovideo_jp_watch_(sm\d+)$/i
export interface NicovideoInfo {
id: string
title: string
owner: string | null
registeredAt: string | null
tags: string[]
}
/** As much of a zstd stream as `data` (a prefix of it) decompresses to. */
export function zstdPrefix(data: Buffer, maxBytes = 4 * 1024 * 1024): Promise<Buffer> {
return new Promise((resolve) => {
const out: Buffer[] = []
let length = 0
const z = createZstdDecompress()
const done = () => resolve(Buffer.concat(out))
z.on('data', (chunk: Buffer) => {
out.push(chunk)
length += chunk.length
if (length >= maxBytes) z.destroy()
})
z.on('error', done)
z.on('close', done)
z.on('end', done)
z.end(data)
})
}
/** Entries at the start of a (possibly truncated) ustar archive. */
export function* tarEntries(tar: Buffer): Generator<{ name: string; data: Buffer }> {
let offset = 0
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512)
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '')
if (!name) return
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/s, '')
const size = Number.parseInt(header.subarray(124, 136).toString('latin1').replace(/\0.*$/s, '').trim() || '0', 8) || 0
yield { name: prefix ? `${prefix}/${name}` : name, data: tar.subarray(offset + 512, offset + 512 + size) }
offset += 512 + Math.ceil(size / 512) * 512
}
}
/** Title, uploader and tags from the start of a capture, or null when they aren't in it. */
export function nicovideoInfo(tar: Buffer): NicovideoInfo | null {
for (const entry of tarEntries(tar)) {
if (!entry.name.endsWith('/server_response.metadata')) continue
try {
const text = entry.data.toString('utf8')
const json = JSON.parse(text.slice(text.indexOf('{')))
const video = json?.data?.response?.video
if (!video?.title) return null
const owner = json.data.response.owner?.nickname ?? json.data.response.channel?.name ?? json.data.metadata?.jsonLds?.[0]?.author?.name ?? null
const tags = (json.data.response.tag?.items ?? []).map((t: any) => String(t.name)).filter(Boolean)
return { id: String(video.id ?? ''), title: String(video.title).trim(), owner: owner ? String(owner).trim() : null, registeredAt: video.registeredAt ?? null, tags }
} catch {
return null
}
}
return null
}
/** Release name for a capture: `<title> - <uploader> (nicovideo sm123)`. */
export function nicovideoReleaseName(info: NicovideoInfo, fallbackId: string): string {
const id = info.id || fallbackId
return `${info.title}${info.owner ? ` - ${info.owner}` : ''} (nicovideo ${id})`
}
+29
View File
@@ -0,0 +1,29 @@
/**
* SFV checksum lists: one `name CRC32` per line, `;` comments. Scene
* releases (music in particular) post one with every album; it names every
* file that belongs to it.
*/
export interface SfvEntry {
name: string
crc: string
}
export function parseSfv(text: string): SfvEntry[] {
const entries: SfvEntry[] = []
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith(';')) continue
const match = /^(.+?)\s+([0-9a-f]{8})$/i.exec(line)
if (match) entries.push({ name: match[1].replace(/^"|"$/g, '').split(/[\\/]/).pop()!, crc: match[2].toLowerCase() })
}
return entries
}
/**
* The album name an SFV gives a release: its stem without the leading
* track number scene groups put on the playlist/SFV/cover files
* (`00-artist-album-web-2026-grp.sfv` → `artist-album-web-2026-grp`).
*/
export function sfvReleaseName(sfvName: string): string {
return sfvName.replace(/\.sfv$/i, '').replace(/^0{1,3}-/, '')
}
+9 -2
View File
@@ -2,6 +2,8 @@
* Recognises a file type from its first bytes, independent of AdonisJS.
* `nzbe` is an encrypted-NZB container seen on Usenet (LOG.md 2026-09-28 (11)):
* "nzbe", 16 random bytes (salt/IV), then ciphertext.
* `yencbin` is the encrypted `.bin` container of `yEncBin@Poster.com`
* (LOG.md 2026-09-30 (3)): a fixed header, then data with 8 bits/byte of entropy.
*/
export type ContentType =
| 'nzbe'
@@ -16,6 +18,7 @@ export type ContentType =
| 'matroska'
| 'mp4'
| 'rclone'
| 'yencbin'
| 'iso'
| 'udf'
| 'pdf'
@@ -29,7 +32,11 @@ export type ContentType =
| 'ogg'
| 'bzip2'
const SIGNATURES: [ContentType, number, number[]][] = [
/** Magic bytes at an offset; null matches any byte. */
const SIGNATURES: [ContentType, number, (number | null)[]][] = [
// Two header variants seen (54f399d117c49269 6e4dc4ef5b60c11d…, 54f399e917c49269 6a4e9fb05b60c31d…):
// the bytes they share.
['yencbin', 0, [0x54, 0xf3, 0x99, null, 0x17, 0xc4, 0x92, 0x69, null, null, null, null, 0x5b, 0x60, null, 0x1d, 0x7f, 0x83, 0xe9, 0x2d]],
['nzbe', 0, [0x6e, 0x7a, 0x62, 0x65]],
['par2', 0, [0x50, 0x41, 0x52, 0x32, 0x00, 0x50, 0x4b, 0x54]],
['rar5', 0, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]],
@@ -53,7 +60,7 @@ const SIGNATURES: [ContentType, number, number[]][] = [
export function sniffContentType(data: Buffer): ContentType | null {
for (const [type, offset, bytes] of SIGNATURES) {
if (data.length >= offset + bytes.length && bytes.every((b, i) => data[offset + i] === b)) return type
if (data.length >= offset + bytes.length && bytes.every((b, i) => b === null || data[offset + i] === b)) return type
}
// Disc images, visible when the first part is big enough (sector 16 at 32 KiB):
// ISO 9660 has "CD001"; UDF (DVD/Blu-ray) has BEA01 followed by NSR02/NSR03.
+105
View File
@@ -0,0 +1,105 @@
import { parseXrefGroups } from '../nntp/xref.js'
import { fileKey, parseSubject, weakFileKey } from '../subject/index.js'
import { releaseIdentity, type ReleaseIdentity } from '../release/index.js'
import { inferTotalParts, type YencMeta } from '../yenc/index.js'
/** One segment, with the identity of the file it belongs to already resolved. */
export interface IngestSegment {
fileKey: string
poster: string
/** Subject without segment counter. */
subject: string
name: string
totalParts: number
number: number
/** Without angle brackets. */
messageId: string
bytes: number
articleNumber?: bigint | number
postedAt?: Date
groups: string[]
/**
* The identity comes from the yEnc name only (obfuscated posts). Such names
* can be shared by several equal-sized files of one post, so a second
* article for the same part number marks the binary as conflicted.
*/
weakIdentity?: boolean
/** Release (download) this file belongs to, when it can be determined. */
release?: ReleaseIdentity | null
/** Exact file size from =ybegin (body-derived segments only). */
size?: number
/** yEnc CRCs (body-derived segments only), to verify split volumes. */
pcrc32?: string
partSize?: number
fileCrc32?: string
/** Part 1 only: MD5 of the first 16 KiB of the file, as par2 records it. */
md5_16k?: string
}
/** An XOVER overview entry as returned by nntp-js. */
export type OverviewHeader = Record<string, string>
function common(header: OverviewHeader, groupName: string, articleNumber?: bigint | number) {
const xrefGroups = parseXrefGroups(header.xref)
return {
poster: header.from ?? '',
messageId: (header['message-id'] ?? '').replace(/^<|>$/g, ''),
bytes: Number.parseInt(header[':bytes'] ?? '0', 10) || 0,
articleNumber,
postedAt: header.date ? new Date(header.date) : undefined,
groups: xrefGroups.length > 0 ? xrefGroups : [groupName],
}
}
/** Builds a segment from the subject line; null when the subject is obfuscated. */
export function segmentFromHeader(
header: OverviewHeader,
groupName: string,
articleNumber?: bigint | number
): IngestSegment | null {
const parsed = header.subject ? parseSubject(header.subject) : null
if (!parsed || !header['message-id']) return null
const base = common(header, groupName, articleNumber)
return {
...base,
fileKey: fileKey(base.poster, parsed.base, parsed.totalSegments),
subject: parsed.base,
name: parsed.name,
totalParts: parsed.totalSegments,
number: parsed.segment,
release: releaseIdentity({ poster: base.poster, subject: parsed.base, name: parsed.name, weak: false }),
}
}
/**
* Builds a segment for an obfuscated post from the yEnc header in its body.
* Subject and poster can be random per article, so the identity comes from
* the yEnc header only (see weakFileKey).
*/
export function segmentFromYenc(
header: OverviewHeader,
groupName: string,
meta: Pick<YencMeta, 'begin' | 'part'> & Partial<Pick<YencMeta, 'end'>>,
articleNumber?: bigint | number
): IngestSegment | null {
const base = common(header, groupName, articleNumber)
const totalParts = inferTotalParts(meta)
const number = meta.begin.part ?? 1
if (!base.messageId || number < 1 || number > totalParts) return null
const subject = `"${meta.begin.name}" yEnc`
return {
...base,
fileKey: weakFileKey(meta.begin.name, meta.begin.size, totalParts),
subject,
name: meta.begin.name,
totalParts,
number,
weakIdentity: true,
release: releaseIdentity({ poster: base.poster, subject, name: meta.begin.name, weak: true }),
size: meta.begin.size,
// Single-part posts only carry crc32, which is then also the part's CRC.
pcrc32: meta.end?.pcrc32 ?? (meta.part ? undefined : meta.end?.crc32),
partSize: meta.part ? meta.part.end - meta.part.begin + 1 : meta.begin.size,
fileCrc32: meta.end?.crc32,
}
}
+13 -1
View File
@@ -13,6 +13,8 @@ export interface NewznabItem {
poster: string | null
groups: string[]
password: boolean
/** Longer text than the title, e.g. a Spotnet spot's description (UBB markup). */
description?: string
season?: string
episode?: string
}
@@ -26,6 +28,16 @@ export interface NewznabServer {
}
/** Newznab error codes: 100 credentials, 200/201 parameters, 202 function, 300 item. */
/** Spotnet UBB markup (or the HTML of old spots) as plain text: [br] as line breaks, other tags dropped. */
export function ubbToText(ubb: string): string {
return ubb
.replace(/<br\s*\/?>|\[br\]/gi, '\n')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/\[url=([^\]]*)\]([\s\S]*?)\[\/url\]/gi, (_, url: string, label: string) => (label.trim() ? `${label} (${url.trim()})` : url.trim()))
.replace(/\[\/?[a-z]+(?:=[^\]]*)?\]/gi, '')
.trim()
}
export function errorXml(code: number, description: string): string {
return create({ version: '1.0', encoding: 'UTF-8' })
.ele('error', { code: String(code), description })
@@ -74,7 +86,7 @@ export function resultsXml(server: NewznabServer, items: NewznabItem[], offset:
element.ele('comments').txt(`${server.baseUrl}/?release=${item.id}`)
element.ele('pubDate').txt(pubDate)
element.ele('category').txt(categoryLabel(item.category))
element.ele('description').txt(item.title)
element.ele('description').txt(item.description ? ubbToText(item.description) : item.title)
element.ele('enclosure', { url: getUrl, length: String(item.size), type: 'application/x-nzb' })
const attr = (name: string, value: string | number) =>
+27
View File
@@ -0,0 +1,27 @@
/**
* The article number where a group's posts reach `target` (ms since epoch),
* by binary search between `first` and `last`. Article numbers only roughly
* follow posting time, so the answer is within `precision` articles of the
* first one posted at or after `target`.
*
* `dateOf(n)` returns the posting time of the first article at or after `n`
* that still exists, or null when there is none nearby (expired or removed);
* a gap counts as older than `target`.
*/
export async function articleAtDate(
first: bigint,
last: bigint,
target: number,
dateOf: (n: bigint) => Promise<number | null>,
precision = 10_000n
): Promise<bigint> {
let lo = first
let hi = last
while (hi - lo > precision) {
const mid = lo + (hi - lo) / 2n
const date = await dateOf(mid)
if (date === null || date < target) lo = mid
else hi = mid
}
return lo
}
+76
View File
@@ -0,0 +1,76 @@
const CRLF = Buffer.from('\r\n')
/**
* Splits a byte stream into messages that end in a delimiter, which the
* consumer can change per message: `next(delimiter)` returns everything up
* to the next `delimiter` (excluded). A replacement for nntp-js's
* findByteSequence, which has two bugs:
*
* - An empty multi-line reply (`224 …\r\n.\r\n`, e.g. XOVER of a range
* without articles) never ends: the status line takes the CRLF that
* `\r\n.\r\n` starts with. Here, when a delimiter that starts with CRLF
* follows one that ended with CRLF, that CRLF counts for both.
* - A delimiter split over two socket chunks left its first bytes in the
* returned data.
*
* Empty messages for a bare CRLF delimiter are skipped, as nntp-js does.
*/
export async function* delimitedReader(
source: AsyncIterable<Uint8Array>,
initial: Uint8Array
): AsyncGenerator<Buffer, void, Uint8Array | undefined> {
let delimiter = Buffer.from(initial)
let parts: Buffer[] = []
let length = 0
// Bytes at the start of the buffer, already searched without a match.
let searched = 0
// Leading bytes that belong to the previous delimiter (a shared CRLF).
let skip = 0
for await (const chunk of source) {
parts.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))
length += chunk.byteLength
while (true) {
const from = Math.max(0, searched - (delimiter.length - 1))
const found = indexOf(parts, length, delimiter, from)
if (found === -1) {
searched = length
break
}
const all = parts.length === 1 ? parts[0] : Buffer.concat(parts, length)
const message = all.subarray(Math.min(skip, found), found)
const rest = all.subarray(found + delimiter.length)
const previous = delimiter
if (message.length === 0 && delimiter.equals(CRLF)) {
// A stray empty line: keep looking for the same delimiter.
} else {
delimiter = Buffer.from((yield message) ?? delimiter)
}
const shareCrlf = endsWithCrlf(previous) && delimiter.length > 2 && startsWithCrlf(delimiter)
parts = shareCrlf ? [CRLF, rest] : [rest]
length = rest.length + (shareCrlf ? 2 : 0)
skip = shareCrlf ? 2 : 0
searched = 0
}
}
if (length > skip) yield Buffer.concat(parts, length).subarray(skip)
}
const endsWithCrlf = (b: Buffer) => b.length >= 2 && b[b.length - 2] === 13 && b[b.length - 1] === 10
const startsWithCrlf = (b: Buffer) => b.length >= 2 && b[0] === 13 && b[1] === 10
/** indexOf over a list of buffers (their total `length`), starting at `from`; copies only the searched tail. */
function indexOf(parts: Buffer[], length: number, needle: Buffer, from: number): number {
if (parts.length === 1) return parts[0].indexOf(needle, from)
// Collect the parts that cover [from, length).
let start = length
let i = parts.length
while (i > 0 && start > from) start -= parts[--i].length
const window = Buffer.concat(parts.slice(i), length - start)
const found = window.indexOf(needle, from - start)
return found === -1 ? -1 : start + found
}
+39
View File
@@ -0,0 +1,39 @@
import { NNTP } from 'nntp-js'
import { delimitedReader } from './delimited_reader.js'
const CRLF = new Uint8Array([13, 10])
/**
* nntp-js with its reply reader replaced by `delimitedReader` (see there for
* the bugs it fixes; the empty-reply one hung XOVER of empty ranges forever).
* The reader is created in `connect`, so that is overridden: the same steps
* as nntp-js 1.0.4, with the other reader.
*/
const proto = NNTP.prototype as any
if (!proto.__delimitedReader) {
proto.__delimitedReader = true
proto.connect = async function (this: any, readermode = false, timeout: number | undefined = undefined, socket?: any) {
try {
this.sock = socket ?? this._createSocket(timeout)
// The first reply is always the one-line welcome.
this.byteReader = delimitedReader(this.sock, CRLF)
this.welcome = new TextDecoder('utf-8').decode(await this._getresp())
this.caps = undefined
this.connected = true
await this.getcapabilities()
this.readermodeAfterauth = false
if (readermode && !this.caps.READER) {
await this._setreadermode()
if (!this.readermodeAfterauth) {
this.caps = undefined
await this.getcapabilities()
}
}
} catch (error) {
this.sock?.end()
throw error
}
}
}
export { NNTP }
+233
View File
@@ -0,0 +1,233 @@
import net from 'node:net'
import tls from 'node:tls'
import { deflateSync, unzipSync } from 'node:zlib'
export interface XoverClientOptions {
host: string
port: number
user?: string
password?: string
secure?: boolean
/** Hostname the TLS certificate must match, when connecting through a tunnel (default: host). */
servername?: string
/** A command whose reply stalls this long fails, and the connection with it. */
timeoutMs?: number
/** Ask for gzip-compressed overview data (XFEATURE COMPRESS GZIP), when the server has it. */
compress?: boolean
}
export type Overview = [number, Record<string, string>]
/** The overview fields in RFC 3977 order, named as nntp-js names them (segmentFromHeader reads these). */
const FIELDS = ['subject', 'from', 'date', 'message-id', 'references', ':bytes', ':lines', 'xref']
const TERMINATOR = Buffer.from('\r\n.\r\n')
const DOT_LINE = Buffer.from('.\r\n')
/**
* A minimal NNTP client for seeding nodes (CONCEPT.md D37): one connection,
* GROUP and XOVER only, with gzip-compressed overviews. nntp-js can't read
* those: after "224 … [COMPRESS=GZIP]" the lines come as one zlib stream,
* followed by a plain "." line; an empty range is a 420 without body.
* About six times less traffic per header (LOG.md 2026-10-05).
*/
export class XoverClient {
private chunks: Buffer[] = []
private length = 0
private wake?: () => void
private failure?: Error
compressed = false
private constructor(
private sock: net.Socket,
private timeoutMs: number
) {
sock.on('data', (chunk: Buffer) => {
this.chunks.push(chunk)
this.length += chunk.length
this.wake?.()
})
const fail = (error: Error) => {
this.failure ??= error
this.wake?.()
}
sock.on('error', fail)
sock.on('close', () => fail(new Error('NNTP connection closed')))
sock.on('timeout', () => {
fail(new Error('NNTP reply timed out'))
sock.destroy()
})
}
static async connect(options: XoverClientOptions): Promise<XoverClient> {
const sock = options.secure
? tls.connect({ host: options.host, port: options.port, servername: options.servername || options.host })
: net.connect({ host: options.host, port: options.port })
const client = new XoverClient(sock, options.timeoutMs ?? 120_000)
sock.setTimeout(client.timeoutMs)
try {
await client.expect(await client.line(), ['200', '201'])
if (options.user) {
const reply = await client.command(`AUTHINFO USER ${options.user}`)
if (reply.startsWith('381')) await client.expect(await client.command(`AUTHINFO PASS ${options.password ?? ''}`), ['281'])
else await client.expect(reply, ['281'])
}
if (options.compress) client.compressed = (await client.command('XFEATURE COMPRESS GZIP')).startsWith('290')
} catch (error) {
client.close()
throw error
}
// Idle between commands is fine (a node waiting for the seed API); only replies are timed.
sock.setTimeout(0)
return client
}
async group(name: string): Promise<{ count: number; low: number; high: number }> {
return this.timed(() => this.selectGroup(name))
}
/** Overviews of articles `from`..`to` in the current group; an empty range gives []. */
async xover(from: number, to: number): Promise<Overview[]> {
return this.timed(() => this.overviews(from, to))
}
/**
* Overviews of `from`..`to` as one zlib/gzip stream, as the server sent it when it
* compresses (plain replies are deflated here): the seed API passes these to nodes
* without unpacking them (CONCEPT.md D38). `parseOverviews(unzipSync(raw).toString())`.
*/
async xoverRaw(from: number, to: number): Promise<Buffer> {
return this.timed(async () => {
const reply = await this.command(`XOVER ${from}-${to}`)
if (reply.startsWith('420') || reply.startsWith('423')) return deflateSync(Buffer.alloc(0))
await this.expect(reply, ['224'])
return reply.includes('COMPRESS=GZIP') ? (await this.compressedRaw()).raw : deflateSync(Buffer.from(await this.plainBody()))
})
}
/** A reply that stalls for timeoutMs fails the connection; idle time between commands doesn't count. */
private async timed<T>(exchange: () => Promise<T>): Promise<T> {
this.sock.setTimeout(this.timeoutMs)
try {
return await exchange()
} finally {
this.sock.setTimeout(0)
}
}
private async selectGroup(name: string): Promise<{ count: number; low: number; high: number }> {
const reply = await this.command(`GROUP ${name}`)
await this.expect(reply, ['211'])
const [, count, low, high] = reply.split(' ')
return { count: Number(count), low: Number(low), high: Number(high) }
}
private async overviews(from: number, to: number): Promise<Overview[]> {
const reply = await this.command(`XOVER ${from}-${to}`)
if (reply.startsWith('420') || reply.startsWith('423')) return []
await this.expect(reply, ['224'])
const data = reply.includes('COMPRESS=GZIP') ? await this.compressedBody() : await this.plainBody()
return parseOverviews(data)
}
close() {
try {
this.sock.end('QUIT\r\n')
} catch {
// already closed
}
this.sock.destroy()
}
private async command(line: string): Promise<string> {
this.sock.write(`${line}\r\n`)
return this.line()
}
private async expect(reply: string, codes: string[]) {
if (!codes.some((code) => reply.startsWith(code))) throw new Error(`NNTP: unexpected reply "${reply}"`)
}
private buffer(): Buffer {
if (this.chunks.length > 1) this.chunks = [Buffer.concat(this.chunks, this.length)]
return this.chunks[0] ?? Buffer.alloc(0)
}
private take(n: number): Buffer {
const all = this.buffer()
const out = all.subarray(0, n)
const rest = all.subarray(n)
this.chunks = rest.length > 0 ? [rest] : []
this.length = rest.length
return out
}
private async more() {
if (this.failure) throw this.failure
await new Promise<void>((resolve) => (this.wake = resolve))
this.wake = undefined
if (this.failure && this.length === 0) throw this.failure
}
private async line(): Promise<string> {
while (true) {
const at = this.buffer().indexOf('\r\n')
if (at !== -1) return this.take(at + 2).subarray(0, at).toString('latin1')
await this.more()
}
}
/** A plain multi-line body, up to "\r\n.\r\n", dot-unstuffed. */
private async plainBody(): Promise<string> {
let searched = 0
while (true) {
const all = this.buffer()
// An empty body is just ".\r\n".
if (all.length >= 3 && searched === 0 && all.subarray(0, 3).equals(DOT_LINE)) {
this.take(3)
return ''
}
const at = all.indexOf(TERMINATOR, Math.max(0, searched - TERMINATOR.length))
if (at !== -1) return this.take(at + TERMINATOR.length).subarray(0, at + 2).toString('utf8').replace(/^\.\./gm, '.')
searched = all.length
await this.more()
}
}
private async compressedBody(): Promise<string> {
return (await this.compressedRaw()).text
}
/** A zlib/gzip stream followed by a "." line: complete once it ends in ".\r\n" and inflates. */
private async compressedRaw(): Promise<{ raw: Buffer; text: string }> {
while (true) {
const all = this.buffer()
if (all.length > DOT_LINE.length && all.subarray(all.length - DOT_LINE.length).equals(DOT_LINE)) {
const raw = all.subarray(0, all.length - DOT_LINE.length)
try {
const text = unzipSync(raw).toString('utf8')
this.take(all.length)
return { raw: Buffer.from(raw), text }
} catch {
// ".\r\n" inside the compressed data: wait for more.
}
}
await this.more()
}
}
}
/** Overview lines (tab-separated, after the article number) as nntp-js returns them. */
export function parseOverviews(data: string): Overview[] {
const out: Overview[] = []
for (const line of data.split('\r\n')) {
if (!line) continue
const [number, ...tokens] = line.split('\t')
const fields: Record<string, string> = {}
for (let i = 0; i < FIELDS.length && i < tokens.length; i++) {
// Full fields after the defaults carry their name: "Xref: host group:number".
fields[FIELDS[i]] = i >= 7 ? tokens[i].replace(/^[^:\s]+:\s*/, '') : tokens[i]
}
out.push([Number.parseInt(number, 10), fields])
}
return out
}
+6 -9
View File
@@ -1,4 +1,5 @@
import { create } from 'xmlbuilder2'
import type { Segment } from './segments.js'
export interface NzbFile {
/** Name the downloader should use; the real name when it is known. */
@@ -7,8 +8,7 @@ export interface NzbFile {
/** Posting time in milliseconds. */
date: number
groups: string[]
/** Segment number → message-id (without angle brackets) and article size. */
segments: Record<string, { id: string; size: number }>
segments: Segment[]
}
export interface NzbOptions {
@@ -34,20 +34,17 @@ export function buildNzb(files: NzbFile[], options: NzbOptions = {}): string {
}
for (const file of files) {
const numbers = Object.keys(file.segments)
.map(Number)
.sort((a, b) => a - b)
const sorted = [...file.segments].sort((a, b) => a.number - b.number)
const element = root.ele('file', {
poster: file.poster,
date: String(Math.floor(file.date / 1000)),
subject: `"${file.name}" yEnc (1/${numbers.length})`,
subject: `"${file.name}" yEnc (1/${sorted.length})`,
})
const groups = element.ele('groups')
for (const group of file.groups) groups.ele('group').txt(group)
const segments = element.ele('segments')
for (const number of numbers) {
const segment = file.segments[String(number)]
segments.ele('segment', { bytes: String(segment.size), number: String(number) }).txt(segment.id)
for (const segment of sorted) {
segments.ele('segment', { bytes: String(segment.size), number: String(segment.number) }).txt(segment.id)
}
}
return root.end({ prettyPrint: true })
+1
View File
@@ -3,3 +3,4 @@
*/
export { buildNzb, type NzbFile, type NzbOptions } from './build.js'
export { parseNzb, type ParsedNzb, type ParsedNzbFile } from './parse.js'
export type { Segment } from './segments.js'
+33
View File
@@ -0,0 +1,33 @@
import { zstdCompressSync, zstdDecompressSync, constants } from 'node:zlib'
/** One article of a posted file: its number, message-id (without <>) and size in bytes. */
export interface Segment {
number: number
id: string
size: number
}
const VERSION = 1
/**
* Segment lists as stored in `files.segments` (CONCEPT.md D31): a version
* byte, then zstd (level 1) of a JSON line with the sizes (and the numbers,
* unless they are 1..N) followed by one message-id per line. About 20 bytes
* per segment, against ~38 for the pglz-compressed JSON object it replaces:
* message-ids are mostly random, so this is close to what's possible.
*/
export function packSegments(segments: Segment[]): Buffer {
const sorted = [...segments].sort((a, b) => a.number - b.number)
const sequential = sorted.every((s, i) => s.number === i + 1)
const head = JSON.stringify(sequential ? { s: sorted.map((s) => s.size) } : { s: sorted.map((s) => s.size), n: sorted.map((s) => s.number) })
const text = Buffer.from([head, ...sorted.map((s) => s.id)].join('\n'), 'utf8')
const packed = zstdCompressSync(text, { params: { [constants.ZSTD_c_compressionLevel]: 1 } })
return Buffer.concat([Buffer.from([VERSION]), packed])
}
export function unpackSegments(data: Buffer): Segment[] {
if (data[0] !== VERSION) throw new Error(`Unknown segment list version ${data[0]}`)
const [head, ...ids] = zstdDecompressSync(data.subarray(1)).toString('utf8').split('\n')
const { s: sizes, n: numbers } = JSON.parse(head) as { s: number[]; n?: number[] }
return sizes.map((size, i) => ({ number: numbers?.[i] ?? i + 1, id: ids[i], size }))
}
+2 -1
View File
@@ -19,7 +19,8 @@ export interface RarEntry {
}
export interface RarInfo {
format: 'rar4' | 'rar5'
/** 7z listings take the same shape (see lib/sevenzip). */
format: 'rar4' | 'rar5' | '7z'
/** Part of a multi-volume set. */
volume: boolean
/** First volume of the set (null when the format version does not say). */
+1 -1
View File
@@ -1,4 +1,4 @@
/**
* Grouping of files into releases (downloads), independent of AdonisJS.
*/
export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, fileStem, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './release_key.js'
export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './release_key.js'
+13 -1
View File
@@ -197,11 +197,23 @@ export function par2ReleaseName(fileNames: string[]): string | undefined {
* volumes after the first, the first entry is the file that continues from
* the previous volume, so all volumes of a single-file archive agree.
*/
/**
* Folder and file names that say nothing about what a release is: disc
* structures (VIDEO_TS on every DVD), samples, subtitles, disc numbers.
* Kept in step with SQL's is_readable_name().
*/
export const GENERIC_NAMES = /^(video_ts|audio_ts|bdmv|certificate|hvdvd_ts|samples?|subs?|subtitles?|proof|cover|covers|extras?|featurettes?|(cd|dvd|disc|disk)[ ._-]?\d{1,2})$/i
export function isGenericName(name: string): boolean {
return GENERIC_NAMES.test(name.trim())
}
export function rarReleaseIdentity(entries: { name: string; size: number | null }[]): ReleaseIdentity | null {
if (entries.length === 0) return null
const tops = new Set(entries.map((e) => e.name.split(/[\\/]/)[0]))
const nested = entries.some((e) => /[\\/]/.test(e.name))
if (nested && tops.size === 1) {
// A generic top folder (VIDEO_TS) is shared by unrelated archives: key on the first entry instead.
if (nested && tops.size === 1 && !isGenericName([...tops][0])) {
const [top] = tops
return { key: hash('rar-dir', top), name: top, source: 'rar' }
}
+84
View File
@@ -0,0 +1,84 @@
import type { Overview } from '../nntp/xover_client.js'
import { segmentFromHeader, type IngestSegment, type OverviewHeader } from '../ingest/segment.js'
import { FileAggregator } from '../aggregate/index.js'
import { toFragment, toSeedFile, type SeedBatch } from './wire.js'
/** Where a range's overviews come from: an NNTP connection, or the seed API's relay. */
export interface OverviewSource {
group(name: string): Promise<unknown>
xover(from: number, to: number): Promise<Overview[]>
}
export interface RunRangeOptions {
client: OverviewSource
group: string
/** Inclusive article numbers. */
start: number
end: number
/** Articles per XOVER. */
chunk?: number
/** Batches without a new part before an open file leaves as fragments. */
idle?: number
/** XOVER batches per SeedBatch handed to `sink`. */
submitEvery?: number
/** Headers left to the deferred-body path (counted, not indexed). */
isDeferred?: (header: OverviewHeader) => boolean
/** Receives batches in order; the next XOVER is already in flight meanwhile. */
sink: (batch: SeedBatch) => Promise<void>
}
/**
* Indexes one article range on a seeding node (CONCEPT.md D37): XOVER in
* chunks over one connection (the next chunk in flight while the previous
* one is handled), headers parsed with the live rules, grouped into files
* in memory; complete files and given-up fragments go to `sink` in order.
* Shared by seed:range (stores directly) and seed:node (submits over HTTPS).
*/
export async function runRange(options: RunRangeOptions): Promise<void> {
const chunk = options.chunk ?? 20_000
const idle = options.idle ?? 5
const submitEvery = options.submitEvery ?? 5
const aggregator = new FileAggregator<IngestSegment>()
await options.client.group(options.group)
const fetch = (from: number) => {
const promise = options.client.xover(from, Math.min(from + chunk - 1, options.end))
// Awaited after the current batch is handled; until then a failure must not count as unhandled.
promise.catch(() => {})
return promise
}
let batch = emptyBatch(options.start)
let pending = 0
let from = options.start
let next: Promise<Overview[]> | null = fetch(from)
while (next) {
const overviews = await next
from += chunk
next = from <= options.end ? fetch(from) : null
const segments: IngestSegment[] = []
for (const [articleNumber, header] of overviews) {
if (!Number.isFinite(articleNumber) || !header['message-id']) continue
const segment = segmentFromHeader(header, options.group, articleNumber)
if (segment) segments.push(segment)
else if (options.isDeferred?.(header)) batch.deferred++
else batch.obfuscated++
}
batch.headers += overviews.length
batch.files.push(...aggregator.add(segments).map(toSeedFile))
batch.fragments.push(...aggregator.evict(idle).map(toFragment))
batch.next = Math.min(from, options.end + 1)
if (++pending >= submitEvery || !next) {
if (!next) batch.fragments.push(...aggregator.evict(0).map(toFragment))
await options.sink(batch)
batch = emptyBatch(batch.next)
pending = 0
}
}
}
function emptyBatch(next: number): SeedBatch {
return { next, headers: 0, obfuscated: 0, deferred: 0, files: [], fragments: [] }
}
+73
View File
@@ -0,0 +1,73 @@
import type { IngestSegment } from '../ingest/segment.js'
import type { ReleaseIdentity } from '../release/index.js'
import type { AggregatedFile } from '../aggregate/index.js'
/**
* A complete file as a seeding node reports it (CONCEPT.md D37): what the
* file, its release and its binary need, once per file, and the segments
* as [part number, message-id, bytes].
*/
export interface SeedFile {
fileKey: string
name: string
poster: string
subject: string
totalParts: number
groups: string[]
/** Earliest post date of its articles, ISO 8601. */
postedAt: string | null
release: ReleaseIdentity | null
segments: [number, string, number][]
}
/** A segment of a file that didn't complete on the node, for the regular ingest path. */
export type SeedFragment = Omit<IngestSegment, 'postedAt' | 'articleNumber'> & { postedAt?: string; articleNumber?: number }
/** What a node sends per batch: the article it has reached, and what it found up to there. */
export interface SeedBatch {
/** First article number not covered yet (everything below it, from the range start, is in this or earlier batches). */
next: number
headers: number
obfuscated: number
deferred: number
files: SeedFile[]
fragments: SeedFragment[]
}
export function toSeedFile(file: AggregatedFile<IngestSegment>): SeedFile {
const first = file.segments[0]
const times = file.segments.map((s) => s.postedAt?.getTime()).filter((t): t is number => Number.isFinite(t))
return {
fileKey: file.fileKey,
name: first.name,
poster: first.poster,
subject: first.subject,
totalParts: first.totalParts,
groups: first.groups,
postedAt: times.length > 0 ? new Date(Math.min(...times)).toISOString() : null,
release: first.release ?? null,
segments: file.segments.map((s) => [s.number, s.messageId, s.bytes]),
}
}
export function toFragment(segment: IngestSegment): SeedFragment {
const { postedAt, articleNumber, ...rest } = segment
const date = postedAt && !Number.isNaN(postedAt.getTime()) ? postedAt.toISOString() : undefined
return { ...rest, postedAt: date, articleNumber: articleNumber === undefined ? undefined : Number(articleNumber) }
}
export function fromFragment(fragment: SeedFragment): IngestSegment {
// The JSON body parser turns empty strings into null; the columns want ''.
return {
...fragment,
poster: fragment.poster ?? '',
subject: fragment.subject ?? '',
name: fragment.name ?? '',
postedAt: fragment.postedAt ? new Date(fragment.postedAt) : undefined,
}
}
/** A file from a node's JSON, with empty strings back where the body parser made them null. */
export function normalizeSeedFile(file: SeedFile): SeedFile {
return { ...file, name: file.name ?? '', poster: file.poster ?? '', subject: file.subject ?? '' }
}
+97
View File
@@ -0,0 +1,97 @@
import { crc32 } from 'node:zlib'
/**
* 7-Zip archives, independent of AdonisJS. A 7z archive starts with a
* 32-byte signature header that points to the archive's header (the file
* list) at its very end, often LZMA-compressed ("encoded") with its packed
* data just before it. So the list needs the first 32 bytes and the tail:
* for a split archive (.7z.001, .002, …), the start of the first volume and
* the end of the last. Listing itself is left to libarchive (bsdtar).
*/
const SIGNATURE = Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c])
const SIGNATURE_HEADER_SIZE = 32
/** Header property ids (7zFormat.txt). */
const K_ENCODED_HEADER = 0x17
const K_PACK_INFO = 0x06
export interface SevenZipStart {
/** Where the archive header starts, from the start of the archive. */
headerStart: number
headerSize: number
headerCrc: number
/** The archive's total size (the header is its last part). */
archiveSize: number
}
/** Reads the signature header; null when `data` isn't a 7z start or its CRC fails. */
export function parseSevenZipStart(data: Buffer): SevenZipStart | null {
if (data.length < SIGNATURE_HEADER_SIZE || !data.subarray(0, 6).equals(SIGNATURE)) return null
if ((crc32(data.subarray(12, 32)) >>> 0) !== data.readUInt32LE(8)) return null
const offset = Number(data.readBigUInt64LE(12))
const size = Number(data.readBigUInt64LE(20))
const headerStart = SIGNATURE_HEADER_SIZE + offset
return { headerStart, headerSize: size, headerCrc: data.readUInt32LE(28), archiveSize: headerStart + size }
}
/** Whether `header` (the bytes at headerStart) is the archive header its signature header describes. */
export function checkHeader(start: SevenZipStart, header: Buffer): boolean {
return header.length === start.headerSize && (crc32(header) >>> 0) === start.headerCrc
}
/**
* Where the bytes needed for listing start: the header itself, or for an
* encoded header the packed (compressed) header stream it points to.
*/
export function listingStart(start: SevenZipStart, header: Buffer): number {
if (header[0] !== K_ENCODED_HEADER || header[1] !== K_PACK_INFO) return start.headerStart
const [packPos] = readNumber(header, 2)
return SIGNATURE_HEADER_SIZE + packPos
}
/**
* 7z's variable-length number: the leading 1-bits of the first byte count
* the little-endian bytes that follow; its remaining bits are the high part.
*/
export function readNumber(data: Buffer, pos: number): [value: number, next: number] {
const first = data[pos]
let mask = 0x80
let value = 0
for (let i = 0; i < 8; i++) {
if ((first & mask) === 0) {
value += (first & (mask - 1)) * 2 ** (8 * i)
return [value, pos + 1 + i]
}
value += data[pos + 1 + i] * 2 ** (8 * i)
mask >>= 1
}
return [value, pos + 9]
}
export interface SevenZipEntry {
name: string
size: number
directory: boolean
}
/**
* Entries from `bsdtar -tv` output, e.g.
* `-rw-rw-r-- 0 0 0 3000000 Sep 30 19:13 Some.Release/movie.mkv`.
*/
export function parseBsdtarListing(stdout: string): SevenZipEntry[] {
const entries: SevenZipEntry[] = []
for (const line of stdout.split('\n')) {
const match = /^([-dlbcps])\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{2}|\d{4})\s(.+)$/.exec(line)
if (!match) continue
const directory = match[1] === 'd'
entries.push({ name: directory ? match[3].replace(/\/$/, '') : match[3], size: Number(match[2]), directory })
}
return entries
}
/** Volume number of a split 7z name (`x.7z.001` → 1), 0 for an unsplit `.7z`, null otherwise. */
export function sevenZipVolume(name: string): number | null {
const split = /\.7z\.(\d{1,4})$/i.exec(name)
if (split) return Number(split[1])
return /\.7z$/i.test(name) ? 0 : null
}
+333
View File
@@ -0,0 +1,333 @@
/**
* Spotnet's category tree, from Spotweb's SpotCategories (English names).
* A spot has a head category (0–3) and subcategories per type letter:
* `a` format/platform, `b` source/format/genre, `c` language/bitrate/genre,
* `d` genre, `z` kind (movie, series, book, …).
*/
export const SPOT_HEAD_CATEGORIES = ['Image', 'Sound', 'Games', 'Applications']
/** What each type letter means per head category. */
export const SPOT_SUBCAT_TYPES: Record<string, string>[] = [
{ a: 'Format', b: 'Source', c: 'Language', d: 'Genre', z: 'Type' },
{ a: 'Format', b: 'Source', c: 'Bitrate', d: 'Genre', z: 'Type' },
{ a: 'Platform', b: 'Format', c: 'Genre', z: 'Type' },
{ a: 'Platform', b: 'Genre', z: 'Type' },
]
const NAMES: Record<string, Record<number, string>>[] = [
{
a: {
0: 'DivX',
1: 'WMV',
2: 'MPG',
3: 'DVD5',
4: 'HD other',
5: 'ePub',
6: 'Blu-ray',
7: 'HD-DVD',
8: 'WMVHD',
9: 'x264',
10: 'DVD9',
11: 'PDF',
12: 'Bitmap',
13: 'Vector',
14: '3D',
15: 'UHD',
},
b: {
0: 'CAM',
1: '(S)VCD',
2: 'Promo',
3: 'Retail',
4: 'TV',
6: 'Satellite',
7: 'R5',
8: 'Telecine',
9: 'Telesync',
10: 'Scan',
11: 'WEB-DL',
12: 'WEBRip',
13: 'HDRip',
},
c: {
0: 'No subtitles',
1: 'Dutch subtitles (external)',
2: 'Dutch subtitles (builtin)',
3: 'English subtitles (external)',
4: 'English subtitles (builtin)',
6: 'Dutch subtitles (available)',
7: 'English subtitles (available)',
10: 'English audio/written',
11: 'Dutch audio/written',
12: 'German audio/written',
13: 'French audio/written',
14: 'Spanish audio/written',
15: 'Asian audio/written',
},
d: {
0: 'Action',
1: 'Adventure',
2: 'Animation',
3: 'Cabaret',
4: 'Comedy',
5: 'Crime',
6: 'Documentary',
7: 'Drama',
8: 'Family',
9: 'Fantasy',
10: 'Arthouse',
11: 'Television',
12: 'Horror',
13: 'Music',
14: 'Musical',
15: 'Mystery',
16: 'Romance',
17: 'Science Fiction',
18: 'Sport',
19: 'Short movie',
20: 'Thriller',
21: 'War',
22: 'Western',
23: 'Erotica (hetero)',
24: 'Erotica (gay male)',
25: 'Erotica (gay female)',
26: 'Erotica (bi)',
28: 'Asian',
29: 'Anime',
30: 'Cover',
31: 'Comicbook',
32: 'Cartoons',
33: 'Youth',
34: 'Business',
35: 'Computer',
36: 'Hobby',
37: 'Cooking',
38: 'Handwork',
39: 'Craftwork',
40: 'Health',
41: 'History',
42: 'Psychology',
43: 'Newspaper',
44: 'Magazine',
45: 'Science',
46: 'Female',
47: 'Religion',
48: 'Roman',
49: 'Biography',
50: 'Detective',
51: 'Animals',
52: 'Humor',
53: 'Travel',
54: 'True story',
55: 'Non-fiction',
56: 'Politics',
57: 'Poetry',
58: 'Fairy tale',
59: 'Technical',
60: 'Art',
72: 'Bi',
73: 'Lesbian',
74: 'Homo',
75: 'Hetero',
76: 'Amature',
77: 'Group',
78: 'POV',
79: 'Solo',
80: 'Young',
81: 'Soft',
82: 'Fetish',
83: 'Old',
84: 'Fat',
85: 'SM',
86: 'Rough',
87: 'Dark',
88: 'Hentai',
89: 'Outside',
},
z: { 0: 'Movie', 1: 'Series', 2: 'Book', 3: 'Erotica', 4: 'Picture' },
},
{
a: {
0: 'MP3',
1: 'WMA',
2: 'WAV',
3: 'OGG',
4: 'EAC',
5: 'DTS',
6: 'AAC',
7: 'APE',
8: 'FLAC',
},
b: { 0: 'CD', 1: 'Radio', 2: 'Compilation', 3: 'DVD', 4: 'Other', 5: 'Vinyl', 6: 'Stream' },
c: {
0: 'Variable',
1: '< 96kbit',
2: '96kbit',
3: '128kbit',
4: '160kbit',
5: '192kbit',
6: '256kbit',
7: '320kbit',
8: 'Lossless',
9: 'Other',
},
d: {
0: 'Blues',
1: 'Compilation',
2: 'Cabaret',
3: 'Dance',
4: 'Diverse',
5: 'Hardcore',
6: 'World',
7: 'Jazz',
8: 'Youth',
9: 'Classical',
10: 'Kleinkunst',
11: 'Dutch',
12: 'New Age',
13: 'Pop',
14: 'RnB',
15: 'Hiphop',
16: 'Reggae',
17: 'Religious',
18: 'Rock',
19: 'Soundtracks',
20: 'Other',
21: 'Hardstyle',
22: 'Asian',
23: 'Disco',
24: 'Classics',
25: 'Metal',
26: 'Country',
27: 'Dubstep',
28: 'Nederhop',
29: 'DnB',
30: 'Electro',
31: 'Folk',
32: 'Soul',
33: 'Trance',
34: 'Balkan',
35: 'Techno',
36: 'Ambient',
37: 'Latin',
38: 'Live',
},
z: { 0: 'Album', 1: 'Liveset', 2: 'Podcast', 3: 'Audiobook' },
},
{
a: {
0: 'Windows',
1: 'Macintosh',
2: 'Linux',
3: 'Playstation',
4: 'Playstation 2',
5: 'PSP',
6: 'Xbox',
7: 'Xbox 360',
8: 'Gameboy Advance',
9: 'Gamecube',
10: 'Nintendo DS',
11: 'Nintento Wii',
12: 'Playstation 3',
13: 'Windows Phone',
14: 'iOS',
15: 'Android',
16: 'Nintendo 3DS',
17: 'Playstation 4',
18: 'XBox 1',
},
b: { 0: 'ISO', 1: 'Rip', 2: 'Retail', 3: 'DLC', 4: '', 5: 'Patch', 6: 'Crack' },
c: {
0: 'Action',
1: 'Adventure',
2: 'Strategy',
3: 'Roleplaying',
4: 'Simulation',
5: 'Race',
6: 'Flying',
7: 'Shooter',
8: 'Platform',
9: 'Sport',
10: 'Child/youth',
11: 'Puzzle',
12: 'Other',
13: 'Boardgame',
14: 'Cards',
15: 'Education',
16: 'Music',
17: 'Family',
},
},
{
a: {
0: 'Windows',
1: 'Macintosh',
2: 'Linux',
3: 'OS/2',
4: 'Windows Phone',
5: 'Navigation systems',
6: 'iOS',
7: 'Android',
},
b: {
0: 'Audio',
1: 'Video',
2: 'Graphics',
3: 'CD/DVD Tools',
4: 'Media players',
5: 'Rippers &amp; Encoders',
6: 'Plugins',
7: 'Database tools',
8: 'Email software',
9: 'Photo',
10: 'Screensavers',
11: 'Skin software',
12: 'Drivers',
13: 'Browsers',
14: 'Download managers',
15: 'Download',
16: 'Usenet software',
17: 'RSS Readers',
18: 'FTP software',
19: 'Firewalls',
20: 'Antivirus software',
21: 'Antispyware software',
22: 'Optimization software',
23: 'Security software',
24: 'System software',
25: 'Other',
26: 'Educational',
27: 'Office',
28: 'Internet',
29: 'Communication',
30: 'Development',
31: 'Spotnet',
},
},
]
export interface SpotCategoryLabels {
head: string | null
/** In type order (z, a, b, c, d), e.g. { type: 'Language', values: ['Dutch subtitles (builtin)'] }. */
subcats: { type: string; values: string[] }[]
}
/** Readable names for a spot's head category and subcategories (e.g. { a: [9], z: [0] }). */
export function spotCategoryLabels(
category: number | null,
subcats: Record<string, number[]> | null
): SpotCategoryLabels {
if (category === null || !(category in NAMES)) return { head: null, subcats: [] }
const labels: SpotCategoryLabels['subcats'] = []
for (const letter of ['z', 'a', 'b', 'c', 'd']) {
const values = [
...new Set(
(subcats?.[letter] ?? [])
.map((n) => NAMES[category][letter]?.[n])
.filter((v): v is string => Boolean(v))
),
]
if (values.length) labels.push({ type: SPOT_SUBCAT_TYPES[category][letter] ?? letter, values })
}
return { head: SPOT_HEAD_CATEGORIES[category], subcats: labels }
}
+3 -1
View File
@@ -4,4 +4,6 @@
* See CONCEPT.md (D29).
*/
export { parseSpotHeader, verifySpotHeader, spotterId, moderationTarget, checkRsaSignature, spotUnprepareBase64, SPOTNET_KEYS, type SpotHeader } from './header.js'
export { parseSpotXml, parseSpotArticleHeaders, decodeSpotBinary, type SpotXml } from './xml.js'
export { parseSpotXml, parseSpotArticleHeaders, decodeSpotBinary, decodeSpotText, type SpotXml, type SpotImage } from './xml.js'
export { spotCategoryLabels, SPOT_HEAD_CATEGORIES, type SpotCategoryLabels } from './categories.js'
export { passwordFromSpot } from './password.js'
+50
View File
@@ -0,0 +1,50 @@
/**
* Archive passwords that spotters put in the title or description instead of
* the NZB's `<meta type="password">`:
* `Title{{secret}}` (the newznab/SABnzbd convention),
* `Wachtwoord: KKKK2`, `[b]Password[/b]: x`, `WW: p3IU5Rz9R`, `ww= 0112358`,
* `WW FC0401` (abbreviation without separator: only with a digit in it).
* The value must be the rest of its line and a single token, so sentences
* such as "Wachtwoord: deze download is zonder wachtwoord geplaatst" or
* "met een password beschermd" don't count.
*/
const KEYWORD = String.raw`wachtwoord|wachtw|password|passwort|passwd|pwd|pass|ww|pw`
const WITH_SEPARATOR = new RegExp(String.raw`(?:^|[^a-z0-9])(?:${KEYWORD})\s*(?:is\s*)?[:=]\s*(.*)$`, 'i')
const ABBREVIATION = /(?:^|[^a-z0-9])(?:ww|pw)\s+(\S+)\s*$/i
const NO_PASSWORD = /\b(zonder|geen|no|without|none|niet|kein|ohne)\b.{0,20}\b(wachtwoord|password|passwort|ww|pw)\b|\b(wachtwoord|password|passwort)\b.{0,20}\b(niet nodig|not needed|none|geen|nvt|n\/a)\b/i
const NOT_A_VALUE = /^(?:-+|geen|none|no|nee|nvt|n\/a|niet|not|leeg|empty|zonder|without|onbekend|unknown|zie|see|in|staat|volgt|nzb|spot)$/i
/** Lines of a spot description: UBB/HTML line breaks split, other tags dropped. */
function lines(description: string): string[] {
return description
.replace(/\[br\]|<br\s*\/?>/gi, '\n')
.replace(/\[\/?[a-z]+(?:=[^\]]*)?\]|<\/?[a-z][^>]*>/gi, '')
.split(/\r?\n/)
.map((line) => line.trim())
}
function clean(value: string): string | null {
const token = value.trim().replace(/^["'“‘«(<]+|["'”’»)>.,;!]+$/g, '')
if (!token || /\s/.test(token) || token.length < 2 || token.length > 64 || NOT_A_VALUE.test(token)) return null
return token
}
export function passwordFromSpot(title: string, description?: string | null): string | null {
const braces = /\{\{([^{}]{1,64})\}\}/.exec(title)
if (braces && clean(braces[1])) return clean(braces[1])
for (const line of lines(description ?? '')) {
if (!line || NO_PASSWORD.test(line)) continue
const separated = WITH_SEPARATOR.exec(line)
if (separated) {
const value = clean(separated[1])
if (value) return value
continue
}
const abbreviated = ABBREVIATION.exec(line)
if (abbreviated && /\d/.test(abbreviated[1])) {
const value = clean(abbreviated[1])
if (value) return value
}
}
return null
}
+51 -4
View File
@@ -6,18 +6,25 @@ export interface SpotXml {
subcats: string[]
size: number
website: string
/** Spotnet markup: UBB tags such as [br], [b], [url=…]…[/url]. */
description: string
tag: string
/** Newer spots: the image posted as articles (uncompressed); older ones: a web URL. */
image: SpotImage | null
/** Message ids (without <>) of the compressed NZB in alt.binaries.ftd. */
nzbSegments: string[]
created: number | null
}
export type SpotImage = { segments: string[]; width?: number; height?: number } | { url: string }
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
/** Element text: CDATA unwrapped, entities decoded (also inside CDATA, as Spotweb's html_entity_decode does). */
function text(value: string | undefined): string {
if (value === undefined) return ''
const cdata = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/.exec(value)
if (cdata) return cdata[1]
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
return (cdata ? cdata[1] : value).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
if (entity[0] === '#') {
const code = entity[1].toLowerCase() === 'x' ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10)
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
@@ -37,6 +44,9 @@ export function parseSpotXml(xml: string): SpotXml | null {
const rawCategory = Number.parseInt(categoryXml.replace(/<[\s\S]*$/, ''), 10)
const nzb = element(posting, 'NZB') ?? ''
return {
description: text(element(posting, 'Description')).trim(),
tag: text(element(posting, 'Tag')).trim(),
image: parseImage(posting),
title: text(element(posting, 'Title')).trim(),
// Newer keys number categories from 1.
category: Number.isNaN(rawCategory) ? null : key !== 1 ? rawCategory - 1 : rawCategory,
@@ -48,15 +58,52 @@ export function parseSpotXml(xml: string): SpotXml | null {
}
}
/** Header lines of a raw HEAD response; X-XML (split over many lines) joined. */
function parseImage(posting: string): SpotImage | null {
const match = /<Image(\s[^>]*)?>([\s\S]*?)<\/Image>/i.exec(posting)
if (!match) return null
const segments = [...match[2].matchAll(/<Segment>([^<>]*)<\/Segment>/gi)].map((m) => m[1].trim()).filter(Boolean)
if (segments.length > 0) {
const size = (name: string) => Number.parseInt(new RegExp(`${name}=['"]?(\\d+)`, 'i').exec(match[1] ?? '')?.[1] ?? '', 10) || undefined
return { segments, width: size('Width'), height: size('Height') }
}
const url = text(match[2]).trim()
return /^https?:\/\//i.test(url) ? { url } : null
}
/**
* The X-XML as text. Spotnet clients write UTF-8, very old ones ISO-8859-1;
* the header is read as ISO-8859-1 bytes, so re-decode when it's valid UTF-8.
*/
export function decodeSpotText(latin1: string): string {
const utf8 = Buffer.from(latin1, 'latin1').toString('utf8')
return utf8.includes('\uFFFD') ? latin1 : utf8
}
/**
* Header lines of a raw HEAD response; X-XML (split over many lines) joined.
* Folded headers are unfolded: old (key 1) spots put the From address on a
* continuation line.
*/
export function parseSpotArticleHeaders(raw: string): { headers: Record<string, string>; xml: string } {
const headers: Record<string, string> = {}
let xml = ''
let last: string | null = null
let lastIsFirst = false
for (const line of raw.split(/\r?\n/)) {
if (/^[ \t]/.test(line) && last) {
if (last === 'x-xml') xml += line
else if (lastIsFirst) headers[last] += line
continue
}
const colon = line.indexOf(':')
if (colon <= 0) continue
if (colon <= 0) {
last = null
continue
}
const name = line.slice(0, colon).toLowerCase()
const value = line.slice(colon + 1).replace(/^ /, '')
last = name
lastIsFirst = !(name in headers)
if (name === 'x-xml') xml += value
else headers[name] ??= value
}
+17
View File
@@ -28,3 +28,20 @@ export function weakFileKey(name: string, size: number, totalParts: number): str
const identity = looksLikeFilename(name) ? `name\n${name}` : 'shape'
return createHash('sha1').update(`weak\n${identity}\n${size}\n${totalParts}`).digest('hex')
}
/**
* Identity of a file by its counters, for uploads that give every article
* its own random subject (CONCEPT.md D36): poster, file counter `[i/M]`,
* part count and posting day stay the same across the articles of one file.
* The day keeps different uploads with the same shape apart; collisions that
* remain are caught by the conflict check (these files are weak identities).
* Null when the subject has no file counter.
*/
export function counterFileKey(segment: { poster: string; subject: string; totalParts: number; postedAt?: Date }): string | null {
const counter = /\[(\d+)\/(\d+)\]/.exec(segment.subject)
if (!counter || segment.totalParts < 2) return null
const day = segment.postedAt && !Number.isNaN(segment.postedAt.getTime()) ? segment.postedAt.toISOString().slice(0, 10) : ''
return createHash('sha1')
.update(`counter\n${segment.poster}\n${Number(counter[1])}/${Number(counter[2])}\n${segment.totalParts}\n${day}`)
.digest('hex')
}
+1 -1
View File
@@ -2,4 +2,4 @@
* Conventions of binary posts (subject lines, file identity), independent of AdonisJS.
*/
export { parseSubject, type ParsedSubject } from './parse.js'
export { fileKey, weakFileKey, looksLikeFilename } from './file_key.js'
export { fileKey, weakFileKey, counterFileKey, looksLikeFilename } from './file_key.js'
+136
View File
@@ -238,6 +238,43 @@ kbd {
cursor: pointer;
}
/* A chip holding inputs: size bounds and file extensions. */
.field-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 6px 0 13px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
font-size: 13px;
color: var(--muted);
}
.field-chip:focus-within {
border-color: var(--accent);
}
.field-chip input {
width: 64px;
height: 24px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.field-chip input.ext {
width: 96px;
}
.field-chip .unit {
padding-right: 7px;
}
.chip[aria-pressed='true'],
.chip-active {
border-color: var(--accent);
@@ -263,6 +300,41 @@ kbd {
font-size: 13px;
}
/* While a new search runs, the previous results stay in place but greyed out. */
.release-list.stale {
opacity: 0.4;
pointer-events: none;
transition: opacity 0.12s ease-out;
}
.results[aria-busy='true'] .summary::after {
content: '';
display: block;
height: 2px;
margin-top: 6px;
border-radius: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
background-size: 40% 100%;
background-repeat: no-repeat;
animation: searching 1s linear infinite;
}
@keyframes searching {
from {
background-position: -40% 0;
}
to {
background-position: 140% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.results[aria-busy='true'] .summary::after {
animation: none;
background: var(--accent-soft);
}
}
.release-list {
list-style: none;
margin: 0;
@@ -563,6 +635,70 @@ kbd {
color: var(--bad);
}
.notice-good {
background: var(--good-soft);
color: var(--good);
}
.notice .password {
padding: 1px 6px;
border-radius: 4px;
background: var(--surface);
color: var(--text);
font-family: var(--mono);
user-select: all;
overflow-wrap: anywhere;
}
.spot {
display: flex;
gap: 16px;
align-items: flex-start;
}
.spot-image {
flex: none;
width: 140px;
height: auto;
border-radius: 6px;
background: var(--surface-2);
}
.spot-body {
flex: 1;
min-width: 0;
}
.spot-body .facts {
margin-bottom: 14px;
}
.spot-description {
font-size: 13.5px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.spot-description a,
.spot-links a {
color: var(--accent);
}
.spot-links {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 12px;
font-size: 13px;
overflow-wrap: anywhere;
}
@media (max-width: 560px) {
.spot {
flex-direction: column;
}
}
.notice-warn {
background: var(--warn-soft);
color: var(--warn);
+138 -15
View File
@@ -3,6 +3,8 @@
const $ = (selector) => document.querySelector(selector)
const FILTERS = ['readable', 'complete', 'hide_encrypted', 'par2_only']
/** Text inputs that narrow a search: size bounds in MB and file extensions. */
const FIELDS = ['min_size', 'max_size', 'ext']
const SOURCE_LABELS = {
stem: 'Filename',
subject: 'Subject',
@@ -19,6 +21,9 @@ const state = {
sort: 'newest',
source: '',
poster: '',
min_size: '',
max_size: '',
ext: '',
readable: false,
complete: false,
hide_encrypted: false,
@@ -27,6 +32,7 @@ const state = {
release: null,
}
let requestId = 0
let searchAbort = null
// --- Formatting ------------------------------------------------------------
@@ -89,6 +95,7 @@ function readUrl() {
state.sort = params.get('sort') ?? 'newest'
state.source = params.get('source') ?? ''
state.poster = params.get('poster') ?? ''
for (const k of FIELDS) state[k] = params.get(k) ?? ''
for (const f of FILTERS) state[f] = params.get(f) === '1'
state.release = params.get('release') ? Number(params.get('release')) : null
}
@@ -99,6 +106,7 @@ function writeUrl(push = false) {
if (state.sort !== 'newest') params.set('sort', state.sort)
if (state.source) params.set('source', state.source)
if (state.poster) params.set('poster', state.poster)
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
for (const f of FILTERS) if (state[f]) params.set(f, '1')
if (state.release) params.set('release', String(state.release))
const url = `${location.pathname}${params.size ? `?${params}` : ''}`
@@ -109,6 +117,7 @@ function syncControls() {
$('#q').value = state.q
$('#sort').value = state.sort
$('#source').value = state.source
for (const k of FIELDS) $(`#${k}`).value = state[k]
for (const f of FILTERS) {
document.querySelector(`[data-filter="${f}"]`).setAttribute('aria-pressed', String(state[f]))
}
@@ -118,8 +127,8 @@ function syncControls() {
// --- API -------------------------------------------------------------------
async function api(path) {
const response = await fetch(path, { headers: { accept: 'application/json' } })
async function api(path, { signal } = {}) {
const response = await fetch(path, { headers: { accept: 'application/json' }, signal })
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
return response.json()
}
@@ -129,6 +138,7 @@ function searchParams(page) {
if (state.q) params.set('q', state.q)
if (state.source) params.set('source', state.source)
if (state.poster) params.set('poster', state.poster)
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
for (const f of FILTERS) if (state[f]) params.set(f, '1')
return params
}
@@ -139,11 +149,13 @@ async function loadStats() {
try {
const stats = await api('/api/v1/stats')
const readable = stats.releases ? Math.round((100 * stats.readableReleases) / stats.releases) : 0
// Estimated from Postgres statistics and a sample (CONCEPT.md D33).
const about = stats.approximate ? '≈ ' : ''
const items = [
['Releases', number.format(stats.releases)],
['Readable', `${readable}%`],
['Files', number.format(stats.files)],
['Size', formatSize(stats.size)],
['Releases', about + number.format(stats.releases)],
['Readable', `${about}${readable}%`],
['Files', about + number.format(stats.files)],
['Size', about + formatSize(stats.size)],
['Indexed', formatAge(stats.lastIndexedAt) || '–'],
]
$('#stats').replaceChildren(...items.map(([dt, dd]) => el('div', {}, el('dt', { text: dt }), el('dd', { text: dd }))))
@@ -174,9 +186,14 @@ function releaseRow(release) {
if (release.par2Only) badges.append(badge('par2 only', 'warn', 'Only the par2 files arrived; the data they describe is missing'))
if (release.container === 'nzbe') badges.append(badge('Encrypted NZB', 'bad', 'An encrypted NZB container: unusable without its key'))
else if (release.container === 'rclone') badges.append(badge('Encrypted (rclone)', 'bad', 'rclone crypt: names and contents encrypted, unusable without the key'))
else if (release.encrypted) badges.append(badge('Password', 'bad', 'Password-protected archive'))
else if (release.container === 'yencbin') badges.append(badge('Encrypted (.bin)', 'bad', 'An encrypted .bin container (yEncBin): unusable without its key'))
else if (release.passwordState === 'missing') badges.append(badge('Password needed', 'bad', 'Password-protected archive; the password is not known'))
else if (release.passwordState === 'known') {
badges.append(badge('Password known', 'good', 'The password is in the NZB; SABnzbd and NZBGet use it to unpack'))
}
const files = `${release.fileCount} ${release.fileCount === 1 ? 'file' : 'files'}`
if (release.complete) badges.append(badge(files, 'good', 'Complete'))
if (release.listingOnly) badges.append(badge('Spot', 'accent', 'A Spotnet spot: its NZB is fetched from Usenet when you download it'))
else if (release.complete) badges.append(badge(files, 'good', 'Complete'))
else if (release.fileTotal) badges.append(badge(`${release.fileCount}/${release.fileTotal}`, 'warn', 'Files present / announced'))
else if (release.listedFiles) {
badges.append(badge(`${release.listedPresent}/${release.listedFiles} listed`, 'warn', 'Files the par2 set lists: present / total'))
@@ -195,24 +212,37 @@ function releaseRow(release) {
async function loadResults({ append = false } = {}) {
const id = ++requestId
if (!append) state.page = 1
// A newer search makes the running one pointless: cancel it.
searchAbort?.abort()
const abort = (searchAbort = new AbortController())
$('#more').hidden = true
if (!append) $('#summary').textContent = 'Searching…'
if (!append) {
$('#summary').textContent = 'Searching…'
$('#empty').hidden = true
// The old rows no longer match the search; grey them out until the new ones arrive.
$('#list').classList.add('stale')
$('.results').setAttribute('aria-busy', 'true')
}
try {
const result = await api(`/api/v1/releases?${searchParams(state.page)}`)
const result = await api(`/api/v1/releases?${searchParams(state.page)}`, { signal: abort.signal })
if (id !== requestId) return // a newer search superseded this one
$('#list').classList.remove('stale')
$('.results').removeAttribute('aria-busy')
const rows = result.releases.map(releaseRow)
if (append) $('#list').append(...rows)
else $('#list').replaceChildren(...rows)
const shown = $('#list').children.length
$('#summary').textContent =
result.total === 0 ? '' : `${number.format(result.total)} ${result.total === 1 ? 'release' : 'releases'}` +
result.total === 0 ? '' : `${number.format(result.total)}${result.totalCapped ? '+' : ''} ${result.total === 1 ? 'release' : 'releases'}` +
(shown < result.total ? ` · showing ${number.format(shown)}` : '')
$('#empty').hidden = result.total !== 0
$('#more').hidden = shown >= result.total
$('#more').hidden = shown >= result.total && !result.totalCapped
} catch (error) {
if (id !== requestId) return
$('#list').classList.remove('stale')
$('.results').removeAttribute('aria-busy')
$('#summary').textContent = `Search failed: ${error.message}`
}
}
@@ -237,6 +267,80 @@ function section(title, note, content) {
return [el('h3', { class: 'section-title' }, title, note ? el('small', { text: note }) : null), content]
}
/**
* Spotnet descriptions use UBB markup: [br], [b], [i], [u], [url=…]…[/url].
* Built as DOM nodes (never HTML); links only when they are http(s).
*/
function ubb(text) {
const root = el('div', { class: 'spot-description' })
const stack = [root]
const top = () => stack[stack.length - 1]
// Old (key 1) spots use HTML instead: line breaks and simple tags become UBB, the rest is dropped.
text = text
.replace(/<br\s*\/?>/gi, '[br]')
.replace(/<(\/?)(b|i|u)>/gi, '[$1$2]')
.replace(/<\/?[a-z][^>]*>/gi, '')
for (const token of text.split(/(\[\/?[a-z]+(?:=[^\]]*)?\])/i)) {
const tag = /^\[(\/?)([a-z]+)(?:=([^\]]*))?\]$/i.exec(token)
if (!tag) {
if (token) top().append(token)
continue
}
const [, closing, rawName, arg] = tag
const name = rawName.toLowerCase()
if (name === 'br') {
top().append(el('br'))
} else if (!closing && ['b', 'i', 'u', 'url'].includes(name)) {
const url = name === 'url' ? (arg ?? '').trim() : ''
const node = name === 'url'
? /^https?:\/\//i.test(url) ? el('a', { href: url, target: '_blank', rel: 'noopener noreferrer nofollow' }) : el('span')
: el({ b: 'strong', i: 'em', u: 'u' }[name])
node.dataset.ubb = name
top().append(node)
stack.push(node)
} else if (closing && stack.length > 1 && top().dataset.ubb === name) {
stack.pop()
}
// Other tags ([img], [color=…], …) are dropped.
}
return root
}
function spotSection(release) {
const spot = release.spot
if (!spot) return []
const image = spot.image?.url
? el('img', {
class: 'spot-image',
src: spot.image.url,
alt: '',
loading: 'lazy',
width: spot.image.width ?? undefined,
height: spot.image.height ?? undefined,
onerror: (event) => event.target.remove(),
})
: null
const categories = spot.categories?.head
? el('dl', { class: 'facts' },
fact('Category', spot.categories.head),
...spot.categories.subcats.map((sub) => fact(sub.type, sub.values.join(', '))))
: null
const links = el('div', { class: 'spot-links' },
spot.website && /^https?:\/\//i.test(spot.website)
? el('a', { href: spot.website, target: '_blank', rel: 'noopener noreferrer nofollow', text: spot.website })
: null,
spot.image?.external
? el('a', { href: spot.image.external, target: '_blank', rel: 'noopener noreferrer nofollow', text: 'Image (external site)' })
: null)
const note = [spot.poster && `by ${spot.poster}`, spot.tag && spot.tag !== spot.poster && `tag ${spot.tag}`].filter(Boolean).join(' · ')
return section('Spot', note, el('div', { class: 'spot' },
image,
el('div', { class: 'spot-body' },
categories,
spot.description ? ubb(spot.description) : el('p', { class: 'empty', text: 'No description.' }),
links.childElementCount ? links : null)))
}
function renderDetail(release) {
$('#detail-name').textContent = release.name
$('#detail-source').textContent = `Recognised by ${SOURCE_LABELS[release.source] ?? release.source ?? 'filename'}`
@@ -289,10 +393,22 @@ function renderDetail(release) {
const notices = []
if (release.container === 'nzbe') {
notices.push(el('div', { class: 'notice notice-bad', text: 'Encrypted NZB container ("nzbe"): an encrypted download index for others, unreadable without its key.' }))
} else if (release.container === 'yencbin') {
notices.push(el('div', { class: 'notice notice-bad', text: 'Encrypted .bin container (from the yEncBin uploader): the data is encrypted with a key that is not part of the post.' }))
} else if (release.container === 'rclone') {
notices.push(el('div', { class: 'notice notice-bad', text: 'rclone crypt: file names and contents are encrypted with a password and salt that are not part of the post.' }))
} else if (release.encrypted) {
notices.push(el('div', { class: 'notice notice-bad', text: 'Password-protected: the archive needs a password that is not part of the post.' }))
} else if (release.passwordState === 'missing') {
notices.push(el('div', { class: 'notice notice-bad', text: 'Password needed: the archive is password-protected, and neither the post nor its spot says the password.' }))
}
if (release.password) {
notices.push(el('div', { class: 'notice notice-good' },
el('div', {}, 'Password ', el('code', { class: 'password', text: release.password }),
release.passwordSource === 'spot' ? ' (from the spot\'s description)' : '',
` is included in the NZB; SABnzbd and NZBGet use it to unpack.${release.encrypted == null ? ' Whether the archive needs it is not known yet.' : ''}`)))
}
if (release.listingOnly) {
notices.push(el('div', { class: 'notice' },
`A Spotnet spot${release.spot?.poster ? ` by ${release.spot.poster}` : ''}. Its files are read from the spot's NZB on Usenet when you download it; that takes a few seconds the first time.`))
}
if (release.pending.binaries > 0) {
const pct = release.pending.partsTotal ? Math.round((100 * release.pending.partsHave) / release.pending.partsTotal) : 0
@@ -334,6 +450,7 @@ function renderDetail(release) {
actions,
...notices,
facts,
...spotSection(release),
...section('Files', `${release.files.length} posted`, files),
...(par2 ? section('par2 file list', `${listedPresent} of ${release.par2Files.length} present`, par2) : []),
...(contents ? section('Archive contents', `${release.contents.length} entries`, contents) : [])
@@ -375,6 +492,12 @@ $('#q').addEventListener('input', (event) => {
state.q = event.target.value.trim()
scheduleSearch()
})
for (const k of FIELDS) {
$(`#${k}`).addEventListener('input', (event) => {
state[k] = event.target.value.trim()
scheduleSearch()
})
}
$('#sort').addEventListener('change', (event) => {
state.sort = event.target.value
writeUrl()
@@ -420,7 +543,7 @@ window.addEventListener('popstate', () => {
const previous = { ...state }
readUrl()
syncControls()
const searchChanged = ['q', 'sort', 'source', 'poster', ...FILTERS].some((k) => previous[k] !== state[k])
const searchChanged = ['q', 'sort', 'source', 'poster', ...FIELDS, ...FILTERS].some((k) => previous[k] !== state[k])
if (searchChanged) loadResults()
if (state.release && state.release !== previous.release) openRelease(state.release)
if (!state.release && previous.release) {
+12 -1
View File
@@ -39,7 +39,7 @@
<div class="filters" role="group" aria-label="Filters">
<button class="chip" type="button" data-filter="readable" aria-pressed="false">Readable names</button>
<button class="chip" type="button" data-filter="complete" aria-pressed="false">Complete</button>
<button class="chip" type="button" data-filter="hide_encrypted" aria-pressed="false">Hide passworded</button>
<button class="chip" type="button" data-filter="hide_encrypted" aria-pressed="false" title="Hide releases that need a password we don't know">Hide password needed</button>
<button class="chip" type="button" data-filter="par2_only" aria-pressed="false" title="Also show releases that are only par2 files whose data never arrived">Show par2-only</button>
<label class="select select-small">
<span class="visually-hidden">Recognised by</span>
@@ -55,6 +55,17 @@
<option value="spot">Spotnet</option>
</select>
</label>
<span class="field-chip" title="Release size in MB">
<label for="min_size">Size</label>
<input type="number" id="min_size" min="0" step="any" inputmode="decimal" placeholder="min" aria-label="Minimum size in MB" />
<span aria-hidden="true">–</span>
<input type="number" id="max_size" min="0" step="any" inputmode="decimal" placeholder="max" aria-label="Maximum size in MB" />
<span class="unit">MB</span>
</span>
<span class="field-chip" title="Releases with a file or archive entry of these types, e.g. mkv, flac">
<label for="ext">Ext</label>
<input type="text" id="ext" class="ext" placeholder="mkv, flac" autocomplete="off" spellcheck="false" />
</span>
<span class="chip chip-active" id="poster-filter" hidden>
<span id="poster-filter-label"></span>
<button type="button" id="poster-clear" aria-label="Remove poster filter">×</button>
+111
View File
@@ -0,0 +1,111 @@
# Seeding node
A seeding node indexes old article ranges for the indexer (CONCEPT.md D37,
D38). It asks the seed API for a range over HTTPS, gets that range's headers
(by default through the seed API, which fetches them from the news server and
passes them on gzip-compressed), groups them into files, and posts the
results back. It only makes outgoing connections: no port forwarding,
database, Redis or news server account. Only a token and
`SEED_CONNECTIONS` need setting.
Headers through the seed API (`SEED_FETCH=relay`, the default) use the
indexer's news server account: `SEED_NNTP_CONNECTIONS` in the indexer's
`.env` sets how many connections it fetches with for all nodes together.
With `SEED_FETCH=direct` a node uses its own NNTP account instead (fill in
the NNTP lines in `node.env`).
## What a node needs
- Docker with the compose plugin (`docker compose version` works).
- Outgoing HTTPS to `seed.exp.sforum.nl` (the seed API) and
`gitea.exp.sforum.nl` (to build the image), and outgoing NNTP to the news
server (port 563 for TLS).
- Per connection: roughly 0.4 CPU core and 1–3 MB/s download (headers come
gzip-compressed); a few hundred MB of RAM per node. Disk: about 1.5 GB for
the image.
- An NNTP account. Every connection a node opens counts against that
account's connection limit, next to the ~20 the indexer itself uses if it's
the same account. Too many gives `481`/`502` errors at login. Some
providers also limit the number of IP addresses at once (Eweka: 2, as
`502 (max number of simultaneous IP addresses reached: 2)`): a node behind
the same public IP as the indexer doesn't count extra, others do. A node
can share an IP by tunnelling its NNTP traffic (e.g. SSH
`-L 1563:news.eweka.nl:563` to the indexer's machine); then set
`NNTP_HOST` to the tunnel and `NNTP_TLS_SERVERNAME=news.eweka.nl`.
## 1. On the indexer (homelab): a token for the node
cd ~/dev/usenet-indexer
docker compose exec web node ace seed:token my-server
This prints a token starting with `sn_` once. Treat it like a password: it
lets the holder lease ranges and submit results. Running the command again
for the same name replaces the token; `--disable` revokes it.
## 2. On the server: get the files
mkdir -p ~/usenet-seed-node && cd ~/usenet-seed-node
curl -fsSLO https://gitea.exp.sforum.nl/daan/usenet-indexer/raw/branch/master/seed-node/compose.yaml
curl -fsSL -o node.env https://gitea.exp.sforum.nl/daan/usenet-indexer/raw/branch/master/seed-node/node.env.example
chmod 600 node.env
## 3. Configure `node.env`
| Setting | Value |
|---|---|
| `SEED_URL` | `https://seed.exp.sforum.nl` (leave as is) |
| `SEED_TOKEN` | the token from step 1 |
| `SEED_CONNECTIONS` | ranges worked on at once, one NNTP connection each. Start with 2–4. |
| `NNTP_HOST`, `NNTP_PORT`, `NNTP_SECURE` | the news server; over the internet use TLS: port `563`, `NNTP_SECURE=true` (for Eweka: `news.eweka.nl`) |
| `NNTP_USER`, `NNTP_PASSWORD` | the NNTP account |
| `NNTP_TLS_SERVERNAME` | only through a tunnel: the news server's real hostname, for the TLS certificate check |
Leave the block below "Required by the app's configuration, but unused" as
it is: the app checks these settings at startup, but a node never uses them.
## 4. Start it
docker compose up -d --build
docker compose logs -f
The first build takes a few minutes. A working node logs:
[ info ] Seeding node for https://seed.exp.sforum.nl with 2 connection(s).
[ info ] [1] alt.binaries.ath 49834000000–49835999999
[ info ] [1] done: 2,000,000 headers in 237 s (8,450/s)
With nothing planned it waits and asks again every minute (no log lines).
## 5. Plan work and follow it (on homelab)
docker compose exec web node ace seed:plan alt.binaries.ath alt.binaries.mom --days 365
docker compose exec web node ace seed:status
`seed:plan` queues ranges of 2 million articles from below what is already
indexed down to `--days` back (or everything the server has, without
`--days`), newest first and interleaved across groups. It takes over the
rest of those groups' backfill. `seed:status` shows ranges per status,
articles reached, and per node its leases and headers.
## Day to day
- **Update** after a new version of the indexer:
`docker compose build --pull && docker compose up -d`
- **Stop**: `docker compose stop` gives the node's ranges back; another node
resumes them after the last batch that was stored.
- **A node that disappears** (crash, network) loses its ranges to other
nodes after 15 minutes without a batch. A range that fails 5 times is set
aside as `failed` (see `seed:status`).
- **Remove a node**: `docker compose down` on the server, then
`docker compose exec web node ace seed:token my-server --disable` on homelab.
## Troubleshooting
| Log line | Meaning |
|---|---|
| `The seed API rejected this node token` | wrong or revoked `SEED_TOKEN`; the node stops |
| `Seed API …: 404 …; retrying` | the seed API is restarting (a deploy on homelab); the node retries |
| `NNTP login failed: …` | the node didn't take a range and tries again in a minute (5 minutes for the IP-address limit); `481`/`502`: wrong account, too many connections (lower `SEED_CONNECTIONS`) or too many IP addresses |
| `Hostname/IP does not match certificate's altnames: Host: localhost` | TLS through a tunnel: set `NNTP_TLS_SERVERNAME` to the news server's hostname |
| `Range … failed: …` | the range is given back and retried later, possibly by another node |
| `Range … passed to another node; dropping it.` | this node was too slow to report (15 minutes); harmless |
+18
View File
@@ -0,0 +1,18 @@
# A seeding node (CONCEPT.md D38): leases article ranges from the seed API over
# HTTPS, indexes their headers over its own NNTP connections, and posts the
# results back. Outgoing connections only. Builds the indexer image straight
# from the git repository.
#
# cp node.env.example node.env # fill in SEED_TOKEN and the NNTP account
# docker compose up -d --build
name: usenet-seed-node
services:
seed-node:
build: https://gitea.exp.sforum.nl/daan/usenet-indexer.git#master
image: usenet-seed-node:latest
command: ["node", "ace", "seed:node"]
env_file: node.env
restart: unless-stopped
# On SIGTERM the node gives its ranges back (resumed from the last batch elsewhere).
stop_grace_period: 60s
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more