# TorrServer Node.js Proxy for Stremio Addons — v2

Convert torrent infoHashes to playable HTTP video streams using a self-hosted TorrServer daemon. Free alternative to debrid services.

## Architecture

```
Stremio Addon (:3091)
  └─ /api/torr/play/:hash/:fileIdx
       └─ TorrServer (:8090)
            ├─ POST /torrents  → add magnet (or check existing)
            ├─ POLL stat code  → wait until ≥2 (Working)
            └─ GET /play/{hash}/{file_id} → video stream
```

The addon proxies the TorrServer stream, adding proper Content-Type, CORS headers, and range support.

## TorrServer Configuration

### Settings — Change Anytime by Editing JSON + Restart

TorrServer MatriX.142 reads `settings.json` at startup when `StoreSettingsInJson: true` (default). No need to delete `config.db` — the JSON is authoritative.

**Workflow:**
1. Edit `{path}/settings.json`
2. Kill TorrServer (`kill $(pgrep torrserver)`)
3. Restart (`/usr/local/bin/torrserver ...`)
4. New settings take effect immediately

### Recommended Settings

```json
{
  "BitTorr": {
    "CacheSize": 1073741824,
    "UseDisk": false,
    "TorrentsSavePath": "",
    "ConnectionsLimit": 500,
    "PreloadCache": 5,
    "ReaderReadAHead": 95,
    "RetrackersMode": 1,
    "TorrentDisconnectTimeout": 60,
    "UploadRateLimit": 51200,
    "RemoveCacheOnDrop": true
  }
}
```

| Setting | Default | Recommended | Why |
|---------|---------|-------------|-----|
| `CacheSize` | 64 MB | **1 GB** | Enough for smooth streaming of 1080p-4K |
| `UseDisk` | false | **false** | RAM is faster; server has 32GB+ |
| `ConnectionsLimit` | 25 | **500** | More peers = faster swarm join |
| `PreloadCache` | 50 | **5** | **CRITICAL**: 90% with 4GB = 3.6GB pre-buffer (30-60s delay). 5% with 1GB = 50MB pre-buffer (instant playback) |
| `TorrentDisconnectTimeout` | 30s | 60s | Longer window for reconnects |
| `UploadRateLimit` | 0 (unlimited) | **51200 KB/s (50 MB/s)** | Prevents upload from saturating |
| `RemoveCacheOnDrop` | false | **true** | Deletes cached files on removal |

## TorrServer API (MatriX fork specific)

```
POST /torrents           → JSON body: {"action":"add"|"get"|"list"|"rem", ...}
GET  /play/{hash}/{id}   → video stream (with preloading)
GET  /stream/{hash}/{id}  → video stream (without preloading)
GET  /echo                → version string ("MatriX.142")
POST /settings            → get settings (with {"action":"get"})
```

**⚠️ NOT `/api/torrents`**: The documented `/api/torrents` path returns 404. Use bare `/torrents`.
**⚠️ NOT query params**: `/play?hash=X&file_id=Y` returns 404. Must use path format.
**⚠️ File IDs are 1-based**: from `file_stats[].id`. Passing 0 returns HTML error. **Always treat fileIdx=0 as auto-detect** — see auto-detect pattern below.

### CRITICAL: Wait for TorrServer Ready BEFORE Sending Response

TorrServer can take **2-5 seconds** to resolve metadata and find peers (stat 0→1→2→3). During this time, TorrServer's `/play/{hash}/{id}` endpoint returns **nothing** — it blocks until ready.

**The old approach — send 200 + chunked headers immediately, pipe data later — causes proxy/funnel timeouts.** Upstream proxies (Tailscale Funnel, Cloudflare Tunnel, nginx) see a 200 response with zero data for 2-5 seconds and respond 502/504. The client sees "Starting stream..." then error.

**✅ RIGHT — wait for TorrServer to be ready, THEN send headers + data simultaneously:**

```js
// Wait for TorrServer to resolve metadata and start serving:
const readyData = await waitForTorrent(hash, 12000); // up to 12s

// Now get the stream (data is flowing):
const streamResp = await http.get(`${TS_BASE}/play/${hash}/${fileIdx}`, {
  timeout: 0, responseType: 'stream',
});

// Forward TorrServer's headers + body to client:
const headersToForward = ['content-type', 'content-length', 'accept-ranges', 'content-disposition'];
for (const h of headersToForward) {
  if (streamResp.headers[h]) res.setHeader(h, streamResp.headers[h]);
}
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-cache');

// Pipe video data — arrives with the response, no empty-body gap:
streamResp.data.pipe(res);
```

