diff --git a/src/program/db/db_functions.py b/src/program/db/db_functions.py index f879a8d83..8b34bc45a 100644 --- a/src/program/db/db_functions.py +++ b/src/program/db/db_functions.py @@ -352,6 +352,13 @@ def run_thread_with_db_item( item = runner_result.media_items[0] run_at = runner_result.run_at + if item.is_excluded: + logger.trace( + f"Item {item.log_string} is excluded, deleting from DB." + ) + + session.delete(item) + if not cancellation_event.is_set(): # Update parent item based on type if isinstance(input_item, Episode): diff --git a/src/program/managers/event_manager.py b/src/program/managers/event_manager.py index 85fca746a..8b6da8e01 100644 --- a/src/program/managers/event_manager.py +++ b/src/program/managers/event_manager.py @@ -294,6 +294,13 @@ def submit_job( item (Event, optional): The event item to process. Defaults to None. """ + if event and event.content_item and event.content_item.is_excluded: + logger.debug( + f"Event {event.log_message if event else 'N/A'} is excluded from {service.__class__.__name__}, skipping submission." + ) + + return + log_message = f"Submitting service {service.__class__.__name__} to be executed" # Content services dont provide an event. diff --git a/src/program/media/item.py b/src/program/media/item.py index 6e80922d2..e1e47ab40 100644 --- a/src/program/media/item.py +++ b/src/program/media/item.py @@ -1,6 +1,7 @@ """MediaItem class""" from datetime import datetime +from functools import cached_property from typing import Any, Literal, TYPE_CHECKING, Self, TypeVar from kink import di @@ -363,6 +364,23 @@ def schedule( logger.error(f"Failed to schedule task for {self.log_string}: {e}") return False + return False + + @cached_property + def is_excluded(self) -> bool: + """Check if the item is excluded based on its IDs.""" + + from program.utils.exclusions import exclusions + + is_excluded = exclusions.is_excluded(self) + + if is_excluded: + logger.trace( + f"Item {self.log_string} is being excluded as the ID was found in exclusions." + ) + + return is_excluded + @property def is_released(self) -> bool: """Check if an item has been released.""" @@ -763,7 +781,7 @@ def __init__(self, item: dict[str, Any] | None = None): super().__init__(item) def __repr__(self): - return f"Movie:{self.log_string}:{self.state.name}" + return f"Movie [tmdb: {self.tmdb_id} | imdb: {self.imdb_id}]: {self.log_string} - {self.state.name}" def __hash__(self): return super().__hash__() @@ -892,7 +910,7 @@ def store_state( return super().store_state(given_state) def __repr__(self): - return f"Show:{self.log_string}:{self.state.name}" + return f"Show [tvdb: {self.tvdb_id}]: #{self.log_string} - {self.state.name}" def __hash__(self): return super().__hash__() @@ -1079,7 +1097,7 @@ def __getattribute__(self, name: str): return value def __repr__(self): - return f"Season:{self.number}:{self.state.name}" + return f"Season [tvdb: {self.tvdb_id}]: {self.log_string} #{self.number} - {self.state.name}" def __hash__(self): return super().__hash__() @@ -1178,7 +1196,7 @@ def __init__(self, item: dict[str, Any] | None = None): super().__init__(item) def __repr__(self): - return f"Episode:{self.number}:{self.state.name}" + return f"Episode [tvdb: {self.tvdb_id}]: {self.log_string} #{self.number} - {self.state.name}" def __hash__(self): return super().__hash__() diff --git a/src/program/services/filesystem/filesystem_service.py b/src/program/services/filesystem/filesystem_service.py index ae78619da..14de04b71 100644 --- a/src/program/services/filesystem/filesystem_service.py +++ b/src/program/services/filesystem/filesystem_service.py @@ -36,6 +36,7 @@ def get_key(cls) -> str: def _initialize_rivenvfs(self, downloader: Downloader): """Initialize or synchronize RivenVFS""" + try: from .vfs import RivenVFS @@ -74,6 +75,12 @@ def run(self, item: "MediaItem") -> MediaItemGenerator: # Process each episode/movie for episode_or_movie in items_to_process: + if item.is_excluded: + logger.debug( + f"Item {episode_or_movie.log_string} is excluded from filesystem processing, skipping." + ) + continue # Item is excluded, skip processing + success = self.riven_vfs.add(episode_or_movie) if not success: diff --git a/src/program/services/filesystem/vfs/rivenvfs.py b/src/program/services/filesystem/vfs/rivenvfs.py index 82b99eee1..790f4e150 100644 --- a/src/program/services/filesystem/vfs/rivenvfs.py +++ b/src/program/services/filesystem/vfs/rivenvfs.py @@ -531,6 +531,20 @@ def add(self, item: MediaItem) -> bool: True if successfully added, False otherwise """ + if item.is_excluded: + logger.info( + f"Excluding {item.log_string} from VFS add based on exclusion rules" + ) + + return False + + from program.media.media_entry import MediaEntry + + # Only process if this item has a filesystem entry + if not item.filesystem_entry: + logger.debug(f"Item {item.id} has no filesystem_entry, skipping VFS add") + return False + # Only process if this item has a media entry if not (entry := item.media_entry): logger.debug(f"Item {item.id} has no media entry, skipping VFS add") @@ -749,6 +763,7 @@ def _sync_full(self) -> None: # Step 1: Re-match all entries against current library profiles and collect item IDs item_ids = list[int]() rematched_count = 0 + excluded_item_ids = set() with db_session() as session: entries = ( @@ -765,6 +780,10 @@ def _sync_full(self) -> None: ) continue + if item.is_excluded: + excluded_item_ids.add(item.id) + continue + # Re-match library profiles based on current settings new_profiles = matcher.get_matching_profiles(item) old_profiles = entry.library_profiles or [] @@ -780,7 +799,9 @@ def _sync_full(self) -> None: session.commit() - logger.debug(f"Re-matched {rematched_count} entries with updated profiles") + logger.debug( + f"Re-matched {rematched_count} entries with updated profiles; excluded {len(excluded_item_ids)} items due to excluded_items settings." + ) # Step 2: Clear VFS tree and rebuild from scratch logger.debug("Clearing VFS tree for rebuild") @@ -868,6 +889,11 @@ def _sync_individual(self, item: "MediaItem") -> None: item: MediaItem to re-sync """ + if item.is_excluded: + logger.debug(f"Item {item.id} is excluded, skipping individual sync") + + return + from sqlalchemy.orm import object_session from program.db.db import db_session diff --git a/src/program/services/indexers/__init__.py b/src/program/services/indexers/__init__.py index e78516d51..20fa300d0 100644 --- a/src/program/services/indexers/__init__.py +++ b/src/program/services/indexers/__init__.py @@ -32,6 +32,9 @@ def run( ) -> MediaItemGenerator: """Run the appropriate indexer based on item type.""" + if item.is_excluded: + return + if isinstance(item, Movie) or (item.tmdb_id and not item.tvdb_id): yield from self.tmdb_indexer.run( item=item, diff --git a/src/program/services/scrapers/shared.py b/src/program/services/scrapers/shared.py index 04a415832..81e295a3a 100644 --- a/src/program/services/scrapers/shared.py +++ b/src/program/services/scrapers/shared.py @@ -45,6 +45,13 @@ def parse_results( if infohash in processed_infohashes: continue + if infohash in settings_manager.settings.filesystem.excluded_items.infohashes: + logger.trace( + f"Skipping torrent for {item.log_string} due to excluded infohash: {infohash}" + ) + + continue + try: torrent = rtn.rank( raw_title=raw_title, diff --git a/src/program/settings/models.py b/src/program/settings/models.py index d3f381e7e..6aae2e484 100644 --- a/src/program/settings/models.py +++ b/src/program/settings/models.py @@ -227,12 +227,18 @@ def validate_library_path(cls, v: str): return v +class ExcludedItems(BaseModel): + shows: set[str] = Field(default_factory=set) + movies: set[str] = Field(default_factory=set) + infohashes: set[str] = Field(default_factory=set) + + class FilesystemModel(Observable): mount_path: Path = Field( default=Path("/path/to/riven/mount"), description="Path where Riven will mount the virtual filesystem", ) - + excluded_items: ExcludedItems = Field(default_factory=ExcludedItems) library_profiles: dict[str, LibraryProfile] = Field( default_factory=lambda: { "anime": LibraryProfile( diff --git a/src/program/utils/exclusions.py b/src/program/utils/exclusions.py new file mode 100644 index 000000000..0b7a006c3 --- /dev/null +++ b/src/program/utils/exclusions.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from program.media.item import Movie, Show +from program.settings.manager import settings_manager + +if TYPE_CHECKING: + from program.media.item import MediaItem + + +class Exclusions: + excluded_shows: set[str] + excluded_movies: set[str] + + def __init__(self): + excluded_items = settings_manager.settings.filesystem.excluded_items + + self.excluded_movies = excluded_items.movies + self.excluded_shows = excluded_items.shows + + def is_excluded(self, item: "MediaItem") -> bool: + is_excluded_movie = self._is_excluded_movie(item) + is_excluded_show = self._is_excluded_show(item._get_top_parent()) + + return is_excluded_movie or is_excluded_show + + def _is_excluded_show(self, item: Show) -> bool: + if item.tvdb_id is None: + return False + + return str(item.tvdb_id) in self.excluded_shows + + def _is_excluded_movie(self, item: Movie) -> bool: + if item.tmdb_id is None and item.imdb_id is None: + return False + + return ( + str(item.tmdb_id) in self.excluded_movies + or str(item.imdb_id) in self.excluded_movies + ) + + +exclusions = Exclusions()