From fb6b00f23081d4142f95c0ea298c7c1535853dd9 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Fri, 23 Oct 2026 14:59:09 +0200 Subject: [PATCH] Name pass: only holders near the newest; merges drop duplicate candidates The name pass put every holder of a set into its merge: a par2 posted again years later ("Auto Bild Klassik Februar 02-2014", held by a 2014 and a 2026 release) would have joined the repost to the old upload. Now the newest holder anchors it and only holders in its group within 12 hours take part. Every round, three sets failed on the probe candidates' unique key: a member held a candidate of an article the target had. merge() now drops such a candidate (the target's, or a lower member's, stays), as it already does for par2 sets. Co-Authored-By: Claude Opus 5.5 --- app/services/par2_merger.ts | 24 ++++++++++++++++++------ tests/functional/par2_name_merge.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/services/par2_merger.ts b/app/services/par2_merger.ts index 68a531d..76e5927 100644 --- a/app/services/par2_merger.ts +++ b/app/services/par2_merger.ts @@ -288,19 +288,23 @@ export class Par2Merger { } /** - * The releases a set's file names find (mergeByName without merging): its holders, and the releases of the set's - * poster in its group posted within NAME_SPREAD_MS whose files the set all names, besides sidecars, par2 and images. - * Null when there's nothing to merge. + * The releases a set's file names find (mergeByName without merging): its newest holder and the holders near it, + * and the releases of its poster in its group posted within NAME_SPREAD_MS whose files the set all names, besides + * sidecars, par2 and images. Null when there's nothing to merge. Not a holder further off: the same par2 posted + * again years later ("Auto Bild Klassik Februar 02-2014": 2014 and 2026) is a repost (decision d). */ async nameMatches(trx: any, setId: string): Promise | null> { - const { rows: holders } = await trx.rawQuery( + const { rows: all } = await trx.rawQuery( `SELECT r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable, r.poster FROM par2_sets s JOIN releases r ON r.id = s.release_id - WHERE s.set_id = ? ORDER BY r.id`, + WHERE s.set_id = ? ORDER BY r.posted_at DESC NULLS LAST, r.id`, [setId] ) - const anchor: Member | undefined = holders[0] + const anchor: Member | undefined = all[0] // A random poster (one per file) bounds nothing: those uploads are the hash pass's. if (!anchor?.poster || !anchor.posted_at || randomPoster(anchor.poster)) return null + const holders = all.filter( + (h: Member) => h.group_id === anchor.group_id && h.posted_at && Math.abs(new Date(h.posted_at).getTime() - new Date(anchor.posted_at!).getTime()) <= NAME_SPREAD_MS + ) const { rows: described } = await trx.rawQuery(`SELECT name FROM par2_files WHERE set_id = ?`, [setId]) if (described.length === 0) return null const names = new Set(described.map((d: { name: string }) => fileKey(d.name))) @@ -425,6 +429,14 @@ export class Par2Merger { SELECT id, release_key, ?, ?, files, ? FROM releases WHERE id = ANY(?)`, [target.id, key, via, others] ) + // A candidate of an article the target, or another member of lower id, has already (the same file in two releases) + // isn't moved: the name pass met them (every round of 3 sets failed on the unique key; LOG.md 2026-10-23 (99)). + await trx.rawQuery( + `DELETE FROM probe_candidates p WHERE p.release_id = ANY(?) + AND (EXISTS (SELECT 1 FROM probe_candidates t WHERE t.release_id = ? AND t.kind = p.kind AND t.message_id = p.message_id) + OR EXISTS (SELECT 1 FROM probe_candidates o WHERE o.release_id = ANY(?) AND o.release_id < p.release_id AND o.kind = p.kind AND o.message_id = p.message_id))`, + [others, target.id, others] + ) for (const table of ['release_chunks', 'probe_candidates', 'release_inspections', 'file_hashes']) { await trx.rawQuery(`UPDATE ${table} SET release_id = ? WHERE release_id = ANY(?)`, [target.id, others]) } diff --git a/tests/functional/par2_name_merge.spec.ts b/tests/functional/par2_name_merge.spec.ts index 3711a92..d1506d9 100644 --- a/tests/functional/par2_name_merge.spec.ts +++ b/tests/functional/par2_name_merge.spec.ts @@ -65,4 +65,24 @@ test.group('Par2Merger / by file names', (group) => { // Nothing more to find. assert.equal(await new Par2Merger().mergeByName(setId), 0) }) + + test('not a holder years off (a repost of the par2); a member with an article the target has merges without it', async ({ assert }) => { + await store([...file('Some.Album-WEB-2026.par2', 100_010, 0), ...file('Track One.flac', 100_020, 1)]) + // The same par2 posted again a year later, in a range of its own. + await store(file('Some.Album-WEB-2026.par2', 200_010, 365 * 24 * 60)) + const releases = await db.from('releases').orderBy('posted_at').select('id', 'name', 'posted_at') + const [index, track, repost] = releases.map((r) => Number(r.id)) + const setId = 'b'.repeat(32) + await db.table('par2_files').insert({ set_id: setId, file_id: '1'.repeat(32), hash16k: 'f'.repeat(32), length: 600_000, name: 'Track One.flac' }) + await db.table('par2_sets').multiInsert([index, repost].map((id) => ({ set_id: setId, release_id: id, files: 1 }))) + // The repost is the newest holder: nothing near it to join. + assert.equal(await new Par2Merger().mergeByName(setId), 0) + await db.from('par2_sets').where('release_id', repost).delete() + // Both releases hold a candidate of the same article. + await db.table('probe_candidates').multiInsert([index, track].map((id) => ({ release_id: id, kind: 'par2', message_id: '', file: 'x.par2' }))) + assert.equal(await new Par2Merger().mergeByName(setId), 1) + assert.lengthOf(await db.from('probe_candidates').where('message_id', ''), 1) + assert.lengthOf(await db.from('releases').whereIn('id', [index, track]), 1) + assert.lengthOf(await db.from('releases').where('id', repost), 1) + }) })