diff --git a/README.md b/README.md index 3e6717b8..07e750af 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Downloads run in the background while you keep searching, so you can queue up as torlink's Downloads pane: live progress on top, recently downloaded below

+When a download comes with several videos or tracks, torlink drops a `playlist.m3u` into each folder holding more than one, so a course split into modules plays straight through in order. Run `torlnk --no-playlist` if you'd rather it didn't. + ## What it searches A short, hand-picked list of trusted sources: diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 194d976e..09a70bea 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -2,6 +2,22 @@ import { describe, it, expect } from "vitest"; import { parseCliArgs } from "./args"; describe("parseCliArgs", () => { + it.each([ + [], + ["magnet:?xt=urn:btih:abc"], + ["./course.torrent"], + ["watch", "/srv/incoming", "--to", "/srv/downloads", "--daemon"], + ["serve", "--port", "9161", "--daemon"], + ])("allows --no-playlist before or after download arguments: %j", (...args) => { + const expected = { ...parseCliArgs(args), playlist: false }; + expect(parseCliArgs(["--no-playlist", ...args])).toEqual(expected); + expect(parseCliArgs([...args, "--no-playlist"])).toEqual(expected); + }); + it("rejects --no-playlist for commands that do not create playlists", () => { + expect(parseCliArgs(["files", "--no-playlist"]).kind).toBe("invalid"); + expect(parseCliArgs(["attach", "--no-playlist"]).kind).toBe("invalid"); + expect(parseCliArgs(["seed", "./course", "--no-playlist"]).kind).toBe("invalid"); + }); it("defaults to run with no args", () => { expect(parseCliArgs([])).toEqual({ kind: "run" }); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 790b393f..17f443c5 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -6,9 +6,10 @@ export type SearchCategory = "games" | "movies" | "tv" | "anime"; export type CliCommand = | { kind: "version" } | { kind: "help" } - | { kind: "run"; initialMagnet?: string; initialTorrent?: string } + | { kind: "run"; initialMagnet?: string; initialTorrent?: string; playlist?: boolean } | { kind: "watch"; + playlist?: boolean; dir: string; downloadDir?: string; seedTimeMs?: number; @@ -17,6 +18,7 @@ export type CliCommand = } | { kind: "serve"; + playlist?: boolean; port?: number; host?: string; token?: string; @@ -80,6 +82,16 @@ function seedTimeFrom(raw: string | undefined): number | undefined { } export function parseCliArgs(argv: string[]): CliCommand { + const noPlaylist = argv.includes("--no-playlist"); + const cmd = parseCommand(argv.filter((arg) => arg !== "--no-playlist")); + if (!noPlaylist || cmd.kind === "invalid" || cmd.kind === "help" || cmd.kind === "version") return cmd; + if (cmd.kind === "run" || cmd.kind === "watch" || cmd.kind === "serve") { + return { ...cmd, playlist: false }; + } + return { kind: "invalid", arg: "--no-playlist (use with the TUI, watch, or serve)" }; +} + +function parseCommand(argv: string[]): CliCommand { const args = argv.filter((a) => a.trim() !== ""); if (args.length === 0) return { kind: "run" }; const a = args[0]!; @@ -189,6 +201,12 @@ once open: type to search every source at once, enter to run, arrows to move, d to download, ? for keys tip: quote magnet links (they contain & characters) +playlists (TUI/watch/serve): finished downloads automatically get a +playlist.m3u in each folder containing 2+ audio/video files, including nested +folders, in natural filename order. Single-file folders are skipped and +existing playlists are kept. Pass --no-playlist (or set TORLINK_NO_PLAYLIST=1) +to disable creation; existing playlists remain on disk. + 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). diff --git a/src/daemon/files.test.ts b/src/daemon/files.test.ts index 31f9a664..3f5b2565 100644 --- a/src/daemon/files.test.ts +++ b/src/daemon/files.test.ts @@ -7,6 +7,7 @@ describe("contentType", () => { expect(contentType("Movie.mp4")).toBe("video/mp4"); expect(contentType("track.MP3")).toBe("audio/mpeg"); expect(contentType("clip.mkv")).toBe("video/x-matroska"); + expect(contentType("playlist.m3u")).toBe("audio/x-mpegurl; charset=utf-8"); }); it("falls back to octet-stream", () => { expect(contentType("archive.xyz")).toBe("application/octet-stream"); diff --git a/src/daemon/files.ts b/src/daemon/files.ts index 5e26cf9d..092a1a24 100644 --- a/src/daemon/files.ts +++ b/src/daemon/files.ts @@ -17,6 +17,7 @@ import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth"; export const DEFAULT_FILES_PORT = 9160; const MIME: Record = { + ".m3u": "audio/x-mpegurl; charset=utf-8", ".mp4": "video/mp4", ".m4v": "video/mp4", ".mkv": "video/x-matroska", diff --git a/src/daemon/runtime.ts b/src/daemon/runtime.ts index 4cc3fef9..c90e91ee 100644 --- a/src/daemon/runtime.ts +++ b/src/daemon/runtime.ts @@ -31,9 +31,9 @@ export interface Runtime { // Build a queue and restore persisted state, matching the TUI's boot order // (history before seeds — seeds resolve against history). `downloadDir` falls // back to the saved config's dir when the caller doesn't override it. -export async function startRuntime(overrideDir?: string): Promise { +export async function startRuntime(overrideDir?: string, options: { playlist?: boolean } = {}): Promise { const cfg = await loadConfig(); - const queue = new DownloadQueue(); + const queue = new DownloadQueue({ playlist: options.playlist }); queue.setTrackers(cfg.trackers); // Crash-boot breaker, mirroring the TUI: a marker left by the previous run // means it died mid-restore, so restore paused with the engine cold. diff --git a/src/daemon/seed.ts b/src/daemon/seed.ts index e74b58a5..b5101cbe 100644 --- a/src/daemon/seed.ts +++ b/src/daemon/seed.ts @@ -58,7 +58,9 @@ export async function runSeed(target: string, options: SeedOptions = {}): Promis // 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); + // No playlists either: the folder is the user's own, and a playlist.m3u left + // in it would be hashed into the next run's torrent and change the magnet. + const runtime = await startRuntime(root, { playlist: false }); 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"); diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index 6f50e76a..0ca548a6 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -31,6 +31,7 @@ export interface ApiResponse { } export interface ServeOptions { + playlist?: boolean; port?: number; host?: string; token?: string; @@ -342,7 +343,7 @@ export async function runServe(options: ServeOptions = {}): Promise { return; } - const runtime = await startRuntime(options.downloadDir); + const runtime = await startRuntime(options.downloadDir, { playlist: options.playlist }); // Always on: with no --seed-time it only acts on torrents that carry their // own limit (set over the API), and does nothing at all otherwise. diff --git a/src/daemon/watch.ts b/src/daemon/watch.ts index 4f62545e..cb888997 100644 --- a/src/daemon/watch.ts +++ b/src/daemon/watch.ts @@ -85,6 +85,7 @@ const POLL_MS = 2000; // fs.watch is unreliable across platforms (misses events, fires twice, no // recursion guarantees), so we poll — dead simple and identical on every OS. export interface WatchOptions { + playlist?: boolean; seedTimeMs?: number; deleteFiles?: boolean; } @@ -97,7 +98,7 @@ export async function runWatch( const dir = path.resolve(watchDir); await fs.mkdir(dir, { recursive: true }).catch(() => {}); - const runtime = await startRuntime(downloadDir); + const runtime = await startRuntime(downloadDir, { playlist: options.playlist }); runtime.queue.on("completed", (name: string) => log(`done, now seeding: ${name}`)); // Always on: with no --seed-time it only acts on torrents that carry their diff --git a/src/download/engine.test.ts b/src/download/engine.test.ts index 28ae824e..472b7a1a 100644 --- a/src/download/engine.test.ts +++ b/src/download/engine.test.ts @@ -110,6 +110,21 @@ describe("TorrentEngine macOS port-5350 fix (#22)", () => { }); }); +describe("TorrentEngine.filePaths", () => { + it("lists a torrent's file paths, and nothing for an unknown id", async () => { + const { TorrentEngine } = await import("./engine"); + const engine = new TorrentEngine(); + const fakeTorrent = Object.assign(new EventEmitter(), { + files: [{ path: "Course/1.mp4" }, { path: "Course/2.mp4" }], + }); + (engine as unknown as { torrents: Map }).torrents.set("course", fakeTorrent); + + expect(engine.filePaths("course")).toEqual(["Course/1.mp4", "Course/2.mp4"]); + expect(engine.filePaths("missing")).toEqual([]); + engine.destroy(); + }); +}); + describe("TorrentEngine uTP opt-out (TORLINK_NO_UTP)", () => { it("leaves uTP on by default, the way other BitTorrent clients ship it", async () => { const { TorrentEngine } = await import("./engine"); diff --git a/src/download/engine.ts b/src/download/engine.ts index d415aac6..91b832e4 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -126,6 +126,12 @@ export class TorrentEngine { return this.client?.torrentPort ?? null; } + // A torrent's file paths relative to its download dir, top-level folder + // included; empty before metadata arrives or once the torrent is gone. + filePaths(id: string): string[] { + return (this.torrents.get(id)?.files ?? []).map((file) => file.path); + } + stats(id: string): TorrentProgress | null { const t = this.torrents.get(id); if (!t) return null; diff --git a/src/download/playlist.test.ts b/src/download/playlist.test.ts new file mode 100644 index 00000000..00d0fb8b --- /dev/null +++ b/src/download/playlist.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildPlaylists, writePlaylists } from "./playlist"; +import { logCrash } from "../util/crashlog"; + +vi.mock("../util/crashlog", () => ({ logCrash: vi.fn() })); + +describe("buildPlaylists", () => { + it("collects descendants at every ancestor and naturally orders modules and lessons", () => { + const playlists = buildPlaylists([ + "Course/Module 10/1.mp4", + "Course/Module 2/10.mp4", + "Course/Module 2/2.mp4", + "Course/Module 2/1.MP4", + "Course/Module 1/1.mp4", + "Course/Module 2/notes.pdf", + "Course/Module 2/captions.srt", + "Course/Module 2/cover.jpg", + "Course/Module 2/example.ts", + ]); + expect([...playlists.keys()].sort()).toEqual(["Course/Module 2/playlist.m3u", "Course/playlist.m3u"]); + expect(playlists.get("Course/playlist.m3u")).toBe( + "#EXTM3U\n./Module 1/1.mp4\n./Module 2/1.MP4\n./Module 2/2.mp4\n./Module 2/10.mp4\n./Module 10/1.mp4\n", + ); + expect(playlists.get("Course/Module 2/playlist.m3u")).toBe("#EXTM3U\n./1.MP4\n./2.mp4\n./10.mp4\n"); + }); + + it("includes audio and deeply nested single lessons in their common ancestors", () => { + const playlists = buildPlaylists([ + "Course/Lessons/1/intro.MP3", + "Course/Lessons/2/lesson.flac", + "Course/notes.txt", + ]); + expect([...playlists.keys()]).toEqual(["Course/playlist.m3u", "Course/Lessons/playlist.m3u"]); + }); + + it.each([ + [], + ["movie.mp4"], + ["Course/Module 1/lesson.mp4", "Course/readme.txt", "Course/cover.png"], + ["One/one.mp4", "Two/two.mp4"], + ["Course/one.mp4", "Course/one.mp4"], + ["Course/readme.txt", "Course/notes.pdf"], + ])("skips single-media and non-media folders: %j", (...files) => { + expect(buildPlaylists(files as string[]).size).toBe(0); + }); + + it("uses native torrent paths and writes entries that open as-is from the playlist's folder", () => { + // mpv joins an entry onto the playlist's folder verbatim, so nothing may be + // percent-encoded. The `./` keeps "#4 bonus" from reading as a comment. + const names = ["1 #intro 100%.mp4", "2 café & résumé?.mp3", "3: recap.mp4", "#4 bonus.mp4"]; + const playlists = buildPlaylists(names.map((name) => path.join("Course", name))); + const entries = playlists.get("Course/playlist.m3u")!.trim().split("\n").slice(1); + expect(entries).toHaveLength(names.length); + expect(entries.every((entry) => entry.startsWith("./"))).toBe(true); + expect(entries.map((entry) => path.resolve("Course", entry)).sort()) + .toEqual(names.map((name) => path.resolve("Course", name)).sort()); + }); + + it("reserves playlists supplied by the torrent, including differently cased names", () => { + expect(buildPlaylists(["Course/1.mp4", "Course/2.mp4", "Course/Playlist.M3U"]).size).toBe(0); + }); + + it("ignores traversal, absolute paths, and playlist injection", () => { + const bad = ["../escape", "/absolute", "C:/drive", "\\\\server\\share", "Course/../escape", "Course\n#EXTINF:0,bad"]; + const files = bad.flatMap((dir) => [`${dir}/1.mp4`, `${dir}/2.mp4`]); + expect(buildPlaylists(files).size).toBe(0); + }); +}); + +describe("writePlaylists", () => { + let dir: string; + beforeEach(async () => { + vi.clearAllMocks(); + dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-playlists-")); + }); + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("writes only inside the torrent's existing folders and preserves existing playlists", async () => { + const module = path.join(dir, "Course", "Module"); + await fs.mkdir(module, { recursive: true }); + const existing = path.join(module, "playlist.m3u"); + await fs.writeFile(existing, "my custom playlist\n"); + const files = ["Course/Module/1.mp4", "Course/Module/2.mp4"]; + await writePlaylists(dir, files); + expect(await fs.readFile(path.join(dir, "Course", "playlist.m3u"), "utf8")) + .toBe("#EXTM3U\n./Module/1.mp4\n./Module/2.mp4\n"); + expect(await fs.readFile(existing, "utf8")).toBe("my custom playlist\n"); + expect(await fs.readdir(dir)).toEqual(["Course"]); + await writePlaylists(dir, files); + expect(logCrash).not.toHaveBeenCalled(); + }); + + it("does not recreate deleted folders and still writes other eligible playlists", async () => { + await fs.mkdir(path.join(dir, "Course", "Kept"), { recursive: true }); + await expect(writePlaylists(dir, [ + "Course/Deleted/1.mp4", "Course/Deleted/2.mp4", + "Course/Kept/1.mp4", "Course/Kept/2.mp4", + ])).resolves.toBeUndefined(); + expect(await fs.readdir(path.join(dir, "Course"))).toEqual(["Kept", "playlist.m3u"]); + expect(await fs.readFile(path.join(dir, "Course", "Kept", "playlist.m3u"), "utf8")) + .toBe("#EXTM3U\n./1.mp4\n./2.mp4\n"); + expect(logCrash).toHaveBeenCalledWith("playlist", expect.any(Error)); + }); + + it("refuses to write through a directory symlink outside the downloads root", async () => { + const downloads = path.join(dir, "downloads"); + const outside = path.join(dir, "outside"); + await fs.mkdir(downloads); + await fs.mkdir(outside); + await fs.symlink(outside, path.join(downloads, "Course"), "junction"); + await writePlaylists(downloads, ["Course/1.mp4", "Course/2.mp4"]); + expect(await fs.readdir(outside)).toEqual([]); + }); + + it("leaves an existing playlist symlink alone", async () => { + const course = path.join(dir, "Course"); + const outside = path.join(dir, "outside"); + await fs.mkdir(course); + await fs.mkdir(outside); + await fs.symlink(outside, path.join(course, "playlist.m3u"), "junction"); + await writePlaylists(dir, ["Course/1.mp4", "Course/2.mp4"]); + expect((await fs.lstat(path.join(course, "playlist.m3u"))).isSymbolicLink()).toBe(true); + expect(await fs.readdir(outside)).toEqual([]); + }); +}); diff --git a/src/download/playlist.ts b/src/download/playlist.ts new file mode 100644 index 00000000..32c93a18 --- /dev/null +++ b/src/download/playlist.ts @@ -0,0 +1,78 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { logCrash } from "../util/crashlog"; + +const MEDIA_EXTENSIONS = new Set([ + ".aac", ".aiff", ".avi", ".flac", ".flv", ".m2ts", ".m4a", ".m4v", + ".mka", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".oga", + ".ogg", ".ogv", ".opus", ".wav", ".webm", ".wma", ".wmv", +]); +const natural = new Intl.Collator("en", { numeric: true, sensitivity: "base" }); + +// WebTorrent's file paths include the torrent's top-level directory. Group +// media under each ancestor, stopping before the shared downloads directory. +// A course gets a whole-course playlist and one per module with 2+ lessons. +export function buildPlaylists(filePaths: string[]): Map { + const groups = new Map(); + const files = new Set(filePaths.map((file) => file.split(path.sep).join("/"))); + for (const file of files) { + const parts = file.split("/"); + // Reject unsafe/ambiguous paths on every platform, including Windows + // drive/UNC paths and newlines that could inject extra playlist entries. + if ( + /[\\\x00-\x1f]/.test(file) || /^[a-z]:/i.test(file) || + parts.some((p) => !p || p === "." || p === "..") + ) continue; + if (!MEDIA_EXTENSIONS.has(path.posix.extname(file).toLowerCase())) continue; + for (let i = 1; i < parts.length; i++) { + const dir = parts.slice(0, i).join("/"); + const entries = groups.get(dir) ?? []; + entries.push(parts.slice(i).join("/")); + groups.set(dir, entries); + } + } + + const playlists = new Map(); + const occupied = new Set([...files].map((file) => file.toLowerCase())); + for (const [dir, entries] of groups) { + const target = `${dir}/playlist.m3u`; + // Never replace a file supplied by the torrent, even if it is absent on + // disk. Also leave single-file folders alone: opening the file is enough. + if (entries.length < 2 || occupied.has(target.toLowerCase())) continue; + entries.sort((a, b) => natural.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0)); + // Plain relative paths, not URIs: mpv joins an entry onto the playlist's + // folder verbatim, so `Module%201` would never be found, while VLC fixes up + // raw spaces and Unicode itself. The `./` keeps a name that starts with `#` + // from reading as a comment. + const lines = entries.map((entry) => `./${entry}`); + playlists.set(target, `#EXTM3U\n${lines.join("\n")}\n`); + } + return playlists; +} + +// Only create new files in existing torrent directories. Exclusive creation +// preserves user playlists (and symlinks); no mkdir means a removed torrent +// cannot be recreated by this background completion task. +export async function writePlaylists(downloadDir: string, filePaths: string[]): Promise { + try { + const playlists = buildPlaylists(filePaths); + if (playlists.size === 0) return; + const root = await fs.realpath(downloadDir); + for (const [relative, content] of playlists) { + try { + const target = path.join(root, relative); + const parent = await fs.realpath(path.dirname(target)); + const inside = path.relative(root, parent); + if ( + !inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside) + ) continue; + await fs.writeFile(path.join(parent, "playlist.m3u"), content, { encoding: "utf8", flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") logCrash("playlist", error); + } + } + } catch (error) { + // An optional playlist must never turn a verified download into a failure. + logCrash("playlist", error); + } +} diff --git a/src/download/queue.playlist.test.ts b/src/download/queue.playlist.test.ts new file mode 100644 index 00000000..22f9d822 --- /dev/null +++ b/src/download/queue.playlist.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DownloadQueue } from "./queue"; +import { writePlaylists } from "./playlist"; +import type { AddHandlers } from "./engine"; + +// The engine is stubbed to hand back the handlers the queue gives it, so each +// test fires `done` itself. The same event means a download finishing or a +// restored seed passing verification, and only the first may write playlists. +const handlers = new Map(); + +vi.mock("./engine", () => ({ + TorrentEngine: class { + add(id: string, _source: string, _dir: string, h: AddHandlers): void { + handlers.set(id, h); + } + filePaths(): string[] { + return ["Course/1.mp4", "Course/2.mp4"]; + } + remove(): void {} + stats(): undefined { + return undefined; + } + destroy(): void {} + }, +})); +vi.mock("./playlist", () => ({ writePlaylists: vi.fn().mockResolvedValue(undefined) })); + +const MAGNET = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000"; + +beforeEach(() => { + vi.stubEnv("TORLINK_NO_PLAYLIST", ""); +}); +afterEach(() => { + handlers.clear(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("DownloadQueue playlists", () => { + it("writes playlists into the download dir when a download finishes", () => { + const q = new DownloadQueue(); + q.add({ id: "t1", name: "Course", magnet: MAGNET }, "/downloads"); + handlers.get("t1")!.onDone!(); + expect(writePlaylists).toHaveBeenCalledWith("/downloads", ["Course/1.mp4", "Course/2.mp4"]); + q.suspend(); + }); + + // Restored seeds verify on every launch, so writing here would put back a + // playlist the user deleted. + it("leaves a restored seed alone when it passes verification", () => { + const q = new DownloadQueue(); + q.restoreHistory([ + { id: "s1", name: "Course", magnet: MAGNET, dir: "/downloads", sizeBytes: 100, completedAt: 1 }, + ]); + q.restoreSeeds([{ id: "s1", status: "seeding" }]); + handlers.get("s1")!.onDone!(); + expect(writePlaylists).not.toHaveBeenCalled(); + q.suspend(); + }); + + it.each(["option", "environment"])("honors the %s opt-out while completing normally", (optOut) => { + if (optOut === "environment") vi.stubEnv("TORLINK_NO_PLAYLIST", "1"); + const q = new DownloadQueue(optOut === "option" ? { playlist: false } : {}); + q.add({ id: "t2", name: "Course", magnet: MAGNET }, "/downloads"); + handlers.get("t2")!.onDone!(); + expect(writePlaylists).not.toHaveBeenCalled(); + expect(q.getSeed("t2")?.status).toBe("seeding"); + q.suspend(); + }); +}); diff --git a/src/download/queue.ts b/src/download/queue.ts index a5c2f3da..7d3a0c47 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -14,6 +14,7 @@ import { } from "./persist"; import { saveHistory, saveHistorySync, type HistoryItem } from "./history"; import { deleteSeedData } from "./delete-data"; +import { writePlaylists } from "./playlist"; import { disarmBootMarker } from "./bootguard"; import { trackersOf } from "../sources/magnet"; import type { QueueItem, SeedItem } from "./types"; @@ -83,10 +84,13 @@ export class DownloadQueue extends EventEmitter { // Max torrents allowed to download at once; overflow waits as "queued". private readonly maxDownloads: number; + // Write playlist.m3u into a finished download's media folders. + private readonly playlist: boolean; - constructor(opts?: { maxDownloads?: number }) { + constructor(opts?: { maxDownloads?: number; playlist?: boolean }) { super(); this.maxDownloads = opts?.maxDownloads ?? readMaxDownloads(); + this.playlist = opts?.playlist ?? !process.env.TORLINK_NO_PLAYLIST; } // Extra announce URLs appended to every torrent added from now on. @@ -287,6 +291,9 @@ export class DownloadQueue extends EventEmitter { // Opt-out seeding: a finished download is already a complete, verified // torrent, so keep it alive and seeding instead of tearing it down. this.beginSeed(it); + // Only here, never when a restored seed passes verification: that fires + // on every launch, and would put back a playlist the user deleted. + if (this.playlist) void writePlaylists(it.dir, this.engine.filePaths(it.id)); this.emit("completed", it.name); this.changed(); void this.persist(); diff --git a/src/index.tsx b/src/index.tsx index 60c807bb..3048dcdd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -56,9 +56,9 @@ if (cmd.kind === "update") { void import("./update/run").then(({ runUpdate }) => runUpdate({ force: cmd.force }).catch(failHeadless)); } else if (cmd.kind === "watch") { if (cmd.daemon) daemonize("watch"); // parent exits here; the detached child continues - const { dir, downloadDir, seedTimeMs, deleteFiles } = cmd; + const { dir, downloadDir, seedTimeMs, deleteFiles, playlist } = cmd; void import("./daemon/watch").then(({ runWatch }) => - runWatch(dir, downloadDir, { seedTimeMs, deleteFiles }).catch(failHeadless), + runWatch(dir, downloadDir, { seedTimeMs, deleteFiles, playlist }).catch(failHeadless), ); } else if (cmd.kind === "seed") { if (cmd.daemon) daemonize("seed"); @@ -74,6 +74,7 @@ if (cmd.kind === "update") { host: cmd.host, token: cmd.token ?? process.env.TORLINK_API_TOKEN, downloadDir: cmd.downloadDir, + playlist: cmd.playlist, seedTimeMs: cmd.seedTimeMs, deleteFiles: cmd.deleteFiles, }; @@ -139,6 +140,7 @@ const app = render( forceExit(0)} />, { exitOnCtrlC: false }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 441dd469..53ec7f85 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -54,8 +54,9 @@ import type { SourceId } from "../sources/types"; export function App({ initialMagnet, initialTorrent, + playlist, onQuit, -}: { initialMagnet?: string; initialTorrent?: string; onQuit?: () => void } = {}) { +}: { initialMagnet?: string; initialTorrent?: string; playlist?: boolean; onQuit?: () => void } = {}) { useMouseWheel(); const { exit } = useApp(); const { isRawModeSupported } = useStdin(); @@ -120,7 +121,7 @@ export function App({ let alive = true; void (async () => { const cfg = await loadConfig(); - const q = new DownloadQueue(); + const q = new DownloadQueue({ playlist }); q.setTrackers(cfg.trackers); // Crash-boot breaker: a marker left behind by the previous boot means it // died mid-restore, so this one restores everything paused with the @@ -169,7 +170,7 @@ export function App({ return () => { alive = false; }; - }, [initialMagnet, initialTorrent]); + }, [initialMagnet, initialTorrent, playlist]); // Best-effort, once per launch, off the hot path: if a newer release exists, // surface a quiet banner. Any failure (offline, opt-out) just leaves it hidden.