No more "empty body wait" gap. Client receives 200 + headers + video data all at once. TTFB = 2-5s (TorrServer metadata time), but when the response arrives, data flows immediately.

### ExoPlayer Seeking

TorrServer sends `Accept-Ranges: bytes` + `Content-Length`. Forward these as-is for seeking support. No need to strip headers like the old chunked approach required.

**Bypassing the addon proxy entirely (fastest):** For direct streaming without going through Express, set `stream.url` to TorrServer's own IP: `http://SERVER_LAN_IP:8090/play/{hash}/{fileIdx}`. The client connects directly to TorrServer, eliminating the addon server as a bottleneck. Only works if the client can reach port 8090 on the TorrServer machine (LAN or Tailscale subnet).

### fileIdx=0 is NEVER valid

TorrServer's `file_stats` uses **1-based** indexing (file 1, 2, 3...). File 0 does not exist. Passing `/play/{hash}/0` returns an HTML error page.

**Always treat fileIdx=0, null, or NaN as "auto-detect"** — resolve to the largest video file from `file_stats`.

### Auto-Detect Video File

When a torrent has multiple files (subs, nfo, poster, video), default file_id=0 or 1 may point to a non-video file. Auto-detect pattern:
|------|---------------|---------|
| 0 | "Torrent added" | Queued, not started |
| 1 | "Torrent getting info" | Resolving metadata, connecting to peers |
| 2 | "Torrent done" | Metadata ready, ready to play |
| 3 | "Torrent working" | Downloading/seeding, ready to play |
| 4 | (error) | Failed — remove and re-add |

### Polling Pattern

```js
const ADD_TIMEOUT = 30000; // max wait for torrent to become playable
const POLL_INTERVAL = 1500;

async function waitForTorrent(hash, timeoutMs = ADD_TIMEOUT) {
  const start = Date.now();

  while (Date.now() - start < timeoutMs) {
    const resp = await http.post(`${TS_BASE}/torrents`, {
      action: 'get', hash,
    }, { timeout: 5000 });

    const data = resp.data;
    const stat = data.stat;

    if (stat === 2 || stat === 3) return data; // Ready!
    if (stat === 4) throw new Error(`Torrent error: ${data.stat_string}`);

    await new Promise(r => setTimeout(r, POLL_INTERVAL));
  }
  throw new Error(`Timeout: torrent not ready after ${timeoutMs / 1000}s`);
}
```

### Working Public Trackers

These 12 trackers reliably find peers for public torrents:

```
udp://tracker.opentrackr.org:1337/announce
udp://tracker.coppersurfer.io:6969/announce
udp://tracker.leechers-paradise.org:6969/announce
udp://tracker.openbittorrent.com:6969/announce
udp://tracker.torrent.eu.org:451
udp://tracker.moeking.me:6969/announce
udp://tracker.dler.org:6969/announce
udp://tracker.zerobytes.xyz:1337/announce
udp://tracker.bitsearch.top:1337/announce
wss://tracker.btorrent.xyz
wss://tracker.openbit.cc
https://tracker.vectahosting.eu:2053/announce
```

In magnet links, use `tr=${encodeURIComponent(url)}` for each tracker.

**⚠️ IMPORTANT**: The code below is structured for readability. In production, you MUST apply the **wait-first pattern** from the "CRITICAL: Wait for TorrServer Ready BEFORE Sending Response" section above — call `waitForTorrent()` BEFORE sending any response, and forward TorrServer's headers + body to the client. If `waitForTorrent()` throws, return a 502 error since headers are not yet sent.

## Full Node.js Proxy Route (v3 with immediate headers + auto-cleanup + pre-warm)

```js
import { Router } from 'express';
import { http } from '../providers/torrent/shared.js';
import logger from '../services/logger.js';

const router = Router();
const TS_HOST = '127.0.0.1';
const TS_PORT = 8090;
const TS_BASE = `http://${TS_HOST}:${TS_PORT}`;

const ADD_TIMEOUT = 30000;
const POLL_INTERVAL = 1500;

