From 49e91db1733de8452f92d49fa84f1da211ddfa37 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Mon, 14 Sep 2026 23:22:34 +0000
Subject: [PATCH 1/2] feat: generate playlists for multi-media torrent folders
---
README.md | 23 +++++
src/cli/args.test.ts | 16 ++++
src/cli/args.ts | 21 ++++-
src/daemon/files.test.ts | 1 +
src/daemon/files.ts | 1 +
src/daemon/runtime.ts | 4 +-
src/daemon/seed.ts | 3 +-
src/daemon/serve.ts | 3 +-
src/daemon/watch.ts | 3 +-
src/download/engine.playlist.test.ts | 54 +++++++++++
src/download/engine.ts | 7 ++
src/download/playlist.test.ts | 134 +++++++++++++++++++++++++++
src/download/playlist.ts | 74 +++++++++++++++
src/download/queue.ts | 5 +-
src/index.tsx | 10 +-
src/ui/App.tsx | 7 +-
16 files changed, 351 insertions(+), 15 deletions(-)
create mode 100644 src/download/engine.playlist.test.ts
create mode 100644 src/download/playlist.test.ts
create mode 100644 src/download/playlist.ts
diff --git a/README.md b/README.md
index 3e6717b8..f7a6e008 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,29 @@ Downloads run in the background while you keep searching, so you can queue up as
+### Course and media playlists
+
+When a torrent finishes downloading (or an existing seed finishes verification),
+torlink creates `playlist.m3u` in each of its folders with at least two audio or
+video files anywhere beneath it. A course gets a playlist for the whole course
+and for each module with multiple lessons. Entries follow natural filename order
+(`2` before `10`), skip documents and images, and use relative paths so the folder
+can be moved or served with `torlnk files`.
+
+Single-media folders get no playlist. Existing playlists are preserved, and
+playlists stay inside the torrent's folders, never in the shared downloads folder.
+
+This is on by default. Pass `--no-playlist` to disable creation for a run:
+
+```sh
+torlnk --no-playlist
+torlnk serve --no-playlist
+torlnk watch ./incoming --no-playlist
+```
+
+The flag also works with `torlnk seed`. For an environment-based opt-out, set
+`TORLINK_NO_PLAYLIST=1`. Disabling creation leaves existing playlists in place.
+
## 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..f883be5f 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"],
+ ["seed", "./course"],
+ ])("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");
+ });
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..f4683388 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;
@@ -27,6 +29,7 @@ export type CliCommand =
}
| {
kind: "seed";
+ playlist?: boolean;
path: string;
seedTimeMs?: number;
deleteFiles?: boolean;
@@ -80,6 +83,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" || cmd.kind === "seed") {
+ return { ...cmd, playlist: false };
+ }
+ return { kind: "invalid", arg: "--no-playlist (use with the TUI, watch, serve, or seed)" };
+}
+
+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 +202,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/seed): finished torrents 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..f2f23680 100644
--- a/src/daemon/seed.ts
+++ b/src/daemon/seed.ts
@@ -23,6 +23,7 @@ import { startRuntime, addInput } from "./runtime";
import { startSeedReaper } from "./seed-reaper";
export interface SeedOptions {
+ playlist?: boolean;
seedTimeMs?: number;
deleteFiles?: boolean;
}
@@ -58,7 +59,7 @@ 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);
+ const runtime = await startRuntime(root, { playlist: options.playlist });
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.playlist.test.ts b/src/download/engine.playlist.test.ts
new file mode 100644
index 00000000..7477187b
--- /dev/null
+++ b/src/download/engine.playlist.test.ts
@@ -0,0 +1,54 @@
+import { EventEmitter } from "node:events";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TorrentEngine } from "./engine";
+import { writePlaylists } from "./playlist";
+
+const torrents: EventEmitter[] = [];
+vi.mock("webtorrent", () => ({
+ default: class extends EventEmitter {
+ add(): EventEmitter {
+ const torrent = Object.assign(new EventEmitter(), {
+ files: [{ path: "Course/1.mp4" }, { path: "Course/2.mp4" }],
+ destroy() {},
+ });
+ torrents.push(torrent);
+ return torrent;
+ }
+ destroy(): void {}
+ },
+}));
+vi.mock("./playlist", () => ({ writePlaylists: vi.fn().mockResolvedValue(undefined) }));
+
+beforeEach(() => {
+ vi.stubEnv("TORLINK_NO_PLAYLIST", "");
+});
+afterEach(() => {
+ torrents.length = 0;
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+});
+
+describe("TorrentEngine playlists", () => {
+ it("generates by default after completion, without delaying the completion handler", () => {
+ const engine = new TorrentEngine();
+ const onDone = vi.fn();
+ engine.add("course", "magnet:?xt=urn:btih:course", "/downloads", { onDone });
+ torrents[0]!.emit("metadata");
+ expect(writePlaylists).not.toHaveBeenCalled();
+ torrents[0]!.emit("done");
+ expect(writePlaylists).toHaveBeenCalledWith("/downloads", ["Course/1.mp4", "Course/2.mp4"]);
+ expect(onDone).toHaveBeenCalledOnce();
+ engine.destroy();
+ });
+
+ it.each(["option", "environment"])("honors the %s opt-out while completing normally", (optOut) => {
+ if (optOut === "environment") vi.stubEnv("TORLINK_NO_PLAYLIST", "1");
+ const engine = new TorrentEngine(optOut === "option" ? { playlist: false } : {});
+ const onDone = vi.fn();
+ engine.add("course", "magnet:?xt=urn:btih:course", "/downloads", { onDone });
+ torrents[0]!.emit("done");
+ expect(writePlaylists).not.toHaveBeenCalled();
+ expect(onDone).toHaveBeenCalledOnce();
+ engine.destroy();
+ });
+});
diff --git a/src/download/engine.ts b/src/download/engine.ts
index d415aac6..444ed6e7 100644
--- a/src/download/engine.ts
+++ b/src/download/engine.ts
@@ -1,4 +1,5 @@
import WebTorrent, { type Torrent } from "webtorrent";
+import { writePlaylists } from "./playlist";
export interface TorrentProgress {
progress: number;
@@ -35,6 +36,11 @@ export function message(e: unknown): string {
export class TorrentEngine {
private client: WebTorrent | null = null;
private torrents = new Map();
+ private readonly playlist: boolean;
+
+ constructor(options: { playlist?: boolean } = {}) {
+ this.playlist = options.playlist ?? !process.env.TORLINK_NO_PLAYLIST;
+ }
private ensureClient(): WebTorrent {
if (!this.client) {
@@ -108,6 +114,7 @@ export class TorrentEngine {
});
});
torrent.on("done", () => {
+ if (this.playlist) void writePlaylists(dir, (torrent.files ?? []).map((file) => file.path));
// A finished torrent is a complete, verified torrent: keep it alive so it
// can seed. The queue owns its lifetime from here (remove/destroy).
handlers.onDone?.();
diff --git a/src/download/playlist.test.ts b/src/download/playlist.test.ts
new file mode 100644
index 00000000..eca8bf2f
--- /dev/null
+++ b/src/download/playlist.test.ts
@@ -0,0 +1,134 @@
+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 { fileURLToPath, pathToFileURL } from "node:url";
+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%201/1.mp4\n./Module%202/1.MP4\n./Module%202/2.mp4\n./Module%202/10.mp4\n./Module%2010/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 portable relative URI entries", () => {
+ const names = ["1 #intro 100%.mp4", "2 café & résumé?.mp3", "3: recap.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);
+ const local = pathToFileURL(path.resolve("Course/playlist.m3u"));
+ entries.forEach((entry, i) => {
+ expect(fileURLToPath(new URL(entry, local))).toBe(path.resolve("Course", names[i]!));
+ const remote = new URL(entry, "https://example.test/Course/playlist.m3u");
+ expect(decodeURIComponent(remote.pathname)).toBe(`/Course/${names[i]}`);
+ expect(remote.hash).toBe("");
+ expect(remote.search).toBe("");
+ });
+ });
+
+ 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..64f576ae
--- /dev/null
+++ b/src/download/playlist.ts
@@ -0,0 +1,74 @@
+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));
+ const uris = entries.map((entry) => `./${entry.split("/").map(encodeURIComponent).join("/")}`);
+ playlists.set(target, `#EXTM3U\n${uris.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.ts b/src/download/queue.ts
index a5c2f3da..293359ec 100644
--- a/src/download/queue.ts
+++ b/src/download/queue.ts
@@ -73,7 +73,7 @@ export interface RestoreOptions {
export class DownloadQueue extends EventEmitter {
private items = new Map();
- private engine = new TorrentEngine();
+ private engine: TorrentEngine;
private poll: ReturnType | null = null;
private history: HistoryItem[] = [];
private seeds = new Map();
@@ -84,8 +84,9 @@ export class DownloadQueue extends EventEmitter {
// Max torrents allowed to download at once; overflow waits as "queued".
private readonly maxDownloads: number;
- constructor(opts?: { maxDownloads?: number }) {
+ constructor(opts?: { maxDownloads?: number; playlist?: boolean }) {
super();
+ this.engine = new TorrentEngine({ playlist: opts?.playlist });
this.maxDownloads = opts?.maxDownloads ?? readMaxDownloads();
}
diff --git a/src/index.tsx b/src/index.tsx
index 60c807bb..f808a15f 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -56,15 +56,15 @@ 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");
- const { path: target, seedTimeMs, deleteFiles } = cmd;
+ const { path: target, seedTimeMs, deleteFiles, playlist } = cmd;
void import("./daemon/seed")
- .then(({ runSeed }) => runSeed(target, { seedTimeMs, deleteFiles }))
+ .then(({ runSeed }) => runSeed(target, { seedTimeMs, deleteFiles, playlist }))
.then(() => process.exit(0))
.catch(failHeadless);
} else if (cmd.kind === "serve") {
@@ -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.
From baef3349e94ba7d5350b9935a54152ebd8672d0b Mon Sep 17 00:00:00 2001
From: "bairon.dev"
Date: Tue, 15 Sep 2026 00:50:18 -0400
Subject: [PATCH 2/2] fix: write playlists only for finished downloads, as
plain paths
Playlist entries are plain relative paths instead of percent-encoded
URIs. mpv joins a non-URL entry onto the playlist's folder verbatim,
so names with spaces or non-ASCII characters never opened.
Playlists are written from DownloadQueue.complete() instead of the
engine's done event. That event also fires when a restored seed passes
verification, which wrote playlists into existing seed folders at
launch and brought back playlists the user had deleted.
seed never writes playlists and rejects --no-playlist. A playlist left
in the seeded folder was hashed into the next run's torrent, which
changed the magnet.
The README section is cut to one paragraph, and the help text matches
the new behavior.
---
README.md | 23 +--------
src/cli/args.test.ts | 2 +-
src/cli/args.ts | 7 ++-
src/daemon/seed.ts | 5 +-
src/download/engine.playlist.test.ts | 54 ---------------------
src/download/engine.test.ts | 15 ++++++
src/download/engine.ts | 13 +++---
src/download/playlist.test.ts | 20 ++++----
src/download/playlist.ts | 8 +++-
src/download/queue.playlist.test.ts | 70 ++++++++++++++++++++++++++++
src/download/queue.ts | 10 +++-
src/index.tsx | 4 +-
12 files changed, 123 insertions(+), 108 deletions(-)
delete mode 100644 src/download/engine.playlist.test.ts
create mode 100644 src/download/queue.playlist.test.ts
diff --git a/README.md b/README.md
index f7a6e008..07e750af 100644
--- a/README.md
+++ b/README.md
@@ -36,28 +36,7 @@ Downloads run in the background while you keep searching, so you can queue up as
-### Course and media playlists
-
-When a torrent finishes downloading (or an existing seed finishes verification),
-torlink creates `playlist.m3u` in each of its folders with at least two audio or
-video files anywhere beneath it. A course gets a playlist for the whole course
-and for each module with multiple lessons. Entries follow natural filename order
-(`2` before `10`), skip documents and images, and use relative paths so the folder
-can be moved or served with `torlnk files`.
-
-Single-media folders get no playlist. Existing playlists are preserved, and
-playlists stay inside the torrent's folders, never in the shared downloads folder.
-
-This is on by default. Pass `--no-playlist` to disable creation for a run:
-
-```sh
-torlnk --no-playlist
-torlnk serve --no-playlist
-torlnk watch ./incoming --no-playlist
-```
-
-The flag also works with `torlnk seed`. For an environment-based opt-out, set
-`TORLINK_NO_PLAYLIST=1`. Disabling creation leaves existing playlists in place.
+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
diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts
index f883be5f..09a70bea 100644
--- a/src/cli/args.test.ts
+++ b/src/cli/args.test.ts
@@ -8,7 +8,6 @@ describe("parseCliArgs", () => {
["./course.torrent"],
["watch", "/srv/incoming", "--to", "/srv/downloads", "--daemon"],
["serve", "--port", "9161", "--daemon"],
- ["seed", "./course"],
])("allows --no-playlist before or after download arguments: %j", (...args) => {
const expected = { ...parseCliArgs(args), playlist: false };
expect(parseCliArgs(["--no-playlist", ...args])).toEqual(expected);
@@ -17,6 +16,7 @@ describe("parseCliArgs", () => {
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 f4683388..17f443c5 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -29,7 +29,6 @@ export type CliCommand =
}
| {
kind: "seed";
- playlist?: boolean;
path: string;
seedTimeMs?: number;
deleteFiles?: boolean;
@@ -86,10 +85,10 @@ 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" || cmd.kind === "seed") {
+ 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, serve, or seed)" };
+ return { kind: "invalid", arg: "--no-playlist (use with the TUI, watch, or serve)" };
}
function parseCommand(argv: string[]): CliCommand {
@@ -202,7 +201,7 @@ 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/seed): finished torrents automatically get a
+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)
diff --git a/src/daemon/seed.ts b/src/daemon/seed.ts
index f2f23680..b5101cbe 100644
--- a/src/daemon/seed.ts
+++ b/src/daemon/seed.ts
@@ -23,7 +23,6 @@ import { startRuntime, addInput } from "./runtime";
import { startSeedReaper } from "./seed-reaper";
export interface SeedOptions {
- playlist?: boolean;
seedTimeMs?: number;
deleteFiles?: boolean;
}
@@ -59,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, { playlist: options.playlist });
+ // 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/download/engine.playlist.test.ts b/src/download/engine.playlist.test.ts
deleted file mode 100644
index 7477187b..00000000
--- a/src/download/engine.playlist.test.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { EventEmitter } from "node:events";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { TorrentEngine } from "./engine";
-import { writePlaylists } from "./playlist";
-
-const torrents: EventEmitter[] = [];
-vi.mock("webtorrent", () => ({
- default: class extends EventEmitter {
- add(): EventEmitter {
- const torrent = Object.assign(new EventEmitter(), {
- files: [{ path: "Course/1.mp4" }, { path: "Course/2.mp4" }],
- destroy() {},
- });
- torrents.push(torrent);
- return torrent;
- }
- destroy(): void {}
- },
-}));
-vi.mock("./playlist", () => ({ writePlaylists: vi.fn().mockResolvedValue(undefined) }));
-
-beforeEach(() => {
- vi.stubEnv("TORLINK_NO_PLAYLIST", "");
-});
-afterEach(() => {
- torrents.length = 0;
- vi.clearAllMocks();
- vi.unstubAllEnvs();
-});
-
-describe("TorrentEngine playlists", () => {
- it("generates by default after completion, without delaying the completion handler", () => {
- const engine = new TorrentEngine();
- const onDone = vi.fn();
- engine.add("course", "magnet:?xt=urn:btih:course", "/downloads", { onDone });
- torrents[0]!.emit("metadata");
- expect(writePlaylists).not.toHaveBeenCalled();
- torrents[0]!.emit("done");
- expect(writePlaylists).toHaveBeenCalledWith("/downloads", ["Course/1.mp4", "Course/2.mp4"]);
- expect(onDone).toHaveBeenCalledOnce();
- engine.destroy();
- });
-
- it.each(["option", "environment"])("honors the %s opt-out while completing normally", (optOut) => {
- if (optOut === "environment") vi.stubEnv("TORLINK_NO_PLAYLIST", "1");
- const engine = new TorrentEngine(optOut === "option" ? { playlist: false } : {});
- const onDone = vi.fn();
- engine.add("course", "magnet:?xt=urn:btih:course", "/downloads", { onDone });
- torrents[0]!.emit("done");
- expect(writePlaylists).not.toHaveBeenCalled();
- expect(onDone).toHaveBeenCalledOnce();
- engine.destroy();
- });
-});
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 444ed6e7..91b832e4 100644
--- a/src/download/engine.ts
+++ b/src/download/engine.ts
@@ -1,5 +1,4 @@
import WebTorrent, { type Torrent } from "webtorrent";
-import { writePlaylists } from "./playlist";
export interface TorrentProgress {
progress: number;
@@ -36,11 +35,6 @@ export function message(e: unknown): string {
export class TorrentEngine {
private client: WebTorrent | null = null;
private torrents = new Map();
- private readonly playlist: boolean;
-
- constructor(options: { playlist?: boolean } = {}) {
- this.playlist = options.playlist ?? !process.env.TORLINK_NO_PLAYLIST;
- }
private ensureClient(): WebTorrent {
if (!this.client) {
@@ -114,7 +108,6 @@ export class TorrentEngine {
});
});
torrent.on("done", () => {
- if (this.playlist) void writePlaylists(dir, (torrent.files ?? []).map((file) => file.path));
// A finished torrent is a complete, verified torrent: keep it alive so it
// can seed. The queue owns its lifetime from here (remove/destroy).
handlers.onDone?.();
@@ -133,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
index eca8bf2f..00d0fb8b 100644
--- a/src/download/playlist.test.ts
+++ b/src/download/playlist.test.ts
@@ -2,7 +2,6 @@ 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 { fileURLToPath, pathToFileURL } from "node:url";
import { buildPlaylists, writePlaylists } from "./playlist";
import { logCrash } from "../util/crashlog";
@@ -23,7 +22,7 @@ describe("buildPlaylists", () => {
]);
expect([...playlists.keys()].sort()).toEqual(["Course/Module 2/playlist.m3u", "Course/playlist.m3u"]);
expect(playlists.get("Course/playlist.m3u")).toBe(
- "#EXTM3U\n./Module%201/1.mp4\n./Module%202/1.MP4\n./Module%202/2.mp4\n./Module%202/10.mp4\n./Module%2010/1.mp4\n",
+ "#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");
});
@@ -48,19 +47,16 @@ describe("buildPlaylists", () => {
expect(buildPlaylists(files as string[]).size).toBe(0);
});
- it("uses native torrent paths and portable relative URI entries", () => {
- const names = ["1 #intro 100%.mp4", "2 café & résumé?.mp3", "3: recap.mp4"];
+ 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);
- const local = pathToFileURL(path.resolve("Course/playlist.m3u"));
- entries.forEach((entry, i) => {
- expect(fileURLToPath(new URL(entry, local))).toBe(path.resolve("Course", names[i]!));
- const remote = new URL(entry, "https://example.test/Course/playlist.m3u");
- expect(decodeURIComponent(remote.pathname)).toBe(`/Course/${names[i]}`);
- expect(remote.hash).toBe("");
- expect(remote.search).toBe("");
- });
+ 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", () => {
diff --git a/src/download/playlist.ts b/src/download/playlist.ts
index 64f576ae..32c93a18 100644
--- a/src/download/playlist.ts
+++ b/src/download/playlist.ts
@@ -40,8 +40,12 @@ export function buildPlaylists(filePaths: string[]): Map {
// 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));
- const uris = entries.map((entry) => `./${entry.split("/").map(encodeURIComponent).join("/")}`);
- playlists.set(target, `#EXTM3U\n${uris.join("\n")}\n`);
+ // 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;
}
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 293359ec..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";
@@ -73,7 +74,7 @@ export interface RestoreOptions {
export class DownloadQueue extends EventEmitter {
private items = new Map();
- private engine: TorrentEngine;
+ private engine = new TorrentEngine();
private poll: ReturnType | null = null;
private history: HistoryItem[] = [];
private seeds = new Map();
@@ -83,11 +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; playlist?: boolean }) {
super();
- this.engine = new TorrentEngine({ playlist: opts?.playlist });
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.
@@ -288,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 f808a15f..3048dcdd 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -62,9 +62,9 @@ if (cmd.kind === "update") {
);
} else if (cmd.kind === "seed") {
if (cmd.daemon) daemonize("seed");
- const { path: target, seedTimeMs, deleteFiles, playlist } = cmd;
+ const { path: target, seedTimeMs, deleteFiles } = cmd;
void import("./daemon/seed")
- .then(({ runSeed }) => runSeed(target, { seedTimeMs, deleteFiles, playlist }))
+ .then(({ runSeed }) => runSeed(target, { seedTimeMs, deleteFiles }))
.then(() => process.exit(0))
.catch(failHeadless);
} else if (cmd.kind === "serve") {