Skip to content

Commit 4665742

Browse files
authored
Step 13.1: configurable noise-suffix list in SceneFilenameNormalizer (#343)
* Step 13.1: configurable noise-suffix list in SceneFilenameNormalizer Add an ordered (longest-first) NOISE_SUFFIXES const and a pure stripNoiseSuffixes() helper that repeatedly peels trailing edition/noise phrases (end-anchored, word-boundary, after optional separators) from the parsed title. Wired in after stripGroupSuffix()/stripBracketedTags() in both the bracketed-year branch and the main return path. Single-token noise is guarded so it never empties a title (e.g. "DC" -> "DC"); raw is never mutated. * Step 13.1: add TestEngineer edge-case coverage for noise-suffix stripping Adds case-insensitivity (dataProvider), separator variants ( -._), 3x stacked suffixes, noise after/before a bracketed (YYYY) year, and mid-string noise-word negative cases (must NOT strip) — all using pure NOISE_SUFFIX phrases to isolate the Step 13.1 end-anchored peel from the pre-existing QUALITY_TOKENS token stripping. 87 tests / 200 assertions. ---------
1 parent 1b477a0 commit 4665742

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

src/Media/Metadata/SceneFilenameNormalizer.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,27 @@ final class SceneFilenameNormalizer
7070
'FIX',
7171
];
7272

73+
/**
74+
* @var list<string> Trailing edition/noise phrases to peel off a title, ordered
75+
* LONGEST-FIRST so multi-word phrases match before their shorter prefixes.
76+
* Matched end-anchored, case-insensitively, on word boundaries, after any
77+
* optional ` -._` separators. Repeatedly peeled by {@see stripNoiseSuffixes()}.
78+
*/
79+
private const NOISE_SUFFIXES = [
80+
'unrated directors cut',
81+
'uncut & unrated',
82+
'alternate ending',
83+
'extended cut',
84+
'directors cut',
85+
"director's cut",
86+
'theatrical cut',
87+
'remastered',
88+
'extended',
89+
'uncut',
90+
'yify',
91+
'dc',
92+
];
93+
7394
/**
7495
* @var list<string> File extensions to strip before processing.
7596
*/
@@ -118,6 +139,7 @@ public static function normalize(string $filename): array
118139
$titlePart = trim($bracketMatch[1]);
119140
$titlePart = self::stripGroupSuffix($titlePart);
120141
$title = self::stripBracketedTags($titlePart);
142+
$title = self::stripNoiseSuffixes($title);
121143
$title = preg_replace('/\s+/', ' ', $title) ?? $title;
122144
$title = trim($title);
123145

@@ -206,6 +228,7 @@ public static function normalize(string $filename): array
206228

207229
$title = self::stripGroupSuffix($title);
208230
$title = self::stripBracketedTags($title);
231+
$title = self::stripNoiseSuffixes($title);
209232
$title = preg_replace('/\s+/', ' ', $title) ?? $title;
210233
$title = trim($title);
211234

