Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
QBIT_WATCH_DIR=C:\_dev\torlink_\test\movies
QBIT_MUSIC_WATCH_DIR=C:\_dev\torlink_\test\music
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# qBittorrent watched folder for video and unclassified torrents.
QBIT_WATCH_DIR=
# Optional qBittorrent watched folder for audio torrents. Falls back to QBIT_WATCH_DIR.
QBIT_MUSIC_WATCH_DIR=
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ torlink also runs without the TUI, for servers and seedboxes:

Add `--daemon` to keep watch, serve, or files running after you log out; `torlnk --help` has the full list of modes and flags.

### qBittorrent watched folders

The standalone `node search.js "query"` helper writes video and unclassified torrents to `QBIT_WATCH_DIR`. Set `QBIT_MUSIC_WATCH_DIR` to send torrents whose metainfo files are predominantly audio to a separate qBittorrent watched folder; if it is unset, audio uses `QBIT_WATCH_DIR` too.

## Contributing

To run or work on torlink locally:
Expand Down
22 changes: 22 additions & 0 deletions search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
// Node 22's built-in loader keeps this helper dependency-free. Environment
// variables supplied by the shell still take precedence over .env values.
try {
process.loadEnvFile(path.join(here, ".env"));
} catch (e) {
if (e?.code !== "ENOENT") throw e;
}
const entry = path.join(here, "src", "cli", "search-cli.ts");
const systemCa = process.platform === "win32" ? ["--use-system-ca"] : [];
const child = spawn(process.execPath, [...systemCa, "--import", "tsx", entry, ...process.argv.slice(2)], {
stdio: "inherit",
});

child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exitCode = code ?? 1;
});
179 changes: 179 additions & 0 deletions src/cli/search-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { mkdtemp, copyFile, mkdir, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SOURCES } from "../sources/registry";
import { cachedSearch } from "../sources/cache";
import { torrentExportName } from "../download/persist";
import { DownloadQueue } from "../download/queue";
import { formatBytes } from "../util/format";
import { normalizeSearchResult, rankSearchResults } from "./search-rank";
import { classificationSummary, classifyTorrentFile, watchDirForClassification } from "./torrent-classify";

const MAX_SIZE_GB = Number(process.env.MAX_TORRENT_SIZE_GB ?? "6");
const MAX_SIZE_BYTES = Math.max(1, MAX_SIZE_GB) * 1024 ** 3;
const DEBUG = process.env.TORLINK_SEARCH_DEBUG !== "0";

function ts(): string {
return new Date().toISOString();
}

function log(message: string): void {
if (!DEBUG) return;
console.error(`[${ts()}] ${message}`);
}

function logError(message: string): void {
console.error(`[${ts()}] ${message}`);
}

function readWatchDir(): string | null {
return process.env.QBIT_WATCH_DIR?.trim() || null;
}

function cleanFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*\x00-\x1f]/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/[. ]+$/g, "");
}

async function uniqueCopy(source: string, watchDir: string, name: string): Promise<string> {
await mkdir(watchDir, { recursive: true });
const base = cleanFilename(name) || "torrent";
const targetBase = path.join(watchDir, base.endsWith(".torrent") ? base : `${base}.torrent`);
const ext = path.extname(targetBase);
const stem = targetBase.slice(0, -ext.length);
let target = targetBase;
for (let i = 0; i < 1000; i++) {
try {
await copyFile(source, target, 1);
return target;
} catch (e) {
if (i === 999) throw e;
target = `${stem} (${i + 1})${ext}`;
}
}
return targetBase;
}

async function fetchSearchResults(query: string) {
log(`searching ${SOURCES.length} sources`);
const settled = await Promise.allSettled(
SOURCES.map(async (source) => {
log(`source start: ${source.id}`);
const started = Date.now();
const results = await cachedSearch(source, query);
log(`source done: ${source.id} (${results.length} results, ${Date.now() - started}ms)`);
return results.map((r) =>
normalizeSearchResult({
infoHash: r.infoHash,
title: r.name,
seeders: r.seeders,
sizeBytes: r.sizeBytes,
torrentUrl: r.magnet,
source: r.source,
}),
);
}),
);
const all = [];
for (const item of settled) {
if (item.status === "fulfilled") {
all.push(...item.value);
continue;
}
logError(`source failed: ${item.reason instanceof Error ? item.reason.message : String(item.reason)}`);
}
log(`search collected ${all.length} normalized results`);
return all;
}

