Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions app/deleterr.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

from app import logger
from app.config import hang_on_error, load_config
from app.media_cleaner import ConfigurationError, MediaCleaner, parse_leaving_soon_duration
from app.media_cleaner import ConfigurationError, MediaCleaner, WatchDataError, parse_leaving_soon_duration
from app.modules.mdblist import MdblistError
from app.modules.trakt import TraktError
from app.modules.notifications import NotificationManager, RunResult, DeletedItem, LibraryStats
from app.state import StateManager
from app.utils import print_readable_freed_space
Expand Down Expand Up @@ -727,6 +729,25 @@ def _is_universe_member(self, plex_item, universe_ids):
or (guids.get("imdb_id") and guids["imdb_id"] in universe_ids["imdb"])
)

def _handle_library_failure(self, library_name, error):
"""Log a per-library failure and count it, without aborting the run.

TraktError/MdblistError mean exclusions could not be verified, so the
library is skipped entirely (fail safe). ConfigurationError keeps its
own message.
"""
if isinstance(error, WatchDataError):
logger.error(f"Skipping library '{library_name}': {error}")
elif isinstance(error, (TraktError, MdblistError)):
logger.error(
f"Skipping library '{library_name}': {error}. "
"Exclusions could not be verified, so no items were "
"deleted from this library this run."
)
else:
logger.error(str(error))
self.libraries_failed += 1

def process_radarr(self):
for name, radarr in self.radarr.items():
logger.info(f"Processing radarr instance: '{name}'")
Expand Down Expand Up @@ -787,9 +808,8 @@ def process_radarr(self):
# Log library completion time
library_duration = time.time() - library_start
logger.info(f"Library '{library_name}' completed in {logger.format_duration(library_duration)}")
except ConfigurationError as e:
logger.error(str(e))
self.libraries_failed += 1
except (ConfigurationError, TraktError, MdblistError, WatchDataError) as e:
self._handle_library_failure(library_name, e)

logger.log_freed_space(saved_space, "movie", self.config.settings.get("dry_run", True))

Expand Down Expand Up @@ -857,9 +877,8 @@ def process_sonarr(self):
# Log library completion time
library_duration = time.time() - library_start
logger.info(f"Library '{library_name}' completed in {logger.format_duration(library_duration)}")
except ConfigurationError as e:
logger.error(str(e))
self.libraries_failed += 1
except (ConfigurationError, TraktError, MdblistError, WatchDataError) as e:
self._handle_library_failure(library_name, e)

logger.log_freed_space(saved_space, "show", self.config.settings.get("dry_run", True))

Expand Down
97 changes: 82 additions & 15 deletions app/media_cleaner.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pyarr.exceptions import PyarrResourceNotFound, PyarrServerError

from app import logger
from app.modules.justwatch import JustWatch
from app.modules.justwatch import JustWatch, JustWatchError
from app.modules.seerr import Seerr
from app.modules.watch_provider import create_watch_provider
from app.modules.mdblist import Mdblist
Expand Down Expand Up @@ -476,6 +476,50 @@ def get_show_activity(self, library, plex_library):
def get_movie_activity(self, library, movies_library):
return self.watch_provider.get_activity(movies_library.key)

@staticmethod
def _library_uses_watch_rules(library):
"""True when deletion decisions for this library depend on watch data
in the dangerous direction (empty data => everything actionable).

watch_status: watched already fails safe on empty data (nothing
matches), so it does not need the guard.
"""
return (
library.get("watch_status") == "unwatched"
or library.get("last_watched_threshold") is not None
or bool(library.get("apply_last_watch_threshold_to_collections"))
)

def guard_empty_watch_activity(self, library, activity, library_size):
"""Refuse to act on a non-empty library with zero watch activity.

An empty activity set makes find_watched_data return None for every
item, bypassing last_watched_threshold and the unwatched check - one
degraded Tautulli response (DB rebuild, section mismatch, empty auth
result) would mass-delete recently watched content. Fail safe instead.

Genuinely never-watched libraries can opt out with
allow_empty_watch_history: true.
"""
if activity or not library_size:
return
if not self._library_uses_watch_rules(library):
return
if library.get("allow_empty_watch_history", False):
logger.warning(
f"Library '{library.get('name')}' has no watch history but "
f"allow_empty_watch_history is set - treating all "
f"{library_size} items as unwatched."
)
return
raise WatchDataError(
f"No watch history returned for library '{library.get('name')}' "
f"({library_size} items). Treating every item as unwatched could "
f"mass-delete watched content, so this library is skipped. If the "
f"library really has never been watched, set "
f"allow_empty_watch_history: true for it."
)

