diff --git a/edge/src/indexer.rs b/edge/src/indexer.rs index 6fbb180..0aa1e45 100644 --- a/edge/src/indexer.rs +++ b/edge/src/indexer.rs @@ -227,7 +227,7 @@ fn describe(subject: &str, name_end: Option, counter: &Counter, from: &st _ => None, }; let name = match quoted { - Some(name) => name, + Some(name) => name_beside_quotes(&name, &subject[..counter.index]).unwrap_or(name), None => unquoted_name(&subject[..counter.index])?, }; let name = name.as_str(); @@ -273,6 +273,28 @@ fn first_track(text: &str) -> Option { None } +/// nameBesideQuotes (lib/headers/subject.ts): a file name elsewhere when the quoted text has no extension. +pub fn name_beside_quotes(quoted: &str, before_counter: &str) -> Option { + if has_extension(quoted) { + return None; + } + // .replace(/"[^"]*"/g, ' ') + let mut text = String::with_capacity(before_counter.len()); + let mut rest = before_counter; + while let Some(open) = rest.find('"') { + match rest[open + 1..].find('"') { + Some(close) => { + text.push_str(&rest[..open]); + text.push(' '); + rest = &rest[open + 1 + close + 1..]; + } + None => break, + } + } + text.push_str(rest); + unquoted_name(&text).filter(|n| has_extension(n)) +} + /// unquotedName (lib/headers/subject.ts): the file name of a subject without a quoted one, from the text before its counter. pub fn unquoted_name(before_counter: &str) -> Option { let mut text = before_counter.trim_end_matches(js::is_space); @@ -896,4 +918,17 @@ mod unquoted_tests { } } } + +#[cfg(test)] +mod beside_quotes_tests { + use super::name_beside_quotes; + + #[test] + fn a_name_beside_a_quoted_word() { + assert_eq!(name_beside_quotes("Official", "My \"Official\" remaining set's of Inna, Please help up to #69 if you can 025.jpg [25/74] - yEnc ").as_deref(), Some("025.jpg")); + assert_eq!(name_beside_quotes("Dr. Lust", "\"Dr. Lust\" - Lily Marlene, Vanessa Del Rio, Scorpio? - yEnc - [12/21] - Ed_DRL_VDR_LM.part09.rar ").as_deref(), Some("Ed_DRL_VDR_LM.part09.rar")); + assert_eq!(name_beside_quotes("1f0554e5fec14b8b05ca", "77cd6d03d19281a775f5bdc5 [2/8] - \"1f0554e5fec14b8b05ca\" yEnc ").as_deref(), None); + assert_eq!(name_beside_quotes("x.rar", "\"x.rar\" other.zip ").as_deref(), None); + } +} } diff --git a/edge/src/names.rs b/edge/src/names.rs index 4a23706..7f59296 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -36,7 +36,7 @@ static SUFFIXES: LazyLock> = LazyLock::new(|| { (re(r"(?i)\.7z(\.[0-9]{3})?$"), ""), (re(r"(?i)\.zip(\.[0-9]{3})?$"), ""), // JS: (?<=\.(7z|…))\.\d{3}$ — no lookbehind here, so the extension is matched and put back. - (re(r"(?i)(\.(?:7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.[0-9]{3}$"), "$1"), + (re(r"(?i)(\.(?:7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.[0-9]{2,3}$"), "$1"), (re(r"(?i)\.(nfo|sfv|nzb|srr|srs|txt|jpg|png|md5|sha1|srt|sub|idx|ass|ssa|m3u|m3u8|cue|log)$"), ""), (re(r"(?i)\.tar$"), ""), (re(r"(?i)\.(zst|gz|xz|bz2)$"), ""), @@ -114,6 +114,13 @@ fn strip_three_digits(s: &[u8]) -> Option { (n >= 4 && s[n - 4] == b'.' && s[n - 3..].iter().all(u8::is_ascii_digit)).then(|| n - 4) } +/// \.\d{2,3}$: where a split volume's number starts (its dot). +fn strip_volume_digits(s: &[u8]) -> Option { + let n = s.len(); + let d = s.iter().rev().take_while(|c| c.is_ascii_digit()).count(); + ((2..=3).contains(&d) && n > d && s[n - d - 1] == b'.').then(|| n - d - 1) +} + const SPLIT_EXTS: [&[u8]; 14] = [b"7z", b"zip", b"rar", b"tar", b"mkv", b"mp4", b"avi", b"m4v", b"ts", b"m2ts", b"iso", b"img", b"bin", b"vob"]; const SIDE_EXTS: [&[u8]; 19] = [b"nfo", b"sfv", b"nzb", b"srr", b"srs", b"txt", b"jpg", b"png", b"md5", b"sha1", b"srt", b"sub", b"idx", b"ass", b"ssa", b"m3u", b"m3u8", b"cue", b"log"]; const PACK_EXTS: [&[u8]; 4] = [b"zst", b"gz", b"xz", b"bz2"]; @@ -164,9 +171,9 @@ fn strip_suffix_rule(s: &[u8], rule: usize) -> Option { } ends_ci(s, ext).then(|| n - ext.len()) } - // (?<=\.(7z|…))\.\d{3}$: only the volume number goes. + // (?<=\.(7z|…))\.\d{2,3}$: only the volume number goes. 7 => { - let m = strip_three_digits(s)?; + let m = strip_volume_digits(s)?; strip_ext(&s[..m], &SPLIT_EXTS).map(|_| m) } 8 => strip_ext(s, &SIDE_EXTS), diff --git a/lib/edge/scan.ts b/lib/edge/scan.ts index df4b96c..006ace8 100644 --- a/lib/edge/scan.ts +++ b/lib/edge/scan.ts @@ -1,4 +1,4 @@ -import { fileKey, unquotedName } from '../headers/index.js' +import { fileKey, nameBesideQuotes, unquotedName } from '../headers/index.js' import { releaseIdentity, type ReleaseIdentity } from '../release/index.js' /** A file's details, shared by all of its segments (one object per file, not per article). */ @@ -138,7 +138,8 @@ export class SegmentScanner { /** A file's details from its first article seen (as parseSubject and segmentFromHeader). */ private describe(subject: string, nameEnd: number, counter: Counter, from: string, xref: string, group: string): FileMeta | null { const nameStart = nameEnd > 0 ? subject.lastIndexOf('"', nameEnd - 1) : -1 - const found = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : unquotedName(subject.slice(0, counter.index)) + const quoted = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : null + const found = quoted !== null ? (nameBesideQuotes(quoted, subject.slice(0, counter.index)) ?? quoted) : unquotedName(subject.slice(0, counter.index)) if (!found || counter.total < 1) return null const name = own(found) const base = own((subject.slice(0, counter.index) + subject.slice(counter.index + counter.length)).replace(/\s+/g, ' ').trim()) diff --git a/lib/headers/index.ts b/lib/headers/index.ts index 1ce3a54..925708f 100644 --- a/lib/headers/index.ts +++ b/lib/headers/index.ts @@ -1,3 +1,3 @@ -export { parseSubject, unquotedName, type ParsedSubject } from './subject.js' +export { parseSubject, unquotedName, nameBesideQuotes, type ParsedSubject } from './subject.js' export { fileKey, weakFileKey, counterFileKey, looksLikeFilename } from './file_key.js' export { segmentFromHeader, type IngestSegment, type OverviewHeader, type IdentityCache } from './segment.js' diff --git a/lib/headers/subject.ts b/lib/headers/subject.ts index c6214f8..7d52b1e 100644 --- a/lib/headers/subject.ts +++ b/lib/headers/subject.ts @@ -30,6 +30,17 @@ const EXTENSION = /[^.\s]\.[A-Za-z0-9]{2,4}$/ * (01/24)`). Null when no file name is found: the subject stays obfuscated. * Words are split on spaces only (both implementations alike). */ +/** + * The file name of a subject whose quoted text has no extension ("My + * \"Official\" remaining set's of Inna … 025.jpg", "\"Dr. Lust\" - … - Ed_DRL_VDR_LM.part09.rar"): + * a name with one elsewhere, else null (random quoted names keep theirs). + */ +export function nameBesideQuotes(quoted: string, beforeCounter: string): string | null { + if (EXTENSION.test(quoted)) return null + const name = unquotedName(beforeCounter.replace(/"[^"]*"/g, ' ')) + return name && EXTENSION.test(name) ? name : null +} + export function unquotedName(beforeCounter: string): string | null { let text = beforeCounter.trimEnd() if (/yenc$/i.test(text)) text = text.slice(0, -4).trimEnd() @@ -78,7 +89,8 @@ export function parseSubject(subject: string): ParsedSubject | null { if (match.index > nameEnd) counter = match } if (!counter) return null - const name = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : unquotedName(subject.slice(0, counter.index)) + const quoted = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : null + const name = quoted !== null ? (nameBesideQuotes(quoted, subject.slice(0, counter.index)) ?? quoted) : unquotedName(subject.slice(0, counter.index)) if (!name) return null const segment = Number.parseInt(counter[1], 10) diff --git a/lib/release/identity.ts b/lib/release/identity.ts index e568f96..44c18ac 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -33,7 +33,7 @@ const SUFFIXES = [ /\.7z(\.\d{3})?$/i, /\.zip(\.\d{3})?$/i, // Split volumes (x.mkv.001), not codec names (H.264, H.265). - /(?<=\.(7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.\d{3}$/i, + /(?<=\.(7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.\d{2,3}$/i, /\.(nfo|sfv|nzb|srr|srs|txt|jpg|png|md5|sha1|srt|sub|idx|ass|ssa|m3u|m3u8|cue|log)$/i, /\.tar$/i, /\.(zst|gz|xz|bz2)$/i, diff --git a/tests/unit/headers/subject.spec.ts b/tests/unit/headers/subject.spec.ts index 9dae40a..0a5fd73 100644 --- a/tests/unit/headers/subject.spec.ts +++ b/tests/unit/headers/subject.spec.ts @@ -46,6 +46,14 @@ test.group('subject / parseSubject', () => { assert.isNull(parseSubject('Re: Will somebody answer the question? (1/2)')) }) + test('a quoted text without an extension gives way to a file name beside it (nameBesideQuotes)', ({ assert }) => { + assert.equal(parseSubject("My \"Official\" remaining set's of Inna, Please help up to #69 if you can 025.jpg [25/74] - yEnc (1/1)")!.name, '025.jpg') + assert.equal(parseSubject('"Dr. Lust" - Lily Marlene, Vanessa Del Rio, Scorpio? - yEnc - [12/21] - Ed_DRL_VDR_LM.part09.rar (1/80)')!.name, 'Ed_DRL_VDR_LM.part09.rar') + assert.equal(parseSubject('Unknown Model - Cleaning The Hard Drive ~ lr140x16.jpg ( file 046 of 922) "yEnc" (1/1)')!.name, 'lr140x16.jpg') + // A random quoted name with no file name beside it keeps its name. + assert.equal(parseSubject('77cd6d03d19281a775f5bdc5 [2/8] - "1f0554e5fec14b8b05ca" yEnc (14/567)')!.name, '1f0554e5fec14b8b05ca') + }) + test('finds the file name of subjects without a quoted one (unquotedName)', ({ assert }) => { const cases: [string, string, number, number, number?][] = [ ['DVDFab 8.2.0.5.rar (006/332)', 'DVDFab 8.2.0.5.rar', 6, 332], diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index afa7cbb..6e41992 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -214,6 +214,11 @@ test.group('release / par2 names', () => { assert.isFalse(randomPoster('w.merklein@freenet.de (muffin)')) }) + test('split parts with two-digit numbers share a stem', ({ assert }) => { + assert.equal(fileStem('Admiral.EP05.2008.720p.Rus.HDCLUB.mkv.21'), fileStem('Admiral.EP05.2008.720p.Rus.HDCLUB.mkv.22')) + assert.equal(fileStem('Naslednica_po_prjamoy_DVDRip.avi.05'), 'Naslednica_po_prjamoy_DVDRip') + }) + test('image sets: sizes in brackets and one-word set names', ({ assert }) => { const id = (subject: string, name: string) => releaseIdentity({ poster: 'p', subject, name, weak: false })! assert.equal(id('Aletta Ocean - "100628-035.jpg" yEnc [347K]', '100628-035.jpg').key, id('Aletta Ocean - "100628-036.jpg" yEnc [298K]', '100628-036.jpg').key)