Torrent Video Player

Watch a video from a torrent while it's still downloading

Free · High token use

About

A Mac app that plays the video inside a torrent while it downloads, so it starts within seconds instead of after the whole file arrives. Paste a magnet link or a .torrent link, or click a magnet link anywhere on your Mac. It shows whether a torrent has enough people sharing it to play, and jumping ahead in the video works too. Made for films you're allowed to watch, like public-domain classics and open movies.

What you get

A Mac app called Plaier that plays videos from torrents while they download. Built on your own Mac, free and open source.

What you need

  • A Mac with Apple silicon (M1 or newer)
  • An AI coding agent that can run commands on your Mac (Claude Code, Codex or Cursor)
  • About 1 GB of free space for the tools, plus room for videos

Token use

High: more than 250k tokens.

Tokens are how AI assistants measure work. More tokens means a longer build and more of your plan's usage limit, or a higher bill if you pay per use.

Version history

  1. 1.0.0Current 20 Oct 2026 First release
See the recipe This is what your AI reads. You don't need to.

Goal

Build Plaier, a personal macOS desktop app that plays the video inside a torrent while the torrent is still downloading. The person gives it a magnet link or a link to a .torrent file: by pasting it, by opening the app with it, or by clicking a magnet: link anywhere on the system. Pieces are fetched in order, so playback starts within seconds, and a seek to a part that hasn’t downloaded yet plays after about 3 seconds.

The technical specification below is detailed enough to rebuild the app from nothing. Where code is shown, it is the real logic. Follow it closely: “Behaviours worth reproducing exactly” and the packaging notes record pitfalls that cost time the first time around.

Before you start, ask the person

  1. Should Plaier open magnet: links when they click them in a browser or anywhere else on the Mac? (Default: yes. If they say no, set CLAIM_MAGNET_LINKS to false in electron/main.js.)
  2. Where should the app go? (Default: build it in a new folder called plaier in their home folder and install it into /Applications.)
  3. Check that they are on an Apple silicon Mac and have Node 22 or newer. If Node is missing, explain in one sentence what it is, then install it with their permission: brew install node if they have Homebrew, otherwise the official installer from nodejs.org.

Features

  • Paste and play: a paste box that takes a magnet link or a .torrent URL. Each torrent becomes a card. Click a card to play.
  • Starts fast: pieces download in order, and the player starts within a few seconds.
  • Seeking works: jumping to an undownloaded position plays after about 3 seconds. Positions that have already downloaded play instantly.
  • Honest health badge: each card shows whether the swarm is alive, based on a real probe of connected peers, not only on tracker numbers, which are often wrong.
  • Opens from anywhere: from the app’s launch arguments, from a ?magnet= or ?torrent= URL parameter, and from magnet: links on the Mac, whether or not the app is running.
  • Live status: a progress strip showing downloaded, buffered and played, and a line with speed, peers and seeders.
  • Resume: downloaded data stays on disk, so playing the same torrent again starts instantly.
  • Only the video is downloaded: in a torrent with many files, only the chosen video file is fetched.

Constraints

  • macOS on Apple silicon. One Electron app with the Node server embedded in the main process. No UI framework, no build step for the UI, MIT licensed.
  • Use the stack and versions in the specification (Node 22 or newer, Express 5, WebTorrent 3, Electron, electron-builder with npmRebuild: false). Newer patch and minor versions are fine. If a newer major version breaks something, use the one listed.
  • No transcoding: only formats Chromium plays natively (MP4/H.264, WebM, MOV). Other formats are listed but marked not playable.
  • No catalog, no search, no indexers, no metadata lookup, no lists of torrent sites. This is a player for links the person already has. Keep the paste box’s placeholder (“…a .torrent URL you have rights to”).
  • The server listens on 127.0.0.1 only.
  • No code signing. The app is built and run on the same Mac.

Build steps

  1. Check the Mac and the Node version, and ask the questions above.
  2. Create the project layout and package.json from §3, then run npm install.
  3. Write server/config.js, the data model and pickVideoFile (§4–§6).
  4. Write the streaming engine (§7) and try it from Node with the Sintel .torrent before adding the HTTP layer.
  5. Write the seed check and swarm probe, with probe adoption (§8).
  6. Write the HTTP API and the Range stream (§9). Run acceptance checks 1–7 in a browser at http://localhost:3000.
  7. Write the UI (§12): index.html, app.js, style.css.
  8. Write the Electron main process (§10) and run it with npm run electron.
  9. Make the icon (§15), package with npm run dist (§11), and install into /Applications.
  10. Run acceptance checks 8–10 with the installed app.

Technical specification

2. Stack