@@ -317,4 +340,55 @@ private static function stripBracketedTags(string $title): string
317340
$title = preg_replace('/\s+/', ' ', $title) ?? $title;
318341
return trim($title);
319342
}
343+
344+
/**
345+
* Repeatedly peel any trailing edition/noise phrase from a title.
346+
*
347+
* Each iteration strips the LONGEST matching {@see NOISE_SUFFIXES} phrase that
348+
* sits at the very end of the title (case-insensitive, on a word boundary,
349+
* preceded by optional ` -._` separators), then loops to catch stacked
350+
* suffixes (e.g. "Foo Extended Uncut"). A single-token noise phrase is never
351+
* allowed to consume the entire title — if peeling it would leave the title
352+
* empty, the original is returned so the caller's empty-title fallback and the
353+
* "DC" → "DC" guarantee both hold.
354+
*
355+
* @param string $title Title to clean (already group/bracket-stripped).
356+
*
357+
* @return string Title with trailing noise suffixes removed.
358+
*/
359+
private static function stripNoiseSuffixes(string $title): string
360+
{
361+
$title = trim($title);
362+
363+
$changed = true;
364+
while ($changed) {
365+
$changed = false;
366+
367+
// Drop a dangling connector left after token-level quality stripping
368+
// (e.g. "Dune UNCUT &" once "UNRATED" was filtered out as a quality token).
369+
$title = trim(preg_replace('/\s*&\s*$/', '', $title) ?? $title);
370+
371+
foreach (self::NOISE_SUFFIXES as $suffix) {
372+
$pattern = '/[\s\-._]*\b' . preg_quote($suffix, '/') . '[\s\-._&]*$/i';
373+
$stripped = preg_replace($pattern, '', $title);
374+
if ($stripped === null) {
375+
continue;
376+
}
377+
$stripped = trim($stripped);
378+
if ($stripped === $title) {
379+
continue;
380+
}
381+
// Never let a noise token empty the title (e.g. a film literally
382+
// named "DC"): keep the original instead of returning ''.
383+
if ($stripped === '') {
384+
continue;
385+
}
386+
$title = $stripped;
387+
$changed = true;
388+
break;
389+
}
390+
}
391+
392+
return $title;
393+
}
320394
}