def filter_shows(self, library, unfiltered_all_show_data):
return [
show
Expand Down Expand Up @@ -529,6 +573,7 @@ def process_library(self, library, sonarr_instance, unfiltered_all_show_data):

show_activity = self.get_show_activity(library, plex_library)
logger.info(f"Got {len(show_activity)} items in tautulli activity")
self.guard_empty_watch_activity(library, show_activity, plex_library.totalSize)

return self.process_shows(
library,
Expand Down Expand Up @@ -677,6 +722,7 @@ def process_library_movies(self, library, radarr_instance):
logger.info(f"Got {len(mdblist_movies)} mdblist items to exclude")

movie_activity = self.get_movie_activity(library, movies_library)
self.guard_empty_watch_activity(library, movie_activity, movies_library.totalSize)

return self.process_movies(
library,
Expand Down Expand Up @@ -1779,6 +1825,10 @@ def check_excluded_justwatch(media_data, plex_media_item, exclude, justwatch_ins
Returns:
True if media should NOT be excluded (i.e., is actionable)
False if media should be excluded (i.e., skip this media)

Fails safe: if the JustWatch API cannot be queried (timeout, rate limit,
network error), the item is excluded from deletion this run rather than
treating the outage as "not available on streaming".
"""
jw_config = exclude.get("justwatch", {})

Expand All @@ -1790,21 +1840,28 @@ def check_excluded_justwatch(media_data, plex_media_item, exclude, justwatch_ins
# Determine media type based on data structure
media_type = "movie" if "tmdbId" in media_data else "show"

# Check available_on mode (exclude if available on specified providers)
if providers := jw_config.get("available_on"):
if justwatch_instance.available_on(title, year, media_type, providers):
logger.debug(
f"{title} is available on streaming service(s) {providers}, skipping"
)
return False
try:
# Check available_on mode (exclude if available on specified providers)
if providers := jw_config.get("available_on"):
if justwatch_instance.available_on(title, year, media_type, providers):
logger.debug(
f"{title} is available on streaming service(s) {providers}, skipping"
)
return False

# Check not_available_on mode (exclude if NOT available on specified providers)
if providers := jw_config.get("not_available_on"):
if justwatch_instance.is_not_available_on(title, year, media_type, providers):
logger.debug(
f"{title} is not available on streaming service(s) {providers}, skipping"
)
return False
# Check not_available_on mode (exclude if NOT available on specified providers)
if providers := jw_config.get("not_available_on"):
if justwatch_instance.is_not_available_on(title, year, media_type, providers):
logger.debug(
f"{title} is not available on streaming service(s) {providers}, skipping"
)
return False
except JustWatchError as e:
logger.warning(
f"Could not check JustWatch availability for '{title}' ({year}): {e}. "
"Failing safe and skipping this item for this run."
)
return False

return True

Expand Down Expand Up @@ -2233,6 +2290,16 @@ class ConfigurationError(Exception):
pass


class WatchDataError(Exception):
"""Raised when watch activity data looks unusable for deletion decisions.

An empty activity set makes every item look never-watched, which with
unwatched/threshold rules turns the entire library into deletion
candidates. Callers must handle this error and skip the library instead.
"""
pass


def library_meets_disk_space_threshold(library, dpyarr_instance):
for item in library.get("disk_size_threshold", []):
path = item.get("path")
Expand Down
32 changes: 23 additions & 9 deletions app/modules/justwatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"


class JustWatchError(Exception):
"""Raised when the JustWatch API cannot be reached or returns an error.

Distinguishes a service failure (timeout, rate limit, network error) from a
genuine "title not found" result, so callers can fail safe instead of
treating an outage as "not available on streaming".
"""


def _get_graphql_url() -> str:
"""Get the JustWatch API URL, reading from environment at call time.

Expand Down Expand Up @@ -55,10 +64,15 @@ def __init__(self, country, language):

def _search(self, title, max_results=5, detailed=False):
"""
Search for a title on JustWatch API with caching and error handling.
Search for a title on JustWatch API with caching.

Returns:
List of MediaEntry objects, or empty list on error
List of MediaEntry objects (empty when the title is not found)

Raises:
JustWatchError: when the JustWatch API cannot be reached or
returns an error, so callers can tell a service failure
apart from a "title not found" result.
"""
global _rate_limit_warned
cache_key = f"{title}:{max_results}:{detailed}"
Expand All @@ -72,15 +86,15 @@ def _search(self, title, max_results=5, detailed=False):
self._search_cache[cache_key] = results
_rate_limit_warned = False # Reset on successful request
return results
except httpx.TimeoutException:
except httpx.TimeoutException as e:
logger.warning(f"JustWatch API timeout while searching for '{title}' - the service may be slow")
return []
raise JustWatchError(f"JustWatch API timeout for '{title}'") from e
except httpx.HTTPStatusError as e:
status_code = e.response.status_code
if status_code == 429:
if not _rate_limit_warned:
logger.warning(
"JustWatch API rate limit hit. Streaming availability checks will be skipped. "
"JustWatch API rate limit hit. Streaming availability checks cannot be performed. "
"Consider reducing the number of items processed per run."
)
_rate_limit_warned = True
Expand All @@ -90,13 +104,13 @@ def _search(self, title, max_results=5, detailed=False):
logger.warning(f"JustWatch API server error (HTTP {status_code}) - service may be unavailable")
else:
logger.warning(f"JustWatch API error (HTTP {status_code}) for '{title}'")
return []
except httpx.ConnectError:
raise JustWatchError(f"JustWatch API error (HTTP {status_code}) for '{title}'") from e
except httpx.ConnectError as e:
logger.warning("Cannot reach JustWatch API - check internet connection")
return []
raise JustWatchError(f"Cannot reach JustWatch API while searching for '{title}'") from e
except Exception as e:
logger.warning(f"JustWatch search failed for '{title}': {type(e).__name__}: {e}")
return []
raise JustWatchError(f"JustWatch search failed for '{title}': {type(e).__name__}: {e}") from e

def search_by_title_and_year(self, title, year, media_type):
"""
Expand Down
23 changes: 21 additions & 2 deletions app/modules/mdblist.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
from app import logger


class MdblistError(Exception):
"""Raised when a configured Mdblist list cannot be fetched.

Treating a fetch failure as an empty list would silently disable the
exclusion and allow deletion of items the user wanted protected, so
callers must handle this error and fail safe instead.
"""


class Mdblist:
def __init__(self, api_key, ssl_verify=True):
self.api_key = api_key
Expand All @@ -26,8 +35,10 @@ def get_all_items_for_url(self, media_type, mdblist_config):
def _fetch_list_items(self, list_url, media_type, max_items_per_list):
list_path = extract_list_path(list_url)
if not list_path:
logger.error(f"Could not extract list path from URL: {list_url}")
return []
# A misparsed URL must not silently disable the exclusion
raise MdblistError(
f"Could not extract list path from Mdblist URL: {list_url}"
)

# API returns {"movies": [...], "shows": [...]}
response_key = "movies" if media_type == "movie" else "shows"
Expand Down Expand Up @@ -67,9 +78,17 @@ def _fetch_list_items(self, list_url, media_type, max_items_per_list):
if not has_more:
break

except MdblistError:
raise
except Exception as e:
logger.error(f"Failed to fetch Mdblist items from {list_url}")
logger.debug(f"Error: {e}")
# Never return a partial page set as if it were the full list -
# the caller would treat it as complete and delete unprotected items
raise MdblistError(
f"Failed to fetch Mdblist list '{list_url}': "
f"{type(e).__name__}: {e}"
) from e

return all_items[:max_items_per_list]

Expand Down
13 changes: 13 additions & 0 deletions app/modules/trakt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
from app import logger


class TraktError(Exception):
"""Raised when a configured Trakt list cannot be fetched.

Treating a fetch failure as an empty list would silently disable the
exclusion and allow deletion of items the user wanted protected, so
callers must handle this error and fail safe instead.
"""


class Trakt:
def __init__(self, trakt_id, trakt_secret):
self._configure_trakt(trakt_id, trakt_secret)
Expand Down Expand Up @@ -53,6 +62,10 @@ def _fetch_list_items(
except Exception as e:
logger.error(f"Failed to fetch list items for {media_type} {listname}")
logger.debug(f"Error: {e}")
raise TraktError(
f"Failed to fetch Trakt list '{listname}' for {media_type}s: "
f"{type(e).__name__}: {e}"
) from e
return []

def _fetch_user_list_items(
Expand Down
4 changes: 4 additions & 0 deletions app/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,10 @@ class LibraryConfig(BaseModel):
description="Days since added to Plex. Media added within this period is protected",
json_schema_extra={"example": 180},
)
allow_empty_watch_history: bool = Field(
default=False,
description="Allow watch-based rules to run when the library has zero watch history. Off by default: empty history makes everything look unwatched, so the library is skipped to avoid mass deletion",
)
apply_last_watch_threshold_to_collections: bool = Field(
default=False,
description="Apply last watched threshold to all items in the same collection",
Expand Down
1 change: 1 addition & 0 deletions scripts/generate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,7 @@ def main():
# Generate library table without nested objects
library_fields = ["name", "radarr", "sonarr", "series_type", "action_mode", "watch_status",
"last_watched_threshold", "added_at_threshold", "apply_last_watch_threshold_to_collections",
"allow_empty_watch_history",
"add_list_exclusion_on_delete", "max_actions_per_run", "preview_next", "disk_size_threshold", "sort",
"leaving_soon"]
library_lines = ["| Property | Type | Required | Default | Description |"]
Expand Down
Loading