"""#199 part 2 + #197 — give exhausted items a bounded second chance. Attempt exhaustion is permanent and provider-blind. An item that burned its attempts while a provider was broken stays parked forever, and fixing that provider afterwards does nothing for it. Two triggers — elapsed time, and a provider becoming usable again — share one mechanism, because both mean the same thing: the earlier verdict is stale. """ from datetime import UTC, datetime, timedelta from db.models.core import WantedItem from db.repositories.wanted import WantedRepository from extensions import db def _seed(**kw): now = datetime.now(UTC) base = { "item_type": "episode", "title": "t", "file_path": "/x.mkv", "status": "wanted", "search_count": 0, "added_at": now, "updated_at": now, } base.update(kw) row = WantedItem(**base) db.session.add(row) db.session.commit() return row class TestFindExhaustedIds: def test_finds_only_the_ones_that_gave_up(self, app_ctx): repo = WantedRepository() now = datetime.now(UTC) trying = _seed(file_path="/trying.mkv", search_count=1) gave_up = _seed(file_path="/gaveup.mkv", search_count=3) slow = _seed( file_path="/slow.mkv", search_count=3, failure_kind="no_result_slow", retry_after=now + timedelta(days=10), ) try: found = set(repo.find_exhausted_ids(max_attempts=3)) assert gave_up.id in found assert trying.id not in found assert slow.id not in found, "slow-mode items have not given up" finally: for r in (trying, gave_up, slow): db.session.delete(r) db.session.commit() def test_idle_since_excludes_recently_searched(self, app_ctx): repo = WantedRepository() now = datetime.now(UTC) fresh = _seed(file_path="/fresh.mkv", search_count=3, last_search_at=now) stale = _seed( file_path="/stale.mkv", search_count=3, last_search_at=now - timedelta(days=40) ) try: found = set( repo.find_exhausted_ids(max_attempts=3, idle_since=now - timedelta(days=30)) ) assert stale.id in found assert fresh.id not in found finally: for r in (fresh, stale): db.session.delete(r) db.session.commit() def test_limit_caps_one_run(self, app_ctx): """A large parked backlog has to come back in slices — the reporting install had 869 exhausted items at once.""" repo = WantedRepository() rows = [_seed(file_path=f"/cap{i}.mkv", search_count=3) for i in range(5)] try: assert len(repo.find_exhausted_ids(max_attempts=3, limit=2)) == 2 finally: for r in rows: db.session.delete(r) db.session.commit() class TestReviveByAge: def test_disabled_by_default_does_nothing(self, app_ctx, monkeypatch): """Reviving on its own schedule is a behaviour change nobody asked for on upgrade.""" from services import wanted_revive row = _seed( file_path="/off.mkv", search_count=3, last_search_at=datetime.now(UTC) - timedelta(days=999), ) try: assert wanted_revive.revive_exhausted_by_age() == 0 db.session.refresh(row) assert row.search_count == 3 finally: db.session.delete(row) db.session.commit() def test_enabled_revives_the_old_ones_only(self, app_ctx, monkeypatch): from services import wanted_revive now = datetime.now(UTC) old = _seed(file_path="/old.mkv", search_count=3, last_search_at=now - timedelta(days=90)) recent = _seed( file_path="/recent.mkv", search_count=3, last_search_at=now - timedelta(days=2) ) class S: wanted_max_search_attempts = 3 wanted_adaptive_backoff_enabled = True wanted_revive_exhausted_after_days = 30 wanted_revive_max_per_run = 200 monkeypatch.setattr(wanted_revive, "peek_settings", lambda: S(), raising=False) monkeypatch.setattr("config.peek_settings", lambda: S()) try: assert wanted_revive.revive_exhausted_by_age() == 1 db.session.refresh(old) db.session.refresh(recent) assert old.search_count == 0, "the parked item is back in rotation" assert old.retry_after is None assert recent.search_count == 3, "a recent failure is not stale yet" finally: for r in (old, recent): db.session.delete(r) db.session.commit() class TestReviveAfterProviderChange: def test_no_age_restriction(self, app_ctx, monkeypatch): """The point of #197 is retroactive effect: fixing a provider should help items that gave up five minutes ago as much as ones from months back.""" from services import wanted_revive class S: wanted_max_search_attempts = 3 wanted_adaptive_backoff_enabled = True wanted_revive_max_per_run = 200 monkeypatch.setattr("config.peek_settings", lambda: S()) recent = _seed( file_path="/justnow.mkv", search_count=3, last_search_at=datetime.now(UTC), ) try: assert wanted_revive.revive_after_provider_change(["jimaku"]) == 1 db.session.refresh(recent) assert recent.search_count == 0 finally: db.session.delete(recent) db.session.commit() class TestTheSettingsAreReachable: """Since 1.14.0 /config validates its keys against the _SettingsView subclasses. A setting no view declares is a setting nobody can turn on — the feature would ship switched off with no way to switch it on.""" def _declared(self) -> set[str]: import config_views fields: set[str] = set() for name in dir(config_views): obj = getattr(config_views, name) declared = getattr(obj, "_fields", None) if isinstance(declared, frozenset): fields |= set(declared) return fields def test_the_new_keys_are_declared(self, app_ctx): declared = self._declared() assert "wanted_revive_exhausted_after_days" in declared assert "wanted_revive_max_per_run" in declared def test_the_check_would_notice_a_missing_one(self, app_ctx): """Guards the guard: if _declared() returned everything, the test above would pass for any key at all.""" assert "definitely_not_a_setting" not in self._declared() class TestReviveReachesTheRoute: """The two triggers above were only ever tested by calling them directly. The route that fires the provider trigger was not, and that is where it broke: ``update_config`` assigns a local ``_provider_keys`` set partway through, which shadows the module-level function of the same name for the whole function body. The later call therefore tried to call a set, raised TypeError, and the surrounding ``except Exception`` — there so a bonus behaviour can never fail the settings save — logged a warning and moved on. Silent, and on every single provider change since the feature shipped. """ def test_enabling_a_provider_revives_the_items_that_gave_up(self, client): # The payload has to be an actual addition. Saving "opensubtitles" over # the default empty value looks like enabling one provider but is the # opposite — empty means every provider is enabled, so that save turns # six of them off. #214 is exactly this confusion, and the revive no # longer fires on it, so the starting point is now stated explicitly. with client.application.app_context(): from db.config import save_config_entry save_config_entry("providers_enabled", "opensubtitles") gave_up = _seed(file_path="/parked.mkv", search_count=3) item_id = gave_up.id resp = client.put("/api/v1/config", json={"providers_enabled": "opensubtitles,subdl"}) assert resp.status_code == 200, resp.get_data(as_text=True)[:200] with client.application.app_context(): row = db.session.get(WantedItem, item_id) assert row.search_count == 0, ( "an item that gave up stayed parked after a provider was enabled" ) def test_a_setting_that_is_not_a_provider_leaves_the_backlog_alone(self, client): """Every settings save must not reset the queue — only provider ones.""" with client.application.app_context(): gave_up = _seed(file_path="/untouched.mkv", search_count=3) item_id = gave_up.id resp = client.put("/api/v1/config", json={"log_level": "INFO"}) assert resp.status_code == 200, resp.get_data(as_text=True)[:200] with client.application.app_context(): row = db.session.get(WantedItem, item_id) assert row.search_count == 3