ComponentPackageVersionNotes
RuntimeNode≥ 22 (built on 26)ESM only: "type": "module"
HTTPexpress5.3UI, JSON API, HTTP Range video stream
Torrentswebtorrent3.0.21ESM, Node ≥ 22, BEP19 webseeds enabled
Tracker scrapebittorrent-tracker11.2.3import Client from 'bittorrent-tracker/client'
Parsingparse-torrent11.0.24magnet URIs and .torrent buffers
Desktopelectron44.9.1ESM main process (supported since Electron 28)
Packagingelectron-builder26.15.3with npmRebuild: false (see §11)

3. Repository layout

package.json              "type":"module", "main":"electron/main.js", build config
electron/main.js          Electron main process (full source in §10)
server/index.js           Express app, routes, Range stream, startServer()/stopServer()
server/config.js          paths and env knobs; data dir overridable via PLAIER_DATA_DIR
server/custom.js          add/list/remove torrents, video file choice, content types
server/torrentSource.js   .torrent buffer (cached on disk, fetched with retry) or magnet
server/seeds.js           seed check: tracker scrape + swarm probe, cache, dedupe
server/streamer.js        WebTorrent client: start/stop/status, swarm probe, adoption
public/index.html         paste box, torrent grid, player view
public/app.js             UI logic, URL-parameter handling, status polling
public/style.css          dark theme
public/icon.png           favicon (copy of build/icon.png)
build/icon.svg, icon.png  app icon source and 1024×1024 raster
data/                     web mode only: custom.json, torrents/, downloads/   (git-ignored)
dist/                     electron-builder output                            (git-ignored)

package.json:

{
  "name": "plaier", "version": "0.2.0", "private": true, "type": "module", "license": "MIT",
  "main": "electron/main.js",
  "scripts": {
    "start":    "node server/index.js",
    "dev":      "node --watch server/index.js",
    "electron": "electron .",
    "dist":     "electron-builder --mac",
    "dist:dir": "electron-builder --mac --dir"
  },
  "dependencies": { "bittorrent-tracker": "^11.2.3", "express": "^5.3.0",
                    "parse-torrent": "^11.0.24", "webtorrent": "^3.0.21" },
  "devDependencies": { "electron": "^44.9.1", "electron-builder": "^26.15.3" },
  "build": {
    "appId": "local.plaier", "productName": "Plaier", "asar": true, "npmRebuild": false,
    "files": ["electron/**", "server/**", "public/**", "package.json"],
    "directories": { "output": "dist" },
    "mac": { "target": ["dmg", "zip"], "category": "public.app-category.video",
             "identity": null, "icon": "build/icon.png" },
    "protocols": [{ "name": "BitTorrent magnet link", "schemes": ["magnet"], "role": "Viewer" }]
  }
}

.gitignore: node_modules/, data/, dist/.

4. Configuration (server/config.js)

const root = <repo root, derived from import.meta.url>;
const dataDir = process.env.PLAIER_DATA_DIR || path.join(root, 'data');
export const config = {
  root, dataDir,
  downloadsDir:    path.join(dataDir, 'downloads'),
  torrentsDir:     path.join(dataDir, 'torrents'),
  port:            Number(process.env.PORT || 3000),
  minSeeds:        Number(process.env.MIN_SEEDS || 1),
  maxBitrateBps:   Number(process.env.MAX_BITRATE || 8_000_000),
  seedTtlMs:       5 * 60 * 1000,   // seed-check result cache
  scrapeTimeoutMs: 5000,            // per tracker
  idleDestroyMs:   10 * 60 * 1000,  // stop torrents nobody has streamed from
};

PLAIER_DATA_DIR is read when the module loads, so Electron must set it before importing any server module (it does so with a dynamic import()).

Streamer constants (server/streamer.js): METADATA_TIMEOUT_MS = 90_000, PROBE_METADATA_MS = 15_000, PROBE_SETTLE_MS = 6_000.

5. Data model

A torrent entry, persisted as a JSON object map in <dataDir>/custom.json keyed by identifier (custom- + 40-hex infohash):

{
  "identifier": "custom-dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c",
  "kind": "custom",
  "title": "Big Buck Bunny",                 // torrent name, or the infohash until known
  "description": "Magnet link",              // or "Torrent file from <host>"
  "torrentUrl": null,                        // https URL when added from a .torrent
  "magnet": "magnet:?xt=urn:btih:…",         // the magnet when added from one
  "infoHash": "dd8255ec…",
  "announce": ["<trackers from the source>", "…plus DEFAULT_TRACKERS"],
  "file": null,                              // null until metadata is known (magnets)
  // once known:
  // "file": { "name": "Big Buck Bunny.mp4", "path": "Big Buck Bunny/Big Buck Bunny.mp4",
  //           "format": "mp4", "size": 276134947, "lengthSec": null, "bitrateBps": null,
  //           "native": true },
  "playableFile": true,                      // file.native, or true while file is unknown
  "metadataKnown": false,
  "addedAt": 1792517000000
}