async function main(): Promise<number> {
process.on("unhandledRejection", (reason) => {
logError(`unhandledRejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
});
process.on("uncaughtException", (err) => {
logError(`uncaughtException: ${err.stack ?? err.message}`);
});

const query = process.argv.slice(2).join(" ").trim();
log(`argv: ${JSON.stringify(process.argv.slice(2))}`);
if (!query) {
logError("Usage: node search.js \"content name\"");
return 1;
}

const defaultWatchDir = readWatchDir();
if (!defaultWatchDir) {
logError("QBIT_WATCH_DIR is not set.");
return 1;
}

log(`query: ${query}`);
log(`watch dir: ${defaultWatchDir}`);
log(`max size: ${MAX_SIZE_BYTES} bytes (${MAX_SIZE_GB} GB)`);

console.log(`Search: ${query}`);
const results = await fetchSearchResults(query);
log(`ranking ${results.length} candidates`);
const ranked = rankSearchResults(query, results, { maxSizeBytes: MAX_SIZE_BYTES });
log(`ranked ${ranked.length} acceptable candidates`);
if (ranked.length === 0) {
console.log("No acceptable torrent found.");
return 0;
}

for (const [idx, r] of ranked.slice(0, 5).entries()) {
console.log(
`${idx + 1}. ${r.title} | ${formatBytes(r.sizeBytes)} | ${r.seeders} seeders${r.source ? ` | ${r.source}` : ""}`,
);
}

const selected = ranked[0]!;
console.log(`Selected: ${selected.title}`);

const queue = new DownloadQueue();
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "torlink-search-"));
try {
log(`export staging dir: ${tmpDir}`);
log(`fetching torrent metadata for ${selected.infoHash}`);
const torrentPath = await queue.fetchAndExportTorrent(
{ id: selected.infoHash, name: selected.title, magnet: selected.torrentUrl },
tmpDir,
(reason) => logError(`Torrent metadata retrieval failed: ${reason}`),
);
if (!torrentPath) {
logError("Failed to fetch torrent metadata.");
return 1;
}
log(`torrent staged at ${torrentPath}`);
let classification;
try {
classification = await classifyTorrentFile(torrentPath);
} catch (e) {
logError(`Torrent metadata inspection failed: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
classification = { type: "unknown" as const, audioBytes: 0, videoBytes: 0, totalBytes: 0 };
}
const watchDir = watchDirForClassification(classification, defaultWatchDir);
log(`${classificationSummary(classification)} -> ${watchDir}`);
const finalName = torrentExportName(selected.title, selected.torrentUrl);
log(`copying to watch folder as ${finalName}`);
const finalPath = await uniqueCopy(torrentPath, watchDir, finalName);
const size = (await stat(finalPath)).size;
console.log(`Wrote: ${finalPath} (${formatBytes(size)})`);
return 0;
} finally {
log(`cleaning up staging dir ${tmpDir}`);
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
queue.suspend();
log("shutdown complete");
}
}

main()
.then((code) => process.exitCode = code)
.catch((e) => {
logError(e instanceof Error ? e.stack ?? e.message : String(e));
process.exitCode = 1;
});
98 changes: 98 additions & 0 deletions src/cli/search-rank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { rankSearchResults } from "./search-rank";

const opts = { maxSizeBytes: 6 * 1024 ** 3 };

describe("rankSearchResults", () => {
it("prefers an exact title match over a partial unrelated match", () => {
const ranked = rankSearchResults(
"The Example Movie",
[
{ infoHash: "a", title: "The.Example.Movie.2025.1080p", seeders: 20, sizeBytes: 2e9, torrentUrl: "a" },
{ infoHash: "b", title: "Example.Movie.Collection", seeders: 999, sizeBytes: 2e9, torrentUrl: "b" },
],
opts,
);

expect(ranked[0]?.title).toBe("The.Example.Movie.2025.1080p");
});

it("normalizes dots, hyphens, and underscores", () => {
const ranked = rankSearchResults(
"Spider Man No Way Home",
[
{ infoHash: "a", title: "Spider-Man.No_Way_Home.2021.1080p", seeders: 50, sizeBytes: 3e9, torrentUrl: "a" },
],
opts,
);

expect(ranked).toHaveLength(1);
expect(ranked[0]?.title).toContain("Spider-Man.No_Way_Home");
});

it("handles sequel markers like part ii / 2", () => {
const ranked = rankSearchResults(
"back to the future 2",
[
{ infoHash: "a", title: "Back.To.The.Future.Part.II.1989.1080p", seeders: 200, sizeBytes: 2e9, torrentUrl: "a" },
{ infoHash: "b", title: "Back.To.The.Future.1985.1080p", seeders: 300, sizeBytes: 2e9, torrentUrl: "b" },
],
opts,
);

expect(ranked[0]?.title).toContain("Part.II");
});

it("> 6 GB results are excluded", () => {
const ranked = rankSearchResults(
"Big Movie",
[
{ infoHash: "a", title: "Big.Movie.7GB", seeders: 999, sizeBytes: 7 * 1024 ** 3, torrentUrl: "a" },
{ infoHash: "b", title: "Big.Movie.5GB", seeders: 100, sizeBytes: 5 * 1024 ** 3, torrentUrl: "b" },
],
opts,
);

expect(ranked).toHaveLength(1);
expect(ranked[0]?.sizeBytes).toBe(5 * 1024 ** 3);
});

it("prefers higher seeder counts", () => {
const ranked = rankSearchResults(
"Example Movie",
[
{ infoHash: "a", title: "Example.Movie.1080p", seeders: 20, sizeBytes: 2e9, torrentUrl: "a" },
{ infoHash: "b", title: "Example.Movie.720p", seeders: 100, sizeBytes: 1e9, torrentUrl: "b" },
],
opts,
);

expect(ranked[0]?.seeders).toBe(100);
});

it("uses file size as a tie breaker when seeders are similar", () => {
const ranked = rankSearchResults(
"Example Movie",
[
{ infoHash: "a", title: "Example.Movie.Small", seeders: 50, sizeBytes: 1e9, torrentUrl: "a" },
{ infoHash: "b", title: "Example.Movie.Big", seeders: 50, sizeBytes: 3e9, torrentUrl: "b" },
],
opts,
);

expect(ranked[0]?.sizeBytes).toBe(3e9);
});

it("returns no valid results when nothing matches cleanly", () => {
const ranked = rankSearchResults(
"The Example Movie",
[
{ infoHash: "a", title: "Another Film", seeders: 500, sizeBytes: 2e9, torrentUrl: "a" },
{ infoHash: "b", title: "Example Movie Sample Pack", seeders: 5, sizeBytes: 0, torrentUrl: "b" },
],
opts,
);

expect(ranked).toHaveLength(0);
});
});
Loading