const TRACKERS = [
  'udp://tracker.opentrackr.org:1337/announce',
  'udp://tracker.coppersurfer.io:6969/announce',
  'udp://tracker.leechers-paradise.org:6969/announce',
  'udp://tracker.openbittorrent.com:6969/announce',
  'udp://tracker.torrent.eu.org:451',
  'udp://tracker.moeking.me:6969/announce',
  'udp://tracker.dler.org:6969/announce',
  'udp://tracker.zerobytes.xyz:1337/announce',
  'udp://tracker.bitsearch.top:1337/announce',
  'wss://tracker.btorrent.xyz',
  'wss://tracker.openbit.cc',
  'https://tracker.vectahosting.eu:2053/announce',
];

const VIDEO_EXTS = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v'];

function buildMagnet(hash) {
  const trackers = TRACKERS.map(t => `tr=${encodeURIComponent(t)}`).join('&');
  return `magnet:?xt=urn:btih:${hash}&dn=video&${trackers}`;
}

function autoDetectVideoFile(fileStats) {
  if (!fileStats?.length) return null;
  const videos = fileStats.filter(f =>
    VIDEO_EXTS.some(ext => (f.path || '').toLowerCase().endsWith(ext))
  );
  if (!videos.length) return null;
  videos.sort((a, b) => (b.length || 0) - (a.length || 0));
  return videos[0].id;
}

async function waitForTorrent(hash, timeoutMs = ADD_TIMEOUT) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    let resp;
    try {
      resp = await http.post(`${TS_BASE}/torrents`, {
        action: 'get', hash,
      }, { timeout: 5000 });
    } catch {
      await new Promise(r => setTimeout(r, POLL_INTERVAL));
      continue;
    }
    const data = resp.data;
    if (data.stat === 2 || data.stat === 3) return data;
    if (data.stat === 4) throw new Error(`Torrent error: ${data.stat_string}`);
    await new Promise(r => setTimeout(r, POLL_INTERVAL));
  }
  throw new Error(`Timeout: torrent not ready after ${timeoutMs / 1000}s`);
}

router.get('/torr/play/:hash/:fileIdx?', async (req, res) => {
  const hash = req.params.hash?.toLowerCase();
  let fileIdx = parseInt(req.params.fileIdx, 10);

  if (!hash || !/^[a-f0-9]{40}$/.test(hash)) {
    return res.status(400).json({ error: 'Invalid hash' });
  }

  const magnet = buildMagnet(hash);

  try {
    // Step 1: Check if already in TS
    let torrentData;
    try {
      const listResp = await http.post(`${TS_BASE}/torrents`, {
        action: 'get', hash,
      }, { timeout: 5000 });
      const stat = listResp.data.stat;
      if (stat === 2 || stat === 3) {
        torrentData = listResp.data; // Already ready
      } else if (stat === 4) {
        // Error state — remove and re-add
        await http.post(`${TS_BASE}/torrents`, { action: 'rem', hash }, { timeout: 3000 });
        torrentData = null;
      } else {
        torrentData = listResp.data; // Exists but not ready — poll below
      }
    } catch {
      torrentData = null; // Not in TS
    }

    // Step 2: Add magnet if needed
    if (!torrentData || (torrentData.stat !== 2 && torrentData.stat !== 3)) {
      const addResp = await http.post(`${TS_BASE}/torrents`, {
        action: 'add', link: magnet, save_to_db: false,
      }, { timeout: 10000, headers: { 'Content-Type': 'application/json' } });
      torrentData = addResp.data;
    }

    // Step 3: Wait for ready
    const readyData = await waitForTorrent(hash);

    // Step 4: Auto-detect video file
    // TorrServer uses 1-based file indexes. 0/null/NaN = auto-detect.
    if (fileIdx == null || fileIdx === 0 || isNaN(fileIdx)) {
      const detectedId = autoDetectVideoFile(readyData.file_stats);
      fileIdx = detectedId || 1;  // Fallback to file 1
    }

    // Step 5: Pipe the stream
    const tsUrl = `${TS_BASE}/play/${hash}/${fileIdx}`;
    const rangeHeader = req.headers.range || '';

    const streamResp = await http.get(tsUrl, {
      timeout: 0,
      responseType: 'stream',
      validateStatus: () => true,
      headers: rangeHeader ? { Range: rangeHeader } : {},
    });

    const contentType = streamResp.headers['content-type'] || 'video/mp4';

    if (contentType.startsWith('text/')) {
      // Wrong file index — try next sequential file
      logger.warn(`TorrServer returned text for ${hash}/${fileIdx}, trying ${fileIdx + 1}`);
      const altResp = await http.get(`${TS_BASE}/play/${hash}/${fileIdx + 1}`, {
        timeout: 10000, responseType: 'stream', validateStatus: () => true,
      });
      if (!altResp.headers['content-type']?.startsWith('text/')) {
        streamResp.data.destroy();
        altResp.data.pipe(res);
        return;
      }
      res.end(); // Headers already sent, just close
      return;
    }

    res.setHeader('Content-Type', contentType);
    res.setHeader('Accept-Ranges', 'bytes');
    res.setHeader('Content-Disposition', 'inline');
    res.setHeader('Access-Control-Allow-Origin', '*');

    if (streamResp.headers['content-range']) {
      res.setHeader('Content-Range', streamResp.headers['content-range']);
      res.status(206);
    } else {
      res.status(200);
    }

    if (streamResp.headers['content-length']) {
      res.setHeader('Content-Length', streamResp.headers['content-length']);
    }

    streamResp.data.pipe(res);
  } catch (err) {
    logger.error(`TorrServer error for ${hash}: ${err.message}`);
    res.status(502).json({ error: `Stream failed: ${err.message}` });
  }
});