DEFAULT_TRACKERS, appended to every item’s announce so scrapes and DHT bootstrap have something to work with:

udp://tracker.opentrackr.org:1337/announce
udp://open.demonii.com:1337/announce
udp://tracker.openbittorrent.com:6969/announce
udp://exodus.desync.com:6969/announce
udp://tracker.torrent.eu.org:451/announce

custom.json is read once into memory and rewritten in full on every change. If the file contains anything other than an object (e.g. []), treat it as empty.

Video file choice: pickVideoFile(files)

Input: [{ name, path, length }]. Keep names matching \.(mp4|m4v|webm|mov|mkv|avi|wmv|mpg|mpeg|ts|flv|ogv)$ (case-insensitive) and not containing sample. Sort natively playable (mp4|m4v|webm|mov) first, then by size descending. Return the first as { name, path, format: <extension>, size, lengthSec: null, bitrateBps: null, native }, or null if there is no video file.

Content-Type by extension: mp4/m4v → video/mp4, mov → video/quicktime, webm → video/webm, mkv → video/x-matroska, avi → video/x-msvideo, ogv → video/ogg, ts → video/mp2t, otherwise application/octet-stream.

6. Adding a source: addCustom(source)

  1. Trim. magnet: prefix → parseTorrent(source). http:// or https:// → fetch, parse the buffer, cache it at <torrentsDir>/custom-<infohash>.torrent. Anything else → error paste a magnet: link or an http(s) URL to a .torrent file. No infohash → error.
  2. .torrent input knows its files: run pickVideoFile, set metadataKnown: true, playableFile: file.native. Magnet input: file: null, metadataKnown: false, playableFile: true.
  3. Write the entry (replacing any existing entry with the same infohash) and return it.

getTorrentSource(item) returns the .torrent buffer when torrentUrl is set (cached on disk; fetched with up to 3 attempts on HTTP 5xx, backing off 500 ms × attempt), otherwise the magnet string. parseTorrent and client.add accept either.

7. Streaming engine (server/streamer.js)

One process-wide client: new WebTorrent({ webSeeds: true }).

State:

  • active: Map<identifier, { item, torrent, file, lastAccess, startedAt, readyPromise }>
  • probes: Map<identifier, { torrent, promise, adopted, finish }> (see §8)

start(identifier)

  1. Already active → touch lastAccess, await readyPromise, return the entry.
  2. Load the item. Throw unknown torrent <id> or <id> is not browser-playable.
  3. Obtain a torrent object:
    • If a probe is in flight for this id: set probe.adopted = true, call probe.finish(), await probe.promise (ignore rejection). If probe.torrent.destroyed is false, use it and set torrent.strategy = 'sequential'.
    • Otherwise client.add(source, { path: downloadsDir, strategy: 'sequential', deselect: true, announce: item.announce }). deselect: true means no piece is wanted until a file is selected.
  4. Register the entry in active immediately (so /status works while waiting).
  5. Wait for ready: resolve at once if torrent.ready is already true, else on the ready event. A 90 s timer fails with no metadata after 90s (no peers?).
  6. On ready, if item.file is null (magnet): pickVideoFile over torrent.files ({name, path, length}), store it on the item together with playableFile: picked.native, metadataKnown: true, title: torrent.name, and persist to custom.json. No video file → fail no video file in torrent.
  7. Find the WebTorrent file whose path basename or name equals the chosen name. deselect() every other file, select() this one. Only this file’s pieces are ever requested.
  8. Resolve readyPromise with the entry.

Failure path: clear the timer, delete from active, destroy the torrent, reject.

status(identifier)

{ "identifier", "ready", "fileName", "fileLength", "downloaded", "progress",
  "downloadSpeed", "uploadSpeed", "numPeers", "numSeeders", "pieceLength", "elapsedMs" }

numSeeders counts wires whose peerPieces bitfield is set for every piece index (0 when metadata is unknown). Returns null for an inactive id.

stop(identifier)

torrent.destroy({ destroyStore: false }) and remove from active. Downloaded data stays on disk; the next start verifies the store and resumes instantly.

Idle sweeper

Every 60 s destroy active torrents whose lastAccess is older than idleDestroyMs. lastAccess is refreshed by every /stream/:id request and by start.

shutdown()

client.destroy().

8. Seed check (server/seeds.js + probeSwarm in streamer.js)

Tracker scrapes alone are misleading: many swarms report complete: 0 on every tracker while a dozen peers are reachable over DHT and PEX. The badge therefore trusts a real probe of the swarm.

