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) + }) })