diff --git a/src/program/db/db_functions.py b/src/program/db/db_functions.py index 99171c663..459710487 100644 --- a/src/program/db/db_functions.py +++ b/src/program/db/db_functions.py @@ -360,6 +360,13 @@ def run_thread_with_db_item( ) program.em.remove_id_from_queues(input_item.id) + 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 input_item.type == "episode": diff --git a/src/program/managers/event_manager.py b/src/program/managers/event_manager.py index 4e5b6cb79..8ff8729c1 100644 --- a/src/program/managers/event_manager.py +++ b/src/program/managers/event_manager.py @@ -223,6 +223,14 @@ def submit_job(self, service, program, event=None): program (Program): The program containing the service. 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.__name__}, skipping submission." + ) + + return + log_message = f"Submitting service {service.__name__} to be executed" # Content services dont provide an event. if event: diff --git a/src/program/media/item.py b/src/program/media/item.py index 58e55cf4c..469dde59e 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, List, Optional, TYPE_CHECKING import sqlalchemy @@ -321,6 +322,21 @@ def schedule( 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.""" @@ -743,7 +759,7 @@ def __init__(self, item): 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__() @@ -823,7 +839,7 @@ def store_state(self, given_state: States = None) -> tuple[States, States]: 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__() @@ -970,7 +986,7 @@ def __getattribute__(self, name): 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__() @@ -1037,7 +1053,7 @@ def __init__(self, item): 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/program.py b/src/program/program.py index 297f96779..885e4cff9 100644 --- a/src/program/program.py +++ b/src/program/program.py @@ -74,6 +74,7 @@ def initialize_services(self): # Instantiate services fresh on each settings change; settings_manager observers handle reinit _downloader = Downloader() + self.services = { IndexerService: IndexerService(), Scraping: Scraping(), diff --git a/src/program/services/filesystem/common_utils.py b/src/program/services/filesystem/common_utils.py index 93f9e589b..50e89ecbe 100644 --- a/src/program/services/filesystem/common_utils.py +++ b/src/program/services/filesystem/common_utils.py @@ -2,13 +2,11 @@ Common utilities used by FilesystemService (VFS-only). """ -from typing import List - from program.media.item import Episode, MediaItem, Movie, Season, Show from program.media.state import States -def get_items_to_update(item: MediaItem) -> List[MediaItem]: +def get_items_to_update(item: MediaItem) -> list[MediaItem]: """Return leaf items to process (movies/episodes), expanding shows/seasons. Only include episodes that have reached Downloaded state for parent inputs. """ diff --git a/src/program/services/filesystem/filesystem_service.py b/src/program/services/filesystem/filesystem_service.py index 5c51b3e8a..6aa8314d7 100644 --- a/src/program/services/filesystem/filesystem_service.py +++ b/src/program/services/filesystem/filesystem_service.py @@ -27,6 +27,7 @@ def __init__(self, downloader: Downloader): def _initialize_rivenvfs(self, downloader: Downloader): """Initialize or synchronize RivenVFS""" + try: from .vfs import RivenVFS @@ -76,6 +77,12 @@ def run(self, item: MediaItem) -> Generator[MediaItem, None, None]: # 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 791723e38..656e810fe 100644 --- a/src/program/services/filesystem/vfs/rivenvfs.py +++ b/src/program/services/filesystem/vfs/rivenvfs.py @@ -520,6 +520,14 @@ def add(self, item: "MediaItem") -> bool: Returns: 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 @@ -744,6 +752,7 @@ def _sync_full(self) -> None: item_ids = [] rematched_count = 0 + excluded_item_ids = set() with db_module.Session() as session: entries = ( @@ -759,6 +768,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 [] @@ -773,10 +786,14 @@ def _sync_full(self) -> None: item_ids.append(item.id) 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") + with self._tree_lock: # Create new root node self._root = VFSRoot() @@ -857,6 +874,12 @@ def _sync_individual(self, item: "MediaItem") -> None: Args: 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 as db_module diff --git a/src/program/services/indexers/__init__.py b/src/program/services/indexers/__init__.py index f2d5daa05..973592b97 100644 --- a/src/program/services/indexers/__init__.py +++ b/src/program/services/indexers/__init__.py @@ -30,6 +30,9 @@ def run( logger.error("Item is None") return + if in_item.is_excluded: + return + item_type = in_item.type or "mediaitem" if item_type == "movie" or (in_item.tmdb_id and not in_item.tvdb_id): diff --git a/src/program/services/indexers/tvdb_indexer.py b/src/program/services/indexers/tvdb_indexer.py index 76a17fa8e..cc8451296 100644 --- a/src/program/services/indexers/tvdb_indexer.py +++ b/src/program/services/indexers/tvdb_indexer.py @@ -11,7 +11,6 @@ from program.apis.trakt_api import TraktAPI from program.media.item import Episode, MediaItem, Season, Show from program.services.indexers.base import BaseIndexer -from program.settings.manager import settings_manager class TVDBIndexer(BaseIndexer): @@ -21,6 +20,7 @@ class TVDBIndexer(BaseIndexer): def __init__(self): super().__init__() + self.key = "tvdbindexer" self.api = di[TVDBApi] self.trakt_api = di[TraktAPI] diff --git a/src/program/services/scrapers/__init__.py b/src/program/services/scrapers/__init__.py index e788ac802..caa9d980f 100644 --- a/src/program/services/scrapers/__init__.py +++ b/src/program/services/scrapers/__init__.py @@ -43,6 +43,7 @@ def __init__(self): service for service in self.services if service.initialized ] self.initialized = self.validate() + if not self.initialized: return diff --git a/src/program/services/scrapers/jackett.py b/src/program/services/scrapers/jackett.py index 39ee7ba30..d3a75609a 100644 --- a/src/program/services/scrapers/jackett.py +++ b/src/program/services/scrapers/jackett.py @@ -1,7 +1,6 @@ """Jackett scraper module""" import concurrent.futures -from types import SimpleNamespace from typing import Dict, List, Optional from loguru import logger diff --git a/src/program/services/scrapers/shared.py b/src/program/services/scrapers/shared.py index 3de815902..f83a2893e 100644 --- a/src/program/services/scrapers/shared.py +++ b/src/program/services/scrapers/shared.py @@ -24,9 +24,12 @@ def _parse_results( - item: MediaItem, results: Dict[str, str], log_msg: bool = True + item: MediaItem, + results: Dict[str, str], + log_msg: bool = True, ) -> Dict[str, Stream]: """Parse the results from the scrapers into Torrent objects.""" + torrents: Set[Torrent] = set() processed_infohashes: Set[str] = set() correct_title: str = item.get_top_title() @@ -34,6 +37,7 @@ def _parse_results( aliases: Dict[str, list[str]] = ( item.get_aliases() if scraping_settings.enable_aliases else {} ) + # we should remove keys from aliases if we are excluding the language aliases = { k: v for k, v in aliases.items() if k not in ranking_settings.languages.exclude @@ -45,6 +49,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: Torrent = rtn.rank( raw_title=raw_title, diff --git a/src/program/settings/models.py b/src/program/settings/models.py index dd9c22490..a3a9a8d20 100644 --- a/src/program/settings/models.py +++ b/src/program/settings/models.py @@ -238,12 +238,18 @@ def validate_library_path(cls, v): 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/types.py b/src/program/types.py index adf3fb928..0f217decd 100644 --- a/src/program/types.py +++ b/src/program/types.py @@ -35,7 +35,7 @@ class ProcessedEvent: class Event: emitted_by: Service item_id: Optional[str] = None - content_item: Optional[MediaItem] = None + content_item: "MediaItem | None" = None run_at: datetime = datetime.now() item_state: Optional[str] = None # Cached state for priority sorting 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()