From ce40946c2a2ff22057d8a6915b7e339140bf980d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 09:57:49 -0700 Subject: [PATCH 01/21] chore: add ThreatCrush security scan on pull requests (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add .github/workflows/coinpay.yml via sh1pt coinpay-invoice@1.0.0 (#3) Co-authored-by: sh1pt-actions-fleet[bot] <287014002+sh1pt-actions-fleet[bot]@users.noreply.github.com> * Add .github/workflows/vu1nz-scan.yml via sh1pt vu1nz-scan@1.0.1 (#2) Co-authored-by: sh1pt-actions-fleet[bot] <287014002+sh1pt-actions-fleet[bot]@users.noreply.github.com> * chore: add ThreatCrush security scan on pull requests vu1nz-scan.yml reviews a PR's diff with an LLM and needs an API key to do it. This is the other half: offline static analysis over the whole tree, no secrets, no network beyond the install. It looks for what a diff review is worst at spotting — a credential committed three releases ago, an injection pattern in a file nobody touched this PR — and writes SARIF, so findings land in the Security tab with history rather than only in a comment. Fails on critical/high to match vu1nz-scan.yml, which already exits 1 on high/critical. That floor is quieter than it sounds: ThreatCrush caps pattern-confidence findings at medium, so a bare "this construct exists on this line" match cannot break a build. Only contextual and evidence findings reach it. main is at 0 critical / 0 high today, so this is green on arrival and needs no suppressions. Node 22, not the pack's 20: it matches engines.node and the CI matrix floor. Not 24 — better-sqlite3 has no prebuild there yet and the install falls through to a node-gyp source build and fails. The workflow is arranged so a scan that did not run can never report as a scan that found nothing. The SARIF file, not the exit code, is the evidence a scan happened; --format support is checked up front rather than inferred from an exit code, because a CLI without it exits 1 on the unknown option, which is the same code the CLI uses for "findings at or above --fail-on". Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: sh1pt-actions-fleet[bot] <287014002+sh1pt-actions-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/coinpay.yml | 31 +++ .github/workflows/threatcrush-scan.yml | 291 +++++++++++++++++++++++++ .github/workflows/vu1nz-scan.yml | 219 +++++++++++++++++++ 3 files changed, 541 insertions(+) create mode 100644 .github/workflows/coinpay.yml create mode 100644 .github/workflows/threatcrush-scan.yml create mode 100644 .github/workflows/vu1nz-scan.yml diff --git a/.github/workflows/coinpay.yml b/.github/workflows/coinpay.yml new file mode 100644 index 00000000..be2bb4e5 --- /dev/null +++ b/.github/workflows/coinpay.yml @@ -0,0 +1,31 @@ +# Managed by sh1pt Actions Fleet +# pack: coinpay-invoice@1.0.0 +# install: sh1pt-actions-store +# hash: sha256:34ad3313699d6a34845801f6d1c0963e36bee5d375678ffdd2a4bf0f46a17fa2 +name: CoinPayPortal invoice command + +on: + issue_comment: + types: [created] + +permissions: + issues: write + pull-requests: write + +concurrency: + group: coinpay-${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + coinpay: + # Only spin up when a comment actually invokes the bot. + if: startsWith(github.event.comment.body, '/coinpay') + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: profullstack/coinpaybot@v0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + coinpay-api-key: ${{ secrets.COINPAY_API_KEY }} + coinpay-business-id: ${{ secrets.COINPAY_BUSINESS_ID }} + coinpay-base-url: https://coinpayportal.com diff --git a/.github/workflows/threatcrush-scan.yml b/.github/workflows/threatcrush-scan.yml new file mode 100644 index 00000000..61fb4a93 --- /dev/null +++ b/.github/workflows/threatcrush-scan.yml @@ -0,0 +1,291 @@ +# Adapted from the upstream `threatcrush-scan@1.1.0` pack +# (profullstack/threatcrush → .github/workflows/threatcrush-scan.yml). +# Deviations from that pack, all to fit this repo: +# - Node 22, not 20, matching `engines.node` and the CI matrix floor +# - fails on critical/high, matching vu1nz-scan.yml, which already does +# - the legacy text-output compatibility path is dropped; see "Detect the +# CLI output interface" below +# The sh1pt fleet header is deliberately not carried over: this file is no +# longer byte-identical to the pack, and claiming a hash that does not match +# would be worse than not claiming one. +name: threatcrush security scan + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + security-events: write + +# A new push to the same branch supersedes the run in flight, as in ci.yml. +concurrency: + group: threatcrush-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan for credentials and vulnerable patterns + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v7 + + # 22 is this repo's floor (`engines.node`) and the low end of the CI + # matrix. Not 24: ThreatCrush pulls in better-sqlite3, which ships + # prebuilt binaries for 22 but not yet for 24, where the install falls + # through to a node-gyp source build and fails. A security gate that + # cannot install is a security gate that does not run. + - uses: actions/setup-node@v7 + with: + node-version: "22" + + # An unretried `npm i -g` is a network call to a registry that decides + # whether a security gate runs at all. Retry before giving up; a + # transient registry blip is not a security signal and should not read + # like one. + - name: Install ThreatCrush + run: | + for attempt in 1 2 3; do + if npm install -g "@profullstack/threatcrush@latest"; then + exit 0 + fi + delay=$((attempt * 10)) + echo "::warning::ThreatCrush install attempt ${attempt}/3 failed; retrying in ${delay}s" + sleep "${delay}" + done + echo "::error::ThreatCrush install failed after 3 attempts" + exit 1 + + # Recorded into every run log so a release that changes the interface + # shows up immediately, rather than silently scoring zero. + - name: Record the CLI interface + run: | + threatcrush --version || true + threatcrush scan --help || true + + # Checked up front rather than inferred from an exit code, because exit + # codes cannot tell the two failures apart. CLIs before 0.3.0 have no + # `--format`: the scan died with `error: unknown option '--format'` and + # commander exited 1 — the same code the CLI uses for "findings at or + # above --fail-on". Read as a result, that produces a green check on a + # repository nothing has scanned. + # + # Upstream converts legacy text output with a vendored Python script. + # That path is dropped here rather than carrying a converter for a CLI + # older than the current npm release. Failing closed is the same safety + # property: an unrecognised interface stops the job instead of reporting + # an unscanned diff as clean. + - name: Detect the CLI output interface + run: | + if ! threatcrush scan --help 2>&1 | grep -q -- '--format'; then + echo "::error::CLI $(threatcrush --version 2>/dev/null || echo unknown) has no --format; cannot emit SARIF. This diff was NOT scanned." + exit 1 + fi + echo "Native SARIF output available." + + # --fail-on critical,high matches vu1nz-scan.yml, which already exits 1 + # on high/critical findings. It is not as noisy as it sounds: + # `pattern`-confidence findings are capped at medium upstream, so a bare + # "this construct exists" match cannot break a build. Only `contextual` + # and `evidence` findings reach this floor. Drop the flag to make the + # scan advisory. + - name: Scan + id: scan + run: | + set -o pipefail + code=0 + threatcrush scan . --format sarif --output threatcrush.sarif --fail-on critical,high || code=$? + + # The SARIF file is the evidence that a scan happened, and it is the + # only evidence worth trusting. An exit code says what the process + # thought; the file says what it produced. Absent the file there is + # nothing to report, and reporting nothing as "no findings" is the + # failure this whole workflow is arranged to avoid. + if [ ! -s threatcrush.sarif ]; then + echo "status=error" >> "$GITHUB_OUTPUT" + echo "::error::ThreatCrush produced no SARIF (exit ${code}) — this diff was NOT scanned" + exit 1 + fi + + case "$code" in + 0) echo "status=clean" >> "$GITHUB_OUTPUT" ;; + # Exit 1 *with* a SARIF file is the documented "findings at or + # above --fail-on" result; without one it was caught above. + # Propagate it: a gate that records the finding and then lets the + # job pass is not a gate. + 1) + echo "status=findings" >> "$GITHUB_OUTPUT" + exit 1 + ;; + *) + echo "status=error" >> "$GITHUB_OUTPUT" + echo "::error::ThreatCrush scan failed with exit code ${code} — results may be incomplete" + exit "$code" + ;; + esac + + # Reached only when an earlier step already failed the job. The empty + # run exists so the upload does not error on a missing file and bury the + # real cause; it is not a result. The scan step has already set + # status=error (or never ran), so the report says NOT RUN rather than + # rendering this as a clean scan. + - name: Ensure SARIF exists + if: always() + run: | + if [ ! -f threatcrush.sarif ]; then + cat > threatcrush.sarif <<'JSON' + { + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "runs": [{ "tool": { "driver": { "name": "ThreatCrush", "rules": [] } }, "results": [] }] + } + JSON + fi + + - name: Upload to the Security tab + if: always() + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: threatcrush.sarif + category: threatcrush + + - name: Build the report + if: always() + run: | + python3 << 'PYEOF' + import json, os + + status = os.environ.get("SCAN_STATUS", "") + try: + with open("threatcrush.sarif") as handle: + results = json.load(handle)["runs"][0]["results"] + except Exception as err: + results = None + print(f"::warning::could not read SARIF: {err}") + + lines = ["## ThreatCrush Security Scan", ""] + + # Fail closed: render findings only on positive evidence that a scan + # completed. Testing for `status == "error"` would be fail-open — + # when an earlier step fails, the scan step is *skipped*, so + # `status` is the empty string rather than "error", and the comment + # cheerfully reports "0 findings" for a scan that never started. Any + # state that is not a known-good outcome is NOT RUN. + if status not in ("clean", "findings") or results is None: + # Never render "no issues found" for a scan that did not finish. + # An unexamined diff is not a clean one, and the two are + # indistinguishable to whoever reads the comment. + lines += [ + "**NOT RUN** — the scan did not complete, so this diff was not examined.", + "This is not a clean result. See the job log.", + ] + else: + counts = {"error": 0, "warning": 0, "note": 0} + for result in results: + level = result.get("level", "warning") + if level in counts: + counts[level] += 1 + + lines.append(f"**{len(results)}** finding(s)") + lines.append("") + + if results: + badges = [] + if counts["error"]: + badges.append(f"**HIGH/CRITICAL**: {counts['error']}") + if counts["warning"]: + badges.append(f"**MEDIUM**: {counts['warning']}") + if counts["note"]: + badges.append(f"**LOW**: {counts['note']}") + if badges: + lines += [" | ".join(badges), ""] + + lines += ["| Severity | Rule | Location |", "|---|---|---|"] + for result in results[:50]: + location = result["locations"][0]["physicalLocation"] + uri = location["artifactLocation"]["uri"] + line_no = location.get("region", {}).get("startLine", 1) + label = {"error": "HIGH", "warning": "MEDIUM", "note": "LOW"}.get( + result.get("level", "warning"), "INFO" + ) + lines.append(f"| {label} | `{result.get('ruleId','?')}` | `{uri}`:{line_no} |") + if len(results) > 50: + # Say so. A silent truncation reads as "that was everything". + lines += ["", f"_…and {len(results) - 50} more. Full results in the Security tab._"] + lines += ["", "Snippets are redacted; ThreatCrush never prints matched credential material."] + else: + lines.append("No findings.") + + with open(os.environ["RUNNER_TEMP"] + "/threatcrush-comment.md", "w") as handle: + handle.write("\n".join(lines) + "\n") + PYEOF + env: + SCAN_STATUS: ${{ steps.scan.outputs.status }} + + - name: Write report to job summary + if: always() + run: cat "$RUNNER_TEMP/threatcrush-comment.md" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true + + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: threatcrush-sarif + path: threatcrush.sarif + retention-days: 30 + + # Best-effort, exactly as vu1nz-scan.yml treats its own comment step. + # `pull_request` gives fork PRs a read-only token, so this 403s on fork + # submissions — the report is in the job summary either way, and the + # scan's pass/fail is decided by the scan step, not by whether a comment + # posted. Deliberately NOT switching to pull_request_target to get a + # writable token: that event runs with repository secrets in scope + # against a checkout of untrusted contributor code. + - name: Comment on PR + if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + continue-on-error: true + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let body; + try { + body = fs.readFileSync(`${process.env.RUNNER_TEMP}/threatcrush-comment.md`, 'utf8'); + } catch { + body = '## ThreatCrush Security Scan\n\nScan completed but the report could not be read.'; + } + + try { + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes('ThreatCrush Security Scan'), + ); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } + } catch (err) { + core.warning( + `Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. ` + + 'Findings are in the job summary.', + ); + } diff --git a/.github/workflows/vu1nz-scan.yml b/.github/workflows/vu1nz-scan.yml new file mode 100644 index 00000000..da7e322e --- /dev/null +++ b/.github/workflows/vu1nz-scan.yml @@ -0,0 +1,219 @@ +# Managed by sh1pt Actions Fleet +# pack: vu1nz-scan@1.0.1 +# install: sh1pt-actions-store +# hash: sha256:69dca6b225e64533cd02750003f56fd1ccb4f178c37f48d4398a8254a3fe887b +name: vu1nz security scan + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + review: + name: Review PR for security vulnerabilities + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install vu1nz + run: pip install --quiet git+https://github.com/profullstack/vu1nz-gh-actions.git + + - name: Load env file + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + echo "$ENV_FILE" > "$RUNNER_TEMP/.env" + echo "Keys in ENV_FILE:" + grep -oP '^[A-Z_]+(?==)' "$RUNNER_TEMP/.env" || echo "(no keys found or different format)" + ANTHROPIC_API_KEY=$(grep -E '^ANTHROPIC_API_KEY=' "$RUNNER_TEMP/.env" | head -1 | sed 's/^ANTHROPIC_API_KEY=//') + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "::add-mask::$ANTHROPIC_API_KEY" + echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> "$GITHUB_ENV" + echo "ANTHROPIC_API_KEY found and exported" + else + echo "::warning::ANTHROPIC_API_KEY not found in ENV_FILE" + fi + + - name: Review PR + id: review + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NO_COLOR: "1" + TERM: dumb + run: | + vu1nz review-pr main \ + ${{ github.repository }} \ + ${{ github.event.pull_request.number }} \ + --token "$GITHUB_TOKEN" \ + --json \ + | tee "$RUNNER_TEMP/vu1nz-review-raw.txt" || true + + python3 -c " + import json, re, sys + raw = open('$RUNNER_TEMP/vu1nz-review-raw.txt').read() + raw = re.sub(r'\x1b\[[0-9;]*m', '', raw) + start = raw.find('{') + if start >= 0: + obj, _ = json.JSONDecoder(strict=False).raw_decode(raw, start) + json.dump(obj, sys.stdout) + else: + print('{}') + " > "$RUNNER_TEMP/vu1nz-review.json" + + - name: Build PR comment + id: comment + run: | + python3 << 'PYEOF' + import json, os, sys + + review_file = os.environ.get("RUNNER_TEMP", "") + "/vu1nz-review.json" + comment_file = os.environ.get("RUNNER_TEMP", "") + "/vu1nz-comment.md" + + try: + with open(review_file) as f: + data = json.loads(f.read(), strict=False) + except Exception as e: + print(f"::warning::Could not parse review results: {e}") + with open(comment_file, "w") as f: + f.write("## vu1nz Security Review\n\nCould not parse review results.\n") + sys.exit(0) + + findings = data.get("findings", []) + analysis = data.get("analysis", "") + pr = data.get("pr_number", "?") + total = len(findings) + + counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} + for finding in findings: + sev = finding.get("severity", "").lower() + if sev in counts: + counts[sev] += 1 + + has_hc = counts["critical"] > 0 or counts["high"] > 0 + + lines = ["## vu1nz Security Review", ""] + lines.append(f"**{total}** finding(s) in PR #{pr}") + lines.append("") + + badge_parts = [] + for sev in ("critical", "high", "medium", "low"): + if counts[sev] > 0: + badge_parts.append(f"**{sev.upper()}**: {counts[sev]}") + if badge_parts: + lines.append(" | ".join(badge_parts)) + lines.append("") + + if has_hc: + lines.append("> **High or critical findings - review before merging.**") + lines.append("") + + if findings: + lines.append("### Findings") + lines.append("") + lines.append("| Severity | File | Issue | Suggestion |") + lines.append("|----------|------|-------|------------|") + for f in findings: + sev = f.get("severity", "?").upper() + file = f.get("file", "N/A") + issue = f.get("issue", "").replace("\n", " ")[:150] + suggestion = f.get("suggestion", "").replace("\n", " ")[:150] + lines.append(f"| {sev} | `{file}` | {issue} | {suggestion} |") + lines.append("") + else: + lines.append("No security issues found.") + lines.append("") + + if analysis: + lines.append("
Full AI Analysis") + lines.append("") + lines.append(analysis) + lines.append("") + lines.append("
") + + body = "\n".join(lines) + with open(comment_file, "w") as f: + f.write(body) + + with open(os.environ.get("GITHUB_OUTPUT", ""), "a") as out: + out.write(f"total={total}\n") + out.write(f"has_high_critical={'true' if has_hc else 'false'}\n") + + if has_hc: + print(f"::error::vu1nz found high/critical vulnerabilities in PR code") + sys.exit(1) + + print(f"::notice::vu1nz review: {total} finding(s), no high/critical issues") + PYEOF + + - name: Write report to job summary + if: always() + run: | + if [ -f "$RUNNER_TEMP/vu1nz-comment.md" ]; then + cat "$RUNNER_TEMP/vu1nz-comment.md" >> "$GITHUB_STEP_SUMMARY" + else + echo "## vu1nz Security Review" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Scan completed but could not read results." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Comment on PR + # Best-effort only. Skip for Dependabot (read-only token can't comment) + # and never fail the job if posting the comment errors — the scan's + # pass/fail is decided by the "Build PR comment" step, and findings are + # always written to the job summary. + if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + continue-on-error: true + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const commentFile = `${process.env.RUNNER_TEMP}/vu1nz-comment.md`; + let body; + try { + body = fs.readFileSync(commentFile, 'utf8'); + } catch { + body = '## vu1nz Security Review\n\nScan completed but could not read results.'; + } + + try { + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const existing = comments.find(c => + c.user.type === 'Bot' && c.body.includes('vu1nz Security Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body, + }); + } + } catch (err) { + // Posting the comment is best-effort. Read-only tokens return 403 + // and transient GitHub outages return 503 (the "Unicorn" HTML + // page); neither should fail the scan. Findings are in the job + // summary regardless. + core.warning(`Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. Findings are in the job summary.`); + } From 7b297462f69e504efbb543266f4833f80d5fe343 Mon Sep 17 00:00:00 2001 From: StaticAron <66104268+staticaron@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:27:43 +0000 Subject: [PATCH 02/21] Highlight Selected Items (#130) * feat: added support for highlighted rows in search results * feat: added bold to Source Text * feat: added highlights to Added date * feat: added highlighted item support for downloaded items * feat: added highlighted item support for seeding list * fix: formatting * fix: removed highlight colors and only relying on dimColor * fix: formatting * fix: dim happens when the item is not selected * fix: removed highlight colors --------- Co-authored-by: dev --- src/ui/components/Downloads.tsx | 32 +++++++++++++++++++++++++------- src/ui/components/Results.tsx | 26 ++++++++++++++++++++------ src/ui/components/Seeding.tsx | 20 ++++++++++++++++---- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/ui/components/Downloads.tsx b/src/ui/components/Downloads.tsx index 68f4ee98..bfb70265 100644 --- a/src/ui/components/Downloads.tsx +++ b/src/ui/components/Downloads.tsx @@ -188,11 +188,18 @@ export function Downloads() { - {it.totalBytes > 0 ? formatBytes(it.totalBytes) : "-"} + {it.totalBytes > 0 ? formatBytes(it.totalBytes) : "-"} + - - {it.source ? ss.tag : "mag"} + {it.source ? ss.tag : "mag"} @@ -245,14 +252,25 @@ export function Downloads() { - {h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"} + {h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"} + - {when || "-"} + {when || "-"} + - - {h.source ? ss.tag : "mag"} + {h.source ? ss.tag : "mag"} diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index 4d323f55..c28623e6 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -465,11 +465,18 @@ export function Results() { {showStats ? ( <> - {r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"} + {r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"} + - 0 ? COLOR.good : undefined} dimColor={r.seeders === 0}> - {r.seeders || r.leechers + 0 ? COLOR.good : undefined} + dimColor={!here} + bold={here} + >{r.seeders || r.leechers ? `${formatCount(r.seeders)}:${formatCount(r.leechers)}` : "-"} @@ -477,12 +484,19 @@ export function Results() { ) : ( - {formatRelative(r.added) || "-"} + {formatRelative(r.added) || "-"} + )} - - {ss.tag} + {ss.tag} diff --git a/src/ui/components/Seeding.tsx b/src/ui/components/Seeding.tsx index a4d437db..2749be70 100644 --- a/src/ui/components/Seeding.tsx +++ b/src/ui/components/Seeding.tsx @@ -155,14 +155,26 @@ export function Seeding() { - {h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"} + {h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"} + - {truncate(st.text, STATUS_W)} + {truncate(st.text, STATUS_W)} + - - {h.source ? ss.tag : "mag"} + {h.source ? ss.tag : "mag"} From ace41fb99810c0b209655397d2c3a30b5d1f5e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:52:25 +0300 Subject: [PATCH 03/21] fix: keep a torrent's own trackers in the magnet built from its file (#146) magnetFromTorrentFile threw the announce list away and rebuilt the magnet from the public defaults alone, so a torrent that isn't on the public DHT -- a private tracker, a small private swarm -- sat at zero peers forever. On a private tracker the passkey that makes an announce work at all lives in that URL, so dropping it is fatal rather than just slower. This is the path both the watch folder and `torlnk .torrent` take. The file's own trackers now go in ahead of the defaults, deduplicated, and buildMagnet takes them as an optional third argument so every existing caller is untouched. Since we're already stat-ing to read the file: a .torrent is metadata and stays in the low megabytes, so anything larger is refused rather than pulled into memory whole -- a watch folder takes whatever is dropped in it, including a mis-named disk image. Co-authored-by: Claude Opus 5 --- src/parse-torrent.d.ts | 1 + src/sources/magnet.test.ts | 14 +++++ src/sources/magnet.ts | 13 ++++- src/sources/torrentFile.test.ts | 94 +++++++++++++++++++++++++++++++++ src/sources/torrentFile.ts | 16 +++++- 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 src/sources/torrentFile.test.ts diff --git a/src/parse-torrent.d.ts b/src/parse-torrent.d.ts index 57623ce6..c7a11259 100644 --- a/src/parse-torrent.d.ts +++ b/src/parse-torrent.d.ts @@ -2,6 +2,7 @@ declare module "parse-torrent" { interface ParsedTorrent { infoHash: string; name?: string; + announce?: string[]; } export default function parseTorrent( torrentId: Uint8Array | string, diff --git a/src/sources/magnet.test.ts b/src/sources/magnet.test.ts index 9ef91ea4..47af92c9 100644 --- a/src/sources/magnet.test.ts +++ b/src/sources/magnet.test.ts @@ -49,6 +49,20 @@ describe("buildMagnet", () => { // At least one non-UDP tracker, so UDP-blocked networks can still announce. expect(out).toContain(encodeURIComponent("http://tracker.opentrackr.org:1337/announce")); }); + it("puts extra trackers ahead of the public defaults", () => { + const own = "http://private.example.org/announce?pk=xyz"; + const out = buildMagnet("abc123", "Thing", [own]); + const mine = out.indexOf(`&tr=${encodeURIComponent(own)}`); + expect(mine).toBeGreaterThan(-1); + expect(mine).toBeLessThan( + out.indexOf(encodeURIComponent("udp://tracker.opentrackr.org:1337/announce")), + ); + }); + it("keeps one copy of a tracker that is also a default, and drops blanks", () => { + const dupe = "udp://tracker.opentrackr.org:1337/announce"; + const out = buildMagnet("abc123", "Thing", [dupe, " ", dupe]); + expect(out.split(`&tr=${encodeURIComponent(dupe)}`).length - 1).toBe(1); + }); }); describe("isInfoHash", () => { diff --git a/src/sources/magnet.ts b/src/sources/magnet.ts index 84f7061a..99079433 100644 --- a/src/sources/magnet.ts +++ b/src/sources/magnet.ts @@ -14,9 +14,18 @@ const TRACKERS = [ "https://tracker.tamersunion.org:443/announce", ]; -export function buildMagnet(infoHash: string, name: string): string { +// extraTrackers come first and win on duplicates: a torrent that carries its own +// announce list means that list, and the public defaults are only a fallback. +export function buildMagnet(infoHash: string, name: string, extraTrackers: string[] = []): string { const dn = encodeURIComponent(name); - const tr = TRACKERS.map((t) => `&tr=${encodeURIComponent(t)}`).join(""); + const seen = new Set(); + const trackers = [...extraTrackers, ...TRACKERS].filter((t) => { + const url = t.trim(); + if (!url || seen.has(url)) return false; + seen.add(url); + return true; + }); + const tr = trackers.map((t) => `&tr=${encodeURIComponent(t)}`).join(""); return `magnet:?xt=urn:btih:${infoHash}&dn=${dn}${tr}`; } diff --git a/src/sources/torrentFile.test.ts b/src/sources/torrentFile.test.ts new file mode 100644 index 00000000..71f33795 --- /dev/null +++ b/src/sources/torrentFile.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { magnetFromTorrentFile } from "./torrentFile"; + +// Hand-rolled bencode, so the fixture is a real .torrent parse-torrent accepts +// rather than a checked-in binary blob. +function bstr(s: string): Buffer { + const b = Buffer.from(s, "utf8"); + return Buffer.concat([Buffer.from(`${b.length}:`), b]); +} + +function makeTorrent(announce: string[]): Buffer { + const info = Buffer.concat([ + Buffer.from("d"), + bstr("length"), + Buffer.from("i1024e"), + bstr("name"), + bstr("test.bin"), + bstr("piece length"), + Buffer.from("i16384e"), + bstr("pieces"), + Buffer.from("20:"), + Buffer.alloc(20, 7), + Buffer.from("e"), + ]); + const head: Buffer[] = [Buffer.from("d")]; + if (announce[0]) head.push(bstr("announce"), bstr(announce[0])); + if (announce.length) { + head.push(bstr("announce-list"), Buffer.from("l")); + for (const url of announce) head.push(Buffer.from("l"), bstr(url), Buffer.from("e")); + head.push(Buffer.from("e")); + } + return Buffer.concat([...head, bstr("info"), info, Buffer.from("e")]); +} + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-torrentfile-")); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +async function write(name: string, data: Buffer): Promise { + const file = path.join(dir, name); + await fs.writeFile(file, data); + return file; +} + +describe("magnetFromTorrentFile", () => { + it("reads the info hash and name out of a .torrent", async () => { + const file = await write("plain.torrent", makeTorrent([])); + const parsed = await magnetFromTorrentFile(file); + expect(parsed?.name).toBe("test.bin"); + expect(parsed?.infoHash).toMatch(/^[a-f0-9]{40}$/); + }); + + it("puts the torrent's own trackers in the magnet, ahead of the public ones", async () => { + const own = "http://private.example.org/announce?pk=xyz"; + const file = await write("private.torrent", makeTorrent([own])); + const parsed = await magnetFromTorrentFile(file); + const mine = parsed!.magnet.indexOf(`&tr=${encodeURIComponent(own)}`); + expect(mine).toBeGreaterThan(-1); + expect(mine).toBeLessThan( + parsed!.magnet.indexOf(encodeURIComponent("udp://tracker.opentrackr.org:1337/announce")), + ); + }); + + it("keeps one copy of a tracker the defaults already carry", async () => { + const dupe = "udp://tracker.opentrackr.org:1337/announce"; + const file = await write("dupe.torrent", makeTorrent([dupe])); + const parsed = await magnetFromTorrentFile(file); + const hits = parsed!.magnet.split(`&tr=${encodeURIComponent(dupe)}`).length - 1; + expect(hits).toBe(1); + }); + + it("returns null for a missing file, a directory, an empty file, and junk", async () => { + expect(await magnetFromTorrentFile(path.join(dir, "nope.torrent"))).toBe(null); + expect(await magnetFromTorrentFile(dir)).toBe(null); + expect(await magnetFromTorrentFile(await write("empty.torrent", Buffer.alloc(0)))).toBe(null); + expect( + await magnetFromTorrentFile(await write("junk.torrent", Buffer.from("not a torrent"))), + ).toBe(null); + }); + + it("refuses a file too large to be metadata rather than reading it in", async () => { + const big = await write("big.torrent", Buffer.alloc(17 * 1024 * 1024)); + expect(await magnetFromTorrentFile(big)).toBe(null); + }); +}); diff --git a/src/sources/torrentFile.ts b/src/sources/torrentFile.ts index 42397903..321f2cb8 100644 --- a/src/sources/torrentFile.ts +++ b/src/sources/torrentFile.ts @@ -2,14 +2,28 @@ import { promises as fs } from "node:fs"; import parseTorrent from "parse-torrent"; import { buildMagnet, type ParsedMagnet } from "./magnet"; +// A .torrent is metadata, not payload: even a torrent with tens of thousands of +// pieces stays in the low megabytes. The cap is what keeps a mis-named disk +// image dropped in the watch folder from being pulled into memory whole. +const MAX_TORRENT_BYTES = 16 * 1024 * 1024; + export async function magnetFromTorrentFile(path: string): Promise { try { + const stat = await fs.stat(path); + if (!stat.isFile() || stat.size === 0 || stat.size > MAX_TORRENT_BYTES) return null; const buf = await fs.readFile(path); const parsed = await parseTorrent(new Uint8Array(buf)); const infoHash = parsed?.infoHash?.toLowerCase(); if (!infoHash) return null; const name = parsed.name || infoHash; - return { infoHash, name, magnet: buildMagnet(infoHash, name) }; + // Carry the file's own announce list into the magnet. Without it a torrent + // that isn't on the public DHT — a private tracker, a small private swarm — + // sits at zero peers forever, and on a private tracker the passkey that + // makes an announce work at all lives in that URL. + const announce = Array.isArray(parsed.announce) + ? parsed.announce.filter((url): url is string => typeof url === "string") + : []; + return { infoHash, name, magnet: buildMagnet(infoHash, name, announce) }; } catch { return null; } From 38f84f4243763e30e8d7031b33080769f13c1453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:52:34 +0300 Subject: [PATCH 04/21] feat: accept a .torrent dragged onto the search field (#147) Dropping a file on a terminal pastes its path into whatever is reading input, which is already how people expect to hand a client a .torrent. The search field took a magnet or an infohash but treated a path as a search query, so the obvious gesture silently searched for "C:\Users\..." instead. Each emulator escapes that path its own way and none of them agree: Windows Terminal and PowerShell wrap it in double quotes and leave a trailing space, macOS Terminal and iTerm2 escape spaces and parens with backslashes, GNOME Terminal pastes a percent-escaped file:// URI. resolveTorrentPath unwraps all three back to a plain path, with the backslash rule split by platform -- an escape on macOS and Linux, a path separator on Windows, so a Windows path is never mangled. Its tests drive both platforms from either host rather than only the half that matches the runner. Paste (v) takes a path too, since copying a file in a file manager puts one on the clipboard, and `torlnk .torrent` runs its argument through the same normalizer so a shell-quoted or file:// argument works there as well. A path that turns out not to be a readable torrent says so rather than falling back to a search, so the failure is never silent. No new key, no change to an existing one -- Enter on the search field already meant "do something with this", and this widens what it accepts. Co-authored-by: Claude Opus 5 --- README.md | 2 +- src/config/folder.ts | 13 +++++- src/sources/torrentPath.test.ts | 72 +++++++++++++++++++++++++++++++++ src/sources/torrentPath.ts | 66 ++++++++++++++++++++++++++++++ src/ui/App.tsx | 38 +++++++++++++++-- src/ui/views/Splash.tsx | 2 +- 6 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 src/sources/torrentPath.test.ts create mode 100644 src/sources/torrentPath.ts diff --git a/README.md b/README.md index fb61505b..4c9ab736 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ torlink is a torrent finder that lives in your terminal, with zero setup and not npx torlnk ``` -That's the only thing you'll type. torlink opens straight to a search bar: search for what you want, paste in a magnet link or a bare infohash, or just press Enter on an empty box to browse the curated library. From there it's all keypresses, nothing to memorize, and `?` brings up the full list anytime. +That's the only thing you'll type. torlink opens straight to a search bar: search for what you want, paste in a magnet link or a bare infohash, drag a `.torrent` file from your file manager onto the window, or just press Enter on an empty box to browse the curated library. From there it's all keypresses, nothing to memorize, and `?` brings up the full list anytime. ## Finding something diff --git a/src/config/folder.ts b/src/config/folder.ts index 9dcd543f..78669659 100644 --- a/src/config/folder.ts +++ b/src/config/folder.ts @@ -1,13 +1,22 @@ import os from "node:os"; import path from "node:path"; +// node:path doesn't export its interface by name, so borrow it off a member. +type PlatformPath = typeof path.win32; + // We read the raw input field, so expand a leading ~ ourselves (~\ too, for // paths pasted from Windows). ~bob isn't us, so leave it alone. -export function expandHome(input: string, home: string = os.homedir()): string { +// `p` lets a caller reasoning about one platform's paths (and its tests) pass +// path.win32 / path.posix instead of the host's flavour; it defaults to the host. +export function expandHome( + input: string, + home: string = os.homedir(), + p: PlatformPath = path, +): string { const trimmed = input.trim(); if (trimmed === "~") return home; if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { - return path.join(home, trimmed.slice(2)); + return p.join(home, trimmed.slice(2)); } return trimmed; } diff --git a/src/sources/torrentPath.test.ts b/src/sources/torrentPath.test.ts new file mode 100644 index 00000000..375af050 --- /dev/null +++ b/src/sources/torrentPath.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { resolveTorrentPath, unquote, fromFileUrl } from "./torrentPath"; + +// Both platforms are exercised from either host: the point of the module is +// that a path escaped by one OS's terminal is understood, and CI shouldn't +// only check the half that matches the runner. +const WIN = { windows: true, home: "C:\\Users\\u" }; +const NIX = { windows: false, home: "/home/u" }; + +describe("unquote", () => { + it("strips a matching pair of double quotes and the trailing space a drop leaves", () => { + expect(unquote('"C:\\Users\\u\\a.torrent" ')).toBe("C:\\Users\\u\\a.torrent"); + }); + it("strips single quotes too", () => { + expect(unquote("'/home/u/a.torrent'")).toBe("/home/u/a.torrent"); + }); + it("leaves an unmatched or interior quote alone", () => { + expect(unquote('"/home/u/a.torrent')).toBe('"/home/u/a.torrent'); + expect(unquote("/home/u/it's.torrent")).toBe("/home/u/it's.torrent"); + }); +}); + +describe("fromFileUrl", () => { + it("decodes a POSIX file URI", () => { + expect(fromFileUrl("file:///home/u/My%20Show.torrent", false)).toBe("/home/u/My Show.torrent"); + }); + it("drops the slash before a Windows drive letter", () => { + expect(fromFileUrl("file:///C:/Users/u/a.torrent", true)).toBe("C:/Users/u/a.torrent"); + }); + it("accepts the file://localhost/ form", () => { + expect(fromFileUrl("file://localhost/home/u/a.torrent", false)).toBe("/home/u/a.torrent"); + }); + it("returns null for a non-URI and for a broken escape", () => { + expect(fromFileUrl("/home/u/a.torrent", false)).toBe(null); + expect(fromFileUrl("file:///home/u/100%.torrent", false)).toBe(null); + }); +}); + +describe("resolveTorrentPath", () => { + it("takes the quoted path with a trailing space that Windows Terminal drops", () => { + expect(resolveTorrentPath('"C:\\Users\\u\\Downloads\\My Show.torrent" ', WIN)).toBe( + "C:\\Users\\u\\Downloads\\My Show.torrent", + ); + }); + it("keeps Windows backslashes rather than reading them as escapes", () => { + expect(resolveTorrentPath("C:\\Users\\u\\a.torrent", WIN)).toBe("C:\\Users\\u\\a.torrent"); + }); + it("unescapes the backslashes macOS and Linux terminals add for spaces", () => { + expect(resolveTorrentPath("/home/u/My\\ Show\\ \\(2024\\).torrent", NIX)).toBe( + "/home/u/My Show (2024).torrent", + ); + }); + it("resolves the file:// URI GNOME Terminal pastes", () => { + expect(resolveTorrentPath("file:///home/u/My%20Show.torrent", NIX)).toBe( + "/home/u/My Show.torrent", + ); + }); + it("expands a typed ~ path", () => { + expect(resolveTorrentPath("~/Downloads/a.torrent", NIX)).toBe("/home/u/Downloads/a.torrent"); + }); + it("accepts an uppercase extension", () => { + expect(resolveTorrentPath("/home/u/A.TORRENT", NIX)).toBe("/home/u/A.TORRENT"); + }); + it("returns null for a search query, a magnet, and empty input", () => { + expect(resolveTorrentPath("the matrix 1999", NIX)).toBe(null); + expect(resolveTorrentPath("magnet:?xt=urn:btih:" + "a".repeat(40), NIX)).toBe(null); + expect(resolveTorrentPath(" ", NIX)).toBe(null); + }); + it("returns null for a file that isn't a .torrent", () => { + expect(resolveTorrentPath('"C:\\Users\\u\\holiday.mp4"', WIN)).toBe(null); + }); +}); diff --git a/src/sources/torrentPath.ts b/src/sources/torrentPath.ts new file mode 100644 index 00000000..bdf37c2d --- /dev/null +++ b/src/sources/torrentPath.ts @@ -0,0 +1,66 @@ +// A file dragged onto a terminal window never arrives as a clean path: every +// emulator escapes it its own way. Windows Terminal and PowerShell wrap it in +// double quotes (and leave a trailing space), macOS Terminal and iTerm2 escape +// spaces and parens with backslashes, GNOME Terminal pastes a file:// URI. +// Normalize all of those back to a plain path before we touch the disk. + +import os from "node:os"; +import path from "node:path"; +import { expandHome } from "../config/folder"; + +export interface TorrentPathOptions { + home?: string; + // Platform of the *path*, not of the process: a Windows path keeps its + // backslashes, a POSIX one has them stripped as escapes. Defaults to the + // host, and is passed explicitly by the tests so all three shapes are + // checked on every OS rather than only on the one that produces them. + windows?: boolean; +} + +// Strips one matching pair of surrounding quotes. Callers get a trimmed string +// either way, so a dropped path's trailing space is gone before anything else. +export function unquote(input: string): string { + const s = input.trim(); + const first = s[0]; + if (s.length >= 2 && (first === '"' || first === "'") && s[s.length - 1] === first) { + return s.slice(1, -1).trim(); + } + return s; +} + +// file:///home/u/a.torrent -> /home/u/a.torrent, file:///C:/u/a.torrent -> C:/u/a.torrent. +// Decoded by hand rather than via fileURLToPath so the result depends on the +// path's platform, not the running one. Returns null for anything not a file URI. +export function fromFileUrl(input: string, windows: boolean): string | null { + const m = /^file:\/\/(?:localhost)?(\/.*)$/i.exec(input); + if (!m) return null; + let p: string; + try { + p = decodeURIComponent(m[1]!); + } catch { + return null; // a stray % that isn't an escape + } + // A Windows URI carries a leading slash before the drive letter: /C:/x -> C:/x. + if (windows && /^\/[a-z]:/i.test(p)) p = p.slice(1); + return p; +} + +// Raw input field text -> a path to read, or null if this isn't a .torrent path +// at all (an ordinary search query, a magnet link). Existence isn't checked +// here; the caller reads the file and reports its own failure. +export function resolveTorrentPath(raw: string, options: TorrentPathOptions = {}): string | null { + const windows = options.windows ?? process.platform === "win32"; + const home = options.home ?? os.homedir(); + const unquoted = unquote(raw); + if (!unquoted) return null; + + const p = windows ? path.win32 : path.posix; + const url = fromFileUrl(unquoted, windows); + // Backslash is a path separator on Windows, so only POSIX unescapes: there + // `My\ Files` means one folder named "My Files". + const bare = url ?? (windows ? unquoted : unquoted.replace(/\\(.)/g, "$1")); + if (!/\.torrent$/i.test(bare)) return null; + // A URI is already absolute; only typed input can carry a ~. + const expanded = url ?? expandHome(bare, home, p); + return p.normalize(expanded); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3f75f855..441dd469 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -16,6 +16,7 @@ import { import { logCrash } from "../util/crashlog"; import { parseInput } from "../sources/magnet"; import { magnetFromTorrentFile } from "../sources/torrentFile"; +import { resolveTorrentPath } from "../sources/torrentPath"; import { readClipboard, writeClipboard } from "../util/clipboard"; import { openFolder } from "../util/openFolder"; import { cleanText, formatBytes, truncate } from "../util/format"; @@ -152,7 +153,7 @@ export function App({ const launch = initialMagnet ? parseInput(initialMagnet) : initialTorrent - ? await magnetFromTorrentFile(initialTorrent) + ? await magnetFromTorrentFile(resolveTorrentPath(initialTorrent) ?? initialTorrent) : null; if (launch) { await fs.mkdir(cfg.downloadDir, { recursive: true }).catch(() => {}); @@ -384,6 +385,25 @@ export function App({ [queue, config], ); + // A .torrent dragged onto the terminal lands in the search field as a path. + // Read it, hand the queue the magnet built from its metadata, and say so when + // it can't be read rather than quietly searching for the path text. + const startFromTorrentFile = useCallback( + (file: string) => { + setNotice(`Reading torrent file: ${truncate(file, 48)}`); + void (async () => { + const parsed = await magnetFromTorrentFile(file); + if (!parsed) { + setNotice(`Couldn't read a torrent from ${truncate(file, 48)}.`); + return; + } + startDownload({ id: parsed.infoHash, name: parsed.name, magnet: parsed.magnet }); + })(); + setView("browser"); + }, + [startDownload], + ); + const submitQuery = useCallback( (raw: string) => { const q = raw.trim(); @@ -398,13 +418,18 @@ export function App({ setView("browser"); return; } + const file = resolveTorrentPath(q); + if (file) { + startFromTorrentFile(file); + return; + } } setQuery(q); setView("browser"); if (section === "downloads") setSection("all"); setRegion("content"); }, - [section, startDownload], + [section, startDownload, startFromTorrentFile], ); const pasteFromClipboard = useCallback(async () => { @@ -420,8 +445,15 @@ export function App({ setView("browser"); return; } + // Copying a file in a file manager puts its path on the clipboard, so paste + // takes one too — same handling as a drag onto the search field. + const file = resolveTorrentPath(text); + if (file) { + startFromTorrentFile(file); + return; + } setNotice("No magnet link on the clipboard."); - }, [startDownload]); + }, [startDownload, startFromTorrentFile]); useEffect(() => { if (!notice) return; diff --git a/src/ui/views/Splash.tsx b/src/ui/views/Splash.tsx index 478a5628..779880b5 100644 --- a/src/ui/views/Splash.tsx +++ b/src/ui/views/Splash.tsx @@ -58,7 +58,7 @@ export function Splash({ width={barWidth} value="" editing - placeholder="Search or paste a magnet link…" + placeholder="Search, paste a magnet, or drop a .torrent…" onSubmit={submitQuery} onExitDown={() => submitQuery("")} /> From d5e75cbc770412613e1fb96a41322fc893adcc0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=BDan=20Mlakar?= <148876659+zanmlakar@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:10:20 +0000 Subject: [PATCH 05/21] fix: retry apibay search when it answers with its no-results sentinel (#155) --- src/sources/piratebay.test.ts | 75 +++++++++++++++++++++++++++++++++++ src/sources/piratebay.ts | 20 ++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 src/sources/piratebay.test.ts diff --git a/src/sources/piratebay.test.ts b/src/sources/piratebay.test.ts new file mode 100644 index 00000000..7b0585c2 --- /dev/null +++ b/src/sources/piratebay.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { tpbMovies } from "./piratebay"; +import { fetchResilient } from "../util/net"; + +vi.mock("../util/net", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchResilient: vi.fn() }; +}); + +const mockFetch = vi.mocked(fetchResilient); + +const page = (items: unknown[]): Response => + ({ ok: true, status: 200, json: async () => items }) as unknown as Response; + +// apibay answers an empty search with this single placeholder instead of []. +const SENTINEL = { + id: "0", + name: "No results returned", + info_hash: "0000000000000000000000000000000000000000", + category: "0", +}; + +const movieRow = { + id: "1", + name: "Dune Part Two 2024 1080p", + info_hash: "a".repeat(40), + seeders: "12", + leechers: "3", + size: "4000", + num_files: "2", + added: "1700000000", + category: "207", +}; + +const askedUrl = (call: number): string => String(mockFetch.mock.calls[call]![0]); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +// apibay caches search results per exact URL, and a query can be stuck with +// the sentinel on one URL form while the alternate form answers fine (live: +// q=metallica is poisoned bare but healthy with &cat=0; q=dune the other way +// around). One retry on the other form re-rolls that cache key instead of +// showing the user an empty column. +describe("apibay sentinel retry", () => { + it("retries with cat=0 when a search comes back as the no-results sentinel", async () => { + mockFetch.mockResolvedValueOnce(page([SENTINEL])); + mockFetch.mockResolvedValueOnce(page([movieRow])); + const results = await tpbMovies.search("metallica"); + expect(results.map((r) => r.name)).toEqual([movieRow.name]); + expect(askedUrl(0)).toBe("https://apibay.org/q.php?q=metallica"); + expect(askedUrl(1)).toBe("https://apibay.org/q.php?q=metallica&cat=0"); + }); + + it("asks once when the search returns real rows", async () => { + mockFetch.mockResolvedValueOnce(page([movieRow])); + await tpbMovies.search("dune"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("returns empty when both forms answer with the sentinel", async () => { + mockFetch.mockResolvedValueOnce(page([SENTINEL])); + mockFetch.mockResolvedValueOnce(page([SENTINEL])); + expect(await tpbMovies.search("qqqqzzzz")).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("never retries a browse, which hits the precompiled feed", async () => { + mockFetch.mockResolvedValueOnce(page([movieRow])); + await tpbMovies.search(""); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(askedUrl(0)).toContain("/precompiled/"); + }); +}); diff --git a/src/sources/piratebay.ts b/src/sources/piratebay.ts index da4065da..a391aefe 100644 --- a/src/sources/piratebay.ts +++ b/src/sources/piratebay.ts @@ -53,6 +53,21 @@ async function fetchItems(url: string, opts: SearchOptions): Promise { + const items = await fetchItems(`${API}/q.php?q=${encodeURIComponent(q)}`, opts); + if (!isNoResultsSentinel(items)) return items; + return fetchItems(`${API}/q.php?q=${encodeURIComponent(q)}&cat=0`, opts); +} + async function search( query: string, cats: Set, @@ -61,10 +76,7 @@ async function search( opts: SearchOptions, ): Promise { const q = query.trim(); - const items = await fetchItems( - q ? `${API}/q.php?q=${encodeURIComponent(q)}` : browseUrl, - opts, - ); + const items = q ? await searchItems(q, opts) : await fetchItems(browseUrl, opts); const out: TorrentResult[] = []; for (const it of items) { if (q && !cats.has(Number(it.category))) continue; From 205cabb00c348c2272e1761fbf4b46b682c0c275 Mon Sep 17 00:00:00 2001 From: "bairon.dev" Date: Wed, 19 Aug 2026 15:00:07 -0400 Subject: [PATCH 06/21] chore: bump to 1.7.0 and close the category and source sets in CONTRIBUTING --- CONTRIBUTING.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cec7ad28..dfa2a6a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,10 @@ Then check your change against the standards below. The pull request template wa ## The standards +### Four categories, one curated source list + +Games, Movies, TV, and Anime cover the majority of real torrent traffic, and the sidebar reads at a glance because that list is short. The sources feeding those tabs are settled for the same reason: every extra index is one more thing to keep alive and more noise in the results. A pull request adding a category (ebooks, music, software) or a new source is declined however clean the code, so please open an issue before you write one. Fixes to the sources already here are always welcome. + ### Match the existing grain Reuse what's there before you write something new. Cursor movement goes through `wrapStep` (`src/ui/move.ts`). Key hints live in the `Hint` / `HELP_GROUPS` / `footerHints` system (`src/ui/keymap.ts`). Shared app state is the `Store` interface (`src/ui/store.ts`). diff --git a/package-lock.json b/package-lock.json index b95d1891..a8eee9c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "torlnk", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "torlnk", - "version": "1.6.0", + "version": "1.7.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b683fbbb..61ae698c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "torlnk", - "version": "1.6.0", + "version": "1.7.0", "description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.", "type": "module", "bin": { From 9d8926589d19d68661120c962f871ff50ad4ceaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:03:00 +0300 Subject: [PATCH 07/21] fix: take node-datachannel's prebuilt binaries instead of its install script (#173) --- package-lock.json | 470 ++++++++++++++----------------------------- package.json | 2 + src/deps-pin.test.ts | 84 ++++++++ 3 files changed, 234 insertions(+), 322 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8eee9c5..d272068a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -580,6 +580,135 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@node-datachannel/android-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@node-datachannel/android-arm64/-/android-arm64-0.33.0.tgz", + "integrity": "sha512-3/Q8koe++/4X8jh5HvNH4/VTh+4XPHVAuBNZVsE9eI50tyOb9j21rxSfLJTZRK1KL16ELtD7okCMhLx7B/Lpgg==", + "cpu": [ + "arm64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@node-datachannel/darwin-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/darwin-arm64/-/darwin-arm64-0.33.1.tgz", + "integrity": "sha512-6reyGKzuYNzuJypm4KrpJVTpION39rZmLoqDNMiehTVuSZzV1yoYyLHCzJ9XNVpOViGdaUvAWXJTlHcoQOZtrw==", + "cpu": [ + "arm64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@node-datachannel/darwin-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/darwin-x64/-/darwin-x64-0.33.1.tgz", + "integrity": "sha512-1zXH/E79bswwRfbUwilw9iPNCCI4GLul/xxsjx/H7jbPT9SeMgkHKcx7Emuw91NBeYYPvYSYwQ705h4FTVDxow==", + "cpu": [ + "x64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@node-datachannel/linux-arm64-gnu": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-arm64-gnu/-/linux-arm64-gnu-0.33.1.tgz", + "integrity": "sha512-FriA+y9cKnr9shQaNz4AdqkaNb7yqBcj1U/OgAlLvJtY/mJLvRX7R3iic18aUA5BkMMK+wwqBI/0Al3brxdRAw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/linux-arm64-musl": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-arm64-musl/-/linux-arm64-musl-0.33.1.tgz", + "integrity": "sha512-MGtNFlIZ5b9sRBDFD3xS3oAhmAxJyEDrrhuUUEtR4Z4boSGOIzg0IiE5YQalmG7XgpMe0cxigBBTJitDczYyNQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/linux-x64-gnu": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-x64-gnu/-/linux-x64-gnu-0.33.1.tgz", + "integrity": "sha512-0mTxq+0fYatoQ/7y9uMLDSRbnb0/Vrrl1Fhsuys8PfB06ft3IA8+6/qdFgRriHbCNkCkB3mSvWEIjCVjXuPr1A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/linux-x64-musl": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-x64-musl/-/linux-x64-musl-0.33.1.tgz", + "integrity": "sha512-LSIdM/tpdwy8d+pN7Vj5Dd4kU3vtdh1PKzz8MYPEPKpv2If5gBydA9pvmGDldiyn07Ijj1TrIQhV+1ozGBm0dw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/win32-arm64-msvc": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/win32-arm64-msvc/-/win32-arm64-msvc-0.33.1.tgz", + "integrity": "sha512-uERR6Zz3wqLnOhBe1YRFodFSD9c7N3aM2Upe/z7miR7F8sprLr00+SkrTXZ+RayW8BN+xDtZRKFYYROvuJy/ug==", + "cpu": [ + "arm64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@node-datachannel/win32-x64-msvc": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/win32-x64-msvc/-/win32-x64-msvc-0.33.1.tgz", + "integrity": "sha512-i5c3t+hDz6oNcen8aQZetxtAfYnNeUEND67oJrnAL8qQFzaRv6wKO5Q7G1vpjzQUyC54xe6s4oD8uuVo2sKPzA==", + "cpu": [ + "x64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@oxc-project/types": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", @@ -1739,26 +1868,6 @@ "node": ">= 0.6.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/bencode": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/bencode/-/bencode-4.0.1.tgz", @@ -1961,47 +2070,12 @@ "utf-8-validate": "^6.0.4" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/block-iterator": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/block-iterator/-/block-iterator-1.1.2.tgz", "integrity": "sha512-yAHUP44v2K25xLPdrgVTgwtuQctlullzjczu9CoUZom5AP3g4p1R1+aWHjS1GHG9JtcSUVUnbEPiuXiW5YZ24w==", "license": "MIT" }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/bufferutil": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", @@ -2104,12 +2178,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/chrome-dgram": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/chrome-dgram/-/chrome-dgram-3.0.6.tgz", @@ -2399,30 +2467,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/default-gateway": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-7.2.2.tgz", @@ -2638,15 +2682,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2783,12 +2818,6 @@ "node": ">=12.20.0" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs-native-extensions": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.5.0.tgz", @@ -2860,12 +2889,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/http-parser-js": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.13.tgz", @@ -2881,26 +2904,6 @@ "node": ">=14.18.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/immediate-chunk-store": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/immediate-chunk-store/-/immediate-chunk-store-2.2.0.tgz", @@ -2942,12 +2945,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ink": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.0.tgz", @@ -3630,18 +3627,6 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3651,12 +3636,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -3707,12 +3686,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/napi-macros": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", @@ -3729,29 +3702,27 @@ "node": ">= 0.4.0" } }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-datachannel": { - "version": "0.32.3", - "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.32.3.tgz", - "integrity": "sha512-Aok1ZhLsll472lRefgWYuWJ0070jh0ecHravTdRyZEmoESumebMEQV8Y+poBwSW2ZbEwAokAOGsK5Cu8pDDT2g==", - "hasInstallScript": true, + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.33.1.tgz", + "integrity": "sha512-9oryOvUx2WqOmMUQ+JHyCx9w9UPBLGQcZQNcSYDc1LZS/hFSAqDyIRt6b5H5Ue13Gpe/vTjU8y9UvCBrbwpbAQ==", "license": "MPL 2.0", "dependencies": { - "prebuild-install": "^7.1.3" + "detect-libc": "^2.0.4" }, "engines": { "node": ">=18.20.0" + }, + "optionalDependencies": { + "@node-datachannel/android-arm64": "0.33.1", + "@node-datachannel/darwin-arm64": "0.33.1", + "@node-datachannel/darwin-x64": "0.33.1", + "@node-datachannel/linux-arm64-gnu": "0.33.1", + "@node-datachannel/linux-arm64-musl": "0.33.1", + "@node-datachannel/linux-x64-gnu": "0.33.1", + "@node-datachannel/linux-x64-musl": "0.33.1", + "@node-datachannel/win32-arm64-msvc": "0.33.1", + "@node-datachannel/win32-x64-msvc": "0.33.1" } }, "node_modules/node-domexception": { @@ -4058,33 +4029,6 @@ } } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4173,21 +4117,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/rc4": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/rc4/-/rc4-0.1.5.tgz", @@ -4226,6 +4155,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -4477,18 +4407,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4523,51 +4441,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", @@ -4694,6 +4567,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", + "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -4763,15 +4637,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4807,34 +4672,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -5591,18 +5428,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-fest": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", @@ -5743,7 +5568,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/utp-native": { "version": "2.5.3", diff --git a/package.json b/package.json index 61ae698c..5d4b4c83 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "webtorrent": "^2.4.1" }, "overrides": { + "@node-datachannel/android-arm64": "0.33.0", + "node-datachannel": "^0.33.1", "uint8-util": "2.2.6" }, "devDependencies": { diff --git a/src/deps-pin.test.ts b/src/deps-pin.test.ts index 0588147e..8e7de36b 100644 --- a/src/deps-pin.test.ts +++ b/src/deps-pin.test.ts @@ -36,3 +36,87 @@ describe("uint8-util quarantine pin", () => { expect(() => asAny("fd568f2ceba6b2603e761e4b13e5308c8b0f8ae4")).not.toThrow(); }); }); + +// Aug-2026 install breakage: node-datachannel 0.33.0 removed its `install` +// script entirely (0.32.3 ran `prebuild-install -r napi || (npm install +// --ignore-scripts --production=false && npm run _prebuild)`) and moved the +// native binary into nine per-platform optionalDependencies. npm 12 blocks +// install scripts that are not explicitly approved, so on 0.32.3 a fresh +// install leaves node-datachannel without its binary and every import of it +// throws "Cannot find module '../../../build/Release/node_datachannel.node'" — +// the failure behind #166, #60, #81, #89, #135 and #20. Optional dependencies +// need no script at all. webrtc-polyfill still asks for "^0.32.3", and a caret +// on a 0.x version pins the minor, so only an override moves the tree onto the +// prebuilt line. The 0.33 loader still checks a local build/ directory before +// the platform package, which keeps scripts/ensure-webrtc.cjs working as the +// compile-from-source fallback where no prebuilt exists. +const PREBUILT_LINE = "^0.33.1"; +const ANDROID_PUBLISHED = "0.33.0"; + +function atLeast(version: string, min: string): boolean { + const a = version.split(".").map(Number); + const b = min.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0); + } + return true; +} + +describe("node-datachannel prebuilt binaries", () => { + it("package.json overrides the transitive range onto the prebuilt line", () => { + const pkg = readJson("../package.json"); + expect(pkg.overrides["node-datachannel"]).toBe(PREBUILT_LINE); + }); + + it("the lockfile resolved a version that ships prebuilt binaries", () => { + const lock = readJson("../package-lock.json"); + const resolved = lock.packages["node_modules/node-datachannel"].version; + expect(atLeast(resolved, "0.33.1")).toBe(true); + }); + + it("the installed copy carries no install script and declares platform packages", () => { + const installed = readJson("../node_modules/node-datachannel/package.json"); + expect(installed.scripts?.install).toBeUndefined(); + expect(Object.keys(installed.optionalDependencies ?? {})).toEqual( + expect.arrayContaining([ + "@node-datachannel/linux-x64-gnu", + "@node-datachannel/darwin-arm64", + "@node-datachannel/win32-x64-msvc", + ]), + ); + }); + + it("the native binding loads without any build step", async () => { + const ndc = await import("node-datachannel"); + expect(typeof ndc.PeerConnection).toBe("function"); + }); + + // 0.33.1 lists @node-datachannel/android-arm64@0.33.1 among its + // optionalDependencies and that version was never published -- 0.33.0 is the + // only one on the registry. npm then leaves the package out of the lockfile, + // and `npm ci` refuses the result outright: "Missing: + // @node-datachannel/android-arm64@ from lock file". So the override names the + // version that does exist. 0.33.1 only added a Windows ARM64 build, so the + // Android binary is the same one either way. Drop this once upstream + // publishes the missing package. + it("pins the platform package upstream declares but never published", () => { + const pkg = readJson("../package.json"); + expect(pkg.overrides["@node-datachannel/android-arm64"]).toBe(ANDROID_PUBLISHED); + const lock = readJson("../package-lock.json"); + expect(lock.packages["node_modules/@node-datachannel/android-arm64"].version).toBe( + ANDROID_PUBLISHED, + ); + }); + + it("the lockfile carries every platform package the module declares", () => { + const declared = Object.keys( + readJson("../node_modules/node-datachannel/package.json").optionalDependencies ?? {}, + ); + const lock = readJson("../package-lock.json"); + expect(declared.length).toBeGreaterThan(0); + for (const name of declared) { + expect(lock.packages[`node_modules/${name}`], `${name} missing from the lockfile`) + .toBeDefined(); + } + }); +}); From 0cb8ca23cfcf21da00e6a8ab4c08d3d0a7ab3efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:03:04 +0300 Subject: [PATCH 08/21] fix: keep an unreadable feed date out of the sort comparator (#174) --- src/sources/nyaa.ts | 4 ++-- src/sources/subsplease.ts | 3 ++- src/util/format.test.ts | 30 ++++++++++++++++++++++++++++++ src/util/format.ts | 13 +++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/sources/nyaa.ts b/src/sources/nyaa.ts index b20755d6..097f9618 100644 --- a/src/sources/nyaa.ts +++ b/src/sources/nyaa.ts @@ -1,7 +1,7 @@ import { fetchResilient, HttpError, USER_AGENT } from "../util/net"; import { buildMagnet } from "./magnet"; import { unescapeEntities } from "./rss"; -import { parseSize } from "../util/format"; +import { parseSize, parseUnixSeconds } from "../util/format"; import type { SearchOptions, Source, TorrentResult } from "./types"; const BASE = "https://nyaa.si/"; @@ -35,7 +35,7 @@ async function search(query: string, opts: SearchOptions = {}): Promise { expect(stripControl("")).toBe(""); }); }); + +describe("parseUnixSeconds", () => { + it("parses the date shapes the feeds actually send", () => { + // nyaa.si RSS pubDate and the SubsPlease API release_date are both RFC 822. + expect(parseUnixSeconds("Thu, 27 Aug 2026 11:49:25 -0000")).toBe( + Date.UTC(2026, 7, 27, 11, 49, 25) / 1000, + ); + expect(parseUnixSeconds("2026-08-27T11:49:25Z")).toBe( + Date.UTC(2026, 7, 27, 11, 49, 25) / 1000, + ); + }); + + it("returns undefined rather than NaN for anything unreadable", () => { + for (const bad of [undefined, "", " ", "not a date", "0000-13-45", "Thu, 32 Xxx"]) { + expect(parseUnixSeconds(bad)).toBeUndefined(); + } + }); + + it("never yields a value that breaks a sort comparator", () => { + // NaN is a number, so `added ?? 0` would pass it through, and a comparator + // that returns NaN leaves Array.prototype.sort implementation-defined. + const added = ["Thu, 27 Aug 2026 11:49:25 -0000", "not a date", undefined].map( + parseUnixSeconds, + ); + for (const a of added) expect(a === undefined || Number.isFinite(a)).toBe(true); + const cmp = (a?: number, b?: number): number => (b ?? 0) - (a ?? 0); + for (const a of added) for (const b of added) expect(Number.isNaN(cmp(a, b))).toBe(false); + }); +}); diff --git a/src/util/format.ts b/src/util/format.ts index da85c078..9113f7e2 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -28,6 +28,19 @@ export function parseSize(s: string): number { return Math.round(parseFloat(m[1]!) * (SIZE_UNITS[m[2]!.toUpperCase()] ?? 1)); } +// Feed timestamps arrive as free text: an RSS pubDate, a JSON release_date. +// Date.parse returns NaN for anything it cannot read, and NaN is a number, so +// the `added ?? 0` guards downstream never catch it — it flows straight into a +// sort comparator, and a comparator that returns NaN makes Array.prototype.sort +// implementation-defined for the whole array. Returning undefined keeps `added` +// honest: absent, rather than zero or NaN. Same instinct as the Number.isFinite +// guards nyaa.ts already applies to its seeders and leechers. +export function parseUnixSeconds(value?: string): number | undefined { + if (!value) return undefined; + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? ms / 1000 : undefined; +} + export function formatBytesPerSec(bytes?: number): string { if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0) return ""; const units = ["B/s", "KB/s", "MB/s", "GB/s"]; From 67fd4265be4c2d2612b2269c90fa60e285387368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:03:07 +0300 Subject: [PATCH 09/21] fix: fall back to OSC 52 when no clipboard helper can be reached (#176) --- src/util/clipboard.test.ts | 155 +++++++++++++++++++++++++++++++++++++ src/util/clipboard.ts | 58 +++++++++++++- 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/src/util/clipboard.test.ts b/src/util/clipboard.test.ts index 95a52fde..3266cd39 100644 --- a/src/util/clipboard.test.ts +++ b/src/util/clipboard.test.ts @@ -40,3 +40,158 @@ describe("writeClipboard", () => { } }); }); + +type FakeProc = EventEmitter & { + stdin: { end: (value: string) => void }; + stdout: EventEmitter; + kill: () => void; +}; + +// Same shape as the mock above: every spawned command exits with `code`. +function mockSpawn(code: number): void { + spawn.mockImplementation(() => { + const proc = new EventEmitter() as FakeProc; + proc.stdout = new EventEmitter(); + proc.kill = vi.fn(); + proc.stdin = { end: vi.fn(() => queueMicrotask(() => proc.emit("exit", code))) }; + return proc; + }); +} + +// OSC 52 goes to the terminal, not to a child process, so the test watches the +// tty rather than node:child_process. +function captureTty(isTTY: boolean): { written: string[]; restore: () => void } { + const written: string[] = []; + const err = process.stderr as unknown as { isTTY?: boolean; write: unknown }; + const out = process.stdout as unknown as { isTTY?: boolean }; + const prev = { errIsTTY: err.isTTY, errWrite: err.write, outIsTTY: out.isTTY }; + err.isTTY = isTTY; + out.isTTY = isTTY; + err.write = (chunk: unknown) => { + written.push(String(chunk)); + return true; + }; + return { + written, + restore: () => { + err.isTTY = prev.errIsTTY; + err.write = prev.errWrite; + out.isTTY = prev.outIsTTY; + }, + }; +} + +async function onPlatform(platform: string, run: () => Promise): Promise { + const original = process.platform; + Object.defineProperty(process, "platform", { value: platform }); + try { + return await run(); + } finally { + Object.defineProperty(process, "platform", { value: original }); + vi.resetModules(); + vi.unstubAllEnvs(); + spawn.mockReset(); + } +} + +function decodeOsc52(seq: string): string { + const body = seq.slice(seq.indexOf(";c;") + 3, seq.lastIndexOf("\u0007")); + return Buffer.from(body, "base64").toString("utf8"); +} + +describe("writeClipboard OSC 52 fallback", () => { + it("asks the terminal when no Linux clipboard command works", async () => { + const tty = captureTty(true); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + vi.stubEnv("TMUX", ""); + mockSpawn(1); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(true); + // wl-copy, xclip and xsel were all tried first. + expect(spawn).toHaveBeenCalledTimes(3); + expect(tty.written.join("")).toBe( + "\u001b]52;c;bWFnbmV0Oj94dD11cm46YnRpaDphYmM=\u0007", + ); + expect(decodeOsc52(tty.written.join(""))).toBe("magnet:?xt=urn:btih:abc"); + }); + } finally { + tty.restore(); + } + }); + + it("stays quiet when the platform command already worked", async () => { + const tty = captureTty(true); + try { + await onPlatform("darwin", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + mockSpawn(0); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(true); + expect(spawn).toHaveBeenCalledWith("pbcopy", [], { windowsHide: true }); + expect(tty.written).toEqual([]); + }); + } finally { + tty.restore(); + } + }); + + it("prefers the terminal over the remote clipboard inside an SSH session", async () => { + const tty = captureTty(true); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", "/dev/pts/0"); + vi.stubEnv("TMUX", ""); + mockSpawn(0); // wl-copy would have "worked" -- on the wrong machine + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(true); + expect(spawn).not.toHaveBeenCalled(); + expect(decodeOsc52(tty.written.join(""))).toBe("magnet:?xt=urn:btih:abc"); + }); + } finally { + tty.restore(); + } + }); + + it("wraps the sequence in a DCS passthrough inside tmux", async () => { + const tty = captureTty(true); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + vi.stubEnv("TMUX", "/run/user/1000/tmux-1000/default,1234,0"); + mockSpawn(1); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("hi")).resolves.toBe(true); + expect(tty.written.join("")).toBe("\u001bPtmux;\u001b\u001b]52;c;aGk=\u0007\u001b\\"); + }); + } finally { + tty.restore(); + } + }); + + it("still reports failure when there is no terminal to ask", async () => { + const tty = captureTty(false); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + vi.stubEnv("TMUX", ""); + mockSpawn(1); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(false); + expect(tty.written).toEqual([]); + }); + } finally { + tty.restore(); + } + }); +}); diff --git a/src/util/clipboard.ts b/src/util/clipboard.ts index d5ed0295..f54e3cee 100644 --- a/src/util/clipboard.ts +++ b/src/util/clipboard.ts @@ -83,7 +83,7 @@ export async function readClipboard(): Promise { return ""; } -export async function writeClipboard(text: string): Promise { +async function writeNative(text: string): Promise { if (process.platform === "win32") { return write( "powershell", @@ -99,3 +99,59 @@ export async function writeClipboard(text: string): Promise { } return false; } + +// OSC 52 asks the terminal emulator itself to set the clipboard: +// ESC ] 52 ; c ; BEL. It needs no helper binary, which is the whole +// point — it is the one mechanism that still works when wl-copy, xclip and xsel +// are all absent, and the only one that reaches the clipboard of the machine the +// user is actually sitting at when torlink runs over SSH. torlink already knows +// this sequence from the other direction: stripControl() in util/format.ts +// exists so a hijacked source cannot smuggle one in through a name or a magnet. +// +// A terminal never acknowledges OSC 52, so `true` here means the request +// reached the terminal, not that the clipboard now holds the text. That is the +// honest limit of the mechanism, and it beats telling someone the copy failed +// when it almost certainly did not. + +// Terminals cap the payload they will accept. A silently truncated paste is +// worse than no paste, so anything past the cap is left to the helper binaries. +// A magnet with every default tracker is well under 2 KB. +const OSC52_MAX_BASE64 = 100_000; + +function ttyStream(): NodeJS.WriteStream | null { + if (process.stderr.isTTY) return process.stderr; + if (process.stdout.isTTY) return process.stdout; + return null; +} + +function writeOsc52(text: string): boolean { + const stream = ttyStream(); + if (!stream) return false; + const payload = Buffer.from(text, "utf8").toString("base64"); + if (payload.length > OSC52_MAX_BASE64) return false; + const seq = `\u001b]52;c;${payload}\u0007`; + try { + // tmux drops an application's OSC 52 unless set-clipboard is `on`, and its + // default is `external`. Wrapping it in a DCS passthrough hands the sequence + // to the outer terminal whatever that setting is. TMUX is set by tmux itself, + // so this never fires anywhere else. + stream.write(process.env.TMUX ? `\u001bPtmux;\u001b${seq}\u001b\\` : seq); + return true; + } catch { + return false; + } +} + +// Over SSH the helper binaries set the clipboard of the machine torlink is +// running on, not the one in front of the user, so the magnet lands somewhere +// they can never paste from — a success that is really a failure. Where that is +// the situation, ask the terminal first. +function isRemoteSession(): boolean { + return Boolean(process.env.SSH_TTY || process.env.SSH_CONNECTION); +} + +export async function writeClipboard(text: string): Promise { + if (isRemoteSession() && writeOsc52(text)) return true; + if (await writeNative(text)) return true; + return writeOsc52(text); +} From 476782e461abe0655717ff08f77b62205d1e8be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:03:11 +0300 Subject: [PATCH 10/21] fix: make EZTV answer a search instead of returning nothing (#177) --- src/sources/eztv.test.ts | 128 +++++++++++++++++++++++++++++++++++ src/sources/eztv.ts | 140 ++++++++++++++++++++++++++++++++------- 2 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 src/sources/eztv.test.ts diff --git a/src/sources/eztv.test.ts b/src/sources/eztv.test.ts new file mode 100644 index 00000000..6bbbe520 --- /dev/null +++ b/src/sources/eztv.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); + +vi.mock("../util/net", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchResilient: mockFetch }; +}); + +const ok = (torrents: unknown[]): Response => + ({ ok: true, status: 200, json: async () => ({ torrents }) }) as unknown as Response; + +function row(title: string, hash: string, extra: Record = {}) { + return { + title, + hash, + magnet_url: `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(title)}`, + imdb_id: "111", + seeds: 10, + peers: 1, + size_bytes: "1000", + date_released_unix: 1_700_000_000, + ...extra, + }; +} + +// Every request the source makes goes through this, keyed on what the API +// actually reads: the page number and the imdb_id. +function respond(reply: (page: number, imdbId: string | null) => unknown[] | number): void { + mockFetch.mockImplementation(async (url: string) => { + const params = new URL(String(url)).searchParams; + const out = reply(Number(params.get("page")), params.get("imdb_id")); + // A number stands for an HTTP status the source has to deal with itself. + if (typeof out === "number") return { ok: false, status: out } as unknown as Response; + return ok(out); + }); +} + +const urls = (): string[] => mockFetch.mock.calls.map((c) => String(c[0])); + +// The recent-feed index is shared across queries, so each test starts from a +// fresh module rather than a leftover one. +async function freshEztv(): Promise { + vi.resetModules(); + return (await import("./eztv")).eztv; +} + +const JUDY = row("Judy Justice S04E79 1080p WEB h264", "a".repeat(40)); +const JUDY_OLD = row("Judy Justice S01E01 720p WEB h264", "b".repeat(40)); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("eztv search", () => { + it("asks for one page and nothing else when the query is empty", async () => { + respond(() => [JUDY]); + const eztv = await freshEztv(); + + const res = await eztv.search(""); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(urls()[0]).toContain("page=1"); + expect(urls()[0]).not.toContain("imdb_id"); + expect(res).toHaveLength(1); + expect(res[0]!.source).toBe("eztv"); + }); + + it("finds the show in the recent feed, then asks for its whole catalogue", async () => { + // The feed only reaches back a couple of days; S01E01 is only ever going to + // come back from the imdb_id query. + respond((page, imdbId) => { + if (imdbId === "111") return [JUDY, JUDY_OLD]; + return page === 1 ? [JUDY] : []; + }); + const eztv = await freshEztv(); + + const res = await eztv.search("judy justice"); + + expect(urls().some((u) => u.includes("imdb_id=111"))).toBe(true); + expect(res.map((r) => r.infoHash).sort()).toEqual(["a".repeat(40), "b".repeat(40)]); + expect(res.map((r) => r.name)).toContain("Judy Justice S01E01 720p WEB h264"); + }); + + it("never asks for a catalogue when nothing in the feed matches", async () => { + respond(() => [JUDY]); + const eztv = await freshEztv(); + + await expect(eztv.search("south park")).resolves.toEqual([]); + expect(urls().every((u) => !u.includes("imdb_id"))).toBe(true); + }); + + it("narrows rather than fails when a deeper page is unavailable", async () => { + respond((page, imdbId) => { + if (imdbId === "111") return [JUDY, JUDY_OLD]; + if (page === 1) return [JUDY]; + if (page === 4) return 503; + return []; + }); + const eztv = await freshEztv(); + + const res = await eztv.search("judy justice"); + + expect(res.length).toBeGreaterThan(0); + }); + + it("still surfaces the error when the first page is unavailable", async () => { + respond((page) => (page === 1 ? 503 : [])); + const eztv = await freshEztv(); + + await expect(eztv.search("judy justice")).rejects.toThrow(/503/); + }); + + it("reuses the recent feed across queries instead of refetching it", async () => { + respond((page, imdbId) => { + if (imdbId === "111") return [JUDY, JUDY_OLD]; + return page === 1 ? [JUDY] : []; + }); + const eztv = await freshEztv(); + + await eztv.search("judy justice"); + const afterFirst = mockFetch.mock.calls.length; + await eztv.search("judy justice 1080p"); + + // Only the catalogue request, never the ten index pages again. + expect(mockFetch.mock.calls.length - afterFirst).toBe(1); + }); +}); diff --git a/src/sources/eztv.ts b/src/sources/eztv.ts index 7e346143..7389cf76 100644 --- a/src/sources/eztv.ts +++ b/src/sources/eztv.ts @@ -4,11 +4,33 @@ import type { SearchOptions, Source, TorrentResult } from "./types"; const API = "https://eztvx.to/api/get-torrents"; +// EZTV's API takes no text query. limit, page and imdb_id are the only inputs +// it reads -- keywords, q, search and title are accepted and ignored, every one +// of them answering with the same unfiltered latest page -- and the HTML search +// route is behind Cloudflare on every mirror (eztvx.to, eztv.re, eztv.wf, +// eztv.tf, eztv.yt, eztv1.xyz all answer /search/ with 403 "Just a moment"). +// So a show name has to become an imdb_id before EZTV can answer it, and the +// only place to get that mapping without reaching for another service is EZTV's +// own feed: every row carries its show's imdb_id. Match the query against the +// recent feed, then ask for the shows it matched by id, which returns the whole +// catalogue rather than the couple of days the feed itself spans. +const PAGE_LIMIT = 100; // above 100 the API quietly answers with 30 +const INDEX_PAGES = 10; // ~1000 rows, ~250 distinct shows, ~2.5 days of releases +const MAX_SHOWS = 2; +const MAX_RESULTS = 100; // one API page's worth, like every other query here + +// The recent feed is the same for every query, so it is fetched once and shared +// instead of per query, on the same five-minute life the per-query cache in +// cache.ts uses. +const INDEX_TTL_MS = 5 * 60 * 1000; +let index: { at: number; rows: EztvTorrent[] } | null = null; + interface EztvTorrent { title?: string; filename?: string; hash?: string; magnet_url?: string; + imdb_id?: string; seeds?: number; peers?: number; size_bytes?: string | number; @@ -18,35 +40,107 @@ interface EztvResponse { torrents?: EztvTorrent[]; } -async function search(query: string, opts: SearchOptions = {}): Promise { - if (query.trim()) return []; - - const res = await fetchResilient(`${API}?limit=100&page=1`, { +async function fetchPage( + params: Record, + opts: SearchOptions, + retries: number, +): Promise { + const qs = new URLSearchParams({ limit: String(PAGE_LIMIT), ...params }); + const res = await fetchResilient(`${API}?${qs.toString()}`, { headers: { "User-Agent": USER_AGENT }, signal: opts.signal, - retries: 1, + retries, }); if (!res.ok) throw new HttpError(res.status, `EZTV returned ${res.status}`); - const json = (await res.json()) as EztvResponse; - const out: TorrentResult[] = []; - for (const t of json.torrents ?? []) { - const hash = (t.hash ?? "").toLowerCase(); - const name = t.title || t.filename || hash; - const magnet = t.magnet_url || (hash ? buildMagnet(hash, name) : ""); - if (!magnet || !hash) continue; - out.push({ - infoHash: hash, - name, - sizeBytes: Number(t.size_bytes ?? 0) || 0, - seeders: t.seeds ?? 0, - leechers: t.peers ?? 0, - source: "eztv", - magnet, - added: t.date_released_unix, - }); + return json.torrents ?? []; +} + +async function recentIndex(opts: SearchOptions): Promise { + const now = Date.now(); + if (index && now - index.at < INDEX_TTL_MS) return index.rows; + + // Page 1 is the request EZTV has always made here, and it still decides + // whether the source is reachable at all. The deeper pages only widen the + // window, so one of them failing narrows the search instead of breaking it. + const pages = Array.from({ length: INDEX_PAGES }, (_, i) => + fetchPage({ page: String(i + 1) }, opts, i === 0 ? 1 : 0), + ); + const guarded = pages.map((p, i) => (i === 0 ? p : p.catch(() => [] as EztvTorrent[]))); + const rows = (await Promise.all(guarded)).flat(); + index = { at: now, rows }; + return rows; +} + +function haystack(t: EztvTorrent): string { + return `${t.title ?? ""} ${t.filename ?? ""}`.toLowerCase(); +} + +function matches(t: EztvTorrent, tokens: string[]): boolean { + const name = haystack(t); + return tokens.every((token) => name.includes(token)); +} + +function toResult(t: EztvTorrent): TorrentResult | null { + const hash = (t.hash ?? "").toLowerCase(); + const name = t.title || t.filename || hash; + const magnet = t.magnet_url || (hash ? buildMagnet(hash, name) : ""); + if (!magnet || !hash) return null; + return { + infoHash: hash, + name, + sizeBytes: Number(t.size_bytes ?? 0) || 0, + seeders: t.seeds ?? 0, + leechers: t.peers ?? 0, + source: "eztv", + magnet, + added: t.date_released_unix, + }; +} + +// A show's own catalogue overlaps the recent feed by definition, so the same +// hash arrives twice; keep the first and stop there. +function toResults(rows: EztvTorrent[]): TorrentResult[] { + const byHash = new Map(); + for (const row of rows) { + const r = toResult(row); + if (r && !byHash.has(r.infoHash)) byHash.set(r.infoHash, r); + } + return [...byHash.values()]; +} + +function showIdsOf(hits: EztvTorrent[]): string[] { + const ids: string[] = []; + for (const t of hits) { + const id = (t.imdb_id ?? "").trim(); + if (!id || id === "0" || ids.includes(id)) continue; + ids.push(id); + if (ids.length === MAX_SHOWS) break; } - return out; + return ids; +} + +async function search(query: string, opts: SearchOptions = {}): Promise { + const q = query.trim(); + // The empty query is the popular list, and one page is what it has always + // been. Widening the index there would make every startup ten times heavier + // for a view that does not need it. + if (!q) return toResults(await fetchPage({ page: "1" }, opts, 1)); + + const tokens = q.toLowerCase().split(/\s+/).filter(Boolean); + const recent = await recentIndex(opts); + const hits = recent.filter((t) => matches(t, tokens)); + + const catalogues = await Promise.all( + showIdsOf(hits).map((imdb_id) => + fetchPage({ page: "1", imdb_id }, opts, 0).catch(() => [] as EztvTorrent[]), + ), + ); + + const rows = [...hits, ...catalogues.flat().filter((t) => matches(t, tokens))]; + return toResults(rows) + .sort((a, b) => b.seeders - a.seeders) + .slice(0, MAX_RESULTS); } export const eztv: Source = { From f178b42a5cb0e46102f969d22a214265572c6c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:03:15 +0300 Subject: [PATCH 11/21] fix: stop the search cache growing without a ceiling (#179) --- src/sources/cache.test.ts | 100 ++++++++++++++++++++++++++++++++++++++ src/sources/cache.ts | 14 ++++++ 2 files changed, 114 insertions(+) create mode 100644 src/sources/cache.test.ts diff --git a/src/sources/cache.test.ts b/src/sources/cache.test.ts new file mode 100644 index 00000000..4663be68 --- /dev/null +++ b/src/sources/cache.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { Source, SourceId, TorrentResult } from "./types"; + +const MAX_ENTRIES = 100; + +function countingSource(id: SourceId = "nyaa"): Source & { calls: number } { + const source = { + id, + label: id, + homepage: "https://example.org", + reportsHealth: true, + calls: 0, + search: async (query: string): Promise => { + source.calls += 1; + return [ + { + infoHash: "a".repeat(40), + name: query, + sizeBytes: 1, + seeders: 1, + leechers: 0, + source: id, + magnet: `magnet:?xt=urn:btih:${"a".repeat(40)}`, + }, + ]; + }, + }; + return source; +} + +// The cache is module state, so each test starts from a fresh copy. +async function freshCache(): Promise { + vi.resetModules(); + return (await import("./cache")).cachedSearch; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("cachedSearch", () => { + it("serves a repeat query from the cache", async () => { + const cachedSearch = await freshCache(); + const source = countingSource(); + + await cachedSearch(source, "dune"); + const again = await cachedSearch(source, " DUNE "); + + expect(source.calls).toBe(1); + expect(again[0]!.name).toBe("dune"); + }); + + it("goes back to the source once the entry has aged out", async () => { + vi.useFakeTimers(); + const cachedSearch = await freshCache(); + const source = countingSource(); + + await cachedSearch(source, "dune"); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await cachedSearch(source, "dune"); + + expect(source.calls).toBe(2); + }); + + it("keeps only the most recent entries instead of growing forever", async () => { + const cachedSearch = await freshCache(); + const source = countingSource(); + + for (let i = 0; i < MAX_ENTRIES + 10; i++) await cachedSearch(source, `query ${i}`); + const filled = source.calls; + + // The newest is still there; the oldest was dropped to make room. + await cachedSearch(source, `query ${MAX_ENTRIES + 9}`); + expect(source.calls).toBe(filled); + + await cachedSearch(source, "query 0"); + expect(source.calls).toBe(filled + 1); + }); + + it("counts a refreshed entry as the newest, not the oldest", async () => { + vi.useFakeTimers(); + const cachedSearch = await freshCache(); + const source = countingSource(); + + for (let i = 0; i < MAX_ENTRIES; i++) await cachedSearch(source, `query ${i}`); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + // Refetching the oldest query moves it to the back of the queue, so the + // entry evicted by the next insert is the one after it, not this one. + await cachedSearch(source, "query 0"); + await cachedSearch(source, "brand new"); + const filled = source.calls; + + await cachedSearch(source, "query 0"); + expect(source.calls).toBe(filled); + + await cachedSearch(source, "query 1"); + expect(source.calls).toBe(filled + 1); + }); +}); diff --git a/src/sources/cache.ts b/src/sources/cache.ts index ace68055..8cf97eb5 100644 --- a/src/sources/cache.ts +++ b/src/sources/cache.ts @@ -2,6 +2,12 @@ import type { SearchOptions, Source, TorrentResult } from "./types"; const TTL_MS = 5 * 60 * 1000; +// A search fills one entry per source, so this is the last ten searches -- +// more history than a five-minute TTL can put to use. Nothing evicted anything +// before, so the map only ever grew, holding result lists the UI had long since +// replaced: 200 distinct searches across the ten sources retained about 80 MB. +const MAX_ENTRIES = 100; + interface Entry { at: number; results: TorrentResult[]; @@ -23,6 +29,14 @@ export async function cachedSearch( if (hit && Date.now() - hit.at < TTL_MS) return hit.results; const results = await source.search(query, opts); + // Re-inserting rather than overwriting keeps the map's iteration order equal + // to fetch order, so the entry that gives way below is always the oldest. + cache.delete(k); cache.set(k, { at: Date.now(), results }); + while (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } return results; } From 2ede3e4e4ee667cd6ee6ad3b5879bcdd31c06708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:03:19 +0300 Subject: [PATCH 12/21] fix: merge duplicate rows instead of dropping the losers' trackers (#175) --- src/sources/magnet.test.ts | 48 +++++++++++++++- src/sources/magnet.ts | 33 +++++++++++ src/ui/dedupe.test.ts | 88 +++++++++++++++++++++++++++++ src/ui/dedupe.ts | 35 ++++++++++++ src/ui/hooks/useConcurrentSearch.ts | 12 +--- 5 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 src/ui/dedupe.test.ts create mode 100644 src/ui/dedupe.ts diff --git a/src/sources/magnet.test.ts b/src/sources/magnet.test.ts index 47af92c9..b41ebea8 100644 --- a/src/sources/magnet.test.ts +++ b/src/sources/magnet.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { parseMagnet, parseInput, isInfoHash, normalizeInfoHash, buildMagnet } from "./magnet"; +import { + parseMagnet, + parseInput, + isInfoHash, + normalizeInfoHash, + buildMagnet, + mergeMagnetTrackers, +} from "./magnet"; describe("parseMagnet", () => { it("keeps a full 40-char hex info hash", () => { @@ -111,3 +118,42 @@ describe("parseInput", () => { expect(parseInput("")).toBeNull(); }); }); + +describe("mergeMagnetTrackers", () => { + const hash = "abcdef0123456789abcdef0123456789abcdef01"; + const own = "udp://private.example:6969/announce/passkey"; + + it("appends only the trackers the primary is missing", () => { + const primary = buildMagnet(hash, "X"); + const merged = mergeMagnetTrackers(primary, [buildMagnet(hash, "X", [own])]); + const tr = new URL(merged).searchParams.getAll("tr"); + expect(tr).toContain(own); + expect(new Set(tr).size).toBe(tr.length); + expect(merged.startsWith(primary)).toBe(true); + }); + + it("returns the primary untouched when the others add nothing", () => { + const primary = buildMagnet(hash, "X"); + expect(mergeMagnetTrackers(primary, [buildMagnet(hash, "X")])).toBe(primary); + expect(mergeMagnetTrackers(primary, [])).toBe(primary); + }); + + it("keeps parameters buildMagnet does not write", () => { + const primary = `magnet:?xt=urn:btih:${hash}&dn=X&xl=1234`; + const merged = mergeMagnetTrackers(primary, [buildMagnet(hash, "X", [own])]); + expect(new URL(merged).searchParams.get("xl")).toBe("1234"); + expect(new URL(merged).searchParams.getAll("tr")).toContain(own); + }); + + it("leaves anything that is not a magnet exactly as it came", () => { + expect(mergeMagnetTrackers("not a magnet", [buildMagnet(hash, "X", [own])])).toBe( + "not a magnet", + ); + expect(mergeMagnetTrackers("", [])).toBe(""); + }); + + it("ignores others that are not magnets", () => { + const primary = buildMagnet(hash, "X"); + expect(mergeMagnetTrackers(primary, ["nonsense", ""])).toBe(primary); + }); +}); diff --git a/src/sources/magnet.ts b/src/sources/magnet.ts index 99079433..dc294032 100644 --- a/src/sources/magnet.ts +++ b/src/sources/magnet.ts @@ -96,3 +96,36 @@ export function parseInput(input: string): ParsedMagnet | null { const infoHash = normalizeInfoHash(s); return { infoHash, name: infoHash, magnet: buildMagnet(infoHash, infoHash) }; } + +function trackersOf(magnet: string): string[] | null { + const s = magnet.trim(); + if (!/^magnet:\?/i.test(s)) return null; + try { + return new URL(s).searchParams.getAll("tr").map((t) => t.trim()).filter(Boolean); + } catch { + return null; + } +} + +// A magnet's announce list is part of what a source knows about the torrent, so +// when the same infohash arrives from several sources their lists get folded +// together rather than the extras thrown away. `primary` is returned byte for +// byte with only the trackers it is missing appended, which keeps every other +// parameter it carries (xl, ws, so) intact — unlike rebuilding through +// buildMagnet(). Order does not matter to a client: every tr in a magnet ends up +// in one announce list and all of them are contacted. +export function mergeMagnetTrackers(primary: string, others: string[]): string { + const own = trackersOf(primary); + if (!own) return primary; + const seen = new Set(own); + const extra: string[] = []; + for (const other of others) { + for (const url of trackersOf(other) ?? []) { + if (seen.has(url)) continue; + seen.add(url); + extra.push(url); + } + } + if (extra.length === 0) return primary; + return primary.trim() + extra.map((t) => `&tr=${encodeURIComponent(t)}`).join(""); +} diff --git a/src/ui/dedupe.test.ts b/src/ui/dedupe.test.ts new file mode 100644 index 00000000..db443869 --- /dev/null +++ b/src/ui/dedupe.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { dedupeResults } from "./dedupe"; +import { buildMagnet } from "../sources/magnet"; +import type { SourceId, TorrentResult } from "../sources/types"; + +const HASH = "abcdef0123456789abcdef0123456789abcdef01"; + +function row(over: Partial & { source: SourceId }): TorrentResult { + return { + infoHash: HASH, + name: "Some Release 1080p", + sizeBytes: 1_000_000, + seeders: 0, + leechers: 0, + magnet: buildMagnet(HASH, "Some Release 1080p"), + ...over, + }; +} + +function trackers(magnet: string): string[] { + return new URL(magnet).searchParams.getAll("tr"); +} + +describe("dedupeResults", () => { + it("leaves distinct hashes alone", () => { + const other = "0123456789abcdef0123456789abcdef01234567"; + const out = dedupeResults([row({ source: "nyaa" }), row({ source: "yts", infoHash: other })]); + expect(out).toHaveLength(2); + }); + + it("keeps the healthiest row's own fields", () => { + const out = dedupeResults([ + row({ source: "tpb-movies", seeders: 3, name: "TPB name" }), + row({ source: "x1337-movies", seeders: 90, name: "1337x name" }), + ]); + expect(out).toHaveLength(1); + expect(out[0]!.seeders).toBe(90); + expect(out[0]!.source).toBe("x1337-movies"); + expect(out[0]!.name).toBe("1337x name"); + }); + + it("carries the losing row's trackers into the surviving magnet", () => { + // The row 1337x returns carries the torrent's own announce list; the row + // buildMagnet() writes carries only torlink's public defaults. Whichever + // wins on seeders, both lists have to survive. + const own = "udp://private.example:6969/announce/passkey"; + const out = dedupeResults([ + row({ source: "tpb-movies", seeders: 90 }), + row({ source: "x1337-movies", seeders: 3, magnet: buildMagnet(HASH, "x", [own]) }), + ]); + expect(out).toHaveLength(1); + expect(out[0]!.seeders).toBe(90); + expect(trackers(out[0]!.magnet)).toContain(own); + expect(trackers(out[0]!.magnet)).toContain("udp://tracker.opentrackr.org:1337/announce"); + }); + + it("adds no duplicate trackers and no duplicate rows for three sources", () => { + const a = "udp://a.example:6969/announce"; + const b = "udp://b.example:6969/announce"; + const out = dedupeResults([ + row({ source: "tpb-movies", seeders: 1, magnet: buildMagnet(HASH, "x", [a]) }), + row({ source: "yts", seeders: 5, magnet: buildMagnet(HASH, "x", [b]) }), + row({ source: "x1337-movies", seeders: 2, magnet: buildMagnet(HASH, "x", [a]) }), + ]); + expect(out).toHaveLength(1); + const tr = trackers(out[0]!.magnet); + expect(tr).toContain(a); + expect(tr).toContain(b); + expect(new Set(tr).size).toBe(tr.length); + }); + + it("backfills numFiles and added only where the winner has none", () => { + const out = dedupeResults([ + row({ source: "yts", seeders: 90, added: 1_700_000_000 }), + row({ source: "eztv", seeders: 3, numFiles: 7, added: 1_600_000_000 }), + ]); + expect(out[0]!.numFiles).toBe(7); + expect(out[0]!.added).toBe(1_700_000_000); + }); + + it("keeps the first row when seeders tie", () => { + const out = dedupeResults([ + row({ source: "nyaa", seeders: 10 }), + row({ source: "subsplease", seeders: 10 }), + ]); + expect(out[0]!.source).toBe("nyaa"); + }); +}); diff --git a/src/ui/dedupe.ts b/src/ui/dedupe.ts new file mode 100644 index 00000000..91904429 --- /dev/null +++ b/src/ui/dedupe.ts @@ -0,0 +1,35 @@ +import { mergeMagnetTrackers } from "../sources/magnet"; +import type { TorrentResult } from "../sources/types"; + +// The same torrent reaches the list from several sources at once, and only one +// row should survive. The healthiest row still wins — its seeder count is the +// one worth showing — but the rows it beats are folded into it instead of being +// dropped: +// +// - their announce lists. 1337x and EZTV hand back the torrent's own +// trackers, while rows built by buildMagnet() carry only torlink's public +// defaults, so keeping the higher-seeder row alone can trade a working +// announce list for a generic one. Same loss #146 fixed for a .torrent +// file's own trackers. +// - numFiles and added, but only where the winner has none. A field the +// winner never reported is a gap, not a decision. +// +// Everything else stays the winner's, including its magnet URI byte for byte. +function merge(a: TorrentResult, b: TorrentResult): TorrentResult { + const [win, lost] = b.seeders > a.seeders ? [b, a] : [a, b]; + return { + ...win, + magnet: mergeMagnetTrackers(win.magnet, [lost.magnet]), + numFiles: win.numFiles ?? lost.numFiles, + added: win.added ?? lost.added, + }; +} + +export function dedupeResults(list: TorrentResult[]): TorrentResult[] { + const byHash = new Map(); + for (const r of list) { + const existing = byHash.get(r.infoHash); + byHash.set(r.infoHash, existing ? merge(existing, r) : r); + } + return [...byHash.values()]; +} diff --git a/src/ui/hooks/useConcurrentSearch.ts b/src/ui/hooks/useConcurrentSearch.ts index d41c0590..990513f9 100644 --- a/src/ui/hooks/useConcurrentSearch.ts +++ b/src/ui/hooks/useConcurrentSearch.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { SOURCES } from "../../sources/registry"; import { cachedSearch } from "../../sources/cache"; +import { dedupeResults } from "../dedupe"; import { HttpError } from "../../util/net"; import type { SourceId, TorrentResult } from "../../sources/types"; @@ -30,15 +31,6 @@ function blankPerSource(loading: boolean): Record { return out; } -function dedupe(list: TorrentResult[]): TorrentResult[] { - const byHash = new Map(); - for (const r of list) { - const existing = byHash.get(r.infoHash); - if (!existing || r.seeders > existing.seeders) byHash.set(r.infoHash, r); - } - return [...byHash.values()]; -} - // torlink's default ordering: healthiest first. The results view can re-sort // on demand (the `s` key), and its "none"/default state preserves this order. function defaultOrder(list: TorrentResult[]): TorrentResult[] { @@ -78,7 +70,7 @@ export function useConcurrentSearch(query: string): ConcurrentSearchState { const flush = (): void => { setState({ - results: defaultOrder(dedupe(collected.slice())), + results: defaultOrder(dedupeResults(collected.slice())), perSource: { ...per }, loading: done < SOURCES.length, done, From 833dadb05dc91054adb68f76e565e4887a5e5b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:51:58 +0300 Subject: [PATCH 13/21] feat: add TORLINK_NO_WEBRTC to turn the WebRTC stack off per machine (#178) --- scripts/cli-entry.cjs | 96 ++++++++++++++++++++++++-------------- scripts/cli-entry.test.mjs | 30 ++++++++++++ 2 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 scripts/cli-entry.test.mjs diff --git a/scripts/cli-entry.cjs b/scripts/cli-entry.cjs index 51b9ac26..08ec2255 100644 --- a/scripts/cli-entry.cjs +++ b/scripts/cli-entry.cjs @@ -12,47 +12,71 @@ if (major < 22) { process.exit(1); } -// The WebRTC stack (webtorrent -> simple-peer -> webrtc-polyfill) eagerly -// requires node-datachannel's native binary, which only install scripts -// download; npm 12 skips those scripts by default, so the binary is often -// absent and the eager import would kill startup. When it cannot load, -// resolve webrtc-polyfill to an inert stub instead: simple-peer then reports +// Resolve webrtc-polyfill to an inert stub: simple-peer then reports // WEBRTC_SUPPORT = false and downloads run on TCP/uTP and DHT peers alone. -try { - require('node-datachannel'); -} catch (err) { +// Returns false on Node 22.0 to 22.14, which has no module.registerHooks and +// so cannot redirect the eager import at all. +function useWebrtcStub() { var Module = require('node:module'); - if (typeof Module.registerHooks === 'function') { - var stubUrl = require('node:url') - .pathToFileURL(require('node:path').join(__dirname, 'webrtc-stub.mjs')) - .href; - Module.registerHooks({ - resolve: function (specifier, context, nextResolve) { - if (specifier === 'webrtc-polyfill') { - return { url: stubUrl, shortCircuit: true }; - } - return nextResolve(specifier, context); - }, - }); - process.stderr.write( - 'torlnk: WebRTC peers unavailable (native module not installed); ' + - 'TCP/UDP peers still work. https://github.com/baairon/torlink/issues/60\n' - ); + if (typeof Module.registerHooks !== 'function') return false; + var stubUrl = require('node:url') + .pathToFileURL(require('node:path').join(__dirname, 'webrtc-stub.mjs')) + .href; + Module.registerHooks({ + resolve: function (specifier, context, nextResolve) { + if (specifier === 'webrtc-polyfill') { + return { url: stubUrl, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + }); + return true; +} + +// The same switch, thrown on purpose. On some machines node-datachannel's +// native event loop burns a core for as long as torlink is open, idle or not +// (issue #119), and nothing inside it can be turned down at runtime. Rather +// than decide for a whole platform, let the person watching their fan spin opt +// out on their own machine: TCP/uTP and DHT peers are where torlink's swarms +// are anyway. Presence is the switch, like TORLINK_NO_UPDATE_CHECK. +if (process.env.TORLINK_NO_WEBRTC) { + if (useWebrtcStub()) { + process.stderr.write('torlnk: WebRTC peers disabled by TORLINK_NO_WEBRTC.\n'); } else { - // Node 22.0 to 22.14 has no module.registerHooks, so the eager import - // cannot be redirected; a clear explanation beats the raw module error. process.stderr.write( - '\ntorlnk needs the WebRTC native module (node-datachannel), and it is\n' + - 'not installed. Either upgrade to Node 22.15+ (torlnk then runs\n' + - 'without WebRTC peers), or install the build tools and reinstall:\n' + - ' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static\n' + - ' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev\n' + - ' macOS: xcode-select --install\n' + - ' Windows: install CMake and Visual Studio Build Tools\n' + - 'On npm 12, also allow install scripts: npm approve-scripts\n\n' + - 'https://github.com/baairon/torlink/issues/60\n\n' + 'torlnk: TORLINK_NO_WEBRTC needs Node 22.15 or later to take effect; ' + + 'WebRTC peers stay enabled on v' + process.versions.node + '.\n' ); - process.exit(1); + } +} else { + // The WebRTC stack (webtorrent -> simple-peer -> webrtc-polyfill) eagerly + // requires node-datachannel's native binary, which only install scripts + // download; npm 12 skips those scripts by default, so the binary is often + // absent and the eager import would kill startup. + try { + require('node-datachannel'); + } catch (err) { + if (useWebrtcStub()) { + process.stderr.write( + 'torlnk: WebRTC peers unavailable (native module not installed); ' + + 'TCP/UDP peers still work. https://github.com/baairon/torlink/issues/60\n' + ); + } else { + // Node 22.0 to 22.14 has no module.registerHooks, so the eager import + // cannot be redirected; a clear explanation beats the raw module error. + process.stderr.write( + '\ntorlnk needs the WebRTC native module (node-datachannel), and it is\n' + + 'not installed. Either upgrade to Node 22.15+ (torlnk then runs\n' + + 'without WebRTC peers), or install the build tools and reinstall:\n' + + ' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static\n' + + ' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev\n' + + ' macOS: xcode-select --install\n' + + ' Windows: install CMake and Visual Studio Build Tools\n' + + 'On npm 12, also allow install scripts: npm approve-scripts\n\n' + + 'https://github.com/baairon/torlink/issues/60\n\n' + ); + process.exit(1); + } } } diff --git a/scripts/cli-entry.test.mjs b/scripts/cli-entry.test.mjs new file mode 100644 index 00000000..094c8345 --- /dev/null +++ b/scripts/cli-entry.test.mjs @@ -0,0 +1,30 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; + +const entry = fileURLToPath(new URL("./cli-entry.cjs", import.meta.url)); + +// cli-entry settles the WebRTC question and only then imports ./index.js, +// which exists in dist/ and not in scripts/. Run from here that import fails +// and the process exits, which leaves exactly the part under test on stderr. +function runEntry(env) { + const res = spawnSync(process.execPath, [entry], { + env: { ...process.env, ...env }, + encoding: "utf8", + }); + return res.stderr ?? ""; +} + +describe("TORLINK_NO_WEBRTC", () => { + it("turns WebRTC off when it is set", () => { + // The second branch is Node 22.0-22.14, where module.registerHooks does not + // exist and the opt-out says so rather than pretending to work. + expect(runEntry({ TORLINK_NO_WEBRTC: "1" })).toMatch( + /WebRTC peers disabled by TORLINK_NO_WEBRTC|TORLINK_NO_WEBRTC needs Node 22\.15/, + ); + }); + + it("says nothing about the flag when it is unset", () => { + expect(runEntry({ TORLINK_NO_WEBRTC: "" })).not.toMatch(/TORLINK_NO_WEBRTC/); + }); +}); From 8b1df42dd24862011e269315d6cd60cdcf55cd21 Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Fri, 28 Aug 2026 17:18:13 -0400 Subject: [PATCH 14/21] feat: add headless search command (#180) --- README.md | 2 + src/cli/args.test.ts | 28 ++++++++ src/cli/args.ts | 27 ++++++++ src/cli/search.test.ts | 90 +++++++++++++++++++++++++ src/cli/search.ts | 101 ++++++++++++++++++++++++++++ src/index.tsx | 18 ++++- src/ui/hooks/useConcurrentSearch.ts | 10 +-- src/ui/sort.test.ts | 24 ++++++- src/ui/sort.ts | 10 +++ 9 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 src/cli/search.test.ts create mode 100644 src/cli/search.ts diff --git a/README.md b/README.md index 4c9ab736..83d7bfbe 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Games are the only category that can run code, so they come from FitGirl alone, torlink also runs without the TUI, for servers and seedboxes: + torlnk search "" [--category games|movies|tv|anime] + print one JSON document of merged search results torlnk watch download anything dropped into a folder torlnk serve take magnets over HTTP torlnk files stream finished downloads over HTTP diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index e802cb0e..2171e902 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -40,6 +40,34 @@ describe("parseCliArgs", () => { expect(parseCliArgs(["update"])).toEqual({ kind: "update", force: false }); expect(parseCliArgs(["update", "--force"])).toEqual({ kind: "update", force: true }); }); + it("parses headless searches", () => { + expect(parseCliArgs(["search", "ubuntu"])).toEqual({ kind: "search", query: "ubuntu" }); + expect(parseCliArgs(["search", "example", "movie", "--category", "movies"])).toEqual({ + kind: "search", + query: "example movie", + category: "movies", + }); + expect(parseCliArgs(["search", "--category", "games", "ubuntu"])).toEqual({ + kind: "search", + query: "ubuntu", + category: "games", + }); + }); + it("rejects invalid headless searches", () => { + expect(parseCliArgs(["search"])).toEqual({ kind: "invalid", arg: "search (missing query)" }); + expect(parseCliArgs(["search", "ubuntu", "--category", "books"])).toEqual({ + kind: "invalid", + arg: "search (invalid category 'books')", + }); + expect(parseCliArgs(["search", "ubuntu", "--limit", "10"])).toEqual({ + kind: "invalid", + arg: "search (unknown --limit)", + }); + expect(parseCliArgs(["search", "ubuntu", "--category"])).toEqual({ + kind: "invalid", + arg: "search (invalid --category)", + }); + }); it("parses watch with a directory", () => { expect(parseCliArgs(["watch", "/srv/blackhole"])).toEqual({ kind: "watch", diff --git a/src/cli/args.ts b/src/cli/args.ts index fbae28ed..4358088d 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,6 +1,8 @@ import { isInfoHash } from "../sources/magnet"; import { parseDuration } from "../util/duration"; +export type SearchCategory = "games" | "movies" | "tv" | "anime"; + export type CliCommand = | { kind: "version" } | { kind: "help" } @@ -26,6 +28,7 @@ export type CliCommand = | { kind: "files"; port?: number; host?: string; token?: string; dir?: string; daemon?: boolean } | { kind: "attach" } | { kind: "update"; force?: boolean } + | { kind: "search"; query: string; category?: SearchCategory } | { kind: "invalid"; arg: string }; // Valueless boolean flags for the headless subcommands (everything else is a @@ -77,6 +80,28 @@ export function parseCliArgs(argv: string[]): CliCommand { if (a === "--help" || a === "-h") return { kind: "help" }; if (a === "attach") return { kind: "attach" }; if (a === "update") return { kind: "update", force: args.slice(1).includes("--force") }; + if (a === "search") { + const { flags, rest } = readFlags(args.slice(1)); + const unknownFlag = Object.keys(flags).find((flag) => flag !== "category"); + const danglingFlag = rest.find((arg) => arg.startsWith("--")); + if (unknownFlag) return { kind: "invalid", arg: `search (unknown --${unknownFlag})` }; + if (danglingFlag) return { kind: "invalid", arg: `search (invalid ${danglingFlag})` }; + + const query = rest.join(" ").trim(); + if (!query) return { kind: "invalid", arg: "search (missing query)" }; + + const category = flags.category; + if (category === undefined) return { kind: "search", query }; + if ( + category === "games" || + category === "movies" || + category === "tv" || + category === "anime" + ) { + return { kind: "search", query, category }; + } + return { kind: "invalid", arg: `search (invalid category '${category}')` }; + } if (a === "watch") { const { bools, rest: r0 } = splitBooleans(args.slice(1)); const { flags, rest } = readFlags(r0); @@ -129,6 +154,8 @@ usage torlnk open the search TUI torlnk "magnet:?xt=..." start a download on launch torlnk path/to/file.torrent open a .torrent file on launch + torlnk search headless: print search results as JSON + [--category games|movies|tv|anime] torlnk watch headless: download torrents dropped into torlnk serve headless: HTTP add API (POST /add) on :9161 torlnk files headless: serve downloads over HTTP on :9160 diff --git a/src/cli/search.test.ts b/src/cli/search.test.ts new file mode 100644 index 00000000..f7cf9d9d --- /dev/null +++ b/src/cli/search.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cachedSearch } from "../sources/cache"; +import { sourcesByGroup } from "../sources/registry"; +import type { TorrentResult } from "../sources/types"; +import { HttpError } from "../util/net"; +import { runSearch } from "./search"; + +vi.mock("../sources/cache", () => ({ cachedSearch: vi.fn() })); + +const searchMock = vi.mocked(cachedSearch); + +function result( + infoHash: string, + seeders: number, + added: number, + source: TorrentResult["source"], +): TorrentResult { + return { + infoHash, + name: infoHash, + source, + sizeBytes: 1, + seeders, + leechers: 0, + added, + magnet: `magnet:?xt=urn:btih:${infoHash}`, + }; +} + +beforeEach(() => { + searchMock.mockReset(); +}); + +describe("runSearch", () => { + it("selects category sources and preserves partial failures", async () => { + searchMock.mockImplementation(async (source) => { + if (source.id === "yts") return [result("same", 10, 100, source.id)]; + if (source.id === "tpb-movies") { + return [result("same", 20, 100, source.id), result("other", 15, 200, source.id)]; + } + throw new HttpError(503, "source unavailable"); + }); + + const execution = await runSearch({ query: "example movie", category: "movies" }); + const movieSourceIds = sourcesByGroup() + .find(({ group }) => group === "Movies")! + .sources.map(({ id }) => id); + + expect(searchMock.mock.calls.map(([source]) => source.id)).toEqual(movieSourceIds); + expect(execution.exitCode).toBe(0); + expect(execution.document.sources.yts).toEqual({ ok: true, count: 1, error: null, code: null }); + expect(execution.document.sources["x1337-movies"]).toEqual({ + ok: false, + count: 0, + error: "source unavailable", + code: "HTTP 503", + }); + expect( + execution.document.results.map(({ infoHash, seeders }) => ({ infoHash, seeders })), + ).toEqual([ + { infoHash: "same", seeders: 20 }, + { infoHash: "other", seeders: 15 }, + ]); + }); + + it("exits successfully when every source returns an empty result", async () => { + searchMock.mockResolvedValue([]); + + const execution = await runSearch({ query: "legitimate empty search" }); + + expect(execution.exitCode).toBe(0); + expect(execution.document.category).toBe("all"); + expect(execution.document.count).toBe(0); + }); + + it("returns diagnostic output and exit 1 when every source fails", async () => { + searchMock.mockRejectedValue(new Error("offline")); + + const execution = await runSearch({ query: "ubuntu", category: "games" }); + + expect(execution.exitCode).toBe(1); + expect(execution.document.results).toEqual([]); + expect(execution.document.sources.fitgirl).toEqual({ + ok: false, + count: 0, + error: "offline", + code: "no response", + }); + }); +}); diff --git a/src/cli/search.ts b/src/cli/search.ts new file mode 100644 index 00000000..517532ba --- /dev/null +++ b/src/cli/search.ts @@ -0,0 +1,101 @@ +import type { SearchCategory } from "./args"; +import { cachedSearch } from "../sources/cache"; +import { SOURCES, sourcesByGroup } from "../sources/registry"; +import { dedupeResults } from "../ui/dedupe"; +import { defaultOrder } from "../ui/sort"; +import type { Source, SourceGroup, SourceId, TorrentResult } from "../sources/types"; +import { HttpError } from "../util/net"; + +type OutputCategory = SearchCategory | "all"; + +interface SourceOutcome { + ok: boolean; + count: number; + error: string | null; + code: string | null; +} + +export interface SearchDocument { + query: string; + category: OutputCategory; + count: number; + sources: Partial>; + results: TorrentResult[]; +} + +export interface SearchExecution { + document: SearchDocument; + exitCode: 0 | 1; +} + +const GROUPS: Record = { + games: "Games", + movies: "Movies", + tv: "TV", + anime: "Anime", +}; + +function selectSources(category: OutputCategory): readonly Source[] { + if (category === "all") return SOURCES; + return sourcesByGroup().find(({ group }) => group === GROUPS[category])?.sources ?? []; +} + +function errorCode(error: unknown): string { + if (error instanceof HttpError && error.status > 0) return `HTTP ${error.status}`; + return "no response"; +} + +export async function runSearch(options: { + query: string; + category?: SearchCategory; + signal?: AbortSignal; +}): Promise { + const category = options.category ?? "all"; + const attempts = await Promise.all( + selectSources(category).map(async (source) => { + try { + const results = await cachedSearch(source, options.query, { signal: options.signal }); + return { + source, + results, + outcome: { + ok: true, + count: results.length, + error: null, + code: null, + } satisfies SourceOutcome, + }; + } catch (error) { + return { + source, + results: [] as TorrentResult[], + outcome: { + ok: false, + count: 0, + error: error instanceof Error ? error.message : String(error), + code: errorCode(error), + } satisfies SourceOutcome, + }; + } + }), + ); + + const sources: Partial> = {}; + const collected: TorrentResult[] = []; + for (const attempt of attempts) { + sources[attempt.source.id] = attempt.outcome; + collected.push(...attempt.results); + } + + const results = defaultOrder(dedupeResults(collected)); + return { + document: { + query: options.query, + category, + count: results.length, + sources, + results, + }, + exitCode: attempts.some(({ outcome }) => outcome.ok) ? 0 : 1, + }; +} diff --git a/src/index.tsx b/src/index.tsx index f84a9d51..2eeacbc5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,7 +29,12 @@ if (cmd.kind === "invalid") { // try/catch or error event can reach (see util/crashlog.ts). Contained and // logged for every mode; headless runs also echo one line to their log. containUnhandledRejections({ - echo: cmd.kind === "update" || cmd.kind === "watch" || cmd.kind === "serve" || cmd.kind === "files", + echo: + cmd.kind === "update" || + cmd.kind === "search" || + cmd.kind === "watch" || + cmd.kind === "serve" || + cmd.kind === "files", }); // Run/reattach the TUI inside a persistent tmux session (execs tmux, then exits). @@ -74,6 +79,17 @@ if (cmd.kind === "update") { dir: cmd.dir, }; void import("./daemon/files").then(({ runFiles }) => runFiles(options).catch(failHeadless)); +} else if (cmd.kind === "search") { + // One JSON document on stdout, then exit: the shape a script can pipe into + // jq. Exit 1 only when every source failed, so an empty-but-healthy search + // is still a success. + void import("./cli/search") + .then(({ runSearch }) => runSearch({ query: cmd.query, category: cmd.category })) + .then(({ document, exitCode }) => { + process.exitCode = exitCode; + process.stdout.write(`${JSON.stringify(document)}\n`); + }) + .catch(failHeadless); } else { // Enter the alt-screen and hide the hardware cursor: the TUI draws its own diff --git a/src/ui/hooks/useConcurrentSearch.ts b/src/ui/hooks/useConcurrentSearch.ts index 990513f9..c616325d 100644 --- a/src/ui/hooks/useConcurrentSearch.ts +++ b/src/ui/hooks/useConcurrentSearch.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { SOURCES } from "../../sources/registry"; import { cachedSearch } from "../../sources/cache"; import { dedupeResults } from "../dedupe"; +import { defaultOrder } from "../sort"; import { HttpError } from "../../util/net"; import type { SourceId, TorrentResult } from "../../sources/types"; @@ -31,15 +32,6 @@ function blankPerSource(loading: boolean): Record { return out; } -// torlink's default ordering: healthiest first. The results view can re-sort -// on demand (the `s` key), and its "none"/default state preserves this order. -function defaultOrder(list: TorrentResult[]): TorrentResult[] { - return list.sort((a, b) => { - if (b.seeders !== a.seeders) return b.seeders - a.seeders; - return (b.added ?? 0) - (a.added ?? 0); - }); -} - function idleState(): ConcurrentSearchState { return { results: [], diff --git a/src/ui/sort.test.ts b/src/ui/sort.test.ts index d733bade..af6bbed9 100644 --- a/src/ui/sort.test.ts +++ b/src/ui/sort.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { nextSort, sortResults, sortArrow, SORT_CYCLE } from "./sort"; +import { defaultOrder, nextSort, sortResults, sortArrow, SORT_CYCLE } from "./sort"; import type { Sort } from "./sort"; import type { SourceId, TorrentResult } from "../sources/types"; @@ -152,3 +152,25 @@ describe("sortResults", () => { expect(ids(list)).toEqual(before); }); }); + +describe("defaultOrder", () => { + it("orders by seeders, then newest first", () => { + const ordered = defaultOrder([ + r({ infoHash: "few", seeders: 2, added: 300 }), + r({ infoHash: "older", seeders: 8, added: 100 }), + r({ infoHash: "newer", seeders: 8, added: 200 }), + r({ infoHash: "undated", seeders: 8 }), + ]); + expect(ids(ordered)).toEqual(["newer", "older", "undated", "few"]); + }); + + // The TUI and the headless `search` command both order through this, so a + // change here has to stay one change rather than two that drift. + it("puts a missing added last among equal seeders", () => { + const ordered = defaultOrder([ + r({ infoHash: "undated", seeders: 5 }), + r({ infoHash: "dated", seeders: 5, added: 1 }), + ]); + expect(ids(ordered)).toEqual(["dated", "undated"]); + }); +}); diff --git a/src/ui/sort.ts b/src/ui/sort.ts index df03acb9..a305d050 100644 --- a/src/ui/sort.ts +++ b/src/ui/sort.ts @@ -45,6 +45,16 @@ export function sortLabel(sort: Sort): string { return `${sort.field} ${sortArrow(sort.dir)}`; } +// torlink's default ordering: healthiest first. This is what "none" above +// preserves, and it is what both the TUI and the headless `search` command +// hand their merged result list to, so the two orders can never drift. +export function defaultOrder(list: TorrentResult[]): TorrentResult[] { + return list.sort((a, b) => { + if (b.seeders !== a.seeders) return b.seeders - a.seeders; + return (b.added ?? 0) - (a.added ?? 0); + }); +} + export function sortResults(list: TorrentResult[], sort: Sort): TorrentResult[] { const arr = list.slice(); if (sort === "none") return arr; From f187dd6550c8530d67bbf9fef364f9464ded76ae Mon Sep 17 00:00:00 2001 From: Christian Kaiser <5065635+kaiserc@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:16:16 +0100 Subject: [PATCH 15/21] fix(sources): normalize base32 infohashes in 1337x scraper (#181) --- src/sources/x1337.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sources/x1337.ts b/src/sources/x1337.ts index b65fdaea..f6a8b9ce 100644 --- a/src/sources/x1337.ts +++ b/src/sources/x1337.ts @@ -1,6 +1,7 @@ import { fetchResilient, HttpError, USER_AGENT } from "../util/net"; import { unescapeEntities } from "./rss"; import { parseSize } from "../util/format"; +import { normalizeInfoHash } from "./magnet"; import type { SearchOptions, Source, SourceId, TorrentResult } from "./types"; const HOSTS = ["1337x.to", "1337x.st", "x1337x.ws", "1337xx.to"]; @@ -124,8 +125,9 @@ async function search( const settled = await Promise.all( rows.map(async (row): Promise => { const detail = await detailInfo(base, row.path, opts); - const infoHash = detail?.magnet?.match(/urn:btih:([a-zA-Z0-9]+)/i)?.[1]?.toLowerCase(); - if (!detail || !infoHash) return null; + const rawHash = detail?.magnet?.match(/urn:btih:([a-zA-Z0-9]+)/i)?.[1]; + if (!detail || !rawHash) return null; + const infoHash = normalizeInfoHash(rawHash); return { infoHash, name: row.name, From bafc4e908cef58a19d82f9d81ba4e1501f19b37a Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sat, 29 Aug 2026 12:16:21 -0400 Subject: [PATCH 16/21] fix: exit after headless server shutdown (#183) --- src/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 2eeacbc5..ab544b0b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -69,7 +69,10 @@ if (cmd.kind === "update") { seedTimeMs: cmd.seedTimeMs, deleteFiles: cmd.deleteFiles, }; - void import("./daemon/serve").then(({ runServe }) => runServe(options).catch(failHeadless)); + void import("./daemon/serve") + .then(({ runServe }) => runServe(options)) + .then(() => process.exit(0)) + .catch(failHeadless); } else if (cmd.kind === "files") { if (cmd.daemon) daemonize("files"); const options = { From 4c524521dafdeaf4393f3387398948d835b7d3f4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 11:01:10 -0700 Subject: [PATCH 17/21] feat: seed a local path and share it (#184) --- README.md | 18 ++++++- package-lock.json | 1 + package.json | 1 + src/cli/args.test.ts | 25 +++++++++ src/cli/args.ts | 31 ++++++++++- src/create-torrent.d.ts | 24 +++++++++ src/daemon/seed.paths.test.ts | 29 +++++++++++ src/daemon/seed.ts | 74 +++++++++++++++++++++++++++ src/daemon/serve.test.ts | 34 ++++++++++++- src/daemon/serve.ts | 48 +++++++++++++++++- src/download/create.test.ts | 79 +++++++++++++++++++++++++++++ src/download/create.ts | 76 +++++++++++++++++++++++++++ src/download/queue.metadata.test.ts | 61 ++++++++++++++++++++++ src/download/queue.ts | 11 +++- src/index.tsx | 7 +++ src/sources/torrentFile.ts | 24 +++++++-- 16 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 src/create-torrent.d.ts create mode 100644 src/daemon/seed.paths.test.ts create mode 100644 src/daemon/seed.ts create mode 100644 src/download/create.test.ts create mode 100644 src/download/create.ts create mode 100644 src/download/queue.metadata.test.ts diff --git a/README.md b/README.md index 83d7bfbe..48064f3d 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,28 @@ torlink also runs without the TUI, for servers and seedboxes: torlnk search "" [--category games|movies|tv|anime] print one JSON document of merged search results + torlnk seed make a torrent of files you have, and seed it torlnk watch download anything dropped into a folder torlnk serve take magnets over HTTP torlnk files stream finished downloads over HTTP torlnk attach keep the TUI alive across ssh sessions -Add `--daemon` to keep watch, serve, or files running after you log out; `torlnk --help` has the full list of modes and flags. +Add `--daemon` to keep seed, watch, serve, or files running after you log out; `torlnk --help` has the full list of modes and flags. + +### Sharing something of your own + +Every other way in starts from a torrent somebody else made. `seed` is the other direction: + + torlnk seed ./album + +It hashes the files, writes `album.torrent` beside them, prints the magnet, and starts seeding. The `.torrent` is added rather than the magnet on purpose — a magnet carries no piece hashes, so the client would have to fetch metadata from a swarm that, for a torrent nobody has seen yet, has nobody in it, and would sit at zero per cent over data already on the disk. + +`serve` takes an uploaded `.torrent` as well as a magnet, for the same reason: + + POST /add {"magnet":"magnet:?xt=..."} + POST /add {"torrent":""} # or a data: URI, as FileReader gives it + +The bytes travel in the request rather than a path to them: torlink does not let a network caller name a local file, and "add this torrent" should not double as "read this file off your disk". ## Contributing diff --git a/package-lock.json b/package-lock.json index d272068a..dd13f04f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "create-torrent": "^6.1.2", "env-paths": "^4.0.0", "ink": "^7.0.5", "parse-torrent": "^11.0.21", diff --git a/package.json b/package.json index 5d4b4c83..48980cf4 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "access": "public" }, "dependencies": { + "create-torrent": "^6.1.2", "env-paths": "^4.0.0", "ink": "^7.0.5", "parse-torrent": "^11.0.21", diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 2171e902..194d976e 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -200,3 +200,28 @@ describe("parseCliArgs", () => { }); }); }); + +describe("seed", () => { + it("takes the path, and the flags the other headless modes take", () => { + expect(parseCliArgs(["seed", "./album"])).toEqual({ + kind: "seed", + path: "./album", + seedTimeMs: undefined, + deleteFiles: false, + daemon: false, + }); + expect(parseCliArgs(["seed", "--seed-time", "2h", "--daemon", "./album"])).toEqual({ + kind: "seed", + path: "./album", + seedTimeMs: 2 * 60 * 60 * 1000, + deleteFiles: false, + daemon: true, + }); + }); + + // Without a path there is nothing to hash, and defaulting to the cwd would + // make a bare `torlnk seed` start hashing a home directory. + it("is invalid with no path", () => { + expect(parseCliArgs(["seed"])).toEqual({ kind: "invalid", arg: "seed (missing path)" }); + }); +}); diff --git a/src/cli/args.ts b/src/cli/args.ts index 4358088d..7530d80d 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -25,6 +25,13 @@ export type CliCommand = deleteFiles?: boolean; daemon?: boolean; } + | { + kind: "seed"; + path: string; + seedTimeMs?: number; + deleteFiles?: boolean; + daemon?: boolean; + } | { kind: "files"; port?: number; host?: string; token?: string; dir?: string; daemon?: boolean } | { kind: "attach" } | { kind: "update"; force?: boolean } @@ -130,6 +137,19 @@ export function parseCliArgs(argv: string[]): CliCommand { daemon: bools.has("daemon"), }; } + if (a === "seed") { + const { bools, rest: r0 } = splitBooleans(args.slice(1)); + const { flags, rest } = readFlags(r0); + const target = rest[0]; + if (!target) return { kind: "invalid", arg: "seed (missing path)" }; + return { + kind: "seed", + path: target, + seedTimeMs: seedTimeFrom(flags["seed-time"]), + deleteFiles: bools.has("delete-files"), + daemon: bools.has("daemon"), + }; + } if (a === "files") { const { bools, rest: r0 } = splitBooleans(args.slice(1)); const { flags } = readFlags(r0); @@ -156,6 +176,7 @@ usage torlnk path/to/file.torrent open a .torrent file on launch torlnk search headless: print search results as JSON [--category games|movies|tv|anime] + torlnk seed headless: make a torrent of and seed it torlnk watch headless: download torrents dropped into torlnk serve headless: HTTP add API (POST /add) on :9161 torlnk files headless: serve downloads over HTTP on :9160 @@ -172,7 +193,14 @@ watch mode (no TUI): drop a .torrent, or a .magnet/.txt holding a magnet or info hash, into and it downloads then seeds. Add --to to choose where files land. Handled files move to /.processed (or /.failed). -seed mode (watch/serve): --seed-time stops seeding a torrent that long +seed a path (no TUI): torlnk seed ./album hashes the files, writes +album.torrent beside them, prints the magnet, and starts seeding. It adds the +.torrent rather than the magnet on purpose: a magnet carries no piece hashes, +so the client would have to fetch metadata from a swarm that -- for a torrent +nobody has seen yet -- has nobody in it, and would sit at zero per cent over +data already on the disk. Takes --seed-time, --delete-files and --daemon. + +seed expiry (seed/watch/serve): --seed-time stops seeding a torrent that long after it finishes (e.g. 1h, 30m, 90s, 2d); files are kept by default. Add --delete-files to also remove the downloaded data when the timer expires. @@ -185,6 +213,7 @@ left off. Downloads and seeds keep running while detached. serve mode (no TUI): a small HTTP API for handing torlink a magnet. POST /add {"magnet":"..."} queue a magnet or info hash + POST /add {"torrent":""} queue an uploaded .torrent (base64 or data: URI) GET /downloads list active downloads and seeds GET /health liveness (no auth) flags: --port (default 9161), --host (default 127.0.0.1), diff --git a/src/create-torrent.d.ts b/src/create-torrent.d.ts new file mode 100644 index 00000000..2733f38a --- /dev/null +++ b/src/create-torrent.d.ts @@ -0,0 +1,24 @@ +// create-torrent ships no types, in the same way parse-torrent does not. Only +// the callback form is declared, because it is the only one torlink calls: the +// package also accepts streams and File objects, and declaring shapes we never +// use is how a hand-written declaration drifts from the package it describes. +declare module "create-torrent" { + interface CreateTorrentOptions { + announce?: string[]; + name?: string; + comment?: string; + createdBy?: string; + creationDate?: number; + private?: boolean; + pieceLength?: number; + urlList?: string[]; + } + + function createTorrent( + input: string, + opts: CreateTorrentOptions, + callback: (err: Error | null, torrent: Uint8Array) => void, + ): void; + + export = createTorrent; +} diff --git a/src/daemon/seed.paths.test.ts b/src/daemon/seed.paths.test.ts new file mode 100644 index 00000000..e5b3e147 --- /dev/null +++ b/src/daemon/seed.paths.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; + +import { seedRootFor } from "./seed"; + +describe("seedRootFor", () => { + /* + * The one calculation that decides whether seeding works or silently + * re-downloads. A torrent names its own top-level entry, so the client's + * download directory has to be the content's PARENT: pointed at the content + * itself it looks for album/album, finds nothing, and fetches a second copy + * next to the one already on disk. + */ + it("is the content's parent, never the content", () => { + expect(seedRootFor("/srv/media/album")).toBe("/srv/media"); + expect(seedRootFor("/srv/media/film.mkv")).toBe("/srv/media"); + }); + + it("resolves a relative path before taking the parent", () => { + expect(path.isAbsolute(seedRootFor("./album"))).toBe(true); + expect(seedRootFor("./album")).toBe(process.cwd()); + }); + + // A trailing slash is what tab-completion gives you for a directory, and it + // would otherwise make dirname return the directory itself. + it("is not fooled by a trailing slash", () => { + expect(seedRootFor("/srv/media/album/")).toBe("/srv/media"); + }); +}); diff --git a/src/daemon/seed.ts b/src/daemon/seed.ts new file mode 100644 index 00000000..4505806b --- /dev/null +++ b/src/daemon/seed.ts @@ -0,0 +1,74 @@ +// Headless seed mode: make a torrent out of files you already have, and serve +// them to the swarm. +// +// Every other way into torlink starts from a torrent somebody else made. This +// is the origin case, and it is the one thing the client could not do: search +// finds torrents, the watch folder is handed them, the API is posted them — +// none of that helps when the content is yours and no torrent for it exists. +// +// The order matters and is the whole trick. Create the .torrent first, write it +// beside the data, then add it BY PATH. Adding the magnet instead would be +// correct and useless: a magnet carries no piece hashes, so the client has to +// fetch metadata from the swarm before it can verify anything — and the swarm +// for a torrent nobody has ever seen has no one in it to fetch from. It would +// sit at zero per cent forever, seeding data that is already on the disk under +// it. Handed the .torrent, it verifies locally and is seeding in seconds. + +import path from "node:path"; + +import { loadConfig } from "../config/config"; +import { createTorrentFor } from "../download/create"; +import { saveTorrentMeta } from "../download/persist"; +import { startRuntime, addInput } from "./runtime"; +import { startSeedReaper } from "./seed-reaper"; + +export interface SeedOptions { + seedTimeMs?: number; + deleteFiles?: boolean; +} + +function log(message: string): void { + console.log(`[torlnk seed] ${new Date().toISOString()} ${message}`); +} + +// The directory a client must be pointed at for existing data to verify. +// +// A torrent names its own top-level entry, so seeding /srv/media/album means +// the client's download directory has to be /srv/media — point it at the album +// itself and it looks for /srv/media/album/album, finds nothing, and downloads +// a complete copy next to the one already there. +export function seedRootFor(target: string): string { + return path.dirname(path.resolve(target)); +} + +export async function runSeed(target: string, options: SeedOptions = {}): Promise { + const root = seedRootFor(target); + const config = await loadConfig(); + + log(`hashing ${path.resolve(target)}`); + const created = await createTorrentFor(target, config.trackers); + log(`wrote ${created.torrentPath}`); + + // Store the metadata where the queue looks for it BEFORE adding. This is the + // step that makes the difference between seeding and pretending to: with it, + // the engine is handed piece hashes and verifies the files already on disk; + // without it, it gets a bare magnet and waits for a swarm that has nobody in + // it to send back the metadata for a torrent that has never existed. + await saveTorrentMeta(created.infoHash, created.torrentFile); + + // The download dir is the content's parent, not the configured one: this + // torrent's data is already where it is, and moving it is not on offer. + const runtime = await startRuntime(root); + const outcome = await addInput(runtime, created.torrentPath, { allowTorrentPath: true }); + if (outcome === "invalid") throw new Error(`could not seed ${created.torrentPath}`); + if (outcome === "duplicate") log("already in the queue — leaving it alone"); + + if (options.seedTimeMs) { + startSeedReaper(runtime.queue, options.seedTimeMs, { deleteFiles: options.deleteFiles }); + } + + // The magnet on its own line and nothing else on it, so `torlnk seed x | tail + // -1` is a usable thing to write in a script. + log(`seeding ${created.name} (${created.infoHash}) from ${root}`); + console.log(created.magnet); +} diff --git a/src/daemon/serve.test.ts b/src/daemon/serve.test.ts index 092d09eb..f4e36637 100644 --- a/src/daemon/serve.test.ts +++ b/src/daemon/serve.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import os from "node:os"; import path from "node:path"; import { promises as fs } from "node:fs"; -import { handleApi, isAuthorized, extractMagnet, parseControl, applyControl } from "./serve"; +import { handleApi, isAuthorized, extractMagnet, extractTorrentBytes, parseControl, applyControl } from "./serve"; import type { Runtime } from "./runtime"; const HASH = "abcdef0123456789abcdef0123456789abcdef01"; @@ -191,3 +191,35 @@ describe("applyControl", () => { expect(await applyControl(mkRuntime({}), { id: "z", action: "nope", deleteFiles: false })).toBe("unknown-action"); }); }); + +describe("extractTorrentBytes", () => { + // Every torrent is a bencoded dictionary, so it starts with "d". + const b64 = Buffer.from("d4:name4:teste", "utf8").toString("base64"); + + it("reads a base64 .torrent out of the body", () => { + const bytes = extractTorrentBytes(JSON.stringify({ torrent: b64 })); + expect(bytes).not.toBeNull(); + expect(bytes![0]).toBe(0x64); + }); + + // What a browser's FileReader.readAsDataURL hands you, verbatim. + it("accepts a data: URI without making the caller strip it", () => { + const uri = `data:application/x-bittorrent;base64,${b64}`; + expect(extractTorrentBytes(JSON.stringify({ torrent: uri }))).not.toBeNull(); + }); + + /* + * Buffer.from(..., "base64") ignores bytes it cannot decode rather than + * throwing, so garbage yields a short buffer instead of an error. Checking + * for the leading bencode dictionary is what turns that into a rejection. + */ + it("rejects a string that is not a torrent", () => { + expect(extractTorrentBytes(JSON.stringify({ torrent: "hello world" }))).toBeNull(); + expect(extractTorrentBytes(JSON.stringify({ torrent: "" }))).toBeNull(); + }); + + it("is null for a body that carries no torrent at all", () => { + expect(extractTorrentBytes(JSON.stringify({ magnet: "magnet:?xt=urn:btih:" + "a".repeat(40) }))).toBeNull(); + expect(extractTorrentBytes("not json")).toBeNull(); + }); +}); diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index e5dcb335..fc74a1bc 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -9,6 +9,7 @@ import http from "node:http"; import { startRuntime, addInput, type Runtime } from "./runtime"; +import { magnetFromTorrentBytes } from "../sources/torrentFile"; import { startSeedReaper } from "./seed-reaper"; import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth"; import { VERSION } from "../version"; @@ -38,6 +39,40 @@ export interface ServeOptions { // Pull a magnet / info hash out of a request body. Accepts JSON ({ magnet } or // { infohash }) or a raw body that is itself a magnet or info hash — forgiving, // so callers don't have to guess the exact envelope. +// A base64 .torrent from the request body, or null. +// +// The bytes travel in the JSON rather than the path to them: torlink already +// refuses to let a network caller name a local file (runtime.ts's +// allowTorrentPath), and that refusal is worth keeping -- "add this torrent" +// and "read this file off your disk and tell me about it" must not be the same +// request. Uploading the content sidesteps it entirely. +export function extractTorrentBytes(bodyText: string): Uint8Array | null { + const raw = bodyText.trim(); + if (!raw.startsWith("{")) return null; + let obj: Record; + try { + obj = JSON.parse(raw) as Record; + } catch { + return null; + } + const value = obj.torrent ?? obj.torrentFile ?? obj.file; + if (typeof value !== "string" || !value.trim()) return null; + // A data: URI is what a browser's FileReader hands you, and stripping the + // prefix here is cheaper than making every caller remember to. + const b64 = value.replace(/^data:[^,]*,/, "").trim(); + try { + const bytes = Buffer.from(b64, "base64"); + // Buffer.from ignores anything it cannot decode rather than throwing, so a + // non-base64 string yields a short buffer instead of an error. Every + // torrent starts with a bencoded dictionary, which is the cheap check that + // this is one. + if (bytes.length === 0 || bytes[0] !== 0x64) return null; + return new Uint8Array(bytes); + } catch { + return null; + } +} + export function extractMagnet(bodyText: string): string | null { const raw = bodyText.trim(); if (!raw) return null; @@ -166,8 +201,19 @@ export async function handleApi( return { status: 200, body: statusPayload(runtime) }; } if (method === "POST" && urlPath === "/add") { + // A .torrent is tried first because it is strictly more information: it + // carries the piece hashes, so data already on disk verifies locally + // instead of waiting on a swarm to serve metadata back. + const bytes = extractTorrentBytes(bodyText); + if (bytes) { + const parsed = await magnetFromTorrentBytes(bytes); + if (!parsed) return { status: 400, body: { error: "invalid .torrent" } }; + const outcome = await addInput(runtime, parsed.magnet); + if (outcome === "invalid") return { status: 400, body: { error: "invalid .torrent" } }; + return { status: 200, body: { ok: true, outcome, infoHash: parsed.infoHash } }; + } const magnet = extractMagnet(bodyText); - if (!magnet) return { status: 400, body: { error: "missing magnet or info hash" } }; + if (!magnet) return { status: 400, body: { error: "missing magnet, info hash or .torrent" } }; const outcome = await addInput(runtime, magnet); if (outcome === "invalid") return { status: 400, body: { error: "invalid magnet or info hash" } }; return { status: 200, body: { ok: true, outcome } }; diff --git a/src/download/create.test.ts b/src/download/create.test.ts new file mode 100644 index 00000000..340d5f27 --- /dev/null +++ b/src/download/create.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { createTorrentFor, torrentOptions, torrentPathFor } from "./create"; +import { magnetFromTorrentBytes } from "../sources/torrentFile"; + +async function fixture(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-create-")); + const content = path.join(dir, "album"); + await fs.mkdir(content); + await fs.writeFile(path.join(content, "one.txt"), "first\n"); + await fs.writeFile(path.join(content, "two.txt"), "second\n"); + return content; +} + +describe("torrentOptions", () => { + // create-torrent supplies its own announce list when given none, so passing + // an empty array would replace a working default with nothing. + it("omits announce entirely rather than passing an empty list", () => { + expect(torrentOptions([])).toEqual({}); + expect(torrentOptions(["udp://t.test:1337"])).toEqual({ announce: ["udp://t.test:1337"] }); + }); +}); + +describe("torrentPathFor", () => { + // Beside the data, not in a config dir: moving the files and leaving the + // torrent behind is how you end up with a magnet nobody can serve. + it("names the torrent after the content and puts it alongside", () => { + expect(torrentPathFor("/srv/media/album")).toBe("/srv/media/album.torrent"); + expect(torrentPathFor("/srv/media/film.mkv")).toBe("/srv/media/film.mkv.torrent"); + }); +}); + +describe("createTorrentFor", () => { + it("writes a .torrent that parses back to the magnet it returns", async () => { + const content = await fixture(); + const created = await createTorrentFor(content, ["udp://tracker.test:1337/announce"]); + + expect(created.infoHash).toMatch(/^[0-9a-f]{40}$/); + expect(created.name).toBe("album"); + expect(created.magnet).toContain(created.infoHash); + + // The file on disk has to be the same torrent as the magnet describes. + const onDisk = await fs.readFile(created.torrentPath); + const reparsed = await magnetFromTorrentBytes(new Uint8Array(onDisk)); + expect(reparsed?.infoHash).toBe(created.infoHash); + }); + + /* + * The field that makes seeding existing data work at all. + * + * A torrent names its own top-level entry, so a client seeding + * /srv/media/album must be pointed at /srv/media. Point it at the album and + * it looks for album/album, finds nothing, and downloads a second copy + * beside the one already there. + */ + it("reports the content's parent as the directory to seed from", async () => { + const content = await fixture(); + const created = await createTorrentFor(content); + expect(created.contentDir).toBe(path.dirname(content)); + expect(created.contentDir).not.toBe(content); + }); + + // The announce list is the difference between a torrent anyone can find and + // a DHT-only one, so it has to survive into the magnet. + it("carries the trackers it was given into the magnet", async () => { + const content = await fixture(); + const created = await createTorrentFor(content, ["udp://tracker.test:1337/announce"]); + expect(decodeURIComponent(created.magnet)).toContain("udp://tracker.test:1337/announce"); + }); + + // A direct instruction from an operator, unlike a file appearing in a watch + // folder: failing quietly would leave them with no torrent and no reason. + it("throws on a path that is not there", async () => { + await expect(createTorrentFor("/nope/not/here")).rejects.toThrow(); + }); +}); diff --git a/src/download/create.ts b/src/download/create.ts new file mode 100644 index 00000000..2b2df92e --- /dev/null +++ b/src/download/create.ts @@ -0,0 +1,76 @@ +// Making a torrent out of a path you already have. +// +// Everything else in torlink starts from a magnet or a .torrent that someone +// else made: search finds one, the watch folder is handed one, the API is +// posted one. This is the other direction — you have the files, and you want a +// torrent of them — and it is the one thing the client could not do. +// +// It produces both halves on purpose. The .torrent is what lets torlink (and +// any other client) verify the data already on disk and go straight to seeding +// instead of fetching metadata from a swarm that has no seeds yet; the magnet +// is what you actually paste somewhere. + +import createTorrent from "create-torrent"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { magnetFromTorrentBytes } from "../sources/torrentFile"; +import type { ParsedMagnet } from "../sources/magnet"; + +export interface CreatedTorrent extends ParsedMagnet { + // The bencoded .torrent, and where it was written. + torrentFile: Uint8Array; + torrentPath: string; + // The directory a client must be pointed at for the existing data to verify: + // the PARENT of the path, because a torrent names its own top-level entry. + contentDir: string; +} + +// A torrent with no announce list is a DHT-only torrent, which works but is +// slower to be found and invisible to anything watching a tracker. The caller's +// configured trackers are used when it has some; create-torrent supplies its +// own defaults otherwise. +export function torrentOptions(announce: string[]): { announce?: string[] } { + return announce.length > 0 ? { announce } : {}; +} + +// create-torrent is callback-shaped and hashes every piece, so this is the one +// genuinely slow step: a large directory is read end to end. +export function buildTorrent(target: string, announce: string[]): Promise { + return new Promise((resolve, reject) => { + createTorrent(target, torrentOptions(announce), (err: Error | null, torrent: Uint8Array) => { + if (err) reject(err); + else resolve(torrent); + }); + }); +} + +// Where the .torrent goes: beside the data, named after it. Next to the content +// rather than in a config directory because the two belong together — moving +// the files and leaving the torrent behind is how you end up with a magnet +// nobody can serve. +export function torrentPathFor(target: string): string { + const full = path.resolve(target); + return path.join(path.dirname(full), `${path.basename(full)}.torrent`); +} + +// Create a torrent for `target`, write it beside the data, and return both the +// magnet and everything a caller needs to seed it. Throws on a path that cannot +// be read: unlike the watch folder, this is a direct instruction from an +// operator, and failing quietly would leave them with no torrent and no reason. +export async function createTorrentFor( + target: string, + announce: string[] = [], +): Promise { + const full = path.resolve(target); + await fs.stat(full); + + const torrentFile = await buildTorrent(full, announce); + const parsed = await magnetFromTorrentBytes(torrentFile); + if (!parsed) throw new Error("created a torrent that could not be parsed back"); + + const torrentPath = torrentPathFor(full); + await fs.writeFile(torrentPath, torrentFile); + + return { ...parsed, torrentFile, torrentPath, contentDir: path.dirname(full) }; +} diff --git a/src/download/queue.metadata.test.ts b/src/download/queue.metadata.test.ts new file mode 100644 index 00000000..4d8dd7b1 --- /dev/null +++ b/src/download/queue.metadata.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from "vitest"; + +// What startEngine is handed is the whole point of these two tests, so the +// engine is stubbed to record its `source` argument and the metadata store is +// stubbed to say whether a .torrent exists for an id. +const added: { id: string; source: string }[] = []; + +vi.mock("./engine", () => ({ + TorrentEngine: class { + add(id: string, source: string): void { + added.push({ id, source }); + } + remove(): void {} + stats(): undefined { + return undefined; + } + destroy(): void {} + }, +})); + +vi.mock("./persist", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + torrentMetaExists: (id: string) => id === "has-meta", + torrentMetaPath: (id: string) => `/meta/${id}.torrent`, + saveQueue: async () => {}, + saveSeeds: async () => {}, + saveHistory: async () => {}, + }; +}); + +const { DownloadQueue } = await import("./queue"); + +const MAGNET = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000"; + +describe("startEngine source selection", () => { + /* + * The difference between seeding and pretending to. + * + * A magnet carries no piece hashes, so a client handed one cannot verify a + * single byte until the swarm sends it metadata. For a torrent created from + * local content that swarm is empty -- nobody has ever seen this info hash -- + * so it waits forever on data that is already on the disk underneath it. + * Handed the stored .torrent it verifies locally and completes at once. + */ + it("prefers a stored .torrent over the magnet", () => { + added.length = 0; + const q = new DownloadQueue(); + q.add({ id: "has-meta", name: "local", magnet: MAGNET }, "/data"); + expect(added.at(-1)?.source).toBe("/meta/has-meta.torrent"); + }); + + // The ordinary case: nothing downloaded yet, so a magnet is all there is. + it("falls back to the magnet when there is no stored .torrent", () => { + added.length = 0; + const q = new DownloadQueue(); + q.add({ id: "no-meta", name: "remote", magnet: MAGNET }, "/data"); + expect(added.at(-1)?.source).toBe(MAGNET); + }); +}); diff --git a/src/download/queue.ts b/src/download/queue.ts index 21c990eb..79e997c7 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -159,7 +159,16 @@ export class DownloadQueue extends EventEmitter { private startEngine(item: QueueItem): void { try { - this.engine.add(item.id, item.magnet, item.dir, this.engineHandlers(item.id), this.trackers); + // Prefer the stored .torrent over the magnet, exactly as startSeeding + // does. A magnet carries no piece hashes, so the client cannot verify a + // single byte until the swarm serves it metadata -- which is merely slow + // for a popular torrent and terminal for one that nobody else has yet. + // With the metadata on disk it verifies the local files immediately, so + // a re-add of something already downloaded, and a torrent created from + // local content, both go straight to complete instead of waiting on + // peers that may not exist. + const source = torrentMetaExists(item.id) ? torrentMetaPath(item.id) : item.magnet; + this.engine.add(item.id, source, item.dir, this.engineHandlers(item.id), this.trackers); } catch (e) { // engine.add routes webtorrent's own synchronous failures through // onError, so the only throw that reaches here is the client failing to diff --git a/src/index.tsx b/src/index.tsx index ab544b0b..19899d8c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,6 +33,7 @@ containUnhandledRejections({ cmd.kind === "update" || cmd.kind === "search" || cmd.kind === "watch" || + cmd.kind === "seed" || cmd.kind === "serve" || cmd.kind === "files", }); @@ -59,6 +60,12 @@ if (cmd.kind === "update") { void import("./daemon/watch").then(({ runWatch }) => runWatch(dir, downloadDir, { seedTimeMs, deleteFiles }).catch(failHeadless), ); +} else if (cmd.kind === "seed") { + if (cmd.daemon) daemonize("seed"); + const { path: target, seedTimeMs, deleteFiles } = cmd; + void import("./daemon/seed").then(({ runSeed }) => + runSeed(target, { seedTimeMs, deleteFiles }).catch(failHeadless), + ); } else if (cmd.kind === "serve") { if (cmd.daemon) daemonize("serve"); const options = { diff --git a/src/sources/torrentFile.ts b/src/sources/torrentFile.ts index 321f2cb8..caffac3e 100644 --- a/src/sources/torrentFile.ts +++ b/src/sources/torrentFile.ts @@ -7,12 +7,31 @@ import { buildMagnet, type ParsedMagnet } from "./magnet"; // image dropped in the watch folder from being pulled into memory whole. const MAX_TORRENT_BYTES = 16 * 1024 * 1024; +// The same read, from bytes already in hand rather than a path. The HTTP add +// API needs this: it accepts an uploaded .torrent, and must never be able to +// point the daemon at a local file (see runtime.ts's allowTorrentPath). +export async function magnetFromTorrentBytes(bytes: Uint8Array): Promise { + try { + if (bytes.length === 0 || bytes.length > MAX_TORRENT_BYTES) return null; + return await readParsed(bytes); + } catch { + return null; + } +} + export async function magnetFromTorrentFile(path: string): Promise { try { const stat = await fs.stat(path); if (!stat.isFile() || stat.size === 0 || stat.size > MAX_TORRENT_BYTES) return null; const buf = await fs.readFile(path); - const parsed = await parseTorrent(new Uint8Array(buf)); + return await readParsed(new Uint8Array(buf)); + } catch { + return null; + } +} + +async function readParsed(bytes: Uint8Array): Promise { + const parsed = await parseTorrent(bytes); const infoHash = parsed?.infoHash?.toLowerCase(); if (!infoHash) return null; const name = parsed.name || infoHash; @@ -24,7 +43,4 @@ export async function magnetFromTorrentFile(path: string): Promise typeof url === "string") : []; return { infoHash, name, magnet: buildMagnet(infoHash, name, announce) }; - } catch { - return null; - } } From 918d3f6c9659d6595348a872f1c83acfbe3214c9 Mon Sep 17 00:00:00 2001 From: "bairon.dev" Date: Sat, 29 Aug 2026 14:01:50 -0400 Subject: [PATCH 18/21] fix: seed shutdown, the serve body cap, and trackers on resume Follow-up to #184. runSeed returned as soon as the torrent was added, so the process stayed alive only because webtorrent's handles did and queue.suspend() never ran on shutdown. watch and serve both hold themselves open and suspend on both signals; seed now does the same, and the CLI exits once that flush returns. POST /add accepts a base64 .torrent, which is one 20-byte hash per piece and grows by a third again in base64. The 64KB body cap was sized for magnets, so it fit every torrent small enough to test with and answered 413 on a large multi-file release. startEngine prefers the stored .torrent, which does not carry the announce URLs that mergeMagnetTrackers folds onto a row assembled from several sources. Metadata is saved as soon as it arrives, so resuming a partial download dropped them; the magnet's trackers are now passed as announce regardless of which source wins. The new path tests asserted POSIX literals, which resolve drive-qualified on Windows and failed the suite there. Also plainer seed wording in the README and --help, and readParsed re-indented after its extraction. --- README.md | 12 ++++---- src/cli/args.ts | 12 ++++---- src/daemon/seed.paths.test.ts | 11 ++++++-- src/daemon/seed.ts | 13 +++++++++ src/daemon/serve.ts | 6 +++- src/download/create.test.ts | 10 +++++-- src/download/queue.metadata.test.ts | 43 +++++++++++++++++++++++------ src/download/queue.ts | 11 ++++++-- src/index.tsx | 7 +++-- src/sources/magnet.ts | 5 +++- src/sources/torrentFile.ts | 24 ++++++++-------- 11 files changed, 107 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 48064f3d..c608f88e 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ torlink also runs without the TUI, for servers and seedboxes: torlnk search "" [--category games|movies|tv|anime] print one JSON document of merged search results - torlnk seed make a torrent of files you have, and seed it + torlnk seed share files you already have torlnk watch download anything dropped into a folder torlnk serve take magnets over HTTP torlnk files stream finished downloads over HTTP @@ -65,18 +65,16 @@ Add `--daemon` to keep seed, watch, serve, or files running after you log out; ` ### Sharing something of your own -Every other way in starts from a torrent somebody else made. `seed` is the other direction: +Everything else starts with a torrent someone else made. `seed` goes the other way: torlnk seed ./album -It hashes the files, writes `album.torrent` beside them, prints the magnet, and starts seeding. The `.torrent` is added rather than the magnet on purpose — a magnet carries no piece hashes, so the client would have to fetch metadata from a swarm that, for a torrent nobody has seen yet, has nobody in it, and would sit at zero per cent over data already on the disk. +It turns the folder into a torrent, saves `album.torrent` next to it, prints the magnet, and starts sharing right away. Send anyone the magnet and they pull the files from you. -`serve` takes an uploaded `.torrent` as well as a magnet, for the same reason: +`serve` takes a `.torrent` as well as a magnet, so you can hand it one you already have: POST /add {"magnet":"magnet:?xt=..."} - POST /add {"torrent":""} # or a data: URI, as FileReader gives it - -The bytes travel in the request rather than a path to them: torlink does not let a network caller name a local file, and "add this torrent" should not double as "read this file off your disk". + POST /add {"torrent":""} ## Contributing diff --git a/src/cli/args.ts b/src/cli/args.ts index 7530d80d..7ed431be 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -176,7 +176,7 @@ usage torlnk path/to/file.torrent open a .torrent file on launch torlnk search headless: print search results as JSON [--category games|movies|tv|anime] - torlnk seed headless: make a torrent of and seed it + torlnk seed headless: share files you already have torlnk watch headless: download torrents dropped into torlnk serve headless: HTTP add API (POST /add) on :9161 torlnk files headless: serve downloads over HTTP on :9160 @@ -193,12 +193,10 @@ watch mode (no TUI): drop a .torrent, or a .magnet/.txt holding a magnet or info hash, into and it downloads then seeds. Add --to to choose where files land. Handled files move to /.processed (or /.failed). -seed a path (no TUI): torlnk seed ./album hashes the files, writes -album.torrent beside them, prints the magnet, and starts seeding. It adds the -.torrent rather than the magnet on purpose: a magnet carries no piece hashes, -so the client would have to fetch metadata from a swarm that -- for a torrent -nobody has seen yet -- has nobody in it, and would sit at zero per cent over -data already on the disk. Takes --seed-time, --delete-files and --daemon. +seed a path (no TUI): torlnk seed ./album turns the folder into a torrent, +saves album.torrent next to it, prints the magnet, and starts sharing. Send +anyone the magnet and they pull the files from you. Takes --seed-time, +--delete-files and --daemon. seed expiry (seed/watch/serve): --seed-time stops seeding a torrent that long after it finishes (e.g. 1h, 30m, 90s, 2d); files are kept by default. Add diff --git a/src/daemon/seed.paths.test.ts b/src/daemon/seed.paths.test.ts index e5b3e147..d062892a 100644 --- a/src/daemon/seed.paths.test.ts +++ b/src/daemon/seed.paths.test.ts @@ -3,6 +3,11 @@ import path from "node:path"; import { seedRootFor } from "./seed"; +// seedRootFor resolves against the running platform's path rules, so a POSIX +// literal comes back drive-qualified on Windows ("/srv/media" -> "C:\srv\media") +// and every assertion against one fails there. Build the fixtures natively. +const MEDIA = path.resolve(path.join("srv", "media")); + describe("seedRootFor", () => { /* * The one calculation that decides whether seeding works or silently @@ -12,8 +17,8 @@ describe("seedRootFor", () => { * next to the one already on disk. */ it("is the content's parent, never the content", () => { - expect(seedRootFor("/srv/media/album")).toBe("/srv/media"); - expect(seedRootFor("/srv/media/film.mkv")).toBe("/srv/media"); + expect(seedRootFor(path.join(MEDIA, "album"))).toBe(MEDIA); + expect(seedRootFor(path.join(MEDIA, "film.mkv"))).toBe(MEDIA); }); it("resolves a relative path before taking the parent", () => { @@ -24,6 +29,6 @@ describe("seedRootFor", () => { // A trailing slash is what tab-completion gives you for a directory, and it // would otherwise make dirname return the directory itself. it("is not fooled by a trailing slash", () => { - expect(seedRootFor("/srv/media/album/")).toBe("/srv/media"); + expect(seedRootFor(path.join(MEDIA, "album") + path.sep)).toBe(MEDIA); }); }); diff --git a/src/daemon/seed.ts b/src/daemon/seed.ts index 4505806b..e74b58a5 100644 --- a/src/daemon/seed.ts +++ b/src/daemon/seed.ts @@ -71,4 +71,17 @@ export async function runSeed(target: string, options: SeedOptions = {}): Promis // -1` is a usable thing to write in a script. log(`seeding ${created.name} (${created.infoHash}) from ${root}`); console.log(created.magnet); + + // Hold the process open and shut down cleanly, exactly as watch and serve do. + // Without this the mode ends here and stays alive only because webtorrent's + // handles do, so a SIGTERM (systemctl stop, or ctrl-c) would kill it without + // ever flushing state through suspend(). + await new Promise((resolve) => { + const shutdown = (): void => { + runtime.queue.suspend(); + resolve(); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + }); } diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index fc74a1bc..d83804d1 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -18,7 +18,11 @@ export { isAuthorized } from "./auth"; export const DEFAULT_API_PORT = 9161; -const MAX_BODY_BYTES = 64 * 1024; // a magnet is small; cap the body hard +// A magnet is tiny, but /add also takes an uploaded .torrent, and that is one +// 20-byte hash per piece: a large multi-file release runs to a few hundred KB +// before base64 adds a third on top. The old 64KB cap fit every magnet and +// every torrent small enough to test with, then answered 413 on real content. +const MAX_BODY_BYTES = 1024 * 1024; export interface ApiResponse { status: number; diff --git a/src/download/create.test.ts b/src/download/create.test.ts index 340d5f27..e0132d00 100644 --- a/src/download/create.test.ts +++ b/src/download/create.test.ts @@ -27,9 +27,15 @@ describe("torrentOptions", () => { describe("torrentPathFor", () => { // Beside the data, not in a config dir: moving the files and leaving the // torrent behind is how you end up with a magnet nobody can serve. + // Built natively rather than from POSIX literals: torrentPathFor resolves + // against the running platform, so "/srv/media" comes back as "C:\srv\media" + // on Windows and a hardcoded expectation fails there. it("names the torrent after the content and puts it alongside", () => { - expect(torrentPathFor("/srv/media/album")).toBe("/srv/media/album.torrent"); - expect(torrentPathFor("/srv/media/film.mkv")).toBe("/srv/media/film.mkv.torrent"); + const media = path.resolve(path.join("srv", "media")); + expect(torrentPathFor(path.join(media, "album"))).toBe(path.join(media, "album.torrent")); + expect(torrentPathFor(path.join(media, "film.mkv"))).toBe( + path.join(media, "film.mkv.torrent"), + ); }); }); diff --git a/src/download/queue.metadata.test.ts b/src/download/queue.metadata.test.ts index 4d8dd7b1..51e21f77 100644 --- a/src/download/queue.metadata.test.ts +++ b/src/download/queue.metadata.test.ts @@ -1,14 +1,20 @@ import { describe, it, expect, vi } from "vitest"; -// What startEngine is handed is the whole point of these two tests, so the -// engine is stubbed to record its `source` argument and the metadata store is -// stubbed to say whether a .torrent exists for an id. -const added: { id: string; source: string }[] = []; +// What startEngine is handed is the whole point of these tests, so the engine +// is stubbed to record its `source` and `announce` arguments and the metadata +// store is stubbed to say whether a .torrent exists for an id. +const added: { id: string; source: string; announce?: string[] }[] = []; vi.mock("./engine", () => ({ TorrentEngine: class { - add(id: string, source: string): void { - added.push({ id, source }); + add( + id: string, + source: string, + _dir: string, + _handlers: unknown, + announce?: string[], + ): void { + added.push({ id, source, announce }); } remove(): void {} stats(): undefined { @@ -40,9 +46,9 @@ describe("startEngine source selection", () => { * * A magnet carries no piece hashes, so a client handed one cannot verify a * single byte until the swarm sends it metadata. For a torrent created from - * local content that swarm is empty -- nobody has ever seen this info hash -- - * so it waits forever on data that is already on the disk underneath it. - * Handed the stored .torrent it verifies locally and completes at once. + * local content that swarm is empty (nobody has ever seen this info hash), so + * it waits forever on data that is already on the disk underneath it. Handed + * the stored .torrent it verifies locally and completes at once. */ it("prefers a stored .torrent over the magnet", () => { added.length = 0; @@ -58,4 +64,23 @@ describe("startEngine source selection", () => { q.add({ id: "no-meta", name: "remote", magnet: MAGNET }, "/data"); expect(added.at(-1)?.source).toBe(MAGNET); }); + + /* + * A row merged from several sources carries every source's announce URLs on + * its magnet. Once the stored .torrent is what reaches webtorrent, that magnet + * is out of the picture, and the .torrent only knows the list it shipped with, + * so the merged trackers have to be passed as announce or the merge is undone + * on the first resume. + */ + it("carries the magnet's trackers even when the .torrent is used", () => { + added.length = 0; + const merged = `${MAGNET}&tr=${encodeURIComponent("udp://from-a.test:1337/announce")}&tr=${encodeURIComponent("udp://from-b.test:1337/announce")}`; + const q = new DownloadQueue(); + q.add({ id: "has-meta", name: "local", magnet: merged }, "/data"); + + expect(added.at(-1)?.source).toBe("/meta/has-meta.torrent"); + expect(added.at(-1)?.announce).toEqual( + expect.arrayContaining(["udp://from-a.test:1337/announce", "udp://from-b.test:1337/announce"]), + ); + }); }); diff --git a/src/download/queue.ts b/src/download/queue.ts index 79e997c7..d48ee395 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -15,6 +15,7 @@ import { import { saveHistory, saveHistorySync, type HistoryItem } from "./history"; import { deleteSeedData } from "./delete-data"; import { disarmBootMarker } from "./bootguard"; +import { trackersOf } from "../sources/magnet"; import type { QueueItem, SeedItem } from "./types"; import type { SourceId } from "../sources/types"; @@ -161,14 +162,20 @@ export class DownloadQueue extends EventEmitter { try { // Prefer the stored .torrent over the magnet, exactly as startSeeding // does. A magnet carries no piece hashes, so the client cannot verify a - // single byte until the swarm serves it metadata -- which is merely slow + // single byte until the swarm serves it metadata, which is merely slow // for a popular torrent and terminal for one that nobody else has yet. // With the metadata on disk it verifies the local files immediately, so // a re-add of something already downloaded, and a torrent created from // local content, both go straight to complete instead of waiting on // peers that may not exist. const source = torrentMetaExists(item.id) ? torrentMetaPath(item.id) : item.magnet; - this.engine.add(item.id, source, item.dir, this.engineHandlers(item.id), this.trackers); + // The magnet's own trackers ride along regardless of which source won. + // A row merged from several sources carries all of their announce URLs + // (see mergeMagnetTrackers), and a stored .torrent only knows the list it + // shipped with, so passing them explicitly is what keeps that merge from + // being undone on resume. webtorrent dedupes announce internally. + const announce = [...(trackersOf(item.magnet) ?? []), ...this.trackers]; + this.engine.add(item.id, source, item.dir, this.engineHandlers(item.id), announce); } catch (e) { // engine.add routes webtorrent's own synchronous failures through // onError, so the only throw that reaches here is the client failing to diff --git a/src/index.tsx b/src/index.tsx index 19899d8c..60c807bb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -63,9 +63,10 @@ if (cmd.kind === "update") { } else if (cmd.kind === "seed") { if (cmd.daemon) daemonize("seed"); const { path: target, seedTimeMs, deleteFiles } = cmd; - void import("./daemon/seed").then(({ runSeed }) => - runSeed(target, { seedTimeMs, deleteFiles }).catch(failHeadless), - ); + void import("./daemon/seed") + .then(({ runSeed }) => runSeed(target, { seedTimeMs, deleteFiles })) + .then(() => process.exit(0)) + .catch(failHeadless); } else if (cmd.kind === "serve") { if (cmd.daemon) daemonize("serve"); const options = { diff --git a/src/sources/magnet.ts b/src/sources/magnet.ts index dc294032..111850f2 100644 --- a/src/sources/magnet.ts +++ b/src/sources/magnet.ts @@ -97,7 +97,10 @@ export function parseInput(input: string): ParsedMagnet | null { return { infoHash, name: infoHash, magnet: buildMagnet(infoHash, infoHash) }; } -function trackersOf(magnet: string): string[] | null { +// Exported for the queue: when a download resumes from its stored .torrent the +// magnet is no longer what reaches webtorrent, so the trackers merged onto it +// from sibling sources have to be handed over separately. +export function trackersOf(magnet: string): string[] | null { const s = magnet.trim(); if (!/^magnet:\?/i.test(s)) return null; try { diff --git a/src/sources/torrentFile.ts b/src/sources/torrentFile.ts index caffac3e..336c5b6a 100644 --- a/src/sources/torrentFile.ts +++ b/src/sources/torrentFile.ts @@ -31,16 +31,16 @@ export async function magnetFromTorrentFile(path: string): Promise { - const parsed = await parseTorrent(bytes); - const infoHash = parsed?.infoHash?.toLowerCase(); - if (!infoHash) return null; - const name = parsed.name || infoHash; - // Carry the file's own announce list into the magnet. Without it a torrent - // that isn't on the public DHT — a private tracker, a small private swarm — - // sits at zero peers forever, and on a private tracker the passkey that - // makes an announce work at all lives in that URL. - const announce = Array.isArray(parsed.announce) - ? parsed.announce.filter((url): url is string => typeof url === "string") - : []; - return { infoHash, name, magnet: buildMagnet(infoHash, name, announce) }; + const parsed = await parseTorrent(bytes); + const infoHash = parsed?.infoHash?.toLowerCase(); + if (!infoHash) return null; + const name = parsed.name || infoHash; + // Carry the file's own announce list into the magnet. Without it a torrent + // that isn't on the public DHT — a private tracker, a small private swarm — + // sits at zero peers forever, and on a private tracker the passkey that + // makes an announce work at all lives in that URL. + const announce = Array.isArray(parsed.announce) + ? parsed.announce.filter((url): url is string => typeof url === "string") + : []; + return { infoHash, name, magnet: buildMagnet(infoHash, name, announce) }; } From e07572eaee60ede34ec8081584325871d5447974 Mon Sep 17 00:00:00 2001 From: "bairon.dev" Date: Sun, 30 Aug 2026 11:11:33 -0400 Subject: [PATCH 19/21] ci: keep a run for every commit pushed to main The concurrency group is keyed on github.ref, which is one value for all of main, so cancel-in-progress cancelled the run still in flight whenever a second merge landed before the first had finished. A batch of stacked merges left every commit but the last with no CI result of its own. Cancellation is now scoped to pull requests, where superseding the run in flight is the intent: only a branch's latest commit needs a verdict. Every commit pushed to main keeps its own run. Bump to 1.8.0. --- .github/workflows/ci.yml | 7 +++++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d3ebbed..f1c370ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,13 @@ on: permissions: contents: read -# A new push to the same branch supersedes the run in flight. +# On a pull request a new push supersedes the run in flight: only the branch's +# latest commit needs a verdict. Pushes to main are never cancelled, because the +# ref is the same for every commit there, so a batch of stacked merges would +# otherwise leave each commit but the last with no result of its own. concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: diff --git a/package-lock.json b/package-lock.json index dd13f04f..3cacb46c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "torlnk", - "version": "1.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "torlnk", - "version": "1.7.0", + "version": "1.8.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 48980cf4..d5139180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "torlnk", - "version": "1.7.0", + "version": "1.8.0", "description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.", "type": "module", "bin": { From d26d55d632ccf625188c907d5cce08e9ed55a347 Mon Sep 17 00:00:00 2001 From: Christian Kaiser <5065635+kaiserc@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:00:17 +0100 Subject: [PATCH 20/21] feat(ui): add auto-close toggle when all torrent downloads complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an auto-close toggle (Q / Shift+Q) that allows the application to cleanly shut down once the last active/queued torrent finishes downloading. - Adds autoClose state and toggleAutoClose method to Store - Listens to queue 'completed' event and schedules graceful shutdown (with a 2s settle delay for background file moves) when no active/queued downloads remain - Displays '⏱ Auto-close' badge in the header and status feedback in the footer - Adds Q shortcut to HELP_GROUPS under Navigate and Downloads - Keeps previews and test harnesses in sync --- preview/browse.svg | 26 ++++++------ preview/downloads.svg | 27 +++++++------ preview/splash.svg | 2 +- scripts/render-previews-impl.tsx | 2 + src/ui/App.shortcuts.test.tsx | 13 ++++++ src/ui/App.tsx | 69 ++++++++++++++++++++++++++------ src/ui/helpLayout.test.ts | 2 +- src/ui/keymap.test.ts | 28 +++++++++++++ src/ui/keymap.ts | 15 +++++++ src/ui/store.ts | 2 + src/ui/testHarness.ts | 2 + 11 files changed, 147 insertions(+), 41 deletions(-) diff --git a/preview/browse.svg b/preview/browse.svg index 2cdf950e..4e3274c9 100644 --- a/preview/browse.svg +++ b/preview/browse.svg @@ -50,13 +50,13 @@ ╭─ Latest (5) ──────────────────────────────────────────────╮ + E-Books newest across all sources - Downloads + Audiobooks - Seeding # Name @@ -64,7 +64,7 @@ Seed:Lch Src - Completed + Downloads 1 @@ -73,6 +73,7 @@ 1240:88 YTS + Seeding 2 Dune: Part Two (2024) [21… @@ -80,6 +81,7 @@ 910:41 YTS + Completed 3 Breaking Bad S05E14 1080p… @@ -108,15 +110,13 @@ Download i Files - y - Copy - s - Sort - / - Search - f - Filter - b - + v + Metadata + y + Copy + s + Sort + / + Search … \ No newline at end of file diff --git a/preview/downloads.svg b/preview/downloads.svg index 423abb22..3647f081 100644 --- a/preview/downloads.svg +++ b/preview/downloads.svg @@ -42,8 +42,8 @@ Dune: Part Two (2024) [2160p BluRay] - 7.82 GB - YTS + 7.82 GB + YTS Movies @@ -71,6 +71,7 @@ Recently downloaded (2) + Audiobooks Elden Ring: Shadow of t… @@ -78,9 +79,6 @@ 1hr ago FG - - Downloads - (1) Breaking Bad S05E14 108… @@ -88,12 +86,15 @@ 1d 1hr ago EZTV - Seeding + + Downloads + (1) - Completed + Seeding + Completed ╰───────────────────────────────────────────────────────────╯ p Pause @@ -103,12 +104,12 @@ Folder s Export - b - Turtle - tab - Switch - ? - Keys + Q + Auto-close + b + Turtle + tab + Swit… diff --git a/preview/splash.svg b/preview/splash.svg index a604765a..5d209468 100644 --- a/preview/splash.svg +++ b/preview/splash.svg @@ -33,7 +33,7 @@ A curated, terminal-native torrent downloader. - games · movies · tv · anime + games · movies · tv · anime · e-books · audiobooks ╭─ Search ───────────────────────────────────────────────────╮ diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index b798f84e..a7d68d6f 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -120,6 +120,8 @@ function makeStore( setInspectFocusSelected: noop, toggleFileSelection: noop, quitAll: noop, + autoClose: false, + toggleAutoClose: noop, listRows: 14, compact: false, contentWidth: CONTENT_WIDTH, diff --git a/src/ui/App.shortcuts.test.tsx b/src/ui/App.shortcuts.test.tsx index d384e554..62654dde 100644 --- a/src/ui/App.shortcuts.test.tsx +++ b/src/ui/App.shortcuts.test.tsx @@ -173,4 +173,17 @@ describe("App Keyboard Shortcuts", () => { stdin.write("\x1b"); await new Promise((r) => setTimeout(r, 50)); }); + + it("handles the 'Q' shortcut to toggle auto-close mode", async () => { + const { stdin } = render(); + await new Promise((r) => setTimeout(r, 50)); + + // Press 'Q' to toggle auto-close on + stdin.write("Q"); + await new Promise((r) => setTimeout(r, 20)); + + // Press 'Q' again to toggle auto-close off + stdin.write("Q"); + await new Promise((r) => setTimeout(r, 20)); + }); }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 234c542f..0876db18 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -138,6 +138,8 @@ export function App({ }, []); const [updateVersion, setUpdateVersion] = useState(null); + const [autoClose, setAutoClose] = useState(false); + const autoCloseTimerRef = useRef(null); const [recovered, setRecovered] = useState(false); const booting = useRef(false); @@ -223,39 +225,59 @@ export function App({ }; }, []); + const quitAll = useCallback(() => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } + // Flush all state synchronously up front so nothing is lost to the hard + // exit; the unmount effect still runs suspend() for the engine teardown. + autoDownloader?.stop(); + queue?.persistSync(); + if (onQuit) onQuit(); + else exit(); + }, [queue, autoDownloader, onQuit, exit]); + useEffect(() => { if (!queue) return; - const onCompleted = (name: string): void => + const onCompleted = (name: string): void => { setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`); + if (autoClose) { + const remaining = queue.getItems().some( + (item) => item.status === "downloading" || item.status === "queued" + ); + if (!remaining) { + setNotice("All downloads completed · Closing app in 2s..."); + if (autoCloseTimerRef.current) clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = setTimeout(() => { + quitAll(); + }, 2000); + } + } + }; queue.on("completed", onCompleted); return () => { queue.off("completed", onCompleted); }; - }, [queue]); + }, [queue, autoClose, quitAll]); useEffect(() => { setInspectingId(null); setInspectingPeersId(null); }, [section]); - useEffect( () => () => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } autoDownloader?.stop(); queue?.suspend(); }, [queue, autoDownloader], ); - const quitAll = useCallback(() => { - // Flush all state synchronously up front so nothing is lost to the hard - // exit; the unmount effect still runs suspend() for the engine teardown. - autoDownloader?.stop(); - queue?.persistSync(); - if (onQuit) onQuit(); - else exit(); - }, [queue, autoDownloader, onQuit, exit]); - const setConfig = useCallback( (c: Config) => { setConfigState(c); @@ -284,6 +306,18 @@ export function App({ setConfig({ ...config, throttleEnabled: !config.throttleEnabled }); }, [config, setConfig]); + const toggleAutoClose = useCallback(() => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } + setAutoClose((prev) => { + const next = !prev; + setNotice(next ? "Auto-close enabled · will close when downloads finish" : "Auto-close disabled"); + return next; + }); + }, []); + useEffect(() => { if (queue && config) { queue.setThrottle(config.throttleEnabled, config.throttleDownloadLimit, config.throttleUploadLimit); @@ -622,6 +656,8 @@ export function App({ setInspectFocusSelected, toggleFileSelection, toggleThrottle, + autoClose, + toggleAutoClose, quitAll, listRows, compact, @@ -660,6 +696,8 @@ export function App({ inspectingMetaMagnet, toggleFileSelection, toggleThrottle, + autoClose, + toggleAutoClose, listRows, compact, contentWidth, @@ -775,6 +813,10 @@ export function App({ quitAll(); return; } + if (input === "Q") { + toggleAutoClose(); + return; + } if (input === "b") { store?.toggleThrottle(); return; @@ -812,6 +854,7 @@ export function App({ {store.config.throttleEnabled ? 🐢 Throttled : null} {store.config.webServerEnabled ? 🌐 http://localhost:{store.config.webServerPort} : null} + {store.autoClose ? ⏱ Auto-close : null} {notice ? ( @@ -921,7 +964,7 @@ export function App({ {showFooter ? ( -