Skip to content
Merged
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
7 changes: 6 additions & 1 deletion apps/web/src/api/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/web/src/pages/MamAutomation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/pages/Sources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,30 @@ export function MamConnectionForm({ value }: { value: Connection }) {
}}
>
<fieldset className="mam-setup-fields" disabled={busy || controlBusy}>
<section aria-label="MAM search policy">
<h3>Search results</h3>
<label className="check-label">
<input
type="checkbox"
checked={automation.exclude_vip}
onChange={(event) =>
setAutomation({
...automation,
exclude_vip: event.target.checked,
})
}
/>
Exclude VIP releases
</label>
<p className="muted">
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.
</p>
</section>
<AccountAutomation
value={automation}
onChange={setAutomation}
Expand Down
18 changes: 15 additions & 3 deletions apps/web/tests/ui/library-review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ test("library review links unmatched items to Dewarr and Hardcover books", async
page.on("pageerror", (e) => errors.push(e.message));
const matchSearches: string[] = [];
const matches: { asset: string; body: unknown }[] = [];
let releaseNextSearch!: () => void;
const nextSearch = new Promise<void>((resolve) => {
releaseNextSearch = resolve;
});
let imported = false;
const items = [
{ kind: "asset", asset: asset("a1", "Untagged Folder", ["title"]) },
Expand Down Expand Up @@ -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)!;
Expand Down Expand Up @@ -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/ }),
Expand Down
31 changes: 31 additions & 0 deletions apps/web/tests/ui/mam-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
6 changes: 5 additions & 1 deletion docs/DOWNLOAD-RECOVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/MAM-PROXY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions services/app/adapters/mam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions services/app/domain/automatic_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions services/app/domain/download_attempts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
6 changes: 5 additions & 1 deletion services/app/domain/download_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
50 changes: 50 additions & 0 deletions tests/integration/test_automatic_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
24 changes: 24 additions & 0 deletions tests/integration/test_mam_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading