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
17 changes: 15 additions & 2 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
type MaxResolution,
} from "../util/releasePick";
import { runSearch } from "../core/search";
import { enabledSources } from "../sources/registry";
import { enabledSources, getSource } from "../sources/registry";
import { setDnsServers } from "../util/dns";
import { expandHome, normalizeDownloadDir } from "../config/folder";
import type { DebridProviderId, DebridStatus } from "../integrations/debrid/types";
Expand Down Expand Up @@ -168,13 +168,15 @@
import { startWebServer, type WebServerHandle, type WebServerOptions } from "../web/server";
import type { Runtime } from "../daemon/runtime";
import { log } from "../util/logger";
import { shouldBlockDebridAdd } from "../util/debridAddGuard";

export interface DownloadInput {
id: string;
name: string;
magnet: string;
source?: SourceId;
sizeBytes?: number;
seeders?: number;
}

/**
Expand Down Expand Up @@ -1187,7 +1189,7 @@

useEffect(() => {
refreshReccStatus(config);
}, [config?.reccUrl, config?.reccToken, refreshReccStatus]);

Check warning on line 1192 in src/ui/App.tsx

View workflow job for this annotation

GitHub Actions / node 24 on ubuntu-latest

React Hook useEffect has a missing dependency: 'config'. Either include it or remove the dependency array

Check warning on line 1192 in src/ui/App.tsx

View workflow job for this annotation

GitHub Actions / node 24 on macos-latest

React Hook useEffect has a missing dependency: 'config'. Either include it or remove the dependency array

Check warning on line 1192 in src/ui/App.tsx

View workflow job for this annotation

GitHub Actions / node 22 on macos-latest

React Hook useEffect has a missing dependency: 'config'. Either include it or remove the dependency array

Check warning on line 1192 in src/ui/App.tsx

View workflow job for this annotation

GitHub Actions / node 22 on windows-latest

React Hook useEffect has a missing dependency: 'config'. Either include it or remove the dependency array

Check warning on line 1192 in src/ui/App.tsx

View workflow job for this annotation

GitHub Actions / node 24 on windows-latest

React Hook useEffect has a missing dependency: 'config'. Either include it or remove the dependency array

Check warning on line 1192 in src/ui/App.tsx

View workflow job for this annotation

GitHub Actions / node 22 on ubuntu-latest

React Hook useEffect has a missing dependency: 'config'. Either include it or remove the dependency array

// Auto-provision an anonymous reccd account on first run. Fire-and-forget and
// never awaited: reccd is a value-add, and nothing here may delay or break the
Expand Down Expand Up @@ -1488,12 +1490,23 @@
setNotice("Set a Real-Debrid or TorBox token first — open the Settings tab.");
return;
}
if (
input.seeders !== undefined &&
shouldBlockDebridAdd(
input.seeders,
cachedHashes.has(input.id.toLowerCase()),
input.source !== undefined && getSource(input.source).reportsHealth,
)
) {
setNotice("No seeders and not already cached — this would just sit stuck. Skipped.");
return;
}
const meta = getDebridProvider(active.provider);
void fs.mkdir(config.downloadDir, { recursive: true }).catch(() => {});
void queue.addDebrid(input, config.downloadDir, active.provider, active.token);
setNotice(`${meta.label}: ${truncate(cleanText(input.name), 40)}`);
},
[config, queue],
[config, queue, cachedHashes],
);

// Try to play a resolved stream URL: use the configured/detected player, else
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ export function Results({ reccConfig, fetchImpl }: ResultsProps) {
magnet: r.magnet,
source: r.source,
sizeBytes: r.sizeBytes,
seeders: r.seeders,
});

const openDownload = (r: TorrentResult): void => requestP2PDownload(inputFor(r));
Expand Down
32 changes: 32 additions & 0 deletions src/util/debridAddGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Shared by the TUI and the browser (same reason as resultFilter.ts / resultSort.ts:
// `src/web/static/` cannot import `src/ui/**`, so the one place both front ends can
// reach is `src/util`). IMPORTS NOTHING.
//
// A debrid add of a torrent with no seeders and no existing cache entry never
// completes: the provider can't fetch a swarm-less torrent, so it just sits in the
// "active" state forever. TorBox's own progress loop only notices this after a
// multi-minute stall (see DEFAULT_STALL_MS in src/integrations/debrid/torbox.ts),
// by which point the add has already spent one of the account's few concurrent
// slots — for a plan capped at three, three such stuck adds block every future
// download until the user finds and cancels them.
//
// Seeders and cache status are both known before the user clicks "add" (search
// results carry seeders; cachedHashes is refreshed right after search settles), so
// this guard runs there instead of waiting for the provider to time out.

/**
* Whether adding this result via a debrid provider should be blocked because it
* would just sit stuck: not already cached, and reported as having zero seeders
* by a source whose feed actually carries swarm data.
*
* `sourceReportsHealth` must be false for a source with no swarm data — those
* report `seeders: 0` for everything (unknown, not dead), the same convention
* `resultFilter.ts`'s alive-only filter uses.
*/
export function shouldBlockDebridAdd(
seeders: number,
isCached: boolean,
sourceReportsHealth: boolean,
): boolean {
return !isCached && sourceReportsHealth && seeders === 0;
}
7 changes: 7 additions & 0 deletions src/web/static/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3339,7 +3339,14 @@ async function addResult(result: PublicSearchResult, via: AddVia): Promise<void>
sources?.debridConfigured === true,
result.name,
sources?.debridProvider ?? undefined,
result.seeders,
reportsHealthLookup(sources)(result.source),
cachedHashes.has(result.infoHash.toLowerCase()),
);
if (plan.kind === "blocked") {
showNotice(plan.message);
return;
}
if (plan.kind === "confirm" && !confirm(plan.message)) {
showNotice("Nothing was added.");
return;
Expand Down
64 changes: 55 additions & 9 deletions src/web/static/searchModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,15 +455,23 @@ describe("rowForPlay", () => {
});
});

// None of these exercise the debrid stuck-add guard, so seeders/health/cache
// are fixed at values that never trigger it: plenty of seeders on a
// health-reporting source, not cached.
const NOT_STUCK = [10, true, false] as const;

describe("addPlan", () => {
it("adds straight away when Real-Debrid is not configured", () => {
expect(addPlan("p2p", false, "Kestrel", undefined)).toEqual({ kind: "add", via: "p2p" });
expect(addPlan("p2p", false, "Kestrel", undefined, ...NOT_STUCK)).toEqual({
kind: "add",
via: "p2p",
});
});

it("prompts before a P2P add when Real-Debrid is configured", () => {
const plan = addPlan("p2p", true, "Kestrel", "realdebrid");
const plan = addPlan("p2p", true, "Kestrel", "realdebrid", ...NOT_STUCK);
expect(plan.kind).toBe("confirm");
expect(plan.via).toBe("p2p");
expect(plan.kind === "confirm" && plan.via).toBe("p2p");
if (plan.kind !== "confirm") throw new Error("unreachable");
// The consequence is spelled out. "Continue anyway?" is not informed
// consent when the thing consented to is publishing your IP.
Expand All @@ -475,15 +483,47 @@ describe("addPlan", () => {
});

it("never prompts for an explicit Real-Debrid add", () => {
expect(addPlan("debrid", true, "Kestrel", "realdebrid")).toEqual({ kind: "add", via: "debrid" });
expect(addPlan("debrid", true, "Kestrel", "realdebrid", ...NOT_STUCK)).toEqual({
kind: "add",
via: "debrid",
});
});

it("clips a very long release name out of the prompt", () => {
const plan = addPlan("p2p", true, "x".repeat(200), "realdebrid");
const plan = addPlan("p2p", true, "x".repeat(200), "realdebrid", ...NOT_STUCK);
if (plan.kind !== "confirm") throw new Error("unreachable");
expect(plan.message).toContain("…");
expect(plan.message).not.toContain("x".repeat(70));
});

it("blocks an explicit debrid add with no seeders and no cache entry", () => {
const plan = addPlan("debrid", true, "Kestrel", "torbox", 0, true, false);
expect(plan.kind).toBe("blocked");
if (plan.kind !== "blocked") throw new Error("unreachable");
expect(plan.message).toContain("no seeders");
expect(plan.message).not.toContain("undefined");
});

it("does not block a debrid add that is already cached, even with no seeders", () => {
expect(addPlan("debrid", true, "Kestrel", "torbox", 0, true, true)).toEqual({
kind: "add",
via: "debrid",
});
});

it("does not block a debrid add from a source that reports no swarm health", () => {
expect(addPlan("debrid", true, "Kestrel", "torbox", 0, false, false)).toEqual({
kind: "add",
via: "debrid",
});
});

it("does not block a P2P add even with no seeders — the guard only applies to debrid", () => {
expect(addPlan("p2p", false, "Kestrel", undefined, 0, true, false)).toEqual({
kind: "add",
via: "p2p",
});
});
});

describe("debrid copy", () => {
Expand All @@ -498,17 +538,23 @@ describe("debrid copy", () => {
});

it("names the provider in the swarm-exposure prompt", () => {
const plan = addPlan("p2p", true, "Kestrel.2010.1080p.BluRay.x264", "torbox");
const plan = addPlan("p2p", true, "Kestrel.2010.1080p.BluRay.x264", "torbox", ...NOT_STUCK);
expect(plan.kind).toBe("confirm");
expect(plan.kind === "confirm" && plan.message).toContain("TorBox");
});

it("still never prompts for an explicit debrid add", () => {
expect(addPlan("debrid", true, "Ashfall.1999.1080p", "torbox")).toEqual({ kind: "add", via: "debrid" });
expect(addPlan("debrid", true, "Ashfall.1999.1080p", "torbox", ...NOT_STUCK)).toEqual({
kind: "add",
via: "debrid",
});
});

it("still never prompts when no debrid is configured", () => {
expect(addPlan("p2p", false, "Ashfall.1999.1080p", undefined)).toEqual({ kind: "add", via: "p2p" });
expect(addPlan("p2p", false, "Ashfall.1999.1080p", undefined, ...NOT_STUCK)).toEqual({
kind: "add",
via: "p2p",
});
});

// This is the assertion that makes the two-sources-for-one-fact bug
Expand All @@ -517,7 +563,7 @@ describe("debrid copy", () => {
// match one of them.
it("names the exact button text on screen, for every provider", () => {
for (const provider of ["realdebrid", "torbox"] as const) {
const plan = addPlan("p2p", true, "Kestrel.2010.1080p.BluRay.x264", provider);
const plan = addPlan("p2p", true, "Kestrel.2010.1080p.BluRay.x264", provider, ...NOT_STUCK);
if (plan.kind !== "confirm") throw new Error("unreachable");
expect(plan.message).toContain(`“${debridAddLabel(provider)}”`);
expect(plan.message).toContain(debridProviderLabel(provider));
Expand Down
45 changes: 38 additions & 7 deletions src/web/static/searchModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,23 @@
//
// Bundled for the browser: no node:* imports, direct or transitive.
//
// FOUR IMPORTS LEAVE THIS DIRECTORY, all deliberate. `../wire` is types-only
// FIVE IMPORTS LEAVE THIS DIRECTORY, all deliberate. `../wire` is types-only
// and erased at build time. `../../util/resultSort` and
// `../../util/resultFilter` are *value* imports of the TUI's own sort and
// filter — they were `src/ui/sort.ts` and `src/ui/filter.ts` until this file
// needed them, and reimplementing either here is the copy-then-drift bug this
// codebase has hit four times (uploadSpeed, the byte formatter, the progress
// unit, the API path table). `../../util/resultGroup` is the same arrangement
// for title grouping, and the TUI's results list renders the same rows from the
// same `groupRowPlan`. All three are dependency-light and `platform: "browser"`
// in tsup.web.config.ts fails the build if that ever stops being true —
// resultGroup reaches `parse-torrent-title` through util/release.ts, which the
// bundle already carries for streamFlow.ts.
// same `groupRowPlan`. `../../util/debridAddGuard` is the same arrangement for
// the pre-add stuck-download check (see `addPlan` below) — the TUI's
// `startDebridDownload` calls the same predicate. All four value imports are
// dependency-light and `platform: "browser"` in tsup.web.config.ts fails the
// build if that ever stops being true — resultGroup reaches
// `parse-torrent-title` through util/release.ts, which the bundle already
// carries for streamFlow.ts.
import { hintForGroup } from "../../util/release";
import { shouldBlockDebridAdd } from "../../util/debridAddGuard";
import { filterResults } from "../../util/resultFilter";
import {
groupResults,
Expand Down Expand Up @@ -485,7 +489,13 @@ export type AddPlan =
* paying for something that keeps it out of one — the same prompt the TUI
* shows.
*/
| { kind: "confirm"; via: AddVia; message: string };
| { kind: "confirm"; via: AddVia; message: string }
/**
* Refuse outright. A debrid add of a zero-seed, not-yet-cached result never
* completes — it just occupies one of the provider's few concurrent slots
* until the user finds and cancels it. See `shouldBlockDebridAdd`.
*/
| { kind: "blocked"; message: string };

/**
* A debrid provider id as it crosses the wire. Mirrors `DebridProviderId`.
Expand Down Expand Up @@ -532,14 +542,35 @@ export function debridAddedNotice(provider: WireDebridProvider): string {
* message come from the same {@link DEBRID_LABELS} table the button itself
* reads, via {@link debridProviderLabel} and {@link debridAddLabel}, so the
* prompt can never name a button label that is not the one on screen.
*
* `seeders`/`sourceReportsHealth`/`isCached` only matter for `via === "debrid"`
* — an explicit debrid add is where `shouldBlockDebridAdd` applies, mirroring
* the TUI's `startDebridDownload`. `sourceReportsHealth` must already be
* resolved for this result's source (see `reportsHealthLookup`): a source with
* no swarm data reports `seeders: 0` for everything, which is "unknown", not
* "dead".
*/
export function addPlan(
via: AddVia,
debridConfigured: boolean,
name: string,
provider: WireDebridProvider | undefined,
seeders: number,
sourceReportsHealth: boolean,
isCached: boolean,
): AddPlan {
if (via === "debrid" || !debridConfigured) return { kind: "add", via };
if (via === "debrid") {
if (shouldBlockDebridAdd(seeders, isCached, sourceReportsHealth)) {
return {
kind: "blocked",
message:
`“${clip(name)}” has no seeders and isn't already cached — adding it ` +
`via debrid would just sit stuck. Not added.`,
};
}
return { kind: "add", via };
}
if (!debridConfigured) return { kind: "add", via };
const label = provider ? debridProviderLabel(provider) : "your debrid provider";
const addLabel = provider ? debridAddLabel(provider) : "the debrid add button";
return {
Expand Down