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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Downloads run in the background while you keep searching, so you can queue up as
<img src="preview/downloads.svg" alt="torlink's Downloads pane: live progress on top, recently downloaded below" style="max-width: 832px; width: 100%; height: auto;">
</p>

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:
Expand Down
16 changes: 16 additions & 0 deletions src/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
Expand Down
20 changes: 19 additions & 1 deletion src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@ export type CliCommand =
}
| {
kind: "serve";
playlist?: boolean;
port?: number;
host?: string;
token?: string;
Expand Down Expand Up @@ -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]!;
Expand Down Expand Up @@ -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 <dir> and it downloads then seeds. Add --to <dir> to choose
where files land. Handled files move to <dir>/.processed (or /.failed).
Expand Down
1 change: 1 addition & 0 deletions src/daemon/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/daemon/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth";
export const DEFAULT_FILES_PORT = 9160;

const MIME: Record<string, string> = {
".m3u": "audio/x-mpegurl; charset=utf-8",
".mp4": "video/mp4",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Runtime> {
export async function startRuntime(overrideDir?: string, options: { playlist?: boolean } = {}): Promise<Runtime> {
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.
Expand Down
4 changes: 3 additions & 1 deletion src/daemon/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface ApiResponse {
}

export interface ServeOptions {
playlist?: boolean;
port?: number;
host?: string;
token?: string;
Expand Down Expand Up @@ -342,7 +343,7 @@ export async function runServe(options: ServeOptions = {}): Promise<void> {
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.
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/download/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).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");
Expand Down
6 changes: 6 additions & 0 deletions src/download/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
130 changes: 130 additions & 0 deletions src/download/playlist.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
78 changes: 78 additions & 0 deletions src/download/playlist.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const groups = new Map<string, string[]>();
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<string, string>();
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<void> {
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);
}
}
Loading
Loading