diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 7e3938a2..7724e360 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -5790,9 +5790,14 @@ export interface components { }; /** * AccountAutomation - * @description Account actions an administrator can turn on. All of them default off. + * @description MAM search policy and optional account actions. All switches default off. */ AccountAutomation: { + /** + * Exclude Vip + * @default false + */ + exclude_vip: boolean; /** * Seedbox Ip * @default false diff --git a/apps/web/src/pages/MamAutomation.tsx b/apps/web/src/pages/MamAutomation.tsx index 7542c93b..b2ff2cbd 100644 --- a/apps/web/src/pages/MamAutomation.tsx +++ b/apps/web/src/pages/MamAutomation.tsx @@ -5,6 +5,7 @@ export type AutomationSettings = components["schemas"]["AccountAutomation"]; export type UploadAmount = number | "max"; export const AUTOMATION_DEFAULTS: AutomationSettings = { + exclude_vip: false, seedbox_ip: false, seedbox_interval_seconds: 3600, auto_vip: false, diff --git a/apps/web/src/pages/Sources.tsx b/apps/web/src/pages/Sources.tsx index 8561e881..d4d89c46 100644 --- a/apps/web/src/pages/Sources.tsx +++ b/apps/web/src/pages/Sources.tsx @@ -589,6 +589,30 @@ export function MamConnectionForm({ value }: { value: Connection }) { }} >
+
+

Search results

+ +

+ Enable this if your MAM account cannot download VIP releases. + Applies to direct MAM searches and selection; configure Prowlarr + separately. Leave off for VIP/Elite access or if unsure. MAM still + authorizes each download; missing VIP expiry does not establish + access. This setting does not spend points or Freeleech wedges. + Refresh existing searches after saving. +

+
errors.push(e.message)); const matchSearches: string[] = []; const matches: { asset: string; body: unknown }[] = []; + let releaseNextSearch!: () => void; + const nextSearch = new Promise((resolve) => { + releaseNextSearch = resolve; + }); let imported = false; const items = [ { kind: "asset", asset: asset("a1", "Untagged Folder", ["title"]) }, @@ -115,6 +119,7 @@ test("library review links unmatched items to Dewarr and Hardcover books", async data = { items: shown, total: shown.length }; } else if (path === "/api/catalog/works") { matchSearches.push(url.searchParams.get("q") || ""); + if (url.searchParams.get("q") === "The Long Way") await nextSearch; data = { items: [work], total: 1 }; } else if (path.endsWith("/match") && method === "POST") { const id = path.split("/").at(-2)!; @@ -214,9 +219,16 @@ test("library review links unmatched items to Dewarr and Hardcover books", async part_total: null, }, }); - await expect(page.getByRole("status")).toContainText( - "Linked “Untagged Folder”", - ); + try { + // Saving and continuing can announce success while the next lookup is pending. + await expect(detail.getByRole("status")).toHaveText("Searching catalog…"); + await expect( + page.getByRole("status").filter({ hasText: "Linked “Untagged Folder”" }), + ).toBeVisible(); + } finally { + releaseNextSearch(); + } + await expect(detail.getByRole("status")).toHaveCount(0); await expect(cards).toHaveCount(2); await expect( nav.getByRole("link", { name: /^Library review/ }), diff --git a/apps/web/tests/ui/mam-settings.spec.ts b/apps/web/tests/ui/mam-settings.spec.ts index 3ab2d90f..9159ffac 100644 --- a/apps/web/tests/ui/mam-settings.spec.ts +++ b/apps/web/tests/ui/mam-settings.spec.ts @@ -310,3 +310,34 @@ test("background results expire cached proxy success and clear old cookie errors await expect(account).toContainText("Authenticated"); await expect(account).not.toContainText("Failed"); }); + +test("VIP exclusion saves and reloads without enabling account spending", async ({ + page, +}) => { + const state = await setupMam(page); + const form = formFor(page); + const exclude = form.getByRole("checkbox", { name: "Exclude VIP releases" }); + await expect(exclude).not.toBeChecked(); + await exclude.check(); + await form + .getByRole("button", { name: "Save connection", exact: true }) + .click(); + await expect.poll(() => state.writes.length).toBe(1); + expect(state.connection.automation).toMatchObject({ + exclude_vip: true, + use_wedge: false, + auto_vip: false, + spend_bonus: false, + }); + expect(state.actions).toEqual(["save"]); + await page.reload(); + await page + .getByRole("region", { name: "MAM settings" }) + .locator("summary") + .first() + .click(); + await expect(exclude).toBeChecked(); + await expect( + form.getByRole("region", { name: "MAM search policy" }), + ).toContainText("Leave off for VIP/Elite access or if unsure"); +}); diff --git a/docs/DOWNLOAD-RECOVERY.md b/docs/DOWNLOAD-RECOVERY.md index de4007bc..ea14ab9f 100644 --- a/docs/DOWNLOAD-RECOVERY.md +++ b/docs/DOWNLOAD-RECOVERY.md @@ -8,7 +8,11 @@ indexer override such as `prowlarr:12` takes precedence over `prowlarr`. Only verified client observations advance these timers. Paused, queued and checking transfers do not count as stalled. An uncertain submission or failed -client connection must reconcile before it can cause a replacement. +client connection must reconcile before it can cause a replacement. SABnzbd history +states `Failed` and `Aborted` use the same error grace period. Dewarr keeps polling +those verified failures until the grace expires, then blocklists the release and +queues one replacement search. Completed jobs and disabled recovery policies do +not trigger this path. A rejected release is blocklisted for its book and medium, including its known artifact and torrent identities. Recovery tries saved eligible results, then diff --git a/docs/MAM-PROXY.md b/docs/MAM-PROXY.md index abb826c8..b6a6981b 100644 --- a/docs/MAM-PROXY.md +++ b/docs/MAM-PROXY.md @@ -29,6 +29,22 @@ hostname resolution, connection refusal, HTTPS tunnel rejection, timeouts, TLS negotiation, and invalid IP responses. Raw exception messages, cookies, and proxy credentials are not returned to the browser. +## Accounts without VIP access + +Enable **Exclude VIP releases** in MAM connection settings, save, then refresh +existing source searches. Dewarr sends the documented `nVIP` search filter and +skips known VIP releases before automatic inspection, leaving eligible results +from MAM and other sources available. Fresh release detail is checked again +before fetching torrent metadata. If every candidate is excluded, the selection +explains why. This policy applies only to direct MAM; Prowlarr has its own filter. + +The switch defaults off for existing installations. Leave it off for VIP/Elite +accounts or when access is unknown; missing `vip_until` does not establish account +entitlement. Unknown release flags are left for MAM to authorize. Excluding VIP +releases does not purchase VIP or spend Freeleech wedges; those remain separate +explicit settings. The API stores this policy as `automation.exclude_vip` on the +MAM connection. + ## Gluetun works in MouseSearch but Dewarr cannot resolve it If Dewarr reports **The proxy hostname could not be resolved**, check the diff --git a/docs/openapi.json b/docs/openapi.json index b21726c6..686ed6f2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -18698,6 +18698,11 @@ }, "AccountAutomation": { "properties": { + "exclude_vip": { + "type": "boolean", + "title": "Exclude Vip", + "default": false + }, "seedbox_ip": { "type": "boolean", "title": "Seedbox Ip", @@ -18830,7 +18835,7 @@ }, "type": "object", "title": "AccountAutomation", - "description": "Account actions an administrator can turn on. All of them default off." + "description": "MAM search policy and optional account actions. All switches default off." }, "AccountInput": { "properties": { diff --git a/services/app/adapters/mam.py b/services/app/adapters/mam.py index d411f428..22649087 100644 --- a/services/app/adapters/mam.py +++ b/services/app/adapters/mam.py @@ -58,9 +58,13 @@ def filter(self, record): logging.getLogger("httpx").addFilter(PrivateDownloadLogFilter()) +VIP_EXCLUDED = "VIP releases are excluded in MAM settings" + + class AccountAutomation(BaseModel): - """Account actions an administrator can turn on. All of them default off.""" + """MAM search policy and optional account actions. All switches default off.""" + exclude_vip: bool = False seedbox_ip: bool = False seedbox_interval_seconds: int = Field(default=3600, ge=60, le=86400) auto_vip: bool = False @@ -80,6 +84,7 @@ class AccountAutomation(BaseModel): upload_interval_hours: int = Field(default=UPLOAD_CHECK_HOURS, ge=1, le=168) @field_validator( + "exclude_vip", "seedbox_ip", "auto_vip", "use_wedge", @@ -816,7 +821,10 @@ async def request(self, path, payload=None, *, binary=False, params=None, authen raise failure from error async def search(self, query): - return parse_page(await self.request(SEARCH_PATH, query.payload()), query) + payload = query.payload() + if self.automation.exclude_vip: + payload["tor"]["searchType"] = "nVIP" + return parse_page(await self.request(SEARCH_PATH, payload), query) async def detail(self, source_id): query = MAMSearch(q="detail", limit=1) @@ -924,6 +932,16 @@ async def resolve(self, argument): FailureKind.PARSER, "MAM returned a different release than requested." ) item = page.items[0] + # Recheck fresh detail before a metadata download or any wedge request. + # Search flags may have changed since the saved preview. + if self.automation.exclude_vip and item.vip is True: + raise AdapterError(FailureKind.UNSUPPORTED, VIP_EXCLUDED) + if item.vip is True and not value["data"][0].get("dl"): + raise AdapterError( + FailureKind.UNSUPPORTED, + "MAM did not provide a download reference for this VIP release. " + "Check account access or enable Exclude VIP releases in MAM settings.", + ) enabled = requested or self.automation.use_wedge vip_until = None if enabled and item.vip_freeleech is True: diff --git a/services/app/domain/automatic_selection.py b/services/app/domain/automatic_selection.py index 90d6656e..3645ff3c 100644 --- a/services/app/domain/automatic_selection.py +++ b/services/app/domain/automatic_selection.py @@ -12,6 +12,7 @@ from sqlalchemy import select, text from app.adapters.contracts import AdapterError, FailureKind +from app.adapters.mam import VIP_EXCLUDED, stored_automation from app.adapters.nzb_descriptor import load_descriptor from app.adapters.source_releases import release_value as parse_release from app.config import get_settings @@ -458,6 +459,8 @@ async def candidates(db, operation, work, profile, rule, version): ) ) sources = {s.key: s for s in await db.scalars(select(SourceConnection))} + mam = sources.get("mam") + exclude_vip = bool(mam and stored_automation(mam.automation).exclude_vip) blocked_keys = ( await release_blocklist.active_keys(db, work.id, rule["medium"]) if rows else set() ) @@ -500,6 +503,8 @@ async def candidates(db, operation, work, profile, rule, version): ) ) source = sources.get(row.source_key) + if row.source_key == "mam" and exclude_vip and release.vip is True: + problems.append(VIP_EXCLUDED) if ( not source or not source.enabled diff --git a/services/app/domain/download_attempts.py b/services/app/domain/download_attempts.py index d0b03118..7c94c3ed 100644 --- a/services/app/domain/download_attempts.py +++ b/services/app/domain/download_attempts.py @@ -549,12 +549,16 @@ async def finish_observation(db, attempt, selection, state): attempt.recovery_observation, state, policy, datetime.now(UTC) ) attempt.recovery_observation = health + # SAB preserves native status labels (Failed/Aborted) and exposes the + # normalized terminal flag. Keep its grace polling and capacity lifecycle + # consistent with Soulseek's terminal state; qBittorrent errors may resume. + stopped_failure = bool(state and (getattr(state, "failed", False) or state.state == "failed")) if failure: attempt.observation = state.model_dump(mode="json") await download_recovery.failed(db, attempt, failure) # A dead/error transfer is no longer an active Dewarr acquisition. Keep # storage reservations when a stalled transfer is left running. - if state.state == "failed": + if stopped_failure: await capacity.release_slot(db, attempt) return if not state: @@ -593,9 +597,9 @@ async def finish_observation(db, attempt, selection, state): await download_recovery.failed(db, attempt, message) return await record(db, attempt, next_state, message, poll=next_state == "downloading") - if state.state == "failed": + if stopped_failure: await capacity.release_slot(db, attempt) - if next_state == "held" and state.state == "failed" and policy.enabled: + if next_state == "held" and stopped_failure and policy.enabled: # Continue verified failure observations until the configured grace expires. attempt.next_check_at = datetime.now(UTC) + timedelta(seconds=60) if next_state != "complete": diff --git a/services/app/domain/download_recovery.py b/services/app/domain/download_recovery.py index 7dca739f..f7001ca2 100644 --- a/services/app/domain/download_recovery.py +++ b/services/app/domain/download_recovery.py @@ -100,7 +100,11 @@ def observe(health, state, policy, now): } if paused: return {}, None - failure = state.state in {"error", "missingFiles", "failed"} + failure = bool(getattr(state, "failed", False)) or state.state in { + "error", + "missingFiles", + "failed", + } if failure: health.setdefault("error_since", now.isoformat()) if now - datetime.fromisoformat(health["error_since"]) >= timedelta( diff --git a/tests/integration/test_automatic_selection.py b/tests/integration/test_automatic_selection.py index 7fefcfee..5b92eaa3 100644 --- a/tests/integration/test_automatic_selection.py +++ b/tests/integration/test_automatic_selection.py @@ -669,3 +669,53 @@ async def resolve(owner, row, use_wedge=False): assert value["status"] == "completed" and value["inspections"] == 6 assert len(calls) == len(set(calls)) == 6 assert value["artifact_id"] == str(source["artifact"]) + + +@pytest.mark.parametrize("eligible", [True, False]) +async def test_non_vip_policy_skips_vip_results_before_any_inspection( + client, database, source, monkeypatch, eligible +): + blocked = {source["result"]} + for index in range(3): + result_id, _, _ = await additional_candidate( + database, source, source_id=str(502 + index), seeders=8 - index + ) + blocked.add(result_id) + if eligible: + chosen, artifact_id, release = await additional_candidate( + database, source, source_id="600", seeders=1 + ) + async with database() as db, db.begin(): + connection = await db.get(SourceConnection, "mam") + connection.automation = {"exclude_vip": True} + for result_id in blocked: + row = await db.get(SourceResult, result_id) + row.release_snapshot = { + **row.release_snapshot, + "vip": True, + "vip_freeleech": True, + "freeleech": False, + "personal_freeleech": False, + } + calls = [] + + async def resolve(owner, row, use_wedge=False): + calls.append(row.id) + assert eligible and row.id == chosen + assert not use_wedge + return artifact_id, release + + monkeypatch.setattr(automatic, "resolve_candidate", resolve) + operation = await start(client, source) + await automatic.run(UUID(operation["id"])) + await automatic.run(UUID(operation["id"])) + result = await detail(client, operation["id"]) + assert result["status"] == ("completed" if eligible else "held"), result + assert result["inspections"] == (1 if eligible else 0) + assert calls == ([chosen] if eligible else []) + for decision in result["decisions"]: + if UUID(decision["result_id"]) in blocked: + assert not decision["inspected"] + assert "VIP releases are excluded in MAM settings" in decision["reasons"] + if not eligible: + assert "VIP releases are excluded" in result["message"] diff --git a/tests/integration/test_mam_sources.py b/tests/integration/test_mam_sources.py index 15f844f3..712656d1 100644 --- a/tests/integration/test_mam_sources.py +++ b/tests/integration/test_mam_sources.py @@ -510,3 +510,27 @@ async def probe(proxy=None, *args): assert failed["status"] == "unhealthy" assert failed["route"] == "proxy" assert attempts == ["http://proxy.test:8888", None, "http://proxy.test:8888"] + + +async def test_non_vip_search_policy_reaches_provider_and_survives_reload( + client, admin, source_http +): + import json + + configured = await configure(client, automation={"exclude_vip": True}) + assert configured.status_code == 200, configured.text + found = await search(client, medium="audio", offset=50, limit=50) + assert found.status_code == 200, found.text + body = json.loads(source_http["calls"][-1].content) + assert body["tor"]["searchType"] == "nVIP" + assert body["tor"]["startNumber"] == 50 and body["tor"]["main_cat"] == [13] + assert "dlLink" not in body + reloaded = (await client.get("/api/sources/mam/connection")).json() + assert reloaded["automation"]["exclude_vip"] is True + assert reloaded["automation"]["use_wedge"] is False + configured = await configure( + client, expected_generation=1, mam_id=None, automation={"exclude_vip": False} + ) + assert configured.status_code == 200, configured.text + assert (await search(client)).status_code == 200 + assert json.loads(source_http["calls"][-1].content)["tor"]["searchType"] == "all" diff --git a/tests/integration/test_sabnzbd.py b/tests/integration/test_sabnzbd.py index c02b0978..31dcd8e9 100644 --- a/tests/integration/test_sabnzbd.py +++ b/tests/integration/test_sabnzbd.py @@ -1,16 +1,27 @@ # ruff: noqa: F811 +from datetime import UTC, datetime, timedelta from urllib.parse import parse_qs from uuid import UUID import httpx import pytest +from sqlalchemy import func, select from app.adapters.contracts import SubmissionReceipt from app.adapters.prowlarr import ProwlarrRelease from app.adapters.sabnzbd import SabClient from app.config import get_settings -from app.db.models import DownloadAttempt, DownloadInspection, Integration, Operation, SourceResult -from app.domain import automatic_selection +from app.db.models import ( + DownloadAttempt, + DownloadCapacity, + DownloadInspection, + DownloadRecovery, + Integration, + Operation, + ReleaseBlock, + SourceResult, +) +from app.domain import automatic_selection, download_recovery from app.domain import download_attempts as downloads from app.domain import downloaders as downloader_settings from app.security import decrypt_secrets, encrypt_secrets @@ -123,6 +134,7 @@ def __init__(self): self.category = None self.complete = False self.visible = True + self.status = None async def __aenter__(self): return self @@ -148,7 +160,7 @@ def handler(request): "name" if self.complete else "filename": self.tag.replace(":", "_"), "nzb_name": "book.nzb", "category": self.category, - "status": "Completed" if self.complete else "Downloading", + "status": self.status or ("Completed" if self.complete else "Downloading"), "storage": "/downloads/Finished Book" if self.complete else "", } ] @@ -265,3 +277,66 @@ async def test_usenet_grab_is_sent_to_sabnzbd_once( inspection = await db.get(DownloadInspection, attempt.inspection_id) assert attempt.state == "complete" assert inspection.relative_path == "Finished Book" + + +@pytest.mark.parametrize("status", ["Failed", "Aborted"]) +async def test_failed_sab_history_keeps_polling_then_queues_one_recovery( + client, admin, database, selection_route, monkeypatch, prowlarr_http, status +): + await configure(client) + prowlarr_http.update( + releases=[release(protocol="usenet", categories=[{"id": 3030}])], + bytes=nzb_bytes(), + ) + item = (await search(client)).json()["items"][0] + inspected = await resolve(client, item["id"]) + assert inspected.status_code == 200, inspected.text + selection_route["artifact_id"] = inspected.json()["id"] + async with database() as db, db.begin(): + downloader = await db.get(Integration, UUID(selection_route["downloader_id"])) + downloader.kind = "sabnzbd" + downloader.encrypted_secrets = encrypt_secrets({"api_key": "private-sab-key"}) + monkeypatch.setattr(get_settings(), "download_dispatch_enabled", True) + grab = Grab() + monkeypatch.setattr(downloads, "SabClient", lambda *args, **kwargs: grab) + prepared = await prepare(client, selection_route) + assert prepared.status_code == 201, prepared.text + saved = (await start(client, prepared.json())).json() + attempt_id = UUID(saved["id"]) + await downloads.run(attempt_id) + grab.complete, grab.status = True, status # Failed jobs are in SAB history. + await downloads.run(attempt_id) + async with database() as db, db.begin(): + attempt = await db.get(DownloadAttempt, attempt_id) + assert attempt.state == "held" + assert attempt.next_check_at is not None # The worker must revisit the grace period. + assert not (await db.get(DownloadCapacity, attempt_id)).slot_active + assert "error_since" in attempt.recovery_observation + assert not await db.scalar(select(DownloadRecovery.id)) + attempt.recovery_observation = { + **attempt.recovery_observation, + "error_since": (datetime.now(UTC) - timedelta(minutes=6)).isoformat(), + } + await downloads.run(attempt_id) + await downloads.run(attempt_id) + async with database() as db: + recovery = await db.scalar(select(DownloadRecovery)) + assert recovery and recovery.state == "queued" and recovery.job_id + assert recovery.reason == "Downloader reported " + status + recovery_id = recovery.id + assert await db.scalar(select(func.count()).select_from(ReleaseBlock)) == 1 + assert await db.scalar(select(func.count()).select_from(DownloadRecovery)) == 1 + await download_recovery.run(recovery_id) + await download_recovery.run(recovery_id) + async with database() as db: + recovery = await db.get(DownloadRecovery, recovery_id) + assert recovery.state in {"searching", "selecting"}, recovery.message + assert recovery.search_id is not None + search_id = recovery.search_id + await download_recovery.run(recovery_id) + async with database() as db: + assert (await db.get(DownloadRecovery, recovery_id)).search_id == search_id + assert await db.scalar(select(func.count()).select_from(DownloadAttempt)) == 1 + assert grab.calls.count("submit") == 1 + detail = (await client.get(f"/api/acquisition/downloads/{attempt_id}")).json() + assert status in detail["message"] diff --git a/tests/unit/test_download_recovery.py b/tests/unit/test_download_recovery.py index 0a4618ce..9bb12b53 100644 --- a/tests/unit/test_download_recovery.py +++ b/tests/unit/test_download_recovery.py @@ -98,3 +98,38 @@ def test_indexer_scoped_release_keys_and_cross_source_hashes(): assert source == "prowlarr:1" and not set(keys).intersection(other) _, same = release_keys({"source": "mam", "source_id": "xyz"}, {"infohash_v1": "A" * 40}) assert set(keys).intersection(same) == {"infohash_v1:" + "a" * 40} + + +@pytest.mark.parametrize("status", ["Failed", "Aborted", "FAILED", "aborted"]) +def test_sab_normalized_failures_obey_grace_and_verification(status): + from app.adapters.sabnzbd import parse_job, verify_association + + item = parse_job( + { + "nzo_id": "SABnzbd_nzo_test", + "name": "book-search:test", + "category": "books", + "status": status, + }, + completed=True, + ) + now = datetime.now(UTC) + policy = RecoveryPolicy() + assert observe(None, item, policy, now) == ({}, None) + item = verify_association( + [item], tag="book-search:test", category="books", save_path="/downloads" + ) + health, reason = observe(None, item, policy, now) + assert reason is None + assert observe(health, item, policy, now + timedelta(minutes=4))[1] is None + assert ( + observe(health, item, policy, now + timedelta(minutes=5))[1] + == "Downloader reported " + status + ) + assert observe(health, item, RecoveryPolicy(enabled=False), now + timedelta(days=1)) == ( + {}, + None, + ) + assert observe( + health, item.model_copy(update={"completed": True}), policy, now + timedelta(days=1) + ) == ({}, None) diff --git a/tests/unit/test_mam_helpers.py b/tests/unit/test_mam_helpers.py index 78716a7c..3128fe4a 100644 --- a/tests/unit/test_mam_helpers.py +++ b/tests/unit/test_mam_helpers.py @@ -66,6 +66,7 @@ def test_helpers_do_nothing_until_an_administrator_enables_one(): now = datetime(2026, 9, 21, tzinfo=UTC) assert due_command({}, {}, now) is None assert due_command({"use_wedge": True}, {}, now) is None + assert due_command({"exclude_vip": True}, {}, now) is None assert due_command({"seedbox_ip": "yes"}, {}, now) is None command = due_command({"seedbox_ip": True, "auto_vip": True, "protect_ratio": True}, {}, now) assert command is not None @@ -371,3 +372,48 @@ def handler(request): result = await client.maintain({"upload_bonus": True, "bonus_above": 5000}) assert result.upload_purchased assert amounts == ["50", "50"] + + +@pytest.mark.parametrize("vip", [True, False, None]) +async def test_exclude_vip_rechecks_fresh_detail_without_inferring_unknown_access(vip): + calls = [] + + def handler(request): + calls.append(request) + if "download.php" in request.url.path: + assert dict(request.url.params) == {"tid": "501"} + return httpx.Response(200, content=b"d4:infod4:name6:Harboree") + assert request.url.path.endswith("loadSearchJSONbasic.php") + return httpx.Response( + 200, json=search_response(data=[release_row(vip=vip, free=0, fl_vip=1)]) + ) + + async with _client(handler) as client: + client.automation = AccountAutomation(exclude_vip=True) + if vip is True: + # Even an explicit wedge request cannot bypass the saved exclusion. + with pytest.raises(AdapterError, match="VIP releases are excluded") as error: + await client.resolve({"source_id": "501", "use_wedge": True}) + assert error.value.kind == FailureKind.UNSUPPORTED + else: + artifact = await client.resolve("501") + assert artifact.release.vip is vip + assert len(calls) == (1 if vip is True else 2) + + +async def test_vip_without_download_reference_explains_access_without_account_or_spending_calls(): + calls = [] + + def handler(request): + calls.append(request) + return httpx.Response( + 200, json=search_response(data=[release_row(vip=1, free=0, fl_vip=1, dl=None)]) + ) + + async with _client(handler) as client: + with pytest.raises( + AdapterError, match="Check account access or enable Exclude VIP releases" + ) as error: + await client.resolve("501") + assert error.value.kind == FailureKind.UNSUPPORTED + assert len(calls) == 1