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
27 changes: 21 additions & 6 deletions app/deleterr.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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.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 +728,22 @@ 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 means exclusions could not be verified, so the library is
skipped entirely (fail safe). ConfigurationError keeps its own message.
"""
if isinstance(error, TraktError):
logger.error(
f"Skipping library '{library_name}': {error}. "
"Trakt 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 +804,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) 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 +873,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) 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
41 changes: 26 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 @@ -1779,6 +1779,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 +1794,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
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
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
43 changes: 38 additions & 5 deletions tests/modules/test_justwatch.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from unittest.mock import MagicMock, patch

import httpx
import pytest

from app.modules.justwatch import JustWatch
from app.modules.justwatch import JustWatch, JustWatchError


@pytest.mark.unit
Expand Down Expand Up @@ -40,11 +41,43 @@ def test_search_error_handling(self, mock_search):
mock_search.side_effect = Exception("API Error")
justwatch_instance = JustWatch("US", "en")

# Act
result = justwatch_instance._search("test_title")
# Act / Assert - errors must not look like "title not found"
with pytest.raises(JustWatchError):
justwatch_instance._search("test_title")

@patch("app.modules.justwatch._search_justwatch")
def test_search_timeout_raises(self, mock_search):
# Arrange
mock_search.side_effect = httpx.TimeoutException("timed out")
justwatch_instance = JustWatch("US", "en")

# Act / Assert
with pytest.raises(JustWatchError):
justwatch_instance._search("test_title")

@patch("app.modules.justwatch._search_justwatch")
def test_search_rate_limit_raises(self, mock_search):
# Arrange
request = httpx.Request("POST", "https://apis.justwatch.com/graphql")
response = httpx.Response(429, request=request)
mock_search.side_effect = httpx.HTTPStatusError(
"429", request=request, response=response
)
justwatch_instance = JustWatch("US", "en")

# Act / Assert
with pytest.raises(JustWatchError):
justwatch_instance._search("test_title")

@patch("app.modules.justwatch._search_justwatch")
def test_search_by_title_and_year_propagates_errors(self, mock_search):
# Arrange
mock_search.side_effect = httpx.ConnectError("no network")
justwatch_instance = JustWatch("US", "en")

# Assert - should return empty list on error
assert result == []
# Act / Assert - callers must be able to tell error from not-found
with pytest.raises(JustWatchError):
justwatch_instance.search_by_title_and_year("title1", 2001, "movie")

@patch("app.modules.justwatch._search_justwatch")
def test_clear_cache(self, mock_search):
Expand Down
37 changes: 36 additions & 1 deletion tests/modules/test_trakt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import pytest
import trakt

from app.modules.trakt import Trakt, _process_trakt_item_list, extract_info_from_url
from app.modules.trakt import (
Trakt,
TraktError,
_process_trakt_item_list,
extract_info_from_url,
)


@pytest.fixture
Expand Down Expand Up @@ -133,6 +138,36 @@ def test_fetch_list_items(
assert result == []


@patch.object(
Trakt, "_fetch_user_list_items", side_effect=Exception("Trakt API unavailable")
)
def test_fetch_list_items_failure_raises_trakt_error(
mock_user, trakt_instance_and_mock
):
"""A fetch failure must not be silently treated as an empty exclusion list."""
trakt_instance, _ = trakt_instance_and_mock

with pytest.raises(TraktError):
trakt_instance._fetch_list_items("movie", "username", "listname", None, 100)


@patch.object(
Trakt, "_fetch_general_list_items", side_effect=Exception("Trakt API unavailable")
)
def test_get_all_items_for_url_propagates_fetch_failure(
mock_general, trakt_instance_and_mock
):
"""Errors fetching a configured list must propagate to the caller."""
trakt_instance, _ = trakt_instance_and_mock
trakt_config = {
"max_items_per_list": 100,
"lists": ["https://trakt.tv/movies/trending"],
}

with pytest.raises(TraktError):
trakt_instance.get_all_items_for_url("movie", trakt_config)


def test_process_trakt_item_list():
# Arrange
items = {}
Expand Down
27 changes: 27 additions & 0 deletions tests/test_deleterr.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,33 @@ def test_process_radarr(radarr_mock, sonarr_mock, deleterr):
)


@patch("app.deleterr.DSonarr")
@patch("app.modules.radarr.DRadarr")
def test_process_radarr_trakt_failure_skips_library(radarr_mock, sonarr_mock, deleterr):
"""A Trakt outage must skip the library (no deletions) instead of crashing."""
from app.modules.trakt import TraktError

# Arrange
deleterr.radarr = {"Radarr1": MagicMock()}
deleterr.config.settings = {
"libraries": [{"radarr": "Radarr1"}],
}
deleterr.media_cleaner.process_library_movies = MagicMock(
side_effect=TraktError("Failed to fetch Trakt list 'watchlist' for movies")
)
deleterr.run_result = MagicMock()
failed_before = deleterr.libraries_failed
processed_before = deleterr.libraries_processed

# Act - must not raise
deleterr.process_radarr()

# Assert - library counted as failed, nothing deleted
assert deleterr.libraries_failed == failed_before + 1
assert deleterr.libraries_processed == processed_before
deleterr.run_result.add_deleted.assert_not_called()


@patch("app.deleterr.DSonarr")
@patch("app.modules.radarr.DRadarr")
def test_process_sonarr(radarr_mock, sonarr_mock, deleterr):
Expand Down
Loading