diff --git a/.env b/.env new file mode 100644 index 00000000..0ff3622e --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +QBIT_WATCH_DIR=C:\_dev\torlink_\test\movies +QBIT_MUSIC_WATCH_DIR=C:\_dev\torlink_\test\music \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d18dacff --- /dev/null +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 4c9ab736..36183612 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/search.js b/search.js new file mode 100644 index 00000000..e1ca302f --- /dev/null +++ b/search.js @@ -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; +}); diff --git a/src/cli/search-cli.ts b/src/cli/search-cli.ts new file mode 100644 index 00000000..105526ae --- /dev/null +++ b/src/cli/search-cli.ts @@ -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 { + 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 { + 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; + }); diff --git a/src/cli/search-rank.test.ts b/src/cli/search-rank.test.ts new file mode 100644 index 00000000..cc2d1a5a --- /dev/null +++ b/src/cli/search-rank.test.ts @@ -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); + }); +}); diff --git a/src/cli/search-rank.ts b/src/cli/search-rank.ts new file mode 100644 index 00000000..36077a52 --- /dev/null +++ b/src/cli/search-rank.ts @@ -0,0 +1,273 @@ +export interface NormalizedSearchResult { + infoHash: string; + title: string; + seeders: number; + sizeBytes: number; + torrentUrl: string; + source?: string; + rawTitle?: string; +} + +export interface RankedSearchResult extends NormalizedSearchResult { + score: number; + match: { + ok: boolean; + ratio: number; + queryTokens: string[]; + titleTokens: string[]; + }; +} + +export interface RankOptions { + maxSizeBytes: number; +} + +const STOPWORDS = new Set([ + "a", + "an", + "and", + "at", + "bd", + "blu", + "bluray", + "brrip", + "cam", + "complete", + "ddp", + "digital", + "dolby", + "dub", + "dvdrip", + "episode", + "episodes", + "hdr", + "hevc", + "h264", + "h265", + "hdrip", + "internal", + "limited", + "mkv", + "mp4", + "proper", + "repack", + "remastered", + "season", + "subs", + "tc", + "the", + "uhd", + "webrip", + "web", + "x264", + "x265", + "1080p", + "720p", + "2160p", + "480p", + "480i", + "5.1", + "7.1", +]); + +const META_TOKENS = new Set([ + "ac3", + "aac", + "av1", + "blu", + "bluray", + "brrip", + "cam", + "complete", + "ddp", + "digital", + "dub", + "dvdrip", + "encoded", + "extended", + "hdr", + "hevc", + "hdrip", + "imax", + "internal", + "limited", + "proper", + "repack", + "remastered", + "remux", + "repack", + "season", + "web", + "webrip", + "x264", + "x265", +]); + +const SEQUEL_TOKENS = new Set([ + "part", + "pt", + "episode", + "chapter", + "volume", + "vol", + "book", + "ii", + "iii", + "iv", + "v", + "vi", + "vii", + "viii", + "ix", + "x", +]); + +function splitTokens(value: string): string[] { + return value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, " ") + .replace(/[\[\]().,_\-+/|:]+/g, " ") + .replace(/[^a-z0-9\s]+/g, " ") + .split(/\s+/) + .filter(Boolean); +} + +function normalizeTokens(value: string): string[] { + return splitTokens(value).filter((tok) => !STOPWORDS.has(tok)); +} + +function normalizedPhrase(value: string): string { + return normalizeTokens(value).join(" "); +} + +function isMetadataToken(tok: string): boolean { + return META_TOKENS.has(tok) || /^\d{4}$/.test(tok) || /^\d+(?:p|k)$/.test(tok); +} + +function isSequelToken(tok: string): boolean { + return SEQUEL_TOKENS.has(tok) || /^\d+$/.test(tok); +} + +function canonicalToken(tok: string): string { + if (/^\d+$/.test(tok)) return tok; + switch (tok) { + case "i": + return "1"; + case "ii": + return "2"; + case "iii": + return "3"; + case "iv": + return "4"; + case "v": + return "5"; + case "vi": + return "6"; + case "vii": + return "7"; + case "viii": + return "8"; + case "ix": + return "9"; + case "x": + return "10"; + default: + return tok; + } +} + +function tokenSet(tokens: string[]): Set { + return new Set(tokens); +} + +function titleMatch( + query: string, + queryTokens: string[], + title: string, + titleTokens: string[], +): { ok: boolean; ratio: number; phrase: boolean } { + if (queryTokens.length === 0 || titleTokens.length === 0) return { ok: false, ratio: 0 }; + const queryCanonical = queryTokens.map(canonicalToken); + const titleCanonical = titleTokens.map(canonicalToken); + const titleSet = tokenSet(titleCanonical); + let hits = 0; + for (const tok of queryCanonical) { + if (titleSet.has(tok)) hits++; + } + const ratio = hits / queryCanonical.length; + const queryPhrase = normalizedPhrase(query); + const titlePhrase = normalizedPhrase(title); + const phrase = titlePhrase.includes(queryPhrase); + const titleCore = titleTokens.filter((tok) => !isMetadataToken(tok) && !isSequelToken(tok)); + const queryCore = queryTokens.filter((tok) => !isMetadataToken(tok) && !isSequelToken(tok)); + const coreSet = tokenSet(titleCore.map(canonicalToken)); + let coreHits = 0; + for (const tok of queryCore.map(canonicalToken)) { + if (coreSet.has(tok)) coreHits++; + } + const coreRatio = queryCore.length > 0 ? coreHits / queryCore.length : ratio; + return { ok: phrase || ratio >= 0.75 || coreRatio >= 0.7, ratio: Math.max(ratio, coreRatio), phrase }; +} + +function compare(a: RankedSearchResult, b: RankedSearchResult): number { + if (b.match.phrase !== a.match.phrase) return Number(b.match.phrase) - Number(a.match.phrase); + if (b.match.ratio !== a.match.ratio) return b.match.ratio - a.match.ratio; + if (b.seeders !== a.seeders) return b.seeders - a.seeders; + if (b.sizeBytes !== a.sizeBytes) return b.sizeBytes - a.sizeBytes; + return a.title.localeCompare(b.title); +} + +export function normalizeSearchResult(input: { + infoHash: string; + title: string; + seeders?: number; + sizeBytes?: number; + torrentUrl: string; + source?: string; +}): NormalizedSearchResult { + return { + infoHash: input.infoHash.toLowerCase(), + title: input.title.trim(), + seeders: Number.isFinite(input.seeders ?? NaN) ? Math.max(0, Math.floor(input.seeders ?? 0)) : 0, + sizeBytes: Number.isFinite(input.sizeBytes ?? NaN) ? Math.max(0, Math.floor(input.sizeBytes ?? 0)) : 0, + torrentUrl: input.torrentUrl, + source: input.source, + rawTitle: input.title, + }; +} + +export function rankSearchResults( + query: string, + results: NormalizedSearchResult[], + opts: RankOptions, +): RankedSearchResult[] { + const qTokens = normalizeTokens(query); + const out = results + .map((r) => { + const titleTokens = normalizeTokens(r.title); + const match = titleMatch(query, qTokens, r.title, titleTokens); + return { + ...r, + score: 0, + match: { + ok: match.ok, + ratio: match.ratio, + queryTokens: qTokens, + titleTokens, + }, + }; + }) + .filter((r) => r.sizeBytes > 0 && r.sizeBytes <= opts.maxSizeBytes && r.torrentUrl && r.match.ok); + + out.sort(compare); + return out.map((r, idx) => ({ ...r, score: out.length - idx })); +} + +export function isAcceptableSearchResult( + query: string, + result: NormalizedSearchResult, + opts: RankOptions, +): boolean { + return rankSearchResults(query, [result], opts).length > 0; +} diff --git a/src/cli/torrent-classify.test.ts b/src/cli/torrent-classify.test.ts new file mode 100644 index 00000000..7e73501c --- /dev/null +++ b/src/cli/torrent-classify.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { classificationSummary, classifyTorrent, classifyTorrentFile, watchDirForClassification } from "./torrent-classify"; + +const file = (name: string, length: number) => ({ name, length }); +const bstr = (value: string) => `${Buffer.byteLength(value)}:${value}`; + +function torrentFile(files: Array<{ name: string; length: number }>): Buffer { + const single = files[0]!; + const entries = files.length === 1 + ? `6:lengthi${single.length}e4:name${bstr(single.name)}` + : `5:filesl${files.map((f) => `d6:lengthi${f.length}e4:pathl${bstr(f.name)}ee`).join("")}e`; + const rootName = files.length === 1 ? "" : `4:name${bstr("fixture")}`; + return Buffer.from(`d4:infod${entries}${rootName}12:piece lengthi16384e6:pieces20:xxxxxxxxxxxxxxxxxxxxee`); +} + +describe("classifyTorrent", () => { + it("classifies a single movie file", () => { + expect(classifyTorrent({ files: [file("Movie.mkv", 8_000)] }).type).toBe("video"); + }); + + it("ignores movie subtitles and artwork", () => { + expect(classifyTorrent({ files: [file("Movie.mp4", 8_000), file("cover.jpg", 20), file("sub.srt", 10)] })).toMatchObject({ type: "video", videoBytes: 8_000, totalBytes: 8_030 }); + }); + + it("classifies a multi-file FLAC album", () => { + expect(classifyTorrent({ files: [file("01.flac", 2_000), file("02.flac", 2_100)] }).type).toBe("audio"); + }); + + it("classifies a multi-file MP3 album", () => { + expect(classifyTorrent({ files: [file("01.mp3", 200), file("02.mp3", 210)] }).type).toBe("audio"); + }); + + it("ignores album cover art and cue sheets", () => { + const result = classifyTorrent({ files: [file("01.flac", 2_000), file("cover.jpg", 20), file("album.cue", 3)] }); + expect(result).toMatchObject({ type: "audio", audioBytes: 2_000, totalBytes: 2_023 }); + }); + + it("uses byte totals for mixed content and makes ties unknown", () => { + expect(classifyTorrent({ files: [file("clip.mp4", 900), file("song.flac", 1_000)] }).type).toBe("audio"); + expect(classifyTorrent({ files: [file("clip.mp4", 1_000), file("song.flac", 1_000)] }).type).toBe("unknown"); + }); + + it("returns unknown for unavailable or malformed metadata", () => { + expect(classifyTorrent({}).type).toBe("unknown"); + expect(classifyTorrent({ files: [file("broken.mp3", -1)] }).type).toBe("unknown"); + }); + + it("parses actual single-file and multi-file torrent metainfo", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "torlink-classify-")); + try { + const movie = path.join(dir, "movie.torrent"); + const album = path.join(dir, "album.torrent"); + await writeFile(movie, torrentFile([file("Movie.mkv", 8_000)])); + await writeFile(album, torrentFile([file("01.flac", 2_000), file("02.flac", 2_100)])); + await expect(classifyTorrentFile(movie)).resolves.toMatchObject({ type: "video", videoBytes: 8_000 }); + await expect(classifyTorrentFile(album)).resolves.toMatchObject({ type: "audio", audioBytes: 4_100 }); + await writeFile(movie, "not torrent metadata"); + await expect(classifyTorrentFile(movie)).rejects.toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("falls back to the default watch folder when music watch is absent", () => { + const audio = classifyTorrent({ files: [file("track.opus", 1)] }); + expect(watchDirForClassification(audio, "C:/movies", "")).toBe("C:/movies"); + expect(watchDirForClassification(audio, "C:/movies", "C:/music")).toBe("C:/music"); + }); + + it("formats a concise routing log message", () => { + expect(classificationSummary(classifyTorrent({ files: [file("track.mp3", 978), file("cover.jpg", 22)] }))).toBe("Torrent classified AUDIO (97.8% audio)"); + }); +}); diff --git a/src/cli/torrent-classify.ts b/src/cli/torrent-classify.ts new file mode 100644 index 00000000..5f992da8 --- /dev/null +++ b/src/cli/torrent-classify.ts @@ -0,0 +1,64 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import parseTorrent from "parse-torrent"; + +export type TorrentType = "video" | "audio" | "unknown"; + +export interface TorrentClassification { + type: TorrentType; + audioBytes: number; + videoBytes: number; + totalBytes: number; +} + +type TorrentMetadata = { + files?: Array<{ name?: string; path?: string; length: number }>; + name?: string; + length?: number; +}; + +const VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts"]); +const AUDIO_EXTENSIONS = new Set([".mp3", ".flac", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".wma"]); + +export function classifyTorrent(metadata: TorrentMetadata): TorrentClassification { + let audioBytes = 0; + let videoBytes = 0; + let totalBytes = 0; + for (const file of metadata.files ?? []) { + const bytes = Number(file.length); + if (!Number.isFinite(bytes) || bytes < 0) continue; + totalBytes += bytes; + const extension = path.extname(file.path ?? file.name ?? "").toLowerCase(); + if (VIDEO_EXTENSIONS.has(extension)) videoBytes += bytes; + else if (AUDIO_EXTENSIONS.has(extension)) audioBytes += bytes; + } + const type: TorrentType = videoBytes > audioBytes && videoBytes > 0 + ? "video" + : audioBytes > videoBytes && audioBytes > 0 + ? "audio" + : "unknown"; + return { type, audioBytes, videoBytes, totalBytes }; +} + +export async function classifyTorrentFile(torrentPath: string): Promise { + const metadata = await parseTorrent(new Uint8Array(await readFile(torrentPath))); + if (!Array.isArray(metadata.files) || metadata.files.length === 0) { + throw new Error("Torrent metadata contains no file list"); + } + return classifyTorrent(metadata); +} + +export function watchDirForClassification( + classification: TorrentClassification, + defaultWatchDir: string, + musicWatchDir = process.env.QBIT_MUSIC_WATCH_DIR?.trim(), +): string { + return classification.type === "audio" && musicWatchDir ? musicWatchDir : defaultWatchDir; +} + +export function classificationSummary(classification: TorrentClassification): string { + if (classification.type === "unknown") return "Torrent classification UNKNOWN"; + const bytes = classification.type === "audio" ? classification.audioBytes : classification.videoBytes; + const percent = classification.totalBytes > 0 ? (bytes / classification.totalBytes) * 100 : 0; + return `Torrent classified ${classification.type.toUpperCase()} (${percent.toFixed(1)}% ${classification.type})`; +} diff --git a/src/download/persist.ts b/src/download/persist.ts index 537a38a9..29c9ab95 100644 --- a/src/download/persist.ts +++ b/src/download/persist.ts @@ -1,4 +1,4 @@ -import { promises as fs, mkdirSync, writeFileSync, renameSync, existsSync, rmSync } from "node:fs"; +import { promises as fs, mkdirSync, writeFileSync, renameSync, existsSync, rmSync, constants as fsConstants } from "node:fs"; import path from "node:path"; import { queueFile, seedsFile, torrentsDir } from "../config/paths"; import { serializeWrites, writeJsonAtomic } from "../util/atomic"; @@ -98,7 +98,7 @@ export async function exportTorrentMeta(id: string, name: string, dir: string): if (!existsSync(source)) return null; await fs.mkdir(dir, { recursive: true }); const target = path.join(dir, torrentExportName(name, id)); - await fs.copyFile(source, target); + await fs.copyFile(source, target, fsConstants.COPYFILE_EXCL); return target; } catch { return null; diff --git a/src/download/queue.test.ts b/src/download/queue.test.ts index ea8a34a0..e8c35417 100644 --- a/src/download/queue.test.ts +++ b/src/download/queue.test.ts @@ -163,6 +163,7 @@ describe("DownloadQueue.fetchAndExportTorrent", () => { const q = new DownloadQueue(); const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-fetch-export-")); const removed: string[] = []; + const failures: string[] = []; const fakeEngine = ( q as unknown as { engine: { @@ -183,9 +184,11 @@ describe("DownloadQueue.fetchAndExportTorrent", () => { magnet: "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd", }, outDir, + (reason) => failures.push(reason), ); expect(file).toBeNull(); expect(removed).toEqual(["__meta__gone1"]); + expect(failures).toEqual(["no peers"]); } finally { await fs.rm(outDir, { recursive: true, force: true }); q.suspend(); diff --git a/src/download/queue.ts b/src/download/queue.ts index 21c990eb..2c12951e 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -17,6 +17,7 @@ import { deleteSeedData } from "./delete-data"; import { disarmBootMarker } from "./bootguard"; import type { QueueItem, SeedItem } from "./types"; import type { SourceId } from "../sources/types"; +import { logDebug } from "../util/crashlog"; /** * A real seed never pulls data off the network: verifying on-disk files reads @@ -418,14 +419,22 @@ export class DownloadQueue extends EventEmitter { fetchAndExportTorrent( input: { id: string; name: string; magnet: string }, exportDir: string, + onFailure?: (reason: string) => void, ): Promise { + logDebug("torrent-only", `request id=${input.id} name=${JSON.stringify(input.name)} exportDir=${JSON.stringify(exportDir)}`); // Fast path: cached from a previous download. if (torrentMetaExists(input.id)) { - return exportTorrentMeta(input.id, input.name, exportDir); + logDebug("torrent-only", `cached hit id=${input.id}`); + return exportTorrentMeta(input.id, input.name, exportDir).then((file) => { + logDebug("torrent-only", `cached export id=${input.id} file=${JSON.stringify(file)}`); + return file; + }); } // If this torrent is already in the engine (downloading / seeding), its // metadata will arrive through the normal queue flow; don't double-add it. if (this.items.has(input.id) || this.seeds.has(input.id)) { + logDebug("torrent-only", `blocked existing live torrent id=${input.id}`); + onFailure?.("torrent is already active in the download queue"); return Promise.resolve(null); } return new Promise((resolve) => { @@ -434,7 +443,9 @@ export class DownloadQueue extends EventEmitter { const timer = setTimeout(() => { if (done) return; done = true; + logDebug("torrent-only", `metadata timeout id=${input.id}`); this.engine.remove(tempKey); + onFailure?.("timed out waiting for torrent metadata"); resolve(null); }, FETCH_METADATA_TIMEOUT_MS); this.engine.add(tempKey, input.magnet, exportDir, { @@ -442,18 +453,34 @@ export class DownloadQueue extends EventEmitter { if (done) return; done = true; clearTimeout(timer); + logDebug( + "torrent-only", + `metadata arrived id=${input.id} name=${JSON.stringify(meta.name)} total=${meta.total} files=${meta.files} hasTorrentFile=${Boolean(meta.torrentFile)}`, + ); // Tear down synchronously before any file data can be written. this.engine.remove(tempKey); void (async () => { - if (meta.torrentFile) await saveTorrentMeta(input.id, meta.torrentFile); - resolve(await exportTorrentMeta(input.id, input.name, exportDir)); + try { + if (meta.torrentFile) await saveTorrentMeta(input.id, meta.torrentFile); + const file = await exportTorrentMeta(input.id, input.name, exportDir); + logDebug("torrent-only", `export result id=${input.id} file=${JSON.stringify(file)}`); + if (!file) onFailure?.("torrent metadata arrived but no .torrent file was available"); + resolve(file); + } catch (e) { + const reason = message(e); + logDebug("torrent-only", `metadata export failed id=${input.id} reason=${JSON.stringify(reason)}`); + onFailure?.(`could not save torrent metadata: ${reason}`); + resolve(null); + } })(); }, - onError: () => { + onError: (reason) => { if (done) return; done = true; clearTimeout(timer); + logDebug("torrent-only", `metadata error id=${input.id} reason=${JSON.stringify(reason)}`); this.engine.remove(tempKey); + onFailure?.(reason); resolve(null); }, }); diff --git a/src/parse-torrent.d.ts b/src/parse-torrent.d.ts index c7a11259..12e664e7 100644 --- a/src/parse-torrent.d.ts +++ b/src/parse-torrent.d.ts @@ -3,6 +3,12 @@ declare module "parse-torrent" { infoHash: string; name?: string; announce?: string[]; + length?: number; + files?: Array<{ + name?: string; + path?: string; + length: number; + }>; } export default function parseTorrent( torrentId: Uint8Array | string, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 441dd469..e6d0b7fa 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -20,6 +20,7 @@ import { resolveTorrentPath } from "../sources/torrentPath"; import { readClipboard, writeClipboard } from "../util/clipboard"; import { openFolder } from "../util/openFolder"; import { cleanText, formatBytes, truncate } from "../util/format"; +import { logDebug } from "../util/crashlog"; import { StoreContext, type CaptureMode, @@ -358,11 +359,14 @@ export function App({ (input: { id: string; name: string }) => { if (!queue) return; void (async () => { + logDebug("export", `cached-request id=${input.id} name=${JSON.stringify(input.name)}`); const file = await queue.exportTorrentFile(input.id); if (file) { + logDebug("export", `cached-success id=${input.id} file=${JSON.stringify(file)}`); setNotice(`Exported torrent file: ${truncate(file, 48)}`); return; } + logDebug("export", `cached-miss id=${input.id}`); setNotice(`No torrent file yet for ${truncate(cleanText(input.name), 32)}.`); })(); }, @@ -373,12 +377,15 @@ export function App({ (input: { id: string; name: string; magnet: string }) => { if (!queue || !config) return; setNotice("Fetching torrent metadata…"); + logDebug("torrent-only", `ui-request id=${input.id} name=${JSON.stringify(input.name)} downloadDir=${JSON.stringify(config.downloadDir)}`); void (async () => { const file = await queue.fetchAndExportTorrent(input, config.downloadDir); if (file) { + logDebug("torrent-only", `ui-success id=${input.id} file=${JSON.stringify(file)}`); setNotice(`Exported torrent file: ${truncate(file, 48)}`); return; } + logDebug("torrent-only", `ui-failure id=${input.id}`); setNotice(`Couldn't export torrent file for ${truncate(cleanText(input.name), 32)}.`); })(); }, @@ -564,7 +571,7 @@ export function App({ setEditingFolder(true); return; } - if (input === "t") { + if (input === "t" && !(region === "content" && section !== "downloads" && section !== "seeding")) { setShowHelp(false); setEditingTrackers(true); return; diff --git a/src/ui/components/Results.test.tsx b/src/ui/components/Results.test.tsx index 7cfa2515..986ed681 100644 --- a/src/ui/components/Results.test.tsx +++ b/src/ui/components/Results.test.tsx @@ -184,4 +184,25 @@ describe("Results filter UI", () => { await vi.waitFor(() => expect(u.frame()).not.toContain("Filter")); expect(u.frame()).toContain("Results (8)"); }); + + it("t exports the selected result without starting a download", async () => { + const fetchAndExportTorrent = vi.fn(); + searchState.current = settled(LIST); + ui = renderUI( + + + , + ); + const u = ui; + await vi.waitFor(() => expect(u.frame()).toContain("Results (8)")); + + u.press("t"); + await vi.waitFor(() => expect(fetchAndExportTorrent).toHaveBeenCalledTimes(1)); + expect(fetchAndExportTorrent).toHaveBeenCalledWith({ + id: "a1", + name: "ubuntu 24.04 desktop amd64 iso", + magnet: "magnet:?xt=urn:btih:a1", + }); + expect(u.frame()).toContain("Results (8)"); + }); }); diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index c28623e6..bcf827bc 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -108,6 +108,11 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) { Export {` ${ICON.dot} `} + + t + + .torrent only + {` ${ICON.dot} `} esc back @@ -253,6 +258,9 @@ export function Results() { } else if (input === "y") { const r = results[clamped]; if (r) copyResultMagnet(r); + } else if (input === "t") { + const r = results[clamped]; + if (r) fetchAndExportTorrent({ id: r.infoHash, name: r.name, magnet: r.magnet }); } }, { isActive: focused && mode === "list" }, @@ -268,6 +276,8 @@ export function Results() { else if (input === "y" && detail) copyResultMagnet(detail); else if (input === "e" && detail) fetchAndExportTorrent({ id: detail.infoHash, name: detail.name, magnet: detail.magnet }); + else if (input === "t" && detail) + fetchAndExportTorrent({ id: detail.infoHash, name: detail.name, magnet: detail.magnet }); }, { isActive: focused && mode === "detail" }, ); diff --git a/src/ui/keymap.test.ts b/src/ui/keymap.test.ts index ad6d2c1e..51b4909d 100644 --- a/src/ui/keymap.test.ts +++ b/src/ui/keymap.test.ts @@ -23,6 +23,12 @@ describe("downloads/seeding key vocabulary", () => { expect(seeding.find((h) => h.keys === "c")?.label).toBe("Remove from list"); }); + it("shows the torrent-only action in the results footer", () => { + const row = footerHints("content", "all", null, null); + expect(row.some((h) => h.keys === "t")).toBe(true); + expect(row.find((h) => h.keys === "t")?.label).toBe(".torrent only"); + }); + // The results row carries a known pre-existing overflow (f Filter), so the // budget is pinned only for the rows this vocabulary owns. it("keeps the downloads and seeding footer rows inside the 80-col budget", () => { diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index 48b56e75..6fa918b8 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -19,7 +19,7 @@ export const HELP_GROUPS: HelpGroup[] = [ { keys: "tab", label: "Switch pane" }, { keys: "esc", label: "Back" }, { keys: "o", label: "Default download folder" }, - { keys: "t", label: "Extra trackers" }, + { keys: "t", label: "Trackers / .torrent only in results" }, { keys: "q", label: "Quit" }, ], }, @@ -73,6 +73,8 @@ const TORRENT: Hint = { keys: "s", label: "Export" }; const EXPORT: Hint = { keys: "e", label: "Export" }; +const TORRENT_ONLY: Hint = { keys: "t", label: ".torrent only" }; + export function footerHints( region: Region, section: Section, @@ -122,6 +124,7 @@ export function footerHints( { keys: "d", label: "Download" }, { keys: "y", label: "Copy" }, resultFocus === "detail" ? EXPORT : { keys: "s", label: "Sort" }, + TORRENT_ONLY, { keys: "/", label: "Search" }, { keys: "f", label: "Filter" }, SWITCH, diff --git a/src/ui/store.ts b/src/ui/store.ts index b96eeaf5..a6af4e85 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -77,6 +77,9 @@ export interface Store { // Fetches the .torrent metadata for a search result (via magnet if not yet // cached) and exports it to the configured download folder. fetchAndExportTorrent: (input: { id: string; name: string; magnet: string }) => void; + // Fetches the .torrent metadata for a search result (via magnet if not yet + // cached) and exports it to the configured download folder without downloading. + getTorrent: (input: { id: string; name: string; magnet: string }) => void; notice: string | null; setNotice: (s: string | null) => void; diff --git a/src/util/crashlog.ts b/src/util/crashlog.ts index 281e2b94..c28a1c4b 100644 --- a/src/util/crashlog.ts +++ b/src/util/crashlog.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { logsDir } from "../config/paths"; export const crashLogFile = path.join(logsDir, "crash.log"); +export const debugLogFile = path.join(logsDir, "debug.log"); // Append one timestamped entry. Returns false when even logging failed: a // crash logger must never become a crash source itself. @@ -17,6 +18,16 @@ export function logCrash(kind: string, err: unknown): boolean { } } +export function logDebug(kind: string, message: string): boolean { + try { + mkdirSync(logsDir, { recursive: true }); + appendFileSync(debugLogFile, `${new Date().toISOString()} [${kind}] ${message}\n`); + return true; + } catch { + return false; + } +} + // Node kills the process on any unhandled promise rejection, and webtorrent // can produce one from inside its own async internals with no error event and // nothing a caller's try/catch can reach (its fire-and-forget _onTorrentId is diff --git a/test/movies/Ghostbusters.1984.REMASTERED.1080p.BluRay.DDP5.1.x265.10bit-Gala.torrent b/test/movies/Ghostbusters.1984.REMASTERED.1080p.BluRay.DDP5.1.x265.10bit-Gala.torrent new file mode 100644 index 00000000..e1e5be31 Binary files /dev/null and b/test/movies/Ghostbusters.1984.REMASTERED.1080p.BluRay.DDP5.1.x265.10bit-Gala.torrent differ diff --git a/test/movies/Terminator Dark Fate (2019) [WEBRip] [1080p] [YTS] [YIFY].torrent b/test/movies/Terminator Dark Fate (2019) [WEBRip] [1080p] [YTS] [YIFY].torrent new file mode 100644 index 00000000..3aa85889 Binary files /dev/null and b/test/movies/Terminator Dark Fate (2019) [WEBRip] [1080p] [YTS] [YIFY].torrent differ