Torrent Video Player
Watch a video from a torrent while it's still downloading
- Token useHigh
- Estimatemore than 250k
- Category🧰 Computer Tools
- Version1.0.0
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.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
- Should Plaier open
magnet:links when they click them in a browser or anywhere else on the Mac? (Default: yes. If they say no, setCLAIM_MAGNET_LINKStofalseinelectron/main.js.) - Where should the app go? (Default: build it in a new folder called
plaierin their home folder and install it into/Applications.) - 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 nodeif they have Homebrew, otherwise the official installer from nodejs.org.
Features
- Paste and play: a paste box that takes a magnet link or a
.torrentURL. 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 frommagnet: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.1only. - No code signing. The app is built and run on the same Mac.
Build steps
- Check the Mac and the Node version, and ask the questions above.
- Create the project layout and
package.jsonfrom §3, then runnpm install. - Write
server/config.js, the data model andpickVideoFile(§4–§6). - Write the streaming engine (§7) and try it from Node with the Sintel
.torrentbefore adding the HTTP layer. - Write the seed check and swarm probe, with probe adoption (§8).
- Write the HTTP API and the Range stream (§9). Run acceptance checks 1–7 in a browser at
http://localhost:3000. - Write the UI (§12):
index.html,app.js,style.css. - Write the Electron main process (§10) and run it with
npm run electron. - Make the icon (§15), package with
npm run dist(§11), and install into/Applications. - Run acceptance checks 8–10 with the installed app.
Technical specification
2. Stack
| Component | Package | Version | Notes |
|---|---|---|---|
| Runtime | Node | ≥ 22 (built on 26) | ESM only: "type": "module" |
| HTTP | express | 5.3 | UI, JSON API, HTTP Range video stream |
| Torrents | webtorrent | 3.0.21 | ESM, Node ≥ 22, BEP19 webseeds enabled |
| Tracker scrape | bittorrent-tracker | 11.2.3 | import Client from 'bittorrent-tracker/client' |
| Parsing | parse-torrent | 11.0.24 | magnet URIs and .torrent buffers |
| Desktop | electron | 44.9.1 | ESM main process (supported since Electron 28) |
| Packaging | electron-builder | 26.15.3 | with 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)
- Trim.
magnet:prefix →parseTorrent(source).http://orhttps://→ fetch, parse the buffer, cache it at<torrentsDir>/custom-<infohash>.torrent. Anything else → errorpaste a magnet: link or an http(s) URL to a .torrent file. No infohash → error. .torrentinput knows its files: runpickVideoFile, setmetadataKnown: true,playableFile: file.native. Magnet input:file: null,metadataKnown: false,playableFile: true.- 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)
- Already active → touch
lastAccess,await readyPromise, return the entry. - Load the item. Throw
unknown torrent <id>or<id> is not browser-playable. - Obtain a torrent object:
- If a probe is in flight for this id: set
probe.adopted = true, callprobe.finish(),await probe.promise(ignore rejection). Ifprobe.torrent.destroyedis false, use it and settorrent.strategy = 'sequential'. - Otherwise
client.add(source, { path: downloadsDir, strategy: 'sequential', deselect: true, announce: item.announce }).deselect: truemeans no piece is wanted until a file is selected.
- If a probe is in flight for this id: set
- Register the entry in
activeimmediately (so/statusworks while waiting). - Wait for ready: resolve at once if
torrent.readyis already true, else on thereadyevent. A 90 s timer fails withno metadata after 90s (no peers?). - On ready, if
item.fileis null (magnet):pickVideoFileovertorrent.files({name, path, length}), store it on the item together withplayableFile: picked.native,metadataKnown: true,title: torrent.name, and persist tocustom.json. No video file → failno video file in torrent. - Find the WebTorrent file whose
pathbasename ornameequals the chosen name.deselect()every other file,select()this one. Only this file’s pieces are ever requested. - Resolve
readyPromisewith 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;forcebypasses 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):
- Parse the source →
{ infoHash, announce[], urlList[] }; unionitem.announcein. - 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’sresultsis either keyed by infohash or is the single result; useresults[infoHash] || results.trackerSeeders = max(complete),trackerLeechers = max(incomplete)over reachable trackers (0 if none). webseeds = urlList.length.- Probe the swarm (below) when
trackerSeeders + webseeds < minSeedsor the item is a custom item (so always, in this app). A probe error yields{ error, peers: 0, seeders: 0 }. seeders = max(trackerSeeders, swarm.seeders),peers = max(trackerSeeders + trackerLeechers, swarm.peers).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 (whenReadyraced 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: astart()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 }.
| Method | Path | Behaviour |
|---|---|---|
GET | /api/config | { minSeeds, maxBitrateBps } |
GET | /api/movies | { custom: [ item + { seeds: <cached check or null>, active: <status or null> } ] }, newest first |
POST | /api/custom | body { "source": "<magnet or URL>" } → the item (+ seeds, active) |
DELETE | /api/custom/:id | stop if running, remove the entry → { removed: bool } |
POST | /api/movies/:id/check | checkSeeds(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/status | status, or 404 { error: 'not active' } |
DELETE | /api/movies/:id/play | { stopped: bool } |
GET | /api/active | array of statuses |
GET | /stream/:id | HTTP 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-instanceargv (the binary run again while the app is open), and macOSopen-url(Launch Services).open-urlcan fire beforeready, hencepending. open -a Plaier --args …does not work for a running app: macOS passes--argsonly when it launches the process. The URL forms below work in both states.- The
magnet:scheme must be declared in the bundle (build.protocols→CFBundleURLTypesin Info.plist) or Launch Services never delivers URLs to the app; it just activates it.setAsDefaultProtocolClientadditionally makes plainopen "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: falseis required. electron-builder’s default rebuild compilesbufferutil,utf-8-validateandutp-nativefrom 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 loadsutp-nativeinside atry, so nothing is lost by skipping the rebuild.- With npm’s install-script blocking on, the
electronpackage’s postinstall does not download the binary; the firstelectron .orelectron-builderrun does. - A locally built bundle carries no quarantine attribute, so Gatekeeper does not block the unsigned app.
identity: nullskips signing; the default Electron icon is replaced throughmac.icon(electron-builder generates the.icnsfrom 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">← 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:
| State | Badge |
|---|---|
| no seed result yet | not 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) |
| active | streaming 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):
- Show the player view with the title and description. Status text:
Looking for peers to fetch metadata…whenmetadataKnownis false, elseStarting torrent…. - Start a 1 s “waiting” ticker that polls
/api/movies/:id/statusand writesLooking for peers to fetch metadata… 3 peers connected · 12s(orconnectingwhen status is 404). After 20 s without metadata append· still no metadata, this swarm may be dead (a .torrent URL with webseeds works better). POST /api/movies/:id/play?force=1(query only whenforce). On success stop the ticker, merger.iteminto the card’s item, setvideo.src = r.streamUrl, attemptvideo.play(), and start the 1 s status poll. On failure stop the ticker and showFailed: <message> (<reasons>).- 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
206withAccept-Ranges,Content-LengthandContent-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.torrentURL instead; the.torrentcarries 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 infohash08ada5a7a6183aae1e09d831df6748d566095a10) - 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
npm start, openhttp://localhost:3000, paste the Sintel.torrentURL → card shows129 MB · MP4, badge resolves to green within ~10 s, click → playing within seconds.- Paste the Big Buck Bunny magnet →
size unknown until peers found; click → metadata over DHT in ~1 s, playing; after Back the card reads276 MB · MP4with a badge like7 seeds / 10 peers. - Click the native timeline at 80 % → playing again within a few seconds; the status line shows the download jumping to that region.
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.- Back →
GET /api/activeis[]; play again → instant from disk. http://localhost:3000/?magnet=<url-encoded magnet>→ added and playing without clicks, query removed from the address bar.- Paste a magnet with a bogus infohash → status ticks
… N peers connected · 12s, and after 20 s adds the dead-swarm hint; after 90 sFailed: no metadata after 90s. npm run electron→ window opens, server on a random port, data under~/Library/Application Support/plaier/data.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/activeon 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.~/Library/Application Support/plaier/plaier-main.logshowsopen-url,openSource … win= true handle= true,did-navigate, and the renderer’s[plaier] play okfor 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
.torrentlink 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
.torrentlink, 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.