diff --git a/app/services/ArchiveService.ts b/app/services/ArchiveService.ts index 613e30d..00de756 100644 --- a/app/services/ArchiveService.ts +++ b/app/services/ArchiveService.ts @@ -115,9 +115,11 @@ export async function extractEntry(archive: Buffer, entry: RarEntry, maxBytes = * `Joker.Folie.a.Deux.2024.2160p….nzb`). Samples are skipped. */ export function releaseNameFromContents(info: Pick): string | undefined { - const entries = info.entries.filter((e) => !e.directory) - const tops = new Set(info.entries.map((e) => e.name.split(/[\\/]/)[0])) - if (tops.size === 1 && info.entries.some((e) => /[\\/]/.test(e.name))) { + // Hidden entries name nothing: one poster adds the same 22 GB ".1917-….mkv" decoy to every upload (LOG.md 2026-10-06 (2)). + const listed = info.entries.filter((e) => !isHidden(e.name)) + const entries = listed.filter((e) => !e.directory) + const tops = new Set(listed.map((e) => e.name.split(/[\\/]/)[0])) + if (tops.size === 1 && listed.some((e) => /[\\/]/.test(e.name))) { const [top] = tops if (/[A-Za-z]{2,}.*[._-].*[A-Za-z0-9]{2,}/.test(top) && !looksRandom(top)) return top } @@ -129,6 +131,36 @@ export function releaseNameFromContents(info: Pick): string return candidates[0]?.stem } +/** An entry in a hidden directory or a hidden file itself (`.x/…`, `dir/.x`). */ +export function isHidden(name: string): boolean { + return name.split(/[\\/]/).some((part) => part.startsWith('.')) +} + +/** + * Releases named after a hidden archive entry before those were skipped: + * renamed from their stored listing (no downloads). Used by + * `release:rename --hidden`. + */ +export async function renameHiddenNamed(): Promise { + const releases = await db + .from('releases') + .where('name', 'like', '.%') + .whereNot('source', 'spot') + .whereNotNull('contents') + .select('id', 'name', 'contents') + let renamed = 0 + for (const release of releases) { + const entries: any[] = Array.isArray(release.contents) ? release.contents : [] + const fromHidden = entries.some((e) => isHidden(e.name) && fileStem(String(e.name).split(/[\\/]/).pop() ?? '') === release.name) + if (!fromHidden) continue + const better = releaseNameFromContents({ entries }) + if (!better || better === release.name) continue + await db.from('releases').where('id', release.id).update({ name: better }) + renamed++ + } + return renamed +} + /** * Renames random-named releases from their stored archive listing (no * downloads). Used by `release:rename`. diff --git a/commands/ReleaseRename.ts b/commands/ReleaseRename.ts index f06f25d..2950b5f 100644 --- a/commands/ReleaseRename.ts +++ b/commands/ReleaseRename.ts @@ -1,6 +1,6 @@ -import { BaseCommand } from '@adonisjs/core/ace' +import { BaseCommand, flags } from '@adonisjs/core/ace' import { CommandOptions } from '@adonisjs/core/types/ace' -import { renameFromStoredContents } from '#services/ArchiveService' +import { renameFromStoredContents, renameHiddenNamed } from '#services/ArchiveService' import { regroupAndRenameFromPar2 } from '#services/NameService' /** Renames random-named releases from their stored archive listings (no downloads). */ @@ -12,7 +12,14 @@ export default class ReleaseRename extends BaseCommand { startApp: true, } + @flags.boolean({ description: 'Only releases named after a hidden archive entry (e.g. a decoy ".x.mkv")' }) + declare hidden: boolean + public async run() { + if (this.hidden) { + this.logger.info(`Renamed ${await renameHiddenNamed()} releases named after a hidden archive entry.`) + return + } const par2 = await regroupAndRenameFromPar2() this.logger.info(`par2: ${par2.regrouped} files moved into the set that names them, ${par2.renamed} releases renamed.`) this.logger.info(`Renamed ${await renameFromStoredContents()} releases from their archive contents.`) diff --git a/tests/unit/rar.spec.ts b/tests/unit/rar.spec.ts index 1260cfb..92daa13 100644 --- a/tests/unit/rar.spec.ts +++ b/tests/unit/rar.spec.ts @@ -211,6 +211,19 @@ test.group('rar / release naming from contents', () => { assert.isUndefined(releaseNameFromContents(info(['Xq8r2KdLm3Zw91Pq/x.mkv']))) }) + test('hidden entries (a decoy) name nothing', async ({ assert }) => { + const { releaseNameFromContents, isHidden } = await import('#services/ArchiveService') + const decoy = '.1917-2019-2160p_MA_WEB-DL_Hybrid_H265_DV_HDR_DDP_Atmos_5.1_English-HONE-.mkv' + const listing = info([decoy, 'Boston_Blue_S01E02_1080p_AMZN_WEB-DL_DDP5_1_H_264-FLUX', `${decoy}/${decoy}.19OMnL`, 'Boston_Blue_S01E02_1080p_AMZN_WEB-DL_DDP5_1_H_264-FLUX/boston.blue.s01e02.rar']) + listing.entries[0].directory = true + listing.entries[1].directory = true + listing.entries[2].size = 22_093_429_730 + assert.equal(releaseNameFromContents(listing), 'Boston_Blue_S01E02_1080p_AMZN_WEB-DL_DDP5_1_H_264-FLUX') + assert.isTrue(isHidden(`${decoy}/x.mkv`)) + assert.isTrue(isHidden('dir/.hidden.mkv')) + assert.isFalse(isHidden('Some.Release/movie.mkv')) + }) + test('recognises obfuscated names', async ({ assert }) => { const { looksRandom } = await import('#services/ArchiveService') assert.isTrue(looksRandom('CGBWgdhlSCAOAW62gLWRRM'))