---
title: "Torrent Video Player"
slug: torrent-video-player
version: 1.0.0
updated: 2026-10-20
sha256: 2ee45b4372fafc0f933a73d6b901467de4f25a7a9c66ea804b3204ba09974bb5
category: computer-tools
category_name: "Computer Tools"
source: https://promptbai.bonq.foo/r/torrent-video-player.md
check: https://promptbai.bonq.foo/r/torrent-video-player.json
---

# Torrent Video Player — a promptbai recipe (v1.0.0)

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

## Instructions for the AI agent (read first)

You are an AI coding agent. A person (who is probably **not** a programmer) has asked you to build this for them by following this recipe from promptbai (https://promptbai.bonq.foo).

1. **Read the whole recipe before you start.** Then follow it section by section.
2. **Talk to the person in plain language.** Avoid jargon. When you need a decision from them, ask one short question at a time and offer a sensible default.
3. **Ask when something is unclear** instead of guessing on things that matter to them (names, colors, personal details). Use your judgment on purely technical details. If the person doesn't answer or isn't available, use the defaults the recipe gives and keep going.
4. **Respect the Constraints section.** If the person's setup makes a constraint impossible, explain why and suggest the closest alternative.
5. **Check your work** against the Acceptance checks before you say you are done.
6. **Write a file named `promptbai.json`** in the root of the project folder with these values (formatting may differ), so the build can be updated later:

```json
{
  "slug": "torrent-video-player",
  "version": "1.0.0",
  "sha256": "2ee45b4372fafc0f933a73d6b901467de4f25a7a9c66ea804b3204ba09974bb5",
  "source": "https://promptbai.bonq.foo/r/torrent-video-player.md",
  "check": "https://promptbai.bonq.foo/r/torrent-video-player.json"
}
```

7. **If you can't create files yourself** (for example, you are a chat assistant), give the person each file in full, one at a time. Tell them exactly what to name it, where to save it, and how to save it with an app they already have.
8. **Finish with the Hand-off section:** tell the person, simply, where their new thing is, how to open it, and how to use it. Include a short "your choices" note listing the options you used (theirs or the defaults) and how to change them later.
9. **Where practical, keep core logic** (dates, calculations, parsing) in plain functions you can test outside a browser, for example with `node`. In a single-file app, put them in their own `<script id="core">` block and load that block into node for tests.

If anything in this recipe turns out to be unclear or wrong, ask the person whether you may tell the promptbai editors, and see https://promptbai.bonq.foo/agents.md for how.

To check for updates later, fetch https://promptbai.bonq.foo/r/torrent-video-player.json and compare its `version` with the one in `promptbai.json`. The full update protocol is at https://promptbai.bonq.foo/agents.md. The `sha256` is the SHA-256 of everything after the marker line below (UTF-8, LF line endings).

---

<!-- promptbai:recipe-body -->
## 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

| 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`:

```jsonc
{
  "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`)

```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):

```jsonc
{
  "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)`

```json
{ "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:

```json
{ "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 }`.

| 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`

```js
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

```js
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):

```bash
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

```bash
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`

```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:

| 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)**:
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.
