Spots: unmatched ones matched again in turns; a standalone release gives way
Test and deploy / test (push) Successful in 58s
Test and deploy / edge (push) Successful in 2m18s
Test and deploy / deploy (push) Successful in 1m52s

A spot older than what the catalog has indexed (the spotter's first backfill
reaches back ~3 months) matched nothing and would have become a release of
its own, a duplicate once the history pass builds the upload. Unmatched
spots are matched again in turns (rematched_at) for 30 days; the 24 h before
a standalone release count from the inspection; a later match deletes the
spot's own release and links the header pass's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-13 12:19:24 +02:00
1 parent 7df5a638ca
commit 550f63cd84
4 files changed
+61 -6

No files matched your search

+26 -5
View File
@@ -16,6 +16,8 @@ const BATCH = 20
const IDLE_MS = 60_000
/** Unmatched spots are matched again this often (the header pass may have built their releases since). */
const REMATCH_MS = 60 * 60 * 1000
/** Unmatched spots are matched again for this long after their inspection. */
const REMATCH_DAYS = 30
/** A file's release posted further apart than this from the NZB's date is another upload of the same name. */
const MAX_SPREAD_MS = 48 * 3600 * 1000
@@ -62,9 +64,11 @@ export function nzbFileName(subject: string): { name: string; base: string } {
* Each matched release is linked in `release_spots`, and gets the spot's
* password in `release_passwords` (the NZB's own `<meta type="password">`
* first, else what the spot's title or text names) and `passworded`. A spot
* whose files match nothing is matched again every hour; after
* SPOT_STANDALONE_AFTER_HOURS it becomes a release of its own (source `spot`)
* with its NZB.
* whose files match nothing is matched again every hour, for 30 days; once
* SPOT_STANDALONE_AFTER_HOURS have passed since its inspection it becomes a
* release of its own (source `spot`) with its NZB, until the header pass
* builds the releases after all (a spot older than what the catalog has
* indexed): then it moves to those, and its own release goes.
*/
export class SpotInspector {
private stopped = false
@@ -199,6 +203,7 @@ export class SpotInspector {
status: 'inspected',
inspected_at: new Date(),
files: files.length,
matched: 0,
nzb: gzipSync(xml),
...(password && password.source === 'nzb' ? { password: password.password, password_source: 'nzb' } : {}),
})
@@ -254,7 +259,17 @@ export class SpotInspector {
}
const perRelease = new Map<number, number>()
for (const id of fileRelease.values()) perRelease.set(id, (perRelease.get(id) ?? 0) + 1)
if (perRelease.size === 0) return 0
await db.transaction(async (trx) => {
// A release of the spot's own goes now that the header pass built its files' releases.
if (spot.release_id) {
const own = await trx.from('releases').where('id', spot.release_id).where('source', 'spot').select('id').first()
if (own) {
await trx.from('release_spots').where('release_id', own.id).delete()
await trx.from('release_passwords').where('release_id', own.id).delete()
await trx.from('releases').where('id', own.id).delete()
}
}
for (const [idx, releaseId] of fileRelease) await trx.from('spot_files').where({ spot_id: spotId, idx }).update({ release_id: releaseId })
for (const [releaseId, n] of perRelease) {
await trx.table('release_spots').insert({ release_id: releaseId, spot_id: spotId, files: n }).onConflict(['release_id', 'spot_id']).merge({ files: n })
@@ -276,13 +291,19 @@ export class SpotInspector {
/** Unmatched inspected spots matched again; the ones that waited long enough become releases of their own. */
async rematch(): Promise<{ matched: number; standalone: number }> {
const out = { matched: 0, standalone: 0 }
const spots = await db.from('spots').where('status', 'inspected').where('matched', 0).whereNull('release_id').orderBy('inspected_at').limit(500)
const { rows: spots } = await db.rawQuery(
`SELECT message_id, inspected_at, release_id FROM spots
WHERE status = 'inspected' AND matched = 0 AND inspected_at > now() - make_interval(days => ?)
ORDER BY coalesce(rematched_at, inspected_at) LIMIT 500`,
[REMATCH_DAYS]
)
const cutoff = Date.now() - coordinatorConfig.spots.standaloneAfterHours * 3600 * 1000
for (const spot of spots) {
if (this.stopped) break
const n = await this.match(spot.message_id)
await db.from('spots').where('message_id', spot.message_id).update({ rematched_at: new Date() })
out.matched += n
if (n === 0 && spot.posted_at && new Date(spot.posted_at).getTime() < cutoff && (await this.standalone(spot.message_id))) out.standalone++
if (n === 0 && !spot.release_id && new Date(spot.inspected_at).getTime() < cutoff && (await this.standalone(spot.message_id))) out.standalone++
}
return out
}
+2 -1
View File
@@ -9,7 +9,8 @@ import { BaseSchema } from '@adonisjs/lucid/schema'
* (alt.binaries.ftd), keeps its files in `spot_files`, and matches them to
* the releases the header pass built (the same release identity), linking
* them in `release_spots`. A spot whose files match no release becomes a
* release of its own (source `spot`) once a day has passed.
* release of its own (source `spot`) a day after its inspection (until
* the header pass builds its releases after all: migration 2900).
* The NZB itself is kept (gzip) for such a release's downloads.
* Passwords are kept apart from the catalog in `release_passwords`, one row
* per release and password, with where it came from (the NZB's meta, the
@@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Unmatched spots are matched again in turns (`rematched_at`), for 30 days after their inspection, also once they
* became a release of their own: the header pass may build their releases later (a spot's history before the
* catalog's), and then the spot moves to that release (LOG.md 2026-10-13 (45)).
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE spots ADD COLUMN IF NOT EXISTS rematched_at timestamptz`)
// Inspected before the inspector set it (2026-10-13).
this.schema.raw(`UPDATE spots SET matched = 0 WHERE status = 'inspected' AND matched IS NULL`)
this.schema.raw(`DROP INDEX IF EXISTS spots_unmatched_idx`)
this.schema.raw(`CREATE INDEX IF NOT EXISTS spots_unmatched_idx ON spots (coalesce(rematched_at, inspected_at)) WHERE status = 'inspected' AND matched = 0`)
}
async down() {
this.schema.raw(`DROP INDEX IF EXISTS spots_unmatched_idx`)
this.schema.raw(`ALTER TABLE spots DROP COLUMN IF EXISTS rematched_at`)
}
}
+12
View File
@@ -105,5 +105,17 @@ test.group('Spots', (group) => {
assert.equal(release.files, 1)
const { default: NzbService } = await import('#services/nzb_service')
assert.include((await NzbService.build(Number(release.id)))!.xml, 'lonely.file.mkv')
// Not again while it has one; once the header pass builds the file's release, the spot moves there.
assert.deepEqual(await inspector.rematch(), { matched: 0, standalone: 0 })
const group = await db.from('groups').where('name', 'alt.binaries.test.spots').firstOrFail()
const parsed = parseSubject('[1/1] - "lonely.file.mkv" yEnc (1/9)')!
const identity = releaseIdentity({ poster: 'p', subject: parsed.base, name: parsed.name, weak: false })!
const [h] = await db
.table('releases')
.insert({ release_key: identity.key, identity_key: identity.key, name: identity.name, source: identity.source, poster: 'p', groups: '[]', group_id: group.id, files: 1, posted_at: new Date(STAMP * 1000), readable: true })
.returning('id')
assert.equal(await inspector.match(SPOT), 1)
assert.isNull(await db.from('releases').where('id', release.id).first())
assert.equal(Number((await db.from('spots').where('message_id', SPOT).firstOrFail()).release_id), Number(h.id))
})
})