Join an upload's releases by its par2 set's file names
Test and deploy / test (push) Successful in 1m5s
Test and deploy / edge (push) Successful in 2m0s
Test and deploy / deploy (push) Successful in 1m19s

The files of one upload often became several releases whose names
differ slightly: scene tracks apart from their album's nfo/sfv/par2,
"Vermaechtnis" beside a garbled "Vermächtnis", an index named
Die.Bergretter.S18E05... beside its video wishtv-diebergretter-s18e05.
The par2 merge joined releases only by first-16-KiB hashes, which
readable files never get. A name pass now looks, for every set the
merger finds, at the set poster's releases in its group posted within
12 hours: those whose files the set all names (without case or
punctuation, Latin-1-read UTF-8 read back; sidecars, par2 and images
aside) join the set's release, within the hash pass's file bound.
Merges are kept in release_merges as via 'name'. releases:merge-names
runs it over the sets found before; on 300 recent sets 22 merges of 147
releases, all one upload each.

Also: volumes split after .wmv, .mov, .mpg and other video extensions
share a stem ("Morning Lovers.wmv.001" to ".058" were 58 releases), in
the Rust edge and the TypeScript identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-23 14:02:51 +02:00
1 parent e4044cb1d1
commit e00e4f3988
7 files changed
+242 -11

No files matched your search