checkSeeds(identifier, { force })

  • Results are cached for seedTtlMs; force bypasses the cache.
  • Concurrent calls for the same id share one in-flight promise. Without this, the page’s automatic check and a play request both add the same infohash and WebTorrent throws Cannot add duplicate torrent.

runCheck(identifier):

  1. Parse the source → { infoHash, announce[], urlList[] }; union item.announce in.
  2. Scrape every http(s)|udp|ws(s) tracker in parallel: Client.scrape({ announce, infoHash: [infoHash] }, cb) with a 5 s timeout each; failures are recorded, not thrown. The callback’s results is either keyed by infohash or is the single result; use results[infoHash] || results. trackerSeeders = max(complete), trackerLeechers = max(incomplete) over reachable trackers (0 if none).
  3. webseeds = urlList.length.
  4. Probe the swarm (below) when trackerSeeders + webseeds < minSeeds or the item is a custom item (so always, in this app). A probe error yields { error, peers: 0, seeders: 0 }.
  5. seeders = max(trackerSeeders, swarm.seeders), peers = max(trackerSeeders + trackerLeechers, swarm.peers).
  6. playable = playableFile && (seeders + webseeds >= minSeeds) && (bitrateBps == null || bitrateBps <= maxBitrateBps) && (file == null || file.native). reasons[] names each failed condition, e.g. needs 1 seed, found 0 (trackers 0, swarm 0), mkv is not browser-playable, bitrate 9.1 Mbit/s over limit.

Result:

{ "identifier", "infoHash", "seeders", "peers", "webseeds",
  "trackerSeeders", "trackerLeechers", "trackersReachable",
  "trackers": [ { "announce", "complete", "incomplete", "downloaded" } | { "announce", "error" } ],
  "swarm": { "peers", "seeders", "metadata", "source": "probe" | "adopted" | "active" } | null,
  "playable": true, "reasons": [], "checkedAt": 1792517000000 }

probeSwarm(item)

  • If the torrent is active: await ready, wait 2 s, count, return source: 'active'.
  • If a probe is in flight: return its promise.
  • Otherwise client.add(source, { path: downloadsDir, deselect: true, announce }) so nothing downloads. Wait for metadata up to 15 s (whenReady raced against a timer and against an abort promise). Then let peers connect for up to 6 s, polling every 500 ms, stopping early at 20 wires or when adopted. Count and return { peers: wires.length, seeders, metadata, source: adopted ? 'adopted' : 'probe' }.
  • finally: first destroy the torrent (unless adopted), then delete the probe from the map. The order matters: a start() that arrives during teardown must still find the probe and wait on it, otherwise it adds a duplicate torrent.

Probe adoption

start() must neither wait ~20 s for a probe nor add a second copy of the torrent. The probe exposes { torrent, adopted, finish }. start() sets adopted, calls finish() (resolving the probe’s abort promise so it stops waiting), awaits the probe promise, and reuses the torrent if it was not destroyed meanwhile; if it was, it adds a fresh one.

9. HTTP API (server/index.js)

Server binds 127.0.0.1. express.json() for bodies. express.static('public') for the UI. Async routes are wrapped so rejections reach one error middleware that answers 500 { error: message }.

MethodPathBehaviour
GET/api/config{ minSeeds, maxBitrateBps }
GET/api/movies{ custom: [ item + { seeds: <cached check or null>, active: <status or null> } ] }, newest first
POST/api/custombody { "source": "<magnet or URL>" } → the item (+ seeds, active)
DELETE/api/custom/:idstop if running, remove the entry → { removed: bool }
POST/api/movies/:id/checkcheckSeeds(id, { force: query.force === '1' }) → result
POST/api/movies/:id/play?force=1: start immediately without any check. Else await the check and answer 409 { error: 'not playable', reasons } when it fails. Success → { streamUrl: '/stream/<id>', file: { name, length }, item, seeds }
GET/api/movies/:id/statusstatus, or 404 { error: 'not active' }
DELETE/api/movies/:id/play{ stopped: bool }
GET/api/activearray of statuses
GET/stream/:idHTTP Range stream (below)

/stream/:id

const entry = streamer.touch(id) || await streamer.start(id);
await entry.readyPromise;
const file = entry.file, total = file.length;
let start = 0, end = total - 1;
const range = req.headers.range;
if (range) {
  const m = /^bytes=(\d*)-(\d*)$/.exec(range);
  if (!m || (m[1] === '' && m[2] === '')) return 416 with 'Content-Range: bytes */total';
  if (m[1] === '') start = Math.max(0, total - Number(m[2]));          // suffix range
  else { start = Number(m[1]); if (m[2] !== '') end = Math.min(Number(m[2]), total - 1); }
  if (start > end || start >= total) return 416 with 'Content-Range: bytes */total';
}
res.status(range ? 206 : 200);
res.set({ 'Content-Type': contentTypeFor(file.name), 'Accept-Ranges': 'bytes',
          'Content-Length': String(end - start + 1), 'Cache-Control': 'no-store' });
