From 1520a8dc351ede6ae63f53b62532ebb2ed774c98 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Thu, 8 Oct 2026 15:23:24 +0200 Subject: [PATCH] Spots: a corrupt NZB is a result, not a reason to read the listing again Co-Authored-By: Claude Opus 5.5 --- app/services/SpotService.ts | 24 +++++++++++++++++++----- tests/functional/spot_listing.spec.ts | 16 +++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/services/SpotService.ts b/app/services/SpotService.ts index f8a1bea..3bfb5c8 100644 --- a/app/services/SpotService.ts +++ b/app/services/SpotService.ts @@ -31,6 +31,9 @@ const MAX_IMAGE_SEGMENTS = 10 /** A claimed spot whose worker died is taken again after this long. */ const RECLAIM_AFTER = '1 hour' +/** The NZB's articles were read, but they don't decode to an NZB (e.g. "invalid code lengths set"). */ +export class NzbUnreadable extends Error {} + /** * 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. @@ -333,10 +336,17 @@ class SpotService { try { nzbXml = await this.readNzb(head) } catch (error: any) { - if (!/^4\d\d/.test(String(error.message))) throw error - // The NZB's articles are gone: the listing can't be downloaded any more. - update.error = 'NZB articles gone' - outcome = 'nzb-gone' + if (error instanceof NzbUnreadable) { + // Corrupt: reading it again won't help (LOG.md 2026-10-08 (12)). + update.error = `NZB unreadable: ${error.message}`.slice(0, 500) + outcome = 'nzb-unreadable' + } else if (/^4\d\d/.test(String(error.message))) { + // The NZB's articles are gone: the listing can't be downloaded any more. + update.error = 'NZB articles gone' + outcome = 'nzb-gone' + } else { + throw error + } } const nzb = nzbXml ? parseNzb(nzbXml) : null if (nzb && nzb.files.length > 0 && isDummyNzb(nzb, head.header.size)) { @@ -405,7 +415,11 @@ class SpotService { if (segments.length === 0 || segments.length > MAX_NZB_SEGMENTS) return null const bodies = [] for (const id of segments) bodies.push(await this.fetch(`BODY <${id.replace(/^<|>$/g, '')}>`)) - return decodeSpotBinary(bodies).toString('utf8') + try { + return decodeSpotBinary(bodies).toString('utf8') + } catch (error: any) { + throw new NzbUnreadable(String(error.message)) + } } /** Downloads the spot's compressed NZB and imports it as a release (D26). */ diff --git a/tests/functional/spot_listing.spec.ts b/tests/functional/spot_listing.spec.ts index 3f9e9c1..438f909 100644 --- a/tests/functional/spot_listing.spec.ts +++ b/tests/functional/spot_listing.spec.ts @@ -2,7 +2,7 @@ import { test } from '@japa/runner' import { createHash } from 'node:crypto' import db from '@adonisjs/lucid/services/db' import testUtils from '@adonisjs/core/services/test_utils' -import SpotService, { isDummyNzb } from '#services/SpotService' +import SpotService, { isDummyNzb, NzbUnreadable } from '#services/SpotService' import SearchService from '#services/SearchService' test.group('SpotService listings', (group) => { @@ -117,7 +117,7 @@ test.group('SpotService reading listings', (group) => { a1@x` /** A listed spot, as worker:spots leaves an old one, with its article and NZB as given. */ - async function listedSpot(id: string, read: { description?: string; nzb?: string | null; error?: string }) { + async function listedSpot(id: string, read: { description?: string; nzb?: string | null; error?: string; nzbError?: Error }) { await db.table('spots').insert({ message_id: id, group_name: 'free.pt', article_number: 1, title: `Listed ${id}`, poster: 'Bart', key_id: 7, size: 700_000_000, posted_at: new Date('2019-05-01T12:00:00Z'), verified: true, created_at: new Date(), status: 'pending', @@ -129,7 +129,10 @@ test.group('SpotService reading listings', (group) => { if (read.error) throw new Error(read.error) return { header: { size: 700_000_000 }, xml: { description: read.description ?? '', nzbSegments: ['n@x'] } } } - service.readNzb = async () => read.nzb ?? null + service.readNzb = async () => { + if (read.nzbError) throw read.nzbError + return read.nzb ?? null + } } const restore = () => { @@ -179,5 +182,12 @@ test.group('SpotService reading listings', (group) => { await listedSpot('l6@spot.net', { error: '430 No Such Article' }) assert.deepEqual(await SpotService.readListings(10, 1), { gone: 1 }) assert.isNotNull((await db.from('spots').where('message_id', 'l6@spot.net').first()).nzb_read_at) + + // A corrupt NZB is a result, not something to read again. + await listedSpot('l7@spot.net', { nzbError: new NzbUnreadable('invalid code lengths set') }) + assert.deepEqual(await SpotService.readListings(10, 1), { 'nzb-unreadable': 1 }) + const l7 = await db.from('spots').where('message_id', 'l7@spot.net').first() + assert.isNotNull(l7.nzb_read_at) + assert.equal(l7.error, 'NZB unreadable: invalid code lengths set') }) })