+94 -3
View File
@@ -2,7 +2,9 @@ import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { par2ReleaseName } from '#lib/release/index'
import { looksRandom } from '#lib/release/random'
import { SidecarFolder } from '#services/sidecar_folder'
import { SidecarFolder, SIDECAR } from '#services/sidecar_folder'
import { randomPoster } from '#lib/release/identity'
import NzbService from '#services/nzb_service'
import { Par2Pruner } from '#services/par2_pruner'
import { pruneSevenZipTails } from '#services/seven_zip_tails'
import { decidePar2Completeness } from '#services/par2_completeness'
@@ -23,6 +25,14 @@ const SLACK_FILES = 10
* ran out of heap every few minutes (LOG.md 2026-10-20 (85)). The judge calls 20 releases repeated.
*/
const COMMON_FILE = 20
/**
* The name pass: a set's own poster's releases in its group posted this close to it are looked at; at most
* NAME_CANDIDATES of them (a poster's uploads of a day).
*/
const NAME_SPREAD_MS = 12 * 3600 * 1000
const NAME_CANDIDATES = 200
/** Files a set's upload carries besides those it describes: sidecars, its par2 files, a cover or preview. */
const besideSet = (name: string) => SIDECAR.test(name) || /\.(par2|jpe?g|png)$/i.test(name)
/** Between rounds. */
const ROUND_MS = 60_000
/** A window looks back this far past the previous one's end (rows committed after it was read). */
@@ -45,6 +55,9 @@ export interface MergeRound {
sets: number
merges: number
releases: number
/** The name pass: merges, releases in them. */
nameMerges: number
nameReleases: number
/** The inner-name pass: stems checked, merges, releases in them. */
stems: number
stemMerges: number
@@ -59,6 +72,19 @@ export interface MergeRound {
*/
const INNER_STEM = `substring(i.result->>'yencName' from '^([A-Za-z0-9]{16,64})\\.')`
/**
* A file name as the name pass compares it: without its folder, case, spaces and punctuation, its UTF-8 read back when
* it was read as Latin-1 ("Vermächtnis" posted, "Vermächtnis" in the par2).
*/
export function fileKey(name: string): string {
let n = name.replace(/^.*[\\/]/, '')
if (/[\u00c2-\u00f4][\u0080-\u00bf]/.test(n)) {
const read = Buffer.from(n, 'latin1').toString('utf8')
if (!read.includes('\ufffd')) n = read
}
return n.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
}
/** The releases holding a par2 file f (its first 16 KiB and size), none when it's a common file (COMMON_FILE). */
const HOLDERS = `SELECT c.release_id FROM (
SELECT l.release_id, count(*) OVER () AS holders
@@ -121,6 +147,7 @@ export class Par2Merger {
}
const round = await this.round()
if (round.merges > 0) logger.info(`Merged ${round.releases} releases into ${round.merges} by par2 set (${round.sets} sets checked).`)
if (round.nameMerges > 0) logger.info(`Merged ${round.nameReleases} releases into ${round.nameMerges} by their par2 set's file names.`)
if (round.stemMerges > 0) logger.info(`Merged ${round.stemReleases} releases into ${round.stemMerges} by inner name (${round.stems} stems checked).`)
if (round.folded?.folded) logger.info(`Folded ${round.folded.folded} sidecars into their uploads (${round.folded.sidecars} sidecar-only of ${round.folded.looked} small releases).`)
} catch (error: any) {
@@ -155,7 +182,7 @@ export class Par2Merger {
foldedAt = Date.now()
if (more.folded) logger.info(`Folded ${more.folded} sidecars into their uploads (${more.sidecars} sidecar-only of ${more.looked} small releases).`)
}
const round: MergeRound = { sets: 0, merges: 0, releases: 0, stems: 0, stemMerges: 0, stemReleases: 0, folded }
const round: MergeRound = { sets: 0, merges: 0, releases: 0, nameMerges: 0, nameReleases: 0, stems: 0, stemMerges: 0, stemReleases: 0, folded }
while (this.since < end) {
if (this.stopped) return round
const from = await this.firstNew(new Date(this.since.getTime() - OVERLAP_MS), end)
@@ -176,6 +203,21 @@ export class Par2Merger {
round.releases += merged + 1
}
}
// Every set the window found, by its files' names: readable files have no hashes (the files of one upload under
// names that differ: scene tracks apart from their album's par2, "Vermaechtnis" beside "Vermächtnis"; LOG.md
// 2026-10-23 (97)).
const { rows: found } = await db.rawQuery(`SELECT DISTINCT set_id FROM par2_sets WHERE created_at >= ? AND created_at <= ?`, [from, until])
for (const { set_id: setId } of found) {
if (this.stopped) return round
const merged = await this.mergeByName(setId.trim()).catch((error) => {
logger.error(`Name merge of set ${setId.trim()} failed: ${error.message}`)
return 0
})
if (merged > 0) {
round.nameMerges++
round.nameReleases += merged + 1
}
}
const { rows: stems } = await db.rawQuery(
`SELECT DISTINCT ${INNER_STEM} AS stem FROM release_inspections i
WHERE i.kind = 'lone' AND i.created_at >= ? AND i.created_at <= ? AND ${INNER_STEM} IS NOT NULL`,
@@ -245,6 +287,55 @@ export class Par2Merger {
return { touched: Number(rows[0].touched), sets: rows[0].sets }
}
/**
* 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.
*/
async nameMatches(trx: any, setId: string): Promise<Map<string, Member> | null> {
const { rows: holders } = 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`,
[setId]
)
const anchor: Member | undefined = holders[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 { 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)))
const at = new Date(anchor.posted_at).getTime()
const { rows: candidates } = await trx.rawQuery(
`SELECT r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable, r.poster FROM releases r
WHERE left(r.poster, 200) = left(?, 200) AND r.poster = ? AND r.group_id = ? AND r.posted_at BETWEEN ? AND ? AND r.id <> ALL(?)
AND r.files <= ?
ORDER BY left(r.poster, 200), r.posted_at DESC NULLS LAST, r.id DESC LIMIT ?`,
// More files than the set describes, with its sidecars, can't all be named in it.
[anchor.poster, anchor.poster, anchor.group_id, new Date(at - NAME_SPREAD_MS), new Date(at + NAME_SPREAD_MS), holders.map((h: Member) => h.id), described.length + SLACK_FILES, NAME_CANDIDATES]
)
const found = new Map<string, Member>(holders.map((h: Member) => [String(h.id), h]))
let files = holders.reduce((n: number, h: Member) => n + Number(h.files), 0)
for (const c of candidates) {
const own = await NzbService.files(Number(c.id), trx)
const content = own.filter((f) => !besideSet(f.name))
if (content.length === 0 || !content.every((f) => names.has(fileKey(f.name)))) continue
found.set(String(c.id), c)
files += Number(c.files)
}
if (found.size < 2) return null
// No more files than the set describes, with its par2 volumes and sidecars (as the hash pass).
if (files > MAX_FILES_PER_SET_FILE * names.size + SLACK_FILES) return null
return found
}
/** Merges the releases whose files one set names (nameMatches); how many went into another. */
async mergeByName(setId: string): Promise<number> {
return db.transaction(async (trx) => {
const found = await this.nameMatches(trx, setId)
return found ? this.merge(trx, found, setId, 'name') : 0
})
}
/** Merges the releases whose lone files' inner names share a random stem; how many went into another. */
async mergeStem(stem: string): Promise<number> {
return db.transaction(async (trx) => {
@@ -319,7 +410,7 @@ export class Par2Merger {
* Merges the found releases into the one with the most files (named by the par2 set's files when none
* is readable); how many went into it. Each merge is kept in release_merges, with its set or stem.
*/
private async merge(trx: any, found: Map<string, Member>, key: string, via: 'par2' | 'inner' | 'sidecar'): Promise<number> {
private async merge(trx: any, found: Map<string, Member>, key: string, via: 'par2' | 'inner' | 'sidecar' | 'name'): Promise<number> {
{
// Locked in id order (the batch store and other rounds take the same rows).
const { rows: locked } = await trx.rawQuery(`SELECT id FROM releases WHERE id = ANY(?) ORDER BY id FOR UPDATE`, [[...found.keys()]])