if (range) res.set('Content-Range', `bytes ${start}-${end}/${total}`);
if (req.method === 'HEAD') return res.end();

// Make the pieces at the requested offset critical so a seek (or the first request)
// is served before the sequential front fill. This turns a ~15 s seek stall into ~3 s.
const pieceLength = entry.torrent.pieceLength;
const firstPiece = Math.floor((file.offset + start) / pieceLength);
const lastPiece  = Math.floor((file.offset + end) / pieceLength);
entry.torrent.critical(firstPiece, Math.min(firstPiece + 2, lastPiece));

const stream = file.createReadStream({ start, end });
res.on('close', () => stream.destroy());          // browsers abort range requests constantly
stream.on('error', (err) => res.destroy(err));
stream.pipe(res);

file.createReadStream already raises the priority of the pieces the stream needs; critical additionally lets those requests pre-empt slow peers.

startServer(port) / stopServer(handle)

startServer(port = config.port) listens on 127.0.0.1 and resolves { server, port: <actual port> }; pass 0 for an ephemeral port. stopServer closes the server and calls streamer.shutdown(). When the module is the CLI entry (import.meta.url === pathToFileURL(process.argv[1]).href) it starts on config.port and installs SIGINT/SIGTERM handlers that call stopServer and exit.

10. Electron main process (electron/main.js), complete

import { app, BrowserWindow, shell } from 'electron';
import path from 'node:path';
import fs from 'node:fs';

// Must be set before the server modules are imported (config.js reads it at load).
process.env.PLAIER_DATA_DIR = path.join(app.getPath('userData'), 'data');

let win = null;
let handle = null;   // { server, port } once the embedded server is up
let pending = null;  // a source that arrived before the window existed
const CLAIM_MAGNET_LINKS = true;  // false if the person said no in "Before you start"

// Small file log so URL handling can be inspected in the packaged app.
const logFile = path.join(app.getPath('userData'), 'plaier-main.log');
function log(...parts) {
  const line = `${new Date().toISOString()} ${parts.join(' ')}\n`;
  console.log(line.trim());
  try { fs.appendFileSync(logFile, line); } catch {}
}

function sourceFromArgv(argv) {
  return argv.slice(1).find((a) => /^magnet:/i.test(a) || /^https?:\/\/.*\.torrent(\?.*)?$/i.test(a) || /^--(magnet|torrent)=/.test(a))
    ?.replace(/^--(magnet|torrent)=/, '') || null;
}

function pageUrl(source) {
  const base = `http://127.0.0.1:${handle.port}/`;
  if (!source) return base;
  const key = /^magnet:/i.test(source) ? 'magnet' : 'torrent';
  return `${base}?${key}=${encodeURIComponent(source)}`;
}

// Open a source in the window, or remember it until the window exists.
function openSource(source) {
  log('openSource', source ? source.slice(0, 60) : '(none)', 'win=', Boolean(win), 'handle=', Boolean(handle));
  if (!source) return;
  if (!win || !handle) { pending = source; return; }
  win.loadURL(pageUrl(source));
  if (win.isMinimized()) win.restore();
  win.focus();
}

function createWindow(source) {
  win = new BrowserWindow({
    width: 1200, height: 800, minWidth: 700, minHeight: 500,
    title: 'Plaier', backgroundColor: '#141414',
    webPreferences: { contextIsolation: true, nodeIntegration: false },
  });
  win.on('closed', () => { log('window closed'); win = null; });
  win.webContents.on('did-navigate', (_e, url) => log('did-navigate', url.replace(/\?.*$/, '?…')));
  win.webContents.on('did-fail-load', (_e, code, desc, url) => log('did-fail-load', code, desc, url.slice(0, 80)));
  win.webContents.on('did-finish-load', () => log('did-finish-load'));
  win.webContents.on('console-message', (ev) => log('renderer:', ev.level, String(ev.message).slice(0, 160)));
  // Keep the app inside the window; external links go to the system browser.
  win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
  win.loadURL(pageUrl(source));
}