router.get('/torr/status/:hash', async (req, res) => {
  const hash = req.params.hash?.toLowerCase();
  if (!hash || !/^[a-f0-9]{40}$/.test(hash)) {
    return res.status(400).json({ error: 'Invalid hash' });
  }
  try {
    const resp = await http.post(`${TS_BASE}/torrents`, {
      action: 'get', hash,
    }, { timeout: 5000 });
    const data = resp.data;
    res.json({
      hash, name: data.name, stat: data.stat, stat_string: data.stat_string,
      torrent_size: data.torrent_size, total_peers: data.total_peers,
      active_peers: data.active_peers, connected_seeders: data.connected_seeders,
      download_speed: data.download_speed, bytes_written: data.bytes_written,
      file_stats: data.file_stats?.map(f => ({ id: f.id, path: f.path, length: f.length })),
    });
  } catch (err) {
    res.json({ hash, status: 'unknown', error: err.message });
  }
});

export default router;
```

## Integration with Stream Output

In the Stremio `/stream/:type/:id.json` route, only offer TorrServer URLs for streams with enough seeders:

```js
const seeders = r.seeders || 0;
const hasEnoughSeeders = seeders >= 5;  // Minimum for playable streaming
const offerTorrServer = idx < 8 && r.infoHash && hasEnoughSeeders;

return {
  name: formatted.name,
  title: r.title,
  description: formatted.description,
  infoHash: r.infoHash,
  fileIdx: r.fileIdx ?? 0,
  seeders,
  cached: false,
  ...(offerTorrServer ? {
    url: `https://${req.get('host')}/api/torr/play/${r.infoHash}/${r.fileIdx ?? 0}`,
    isFree: true,
  } : {}),
  behaviorHints: {
    bingeGroup: `provider:${r.source || 'unknown'}`,
    defaultVideo: idx === 0,
    notWebReady: !offerTorrServer,
  },
};
```

### Seeder Threshold Logic (for TorrServer P2P streaming, no debrid)

| Seeders | Offer TorrServer URL? | Rationale |
|---------|----------------------|-----------|
| ≥100 | ✅ Always (top 10 by score) | Strong swarm, ~instant start |
| ≥10 | ✅ If in top 10 | Reliable with good connectivity |
| 1-9 | ❌ No TorrServer URL (show infoHash only) | Too few peers, likely to buffer |
| 0 | ❌ Filter out entirely | Dead torrent |

## P2P Streaming Scoring (Non-Debrid)

For users without debrid, scoring must prioritize swarm health over raw quality. Key changes from debrid-focused scoring:

| Factor | Debrid weight | P2P weight | Rationale |
|--------|---------------|------------|-----------|
| Seeders | 0-8 | 0-30 | Most critical for P2P streaming |
| Resolution | 0.2× | 0.2× (1080p=90, 4K=100) | 4K still tops but 1080p close behind |
| Codec | 5-10 | 0.15× (H.264=100, HEVC=80, AV1=70) | H.264 widest HW decode compat |
| File size (ideal) | - | +15 for ideal, -15/-20 for bad | Too small=low quality, too large=buffers |
| Age | - | +10 (week) → +8 (month) → +5 (quarter) | Newer = healthier swarm |
| HDR/audio | 15-20 | 3-5 | Minor boost, not worth the bandwidth |
| Cached | +25 | +15 | Rarely cached without debrid |

### Minimum Seeders Filter

```js
const MIN_SEEDERS = 5;