tests/Unit/Media/Metadata/SceneFilenameNormalizerTest.php

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,236 @@ public function testNormalizeDirtyYtsFilename(): void
537537
$this->assertSame('Three Wise Men And A Baby', $result['title']);
538538
$this->assertSame(2022, $result['year']);
539539
}
540+
541+
// --- Step 13.1: configurable noise-suffix stripping ---------------------
542+
543+
/** A trailing "Directors Cut" edition phrase is peeled off the title. */
544+
public function testNormalizeStripsDirectorsCutSuffix(): void
545+
{
546+
$result = SceneFilenameNormalizer::normalize('Blade Runner Directors Cut');
547+
548+
$this->assertSame('Blade Runner', $result['title']);
549+
$this->assertNull($result['year']);
550+
}
551+
552+
/** Edition noise after a detected year does not pollute the parsed title. */
553+
public function testNormalizeStripsExtendedCutAfterYear(): void
554+
{
555+
$result = SceneFilenameNormalizer::normalize('Aliens 1986 Extended Cut 1080p');
556+
557+
$this->assertSame('Aliens', $result['title']);
558+
$this->assertSame(1986, $result['year']);
559+
}
560+
561+
/** A combined "UNCUT & UNRATED" suffix is fully removed. */
562+
public function testNormalizeStripsUncutAndUnratedSuffix(): void
563+
{
564+
$result = SceneFilenameNormalizer::normalize('Dune UNCUT & UNRATED');
565+
566+
$this->assertSame('Dune', $result['title']);
567+
$this->assertNull($result['year']);
568+
}
569+
570+
/** An "ALTERNATE ENDING" suffix is removed without touching the title number. */
571+
public function testNormalizeStripsAlternateEndingSuffix(): void
572+
{
573+
$result = SceneFilenameNormalizer::normalize('District 9 ALTERNATE ENDING');
574+
575+
$this->assertSame('District 9', $result['title']);
576+
$this->assertNull($result['year']);
577+
}
578+
579+
/** A bare trailing "YIFY" aggregator tag is removed. */
580+
public function testNormalizeStripsTrailingYify(): void
581+
{
582+
$result = SceneFilenameNormalizer::normalize('Foo YIFY');
583+
584+
$this->assertSame('Foo', $result['title']);
585+
$this->assertNull($result['year']);
586+
}
587+
588+
/** A single-token noise word that IS the whole title must NOT empty it. */
589+
public function testNormalizeNoiseTokenDoesNotEmptyTitle(): void
590+
{
591+
$result = SceneFilenameNormalizer::normalize('DC');
592+
593+
$this->assertSame('DC', $result['title']);
594+
$this->assertNull($result['year']);
595+
}
596+
597+
/** Stripping noise suffixes must never mutate the `raw` original filename. */
598+
public function testNormalizeNoiseStrippingPreservesRaw(): void
599+
{
600+
$original = 'Blade Runner Directors Cut';
601+
$result = SceneFilenameNormalizer::normalize($original);
602+
603+
$this->assertSame($original, $result['raw']);
604+
}
605+
606+
/** Stacked trailing edition suffixes are peeled iteratively. */
607+
public function testNormalizeStripsStackedNoiseSuffixes(): void
608+
{
609+
$result = SceneFilenameNormalizer::normalize('Highlander Remastered Directors Cut');
610+
611+
$this->assertSame('Highlander', $result['title']);
612+
$this->assertNull($result['year']);
613+
}
614+
615+
// --- Step 13.1 (TestEngineer edge cases) --------------------------------
616+
617+
/**
618+
* Three+ stacked trailing edition suffixes are all peeled in one normalize pass.
619+
*/
620+
public function testNormalizeStripsThreeStackedNoiseSuffixes(): void
621+
{
622+
$result = SceneFilenameNormalizer::normalize('Highlander Uncut Remastered Directors Cut');
623+
624+
$this->assertSame('Highlander', $result['title']);
625+
$this->assertNull($result['year']);
626+
}
627+
628+
/**
629+
* Noise-suffix matching is case-insensitive regardless of input casing.
630+
*
631+
* @dataProvider provideCaseVariantSuffixes
632+
*/
633+
public function testNormalizeStripsNoiseSuffixCaseInsensitively(string $filename): void
634+
{
635+
$result = SceneFilenameNormalizer::normalize($filename);
636+
637+
$this->assertSame('Blade Runner', $result['title'], "for input: {$filename}");
638+
$this->assertNull($result['year']);
639+
}
640+
641+
/**
642+
* @return array<string, array{string}>
643+
*/
644+
public static function provideCaseVariantSuffixes(): array
645+
{
646+
return [
647+
'lowercase' => ['Blade Runner directors cut'],
648+
'uppercase' => ['Blade Runner DIRECTORS CUT'],
649+
'titlecase' => ['Blade Runner Directors Cut'],
650+
'mixed-case' => ['Blade Runner DiReCtOrS cUt'],
651+
];
652+
}
653+
654+
/**
655+
* The separator between title and noise suffix may be a space, dash, dot, or
656+
* underscore (the ` -._` set the regex peels) — all variants are stripped.
657+
*
658+
* Uses "Directors Cut" (a pure NOISE_SUFFIX phrase, NOT a QUALITY_TOKEN) so the
659+
* assertion isolates the Step 13.1 end-anchored peel from the pre-existing
660+
* token-level quality stripping in the no-year branch.
661+
*
662+
* @dataProvider provideSeparatorVariants
663+
*/
664+
public function testNormalizeStripsNoiseSuffixAcrossSeparatorVariants(string $filename): void
665+
{
666+
$result = SceneFilenameNormalizer::normalize($filename);
667+
668+
$this->assertSame('Highlander', $result['title'], "for input: {$filename}");
669+
$this->assertNull($result['year']);
670+
}
671+
672+
/**
673+
* @return array<string, array{string}>
674+
*/
675+
public static function provideSeparatorVariants(): array
676+
{
677+
return [
678+
// Dotted scene style: "Highlander.Directors.Cut".
679+
'dot-separated' => ['Highlander.Directors.Cut'],
680+
// Underscore-delimited release name.
681+
'underscore-separated' => ['Highlander_Directors_Cut'],
682+
// Dash-attached edition tag.
683+
'dash-separated' => ['Highlander - Directors Cut'],
684+
// Plain space.
685+
'space-separated' => ['Highlander Directors Cut'],
686+
];
687+
}
688+
689+
/**
690+
* A noise suffix that follows a bracketed (YYYY) year is peeled off while the
691+
* bracketed year is still correctly extracted.
692+
*/
693+
public function testNormalizeStripsNoiseSuffixAfterBracketedYear(): void
694+
{
695+
$result = SceneFilenameNormalizer::normalize('Blade Runner Directors Cut (1982)');
696+
697+
$this->assertSame('Blade Runner', $result['title']);
698+
$this->assertSame(1982, $result['year']);
699+
}
700+
701+
/**
702+
* An "Extended" edition tag appearing before a bracketed (YYYY) is also peeled,
703+
* exercising the bracketed-year branch's stripNoiseSuffixes() call.
704+
*/
705+
public function testNormalizeStripsExtendedBeforeBracketedYear(): void
706+
{
707+
$result = SceneFilenameNormalizer::normalize('Aliens Extended (1986)');
708+
709+
$this->assertSame('Aliens', $result['title']);
710+
$this->assertSame(1986, $result['year']);
711+
}
712+
713+
/**
714+
* A legitimate title that merely CONTAINS a noise word mid-string (not at the
715+
* trailing edge) must NOT be stripped — only end-anchored matches are peeled.
716+
*
717+
* @dataProvider provideMidStringNoiseWordTitles
718+
*/
719+
public function testNormalizeDoesNotStripMidStringNoiseWord(string $filename, string $expectedTitle): void
720+
{
721+
$result = SceneFilenameNormalizer::normalize($filename);
722+
723+
$this->assertSame($expectedTitle, $result['title'], "for input: {$filename}");
724+
}
725+
726+
/**
727+
* Each input uses a noise word that is NOT also a QUALITY_TOKEN (so the
728+
* pre-existing token-level quality stripping cannot remove it), proving the
729+
* Step 13.1 end-anchored peel leaves mid-string occurrences untouched.
730+
*
731+
* @return array<string, array{string, string}>
732+
*/
733+
public static function provideMidStringNoiseWordTitles(): array
734+
{
735+
return [
736+
// "Uncut" as a leading title word followed by more title.
737+
'uncut-leading' => ['Uncut Gems', 'Uncut Gems'],
738+
// "Directors Cut" phrase mid-string, with a real word after it.
739+
'directors-mid' => ['The Directors Cut Saga', 'The Directors Cut Saga'],
740+
// "Cut" embedded in a longer non-edition word must not partial-match.
741+
'cut-substring' => ['The Cutting Edge', 'The Cutting Edge'],
742+
// "DC" as a substring of a larger word must not strip.
743+
'dc-substring' => ['Abducted', 'Abducted'],
744+
// "Uncut" embedded mid-word must not partial-match.
745+
'uncut-substring' => ['Uncuttable Bonds', 'Uncuttable Bonds'],
746+
];
747+
}
748+
749+
/**
750+
* A mid-string noise word does not block stripping a genuine trailing one:
751+
* "Uncut Gems Directors Cut" keeps "Uncut Gems" but drops the trailing edition.
752+
*/
753+
public function testNormalizeStripsTrailingButKeepsMidStringNoiseWord(): void
754+
{
755+
$result = SceneFilenameNormalizer::normalize('Uncut Gems Directors Cut');
756+
757+
$this->assertSame('Uncut Gems', $result['title']);
758+
$this->assertNull($result['year']);
759+
}
760+
761+
/**
762+
* A title whose final word legitimately ends with letters that are NOT a
763+
* word-boundary match for a noise token (e.g. "...Extendedness") is preserved,
764+
* confirming the `\b` word-boundary anchor.
765+
*/
766+
public function testNormalizeRespectsWordBoundaryOnTrailingToken(): void
767+
{
768+
$result = SceneFilenameNormalizer::normalize('The Great Uncutting');
769+
770+
$this->assertSame('The Great Uncutting', $result['title']);
771+
}
540772
}

0 commit comments

Comments
 (0)