// Register URL/argv listeners before anything async so an early open-url is not lost.
if (!app.requestSingleInstanceLock()) {
  app.quit();
} else {
  // Second process started with an argument (direct binary launch): forward and focus.
  app.on('second-instance', (_e, argv) => { log('second-instance', JSON.stringify(argv)); openSource(sourceFromArgv(argv)); });
  // macOS Launch Services delivers magnet:/URL opens here, running or not.
  app.on('open-url', (e, url) => { log('open-url', url.slice(0, 60)); e.preventDefault(); openSource(url); });

  app.whenReady().then(async () => {
    // Claim magnet: links for the packaged app only (dev runs would register the Electron binary).
    if (CLAIM_MAGNET_LINKS && app.isPackaged && !app.isDefaultProtocolClient('magnet')) {
      const ok = app.setAsDefaultProtocolClient('magnet');
      console.log(`magnet: handler registration ${ok ? 'done' : 'failed'}`);
    }
    const { startServer } = await import('../server/index.js');
    handle = await startServer(0);
    const initial = pending || sourceFromArgv(process.argv);
    pending = null;
    createWindow(initial);
    app.on('activate', () => { if (!win) createWindow(null); });
  });

  app.on('window-all-closed', () => app.quit());
  app.on('before-quit', async (e) => {
    if (!handle) return;
    e.preventDefault();
    const h = handle;
    handle = null;
    const { stopServer } = await import('../server/index.js');
    await Promise.race([stopServer(h), new Promise((r) => setTimeout(r, 3000))]);
    app.quit();
  });
}

Points that matter:

  • Three ways a source arrives, all funnelled into openSource: argv at launch, second-instance argv (the binary run again while the app is open), and macOS open-url (Launch Services). open-url can fire before ready, hence pending.
  • open -a Plaier --args … does not work for a running app: macOS passes --args only when it launches the process. The URL forms below work in both states.
  • The magnet: scheme must be declared in the bundle (build.protocols → CFBundleURLTypes in Info.plist) or Launch Services never delivers URLs to the app; it just activates it. setAsDefaultProtocolClient additionally makes plain open "magnet:…" (and browser clicks) land in Plaier.
  • Data lives under ~/Library/Application Support/plaier/data, log at ~/Library/Application Support/plaier/plaier-main.log (URL arrivals, navigations, the page’s console).

Launching with a source (all work whether or not Plaier is running):

open "magnet:?xt=urn:btih:..."                         # default handler
open -a Plaier "magnet:?xt=urn:btih:..."               # explicit app
open -a Plaier "https://example.org/file.torrent"      # .torrent URL
/Applications/Plaier.app/Contents/MacOS/Plaier "magnet:..."   # argv / second-instance

For debugging the packaged app’s page, launch the binary with --remote-debugging-port=9222 and use the Chrome DevTools Protocol at http://127.0.0.1:9222/json.

11. Packaging and install

npm install
npm run electron     # dev: opens the window (first run downloads the Electron binary)
npm run dist         # dist/Plaier-<version>-arm64.dmg and -mac.zip, dist/mac-arm64/Plaier.app
cp -R dist/mac-arm64/Plaier.app /Applications/     # then drag to the Dock
  • npmRebuild: false is required. electron-builder’s default rebuild compiles bufferutil, utf-8-validate and utp-native from source against Electron’s headers and fails on current macOS toolchains (unknown type name 'bool'). All three ship prebuilt N-API binaries that work in Electron unchanged, and WebTorrent loads utp-native inside a try, so nothing is lost by skipping the rebuild.
  • With npm’s install-script blocking on, the electron package’s postinstall does not download the binary; the first electron . or electron-builder run does.
  • A locally built bundle carries no quarantine attribute, so Gatekeeper does not block the unsigned app.
  • identity: null skips signing; the default Electron icon is replaced through mac.icon (electron-builder generates the .icns from the 1024 px PNG).

12. UI (public/)

Theme (style.css)

CSS variables: --bg #141414, --panel #1f1f1f, --text #e5e5e5, --muted #9a9a9a, --accent #e50914, --ok #46d369, --bad #e87c03, --pending #6c6c6c. System sans font. Header is sticky with a black-to-transparent gradient; the wordmark PLAIER is in the accent colour. Cards: panel background, 6 px radius, scale 1.04 on hover, .unplayable at 55 % opacity. Grid: repeat(auto-fill, minmax(190px, 1fr)), 16 px gap. Badges: 12 px, 3 px radius, grey by default, .ok green on black, .bad orange on black, .playing accent. The player’s <video> is full width, max 70 vh, black background. The status bar is an 8 px rounded track with three absolutely positioned layers: downloaded (#555), buffered (#888), played (accent).

index.html

<header>
  <h1><a href="#" id="home">PLAIER</a></h1>
  <div id="header-info">Sequential torrent streaming · open with <code>?magnet=…</code> or <code>?torrent=…</code></div>
</header>
<main id="grid-view">
  <form id="custom-form" autocomplete="off">
    <input id="custom-source" type="text" placeholder="Paste a magnet: link or a .torrent URL you have rights to" />
    <button type="submit" id="custom-add">Add torrent</button>
    <span id="custom-msg"></span>
  </form>
  <div id="custom-grid" class="grid"></div>
  <p id="empty" class="muted" hidden>Nothing added yet.</p>
