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
+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.