Spots: unmatched ones matched again in turns; a standalone release gives way
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:
1 parent
7df5a638ca
commit
550f63cd84
4 files changed
+61
-6
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user