</main>
<main id="player-view" hidden>
  <button id="back">&larr; Back</button>
  <h2 id="player-title"></h2>
  <video id="video" controls playsinline></video>
  <div id="status-strip">
    <div class="bar"><div id="bar-downloaded"></div><div id="bar-buffered"></div><div id="bar-played"></div></div>
    <div id="status-text"></div>
  </div>
  <p id="player-desc"></p>
</main>
<script src="app.js"></script>

Plus <link rel="icon" href="icon.png"> and the stylesheet in <head>.

app.js behaviour

State: movies[] (the custom items, newest first), current (identifier being played or null), statusTimer.

Load: GET /api/movies → movies = data.custom → render → start seed checks for every playable item without cached seeds, 2 at a time, updating the card as each result arrives → then handle URL parameters.

URL parameters: read magnet, torrent, or source from location.search. If present: history.replaceState to strip the query, add the source (POST /api/custom), put the card first, start its seed check in the background, and immediately play(item, force = true).

Card: a 2:3 placeholder poster with a 🧲 glyph, a ✕ remove button (top right), title, meta line (276 MB · MP4, or size unknown until peers found), and a badge:

StateBadge
no seed result yetnot checked / checking swarm… (grey)
playable▶ 7 seeds / 10 peers + 1 webseed (green); peers part only if peers > seeders, webseed part only if > 0
not playable✗ <first reason> (orange); full counts in the tooltip
playableFile false<format> not browser-playable (orange, card dimmed)
activestreaming 42% (accent)

Clicking a card always tries to play (force = true); the badge is advisory. Cards with playableFile false show an alert instead. ✕ stops playback if it is the current item, calls DELETE /api/custom/:id, and removes the card.

Paste form: on submit, add the source, show Adding… → Added <title> or the error in the warn colour, clear the input on success.

Play(m, force):

  1. Show the player view with the title and description. Status text: Looking for peers to fetch metadata… when metadataKnown is false, else Starting torrent….
  2. Start a 1 s “waiting” ticker that polls /api/movies/:id/status and writes Looking for peers to fetch metadata… 3 peers connected · 12s (or connecting when status is 404). After 20 s without metadata append · still no metadata, this swarm may be dead (a .torrent URL with webseeds works better).
  3. POST /api/movies/:id/play?force=1 (query only when force). On success stop the ticker, merge r.item into the card’s item, set video.src = r.streamUrl, attempt video.play(), and start the 1 s status poll. On failure stop the ticker and show Failed: <message> (<reasons>).
  4. Log [plaier] url param source: …, [plaier] addSource -> <id>, [plaier] play <id> force=…, [plaier] play ok <streamUrl> / [plaier] play failed … to the console (the Electron shell forwards these to its log file).

Status poll (every second while playing): update the three bar layers (downloaded %, buffered-to %, played %) and the text line:

Big Buck Bunny.mp4 · downloaded 27.2% (35 MB / 129 MB) · 5.7 MB/s (45.87 Mbit/s) · 12 peers (9 seeding) · buffered to 24s · playing

“buffered to” is the end of the video.buffered range that contains currentTime with a ±1 s tolerance (otherwise it reads 0 whenever the playhead sits exactly on a range edge). The last word is buffering when readyState < 3, else paused or playing.

Back (and the wordmark while playing): stop polling, pause, remove src, load(), DELETE /api/movies/:id/play, show the grid.

13. Behaviours worth reproducing exactly

  • Sequential + critical: measured cold start to first frame 1–7 s; a seek to 80 % of a file at 3 % downloaded plays after ~3 s. Seeks inside the downloaded region are instant. Native controls show the timeline because every response is 206 with Accept-Ranges, Content-Length and Content-Range.
  • Resume: data survives stop; a restart serves 20 MB from disk in ~0.3 s.
  • Only the chosen file is selected. One small partial file may still appear for a neighbouring file where a piece straddles the boundary. Expected.
  • Scrapes are advisory: swarms that live on DHT report 0 on trackers. The probe is what the badge trusts; “0 seeds” from a scrape means nothing on its own.
  • Idempotent add: the same infohash twice replaces the entry; a reload of a page that was opened with ?magnet= does nothing new.
  • Magnets built from Internet Archive items have no peers: IA torrents are served by webseeds, which magnets do not carry. Use the item’s https://archive.org/download/<id>/<id>_archive.torrent URL instead; the .torrent carries the webseeds and starts instantly.
  • Timeouts: play metadata 90 s; probe metadata 15 s, settle 6 s; scrape 5 s per tracker; quit waits at most 3 s for the client to close.

