diff --git a/src/server/services/folder_rename_service.py b/src/server/services/folder_rename_service.py index 0b39ee5..f94df53 100644 --- a/src/server/services/folder_rename_service.py +++ b/src/server/services/folder_rename_service.py @@ -66,6 +66,9 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s def _compute_expected_folder_name(title: str, year: str) -> str: """Compute the expected folder name from title and year. + Removes any existing year suffixes (e.g., "(2021)") before adding the + canonical one to prevent duplication across multiple folder rename runs. + Args: title: Series title from NFO. year: Release year from NFO. @@ -73,7 +76,15 @@ def _compute_expected_folder_name(title: str, year: str) -> str: Returns: Sanitised folder name in the format ``"{title} ({year})"``. """ - raw_name = f"{title} ({year})" + import re + + # Remove all trailing year suffixes to prevent duplication. + # This handles cases where the title already contains one or more years. + # Regex pattern: matches one or more " (YYYY)" at the end of the string + clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip() + + year_suffix = f" ({year})" + raw_name = f"{clean_title}{year_suffix}" return sanitize_folder_name(raw_name) diff --git a/tests/unit/test_folder_rename_service.py b/tests/unit/test_folder_rename_service.py index 870c0f0..1d8f736 100644 --- a/tests/unit/test_folder_rename_service.py +++ b/tests/unit/test_folder_rename_service.py @@ -75,6 +75,84 @@ class TestComputeExpectedFolderName: result = _compute_expected_folder_name("A / B", "2021") assert result == "A B (2021)" + def test_does_not_duplicate_year(self) -> None: + result = _compute_expected_folder_name("86 Eighty Six (2021)", "2021") + assert result == "86 Eighty Six (2021)" + assert result.count("(2021)") == 1 + + def test_removes_duplicate_year_suffixes_bug_86_eighty_six(self) -> None: + """Test the bug fix for duplicate year suffixes. + + Issue: "86 Eighty Six (2021) (2021) (2021) (2021) (2021)" + should become "86 Eighty Six (2021)" + """ + result = _compute_expected_folder_name( + "86 Eighty Six (2021) (2021) (2021) (2021) (2021)", "2021" + ) + assert result == "86 Eighty Six (2021)" + assert result.count("(2021)") == 1 + + def test_removes_duplicate_year_suffixes_alma_chan(self) -> None: + """Test the bug fix for duplicate year suffixes with long title. + + Issue: "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)" + should become "Alma-chan Wants to Be a Family! (2025)" + """ + result = _compute_expected_folder_name( + "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)", + "2025", + ) + assert result == "Alma-chan Wants to Be a Family! (2025)" + assert result.count("(2025)") == 1 + + def test_removes_duplicate_year_suffixes_bogus_skill(self) -> None: + """Test the bug fix for duplicate year suffixes with very long title. + + Issue: Long title with duplicated years should be cleaned. + """ + result = _compute_expected_folder_name( + "Bogus Skill Fruitmaster About That Time I Became Able to Eat " + "Unlimited Numbers of Skill Fruits (That Kill You) (2025) (2025)", + "2025", + ) + assert "(2025)" in result + assert result.count("(2025)") == 1 + + def test_removes_multiple_different_year_suffixes(self) -> None: + """Test that old duplicate years are removed and new one added.""" + result = _compute_expected_folder_name( + "Series (2020) (2020) (2020)", "2021" + ) + assert result == "Series (2021)" + assert "(2020)" not in result + assert result.count("(2021)") == 1 + + def test_handles_whitespace_with_duplicate_years(self) -> None: + """Test that extra whitespace is removed along with duplicate years.""" + result = _compute_expected_folder_name( + "Series (2021) (2021) (2021) ", "2021" + ) + assert result == "Series (2021)" + assert result.count("(2021)") == 1 + assert not result.endswith(" ") + + def test_idempotent_multiple_calls(self) -> None: + """Test that calling the function multiple times produces the same result.""" + title = "86 Eighty Six (2021) (2021) (2021)" + year = "2021" + + # First call + result1 = _compute_expected_folder_name(title, year) + # Second call with the result + result2 = _compute_expected_folder_name(result1, year) + # Third call with the result + result3 = _compute_expected_folder_name(result2, year) + + # All results should be identical + assert result1 == result2 == result3 + assert result1 == "86 Eighty Six (2021)" + assert result1.count("(2021)") == 1 + class TestIsSeriesBeingDownloaded: """Tests for _is_series_being_downloaded."""