function filterPlayable(results) {
  return results.filter(r => {
    const seeders = r.seeders || 0;
    const quality = r.quality || 'Unknown';
    if (seeders < MIN_SEEDERS && quality !== 'SD') return false;
    // Size sanity: reject files > 2× the max streaming size for their resolution
    const maxSize = STREAMING_SIZE[quality]?.max || Infinity;
    if (r.size > maxSize * 2) return false;
    return true;
  });
}
```

### Empirical Observations

- **Peers ≠ seeders**: TS reports `total_peers` (which includes leechers). The actual connected seeders matter most.
- **First 2-5s are slow**: Even well-seeded torrents start slow while TS negotiates. The polling loop handles this.
- **Trackers are critical**: Without the right trackers, even well-seeded torrents hang at stat=1 forever. Use all 12 trackers.
- **TorrServer cache persists**: Once a torrent plays, it stays in TS memory (4GB cache). Subsequent plays of the same hash are instant (no re-discovery delay).

## Auto-Cleanup of Stale Torrents (120min idle)

When users stop watching, there's no callback from the client. Implement a background cleanup that removes idle torrents after 120 minutes (up from 15min to prevent re-download during a session):

```js
const STALE_MINUTES = 15;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
const accessTimes = new Map();

// Record access when play endpoint is hit
router.get('/torr/play/:hash/:fileIdx?', async (req, res) => {
  accessTimes.set(hash, Date.now());
  // ... rest of handler
});

// Periodic cleanup
setInterval(async () => {
  const now = Date.now();
  const stale = [];
  for (const [hash, lastAccess] of accessTimes) {
    if (now - lastAccess > STALE_MINUTES * 60 * 1000) stale.push(hash);
  }
  if (stale.length === 0) return;
  for (const hash of stale) {
    try {
      await http.post(`${TS_BASE}/torrents`, { action: 'rem', hash }, { timeout: 5000 });
      accessTimes.delete(hash);
    } catch { accessTimes.delete(hash); }
  }
}, CLEANUP_INTERVAL_MS).unref();
```

Must pair with `RemoveCacheOnDrop: true` in settings.json so cached files are deleted from disk on removal.

## Pre-Warming TorrServer at Scrape Time (top 5 hashes)

Reduce cold-start delay by proactively adding top magnets to TorrServer when search results come in. Pre-warm **top 5 hashes** (was 3) in parallel, fire-and-forget. By the time the user clicks play (~3-10s later), the torrent is already resolving peers.

Reduce cold-start delay by proactively adding top magnets to TorrServer when search results come in. By the time the user clicks play, the torrent is already resolving peers.

Export from torr.routes.js:

```js
export async function prewarmTorrent(hash) {
  if (!hash || !/^[a-f0-9]{40}$/.test(hash)) return false;
  try {
    const check = await http.post(`${TS_BASE}/torrents`, { action: 'get', hash }, { timeout: 3000 });
    if (check.data.stat === 2 || check.data.stat === 3) return true;
  } catch { /* not in TS */ }
  try {
    const magnet = buildMagnet(hash);
    await http.post(`${TS_BASE}/torrents`, { action: 'add', link: magnet, save_to_db: false }, { timeout: 5000 });
    return true;
  } catch { return false; }
}
```

Call from scrape route (fire-and-forget, don't await):

```js
const topHashes = capped.slice(0, 3).filter(r => r.infoHash).map(r => r.infoHash);
for (const hash of topHashes) prewarmTorrent(hash).catch(() => {});
```

Cold-start TTFB drops from ~12s (wait for TS add+poll in handler) to ~3.4s (headers immediate, poll running). After pre-warming, same-hash TTFB is **0.013s**.

## Admin Endpoint: List Active Torrents

```js
router.get('/torr/list', async (req, res) => {
  const resp = await http.post(`${TS_BASE}/torrents`, { action: 'list' }, { timeout: 5000 });
  const list = (resp.data || []).map(t => ({
    hash: t.hash, name: t.name, stat: t.stat, stat_string: t.stat_string,
    torrent_size: t.torrent_size, loaded_size: t.loaded_size,
    progress_pct: t.torrent_size > 0 ? Math.round((t.loaded_size / t.torrent_size) * 100) : 0,
    download_speed: t.download_speed, total_peers: t.total_peers,
    active_peers: t.active_peers, connected_seeders: t.connected_seeders,
    files: t.file_stats?.map(f => ({ id: f.id, path: f.path, size: f.length })),
  }));
  res.json({ torrents: list, count: list.length });
});
```

Available at `/api/torr/list`. Shows download progress, speed, peers for each active torrent.