15. Icon

build/icon.svg, 1024×1024: a rounded square (x=100 y=100 w=824 h=824 rx=184) with a vertical gradient #2b2b2b → #101010 and a soft drop shadow; a red play triangle (M400 300 L700 492 L400 684 Z, gradient #ff2a36 → #c4060f, 44 px rounded stroke, red glow); and a “piece bar” of five 80×44 rounded rectangles at y=736 starting at x=272 with a 100 px pitch, three in #e50914 then #6a6a6a and #3a3a3a. Rasterise to build/icon.png (on macOS without extra tools: qlmanage -t -s 1024 -o <dir> build/icon.svg writes icon.svg.png). Copy to public/icon.png for the favicon.

Acceptance checks

Use these public-domain torrents (Blender open movies):

  • Sintel .torrent: https://webtorrent.io/torrents/sintel.torrent (also as magnet infohash 08ada5a7a6183aae1e09d831df6748d566095a10)
  • Big Buck Bunny magnet: magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny
  • Tears of Steel magnet (WebM): magnet:?xt=urn:btih:209c8226b299b308beaf2b9cd3fb49212dbd13ec&dn=Tears+of+Steel
  1. npm start, open http://localhost:3000, paste the Sintel .torrent URL → card shows 129 MB · MP4, badge resolves to green within ~10 s, click → playing within seconds.
  2. Paste the Big Buck Bunny magnet → size unknown until peers found; click → metadata over DHT in ~1 s, playing; after Back the card reads 276 MB · MP4 with a badge like 7 seeds / 10 peers.
  3. Click the native timeline at 80 % → playing again within a few seconds; the status line shows the download jumping to that region.
  4. curl -I -H 'Range: bytes=0-1' localhost:3000/stream/<id> → 206, Content-Range: bytes 0-1/<total>; bytes=-100 → the last 100 bytes; bytes=999999999999- → 416.
  5. Back → GET /api/active is []; play again → instant from disk.
  6. http://localhost:3000/?magnet=<url-encoded magnet> → added and playing without clicks, query removed from the address bar.
  7. Paste a magnet with a bogus infohash → status ticks … N peers connected · 12s, and after 20 s adds the dead-swarm hint; after 90 s Failed: no metadata after 90s.
  8. npm run electron → window opens, server on a random port, data under ~/Library/Application Support/plaier/data.
  9. npm run dist, copy to /Applications. With Plaier closed, open "<magnet>" launches it into the player. With Plaier running, open -a Plaier "<other magnet>" switches the window to that torrent and /api/active on the app’s port lists it within seconds. Firing a second magnet 3 s after a cold launch (while the page is still probing) must also play; this exercises probe adoption.
  10. ~/Library/Application Support/plaier/plaier-main.log shows open-url, openSource … win= true handle= true, did-navigate, and the renderer’s [plaier] play ok for each send.

Hand-off to the person

Tell them, in plain words:

  • Plaier is in their Applications folder. Suggest dragging it to the Dock.
  • How to use it: paste a magnet link or a .torrent link and click the card. If they chose to, clicking a magnet link in their browser opens Plaier straight away.
  • The badge tells them whether enough people are sharing a torrent for it to play. Green means good. Some torrents simply have nobody sharing them any more.
  • Downloaded videos are kept in ~/Library/Application Support/plaier/data/downloads. Show them how to open that folder in Finder, and that they can delete files there to free up space.
  • While Plaier is playing, it also shares the pieces it has downloaded with other people. That’s how torrents work.
  • Where to find films they’re allowed to watch: the Internet Archive’s feature films and public-domain collections (use the item’s .torrent link, which starts much faster than a magnet), and the Blender Foundation’s open movies such as Sintel and Big Buck Bunny. Only play torrents they have the rights to.
  • Which choices you made: whether Plaier opens magnet links, and where the project folder is.

Get Torrent Video Player

Copy this and paste it into your AI assistant.

Build "Torrent Video Player" for me by following the recipe at https://promptbai.bonq.foo/r/torrent-video-player.md — read the whole recipe first, follow it step by step, and ask me if anything is unclear.

Where do I paste it?

Claude

In the Claude desktop app, open the Code tab, pick or create an empty folder for the project, and paste the sentence. Or use Claude Code in a terminal.

ChatGPT / Codex

Open Codex (in the ChatGPT app or the Codex app), point it at a new empty folder, and paste the sentence.

Cursor or Windsurf

Open a new empty folder in the editor, open the AI chat in agent mode, and paste the sentence.

A plain chat assistant

Paste the sentence into the chat. It will give you the files to save yourself and tell you how. This works best for recipes marked Easy.

New to this? Here's how it works.