diff --git a/src/config/settings.py b/src/config/settings.py index 02a116b..14ee877 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -1,3 +1,4 @@ +import re import secrets from typing import Optional @@ -114,6 +115,40 @@ class Settings(BaseSettings): validation_alias="NFO_PREFER_FSK_RATING", description="Prefer German FSK rating over MPAA rating in NFO files" ) + nfo_folder_ignore_patterns: str = Field( + default="The Last of Us|Loki|Chernobyl|Star Trek Discovery|Marvel|Matrix|Fast & Furious|Jurassic|James Bond|Mission: Impossible|Bourne|Hunger Games|Die Hard|John Wick|Pacific Rim|Guardians of the Galaxy|Avengers|Batman|Superman|Wonder Woman|Spider-Man|X-Men|Fantastic Four|Terminator|Predator|Rambo|Rocky|Expendables|Tomb Raider|Jumanji|Jurassic Park|Pirates of the Caribbean|Harry Potter|Lord of the Rings|Hobbit|Game of Thrones|Westworld|Stranger Things|Breaking Bad|Better Call Saul|Sherlock|Downton Abbey|The Crown|Bridgerton|Sex Education|Normal People|Emily in Paris|The Witcher|Servant|Lucifer|Dark|Shadow and Bone|Grimm|Fairytale", + validation_alias="NFO_FOLDER_IGNORE_PATTERNS", + description="Regex patterns for folder names to skip during scan (pipe-separated)" + ) + + @property + def folder_ignore_patterns(self) -> list[str]: + """Parse ignore patterns from comma-separated string into list. + + Returns: + List of regex patterns to skip during folder scanning. + """ + if not self.nfo_folder_ignore_patterns: + return [] + return [ + pattern.strip() + for pattern in self.nfo_folder_ignore_patterns.split("|") + if pattern.strip() + ] + + def should_ignore_folder(self, folder_name: str) -> bool: + """Check if folder should be ignored based on ignore patterns. + + Args: + folder_name: Name of folder to check. + + Returns: + True if folder matches any ignore pattern, False otherwise. + """ + for pattern in self.folder_ignore_patterns: + if re.search(pattern, folder_name, re.IGNORECASE): + return True + return False @property def allowed_origins(self) -> list[str]: diff --git a/src/core/SerieScanner.py b/src/core/SerieScanner.py index 47eef79..b690c11 100644 --- a/src/core/SerieScanner.py +++ b/src/core/SerieScanner.py @@ -20,6 +20,7 @@ from typing import Callable, Iterable, Iterator, Optional from events import Events +from src.config.settings import settings from src.core.entities.series import Serie from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException from src.core.providers.base_provider import Loader @@ -597,6 +598,9 @@ class SerieScanner: for anime_name in os.listdir(self.directory): anime_path = os.path.join(self.directory, anime_name) if os.path.isdir(anime_path): + if settings.should_ignore_folder(anime_name): + logger.debug("Skipping ignored folder: %s", anime_name) + continue mp4_files: list[str] = [] has_files = False for root, _, files in os.walk(anime_path): diff --git a/src/core/entities/SerieList.py b/src/core/entities/SerieList.py index 066a6a9..0747e7f 100644 --- a/src/core/entities/SerieList.py +++ b/src/core/entities/SerieList.py @@ -17,6 +17,7 @@ import warnings from json import JSONDecodeError from typing import Dict, Iterable, List, Optional +from src.config.settings import settings from src.core.entities.series import Serie logger = logging.getLogger(__name__) @@ -214,6 +215,9 @@ class SerieList: } for anime_folder in entries: + if settings.should_ignore_folder(anime_folder): + logger.debug("Skipping ignored folder: %s", anime_folder) + continue anime_path = os.path.join(self.directory, anime_folder, "data") if os.path.isfile(anime_path): logger.debug("Found data file for folder %s", anime_folder) diff --git a/tests/unit/test_folder_ignore_patterns.py b/tests/unit/test_folder_ignore_patterns.py new file mode 100644 index 0000000..538b90c --- /dev/null +++ b/tests/unit/test_folder_ignore_patterns.py @@ -0,0 +1,222 @@ +"""Tests for folder ignore patterns feature.""" +import os +import tempfile +import warnings +from unittest.mock import patch + +import pytest + +from src.config.settings import Settings + + +class TestShouldIgnoreFolder: + """Test should_ignore_folder method.""" + + def test_ignore_pattern_matches_exact(self): + """Test exact folder name match.""" + settings = Settings() + assert settings.should_ignore_folder("The Last of Us") is True + + def test_ignore_pattern_matches_case_insensitive(self): + """Test case-insensitive matching.""" + settings = Settings() + assert settings.should_ignore_folder("the last of us") is True + assert settings.should_ignore_folder("THE LAST OF US") is True + + def test_ignore_pattern_partial_match(self): + """Test partial folder name match.""" + settings = Settings() + assert settings.should_ignore_folder("Loki Season 2") is True + assert settings.should_ignore_folder("Chernobyl Complete") is True + + def test_non_matching_folder_returns_false(self): + """Test non-matching folder passes through.""" + settings = Settings() + assert settings.should_ignore_folder("Attack on Titan") is False + assert settings.should_ignore_folder("Naruto") is False + + def test_empty_folder_returns_false(self): + """Test empty folder name.""" + settings = Settings() + assert settings.should_ignore_folder("") is False + + def test_custom_patterns_via_env_var(self, monkeypatch): + """Test custom ignore patterns via environment variable.""" + monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "MyShow|AnotherShow") + settings = Settings() + assert settings.should_ignore_folder("MyShow") is True + assert settings.should_ignore_folder("AnotherShow") is True + assert settings.should_ignore_folder("OtherShow") is False + + def test_custom_patterns_case_insensitive_via_env_var(self, monkeypatch): + """Test custom patterns respect case-insensitivity via env var.""" + monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "myshow") + settings = Settings() + assert settings.should_ignore_folder("MyShow") is True + assert settings.should_ignore_folder("MYSHOW") is True + + +class TestFolderIgnorePatternsProperty: + """Test folder_ignore_patterns property.""" + + def test_default_patterns_parsed(self): + """Test default patterns are parsed correctly.""" + settings = Settings() + patterns = settings.folder_ignore_patterns + assert len(patterns) > 0 + assert "The Last of Us" in patterns + assert "Loki" in patterns + + def test_empty_string_via_env_var_returns_empty_list(self, monkeypatch): + """Test empty patterns string via env var.""" + monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "") + settings = Settings() + patterns = settings.folder_ignore_patterns + assert patterns == [] + + def test_single_pattern_via_env_var(self, monkeypatch): + """Test single pattern via env var.""" + monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "TestShow") + settings = Settings() + patterns = settings.folder_ignore_patterns + # Single pattern in pipe-separated string + assert "TestShow" in patterns + + def test_pipe_separated_patterns_via_env_var(self, monkeypatch): + """Test pipe-separated patterns via env var.""" + monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "Show1|Show2|Show3") + settings = Settings() + patterns = settings.folder_ignore_patterns + assert len(patterns) == 3 + assert "Show1" in patterns + assert "Show2" in patterns + assert "Show3" in patterns + + def test_pattern_with_spaces_trimmed_via_env_var(self, monkeypatch): + """Test patterns with spaces are trimmed.""" + monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "Show1 | Show2 | Show3 ") + settings = Settings() + patterns = settings.folder_ignore_patterns + # All patterns should be trimmed of whitespace + for p in patterns: + assert p == p.strip() + + +class TestSerieScannerIgnorePatterns: + """Test SerieScanner respects ignore patterns.""" + + def test_scanner_skips_ignored_folders(self, tmp_path): + """Test scanner skips folders matching ignore patterns.""" + from src.core.SerieScanner import SerieScanner + from src.core.providers.aniworld_provider import AniworldLoader + + # Create test folders + ignored_folder = tmp_path / "The Last of Us" + ignored_folder.mkdir() + (ignored_folder / "S01E01.mp4").touch() + + normal_folder = tmp_path / "Attack on Titan" + normal_folder.mkdir() + (normal_folder / "S01E01.mp4").touch() + + loader = AniworldLoader() + scanner = SerieScanner(str(tmp_path), loader) + + # Get MP4 files - should only find Attack on Titan + mp4_files = list(scanner._SerieScanner__find_mp4_files()) + folder_names = [name for name, _ in mp4_files] + + assert "Attack on Titan" in folder_names + assert "The Last of Us" not in folder_names + + def test_scanner_normal_folders_not_ignored(self, tmp_path): + """Test normal folders are not skipped.""" + from src.core.SerieScanner import SerieScanner + from src.core.providers.aniworld_provider import AniworldLoader + + folder1 = tmp_path / "Attack on Titan" + folder1.mkdir() + (folder1 / "S01E01.mp4").touch() + + folder2 = tmp_path / "Naruto" + folder2.mkdir() + (folder2 / "S01E01.mp4").touch() + + loader = AniworldLoader() + scanner = SerieScanner(str(tmp_path), loader) + + mp4_files = list(scanner._SerieScanner__find_mp4_files()) + folder_names = [name for name, _ in mp4_files] + + assert "Attack on Titan" in folder_names + assert "Naruto" in folder_names + + def test_scanner_respects_default_ignore_patterns(self, tmp_path): + """Test scanner respects default ignore patterns.""" + from src.core.SerieScanner import SerieScanner + from src.core.providers.aniworld_provider import AniworldLoader + + # Create folder matching default ignore pattern (Chernobyl) + ignored_folder = tmp_path / "Chernobyl Complete Series" + ignored_folder.mkdir() + (ignored_folder / "S01E01.mp4").touch() + + normal_folder = tmp_path / "Normal Anime" + normal_folder.mkdir() + (normal_folder / "S01E01.mp4").touch() + + loader = AniworldLoader() + scanner = SerieScanner(str(tmp_path), loader) + mp4_files = list(scanner._SerieScanner__find_mp4_files()) + folder_names = [name for name, _ in mp4_files] + + assert "Normal Anime" in folder_names + assert "Chernobyl Complete Series" not in folder_names + + +class TestSerieListIgnorePatterns: + """Test SerieList respects ignore patterns.""" + + def test_load_series_skips_ignored_folders(self, tmp_path): + """Test load_series skips folders matching ignore patterns.""" + from src.core.entities.SerieList import SerieList + from src.core.entities.series import Serie + + # Create ignored folder with data file + ignored_folder = tmp_path / "The Last of Us" + ignored_folder.mkdir() + ignored_data = ignored_folder / "data" + + ignored_serie = Serie( + key="the-last-of-us", + name="The Last of Us", + site="https://aniworld.to/anime/stream/the-last-of-us", + folder="The Last of Us", + episodeDict={1: [1, 2, 3]} + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + ignored_serie.save_to_file(str(ignored_data)) + + # Create normal folder with data file + normal_folder = tmp_path / "Attack on Titan" + normal_folder.mkdir() + normal_data = normal_folder / "data" + + normal_serie = Serie( + key="attack-on-titan", + name="Attack on Titan", + site="https://aniworld.to/anime/stream/attack-on-titan", + folder="Attack on Titan", + episodeDict={1: [1, 2]} + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + normal_serie.save_to_file(str(normal_data)) + + # Load series + serie_list = SerieList(str(tmp_path)) + + # Verify ignored folder was skipped + assert serie_list.contains("attack-on-titan") is True + assert serie_list.contains("the-last-of-us") is False \ No newline at end of file