跳至主要內容

NIP-35: Torrents

在 Nostr 上分享和發現 BitTorrent 種子

概述

NIP-35 定義了在 Nostr 上分享 BitTorrent 種子(Torrents)的標準格式。 使用者可以發布種子資訊,其他人可以搜尋、訂閱和下載, 實現去中心化的種子分享和發現。

事件類型

Kind 說明 類型
2003 種子 (Torrent) 一般

事件結構

Content

事件的 content 欄位包含種子的描述文字,可以使用 Markdown 格式。

必要標籤

標籤 說明 範例
title 種子標題 ["title", "Ubuntu 24.04 LTS"]
x Info Hash (v1) ["x", "abc123..."]

可選標籤

標籤 說明 範例
file 檔案資訊(名稱、大小) ["file", "ubuntu.iso", "4700000000"]
tracker Tracker URL ["tracker", "udp://tracker.example.com:6969"]
i Info Hash (v2) ["i", "btih:abc123..."]
t 主題標籤 ["t", "linux"]
image 預覽圖片 ["image", "https://..."]

可以從事件的標籤中組合成 Magnet URI:

magnet:?xt=urn:btih:<info-hash>&dn=<title>&tr=<tracker>

範例

開源軟體種子

{
  "kind": 2003,
  "content": "Ubuntu 24.04 LTS (Noble Numbat) 官方 ISO 映像檔。\n\n## 特點\n- 長期支援版本(5年支援)\n- GNOME 46 桌面環境\n- Linux 內核 6.8\n\n請從官方來源驗證 SHA256 校驗碼。",
  "tags": [
    ["title", "Ubuntu 24.04 LTS Desktop (64-bit)"],
    ["x", "abc123def456789..."],
    ["file", "ubuntu-24.04-desktop-amd64.iso", "5748072448"],
    ["tracker", "udp://tracker.ubuntu.com:6969"],
    ["tracker", "udp://tracker.opentrackr.org:1337"],
    ["t", "linux"],
    ["t", "ubuntu"],
    ["t", "開源"],
    ["t", "作業系統"],
    ["image", "https://ubuntu.com/wp-content/uploads/ubuntu-logo.png"]
  ]
}

多檔案種子

{
  "kind": 2003,
  "content": "Bitcoin 核心客戶端 v27.0 所有平台版本。\n\n包含 Windows、macOS、Linux 版本及簽名檔。",
  "tags": [
    ["title", "Bitcoin Core v27.0"],
    ["x", "xyz789..."],
    ["file", "bitcoin-27.0-win64.zip", "45000000"],
    ["file", "bitcoin-27.0-x86_64-linux-gnu.tar.gz", "42000000"],
    ["file", "bitcoin-27.0-osx64.tar.gz", "38000000"],
    ["file", "SHA256SUMS.asc", "5000"],
    ["tracker", "udp://tracker.opentrackr.org:1337"],
    ["t", "bitcoin"],
    ["t", "軟體"],
    ["t", "區塊鏈"]
  ]
}

TypeScript 實作

定義類型

interface TorrentFile {
  name: string;
  size: number;
}

interface Torrent {
  title: string;
  description: string;
  infoHash: string;
  infoHashV2?: string;
  files: TorrentFile[];
  trackers: string[];
  tags?: string[];
  image?: string;
}

建立種子事件

import { finalizeEvent } from 'nostr-tools';

function createTorrentEvent(
  torrent: Torrent,
  secretKey: Uint8Array
) {
  const tags: string[][] = [
    ['title', torrent.title],
    ['x', torrent.infoHash],
  ];

  if (torrent.infoHashV2) {
    tags.push(['i', `btih:${torrent.infoHashV2}`]);
  }

  torrent.files.forEach((file) => {
    tags.push(['file', file.name, file.size.toString()]);
  });

  torrent.trackers.forEach((tracker) => {
    tags.push(['tracker', tracker]);
  });

  torrent.tags?.forEach((tag) => {
    tags.push(['t', tag]);
  });

  if (torrent.image) {
    tags.push(['image', torrent.image]);
  }

  const event = {
    kind: 2003,
    content: torrent.description,
    tags,
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例
const torrentEvent = createTorrentEvent(
  {
    title: 'Debian 12 Bookworm',
    description: 'Debian 12 官方 ISO,穩定版本。',
    infoHash: 'abc123def456...',
    files: [
      { name: 'debian-12-amd64-netinst.iso', size: 600000000 },
    ],
    trackers: [
      'udp://tracker.debian.org:6969',
      'udp://tracker.opentrackr.org:1337',
    ],
    tags: ['linux', 'debian', '開源'],
  },
  secretKey
);

解析種子事件

interface ParsedTorrent {
  title: string;
  description: string;
  infoHash: string;
  infoHashV2?: string;
  files: TorrentFile[];
  trackers: string[];
  tags: string[];
  image?: string;
  magnetUri: string;
  totalSize: number;
}

function parseTorrentEvent(event: any): ParsedTorrent | null {
  if (event.kind !== 2003) return null;

  const getTag = (name: string) => {
    const tag = event.tags.find((t: string[]) => t[0] === name);
    return tag?.[1];
  };

  const getAllTags = (name: string) => {
    return event.tags
      .filter((t: string[]) => t[0] === name)
      .map((t: string[]) => t.slice(1));
  };

  const title = getTag('title');
  const infoHash = getTag('x');

  if (!title || !infoHash) return null;

  const files = getAllTags('file').map((f: string[]) => ({
    name: f[0],
    size: parseInt(f[1] || '0'),
  }));

  const trackers = getAllTags('tracker').map((t: string[]) => t[0]);
  const tags = getAllTags('t').map((t: string[]) => t[0]);

  const totalSize = files.reduce((sum, f) => sum + f.size, 0);

  // 建立 Magnet URI
  let magnetUri = `magnet:?xt=urn:btih:${infoHash}`;
  magnetUri += `&dn=${encodeURIComponent(title)}`;
  trackers.forEach((tracker) => {
    magnetUri += `&tr=${encodeURIComponent(tracker)}`;
  });

  const iTag = event.tags.find((t: string[]) => t[0] === 'i');
  const infoHashV2 = iTag?.[1]?.replace('btih:', '');

  return {
    title,
    description: event.content,
    infoHash,
    infoHashV2,
    files,
    trackers,
    tags,
    image: getTag('image'),
    magnetUri,
    totalSize,
  };
}

// 使用範例
const torrent = parseTorrentEvent(event);
if (torrent) {
  console.log(`標題: ${torrent.title}`);
  console.log(`大小: ${formatBytes(torrent.totalSize)}`);
  console.log(`Magnet: ${torrent.magnetUri}`);
}

搜尋種子

import { SimplePool } from 'nostr-tools';

interface TorrentSearchOptions {
  tags?: string[];
  author?: string;
  limit?: number;
}

async function searchTorrents(
  pool: SimplePool,
  relays: string[],
  options: TorrentSearchOptions = {}
): Promise<ParsedTorrent[]> {
  const filter: any = {
    kinds: [2003],
    limit: options.limit || 50,
  };

  if (options.author) {
    filter.authors = [options.author];
  }

  if (options.tags && options.tags.length > 0) {
    filter['#t'] = options.tags;
  }

  const events = await pool.querySync(relays, filter);

  return events
    .map(parseTorrentEvent)
    .filter((t): t is ParsedTorrent => t !== null)
    .sort((a, b) => b.totalSize - a.totalSize);
}

// 使用範例
const torrents = await searchTorrents(pool, relays, {
  tags: ['linux', '開源'],
  limit: 20,
});

格式化工具

function formatBytes(bytes: number): string {
  if (bytes === 0) return '0 B';

  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  const k = 1024;
  const i = Math.floor(Math.log(bytes) / Math.log(k));

  return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`;
}

function copyMagnetLink(magnetUri: string): void {
  navigator.clipboard.writeText(magnetUri);
}

React 元件

interface TorrentCardProps {
  torrent: ParsedTorrent;
}

function TorrentCard({ torrent }: TorrentCardProps) {
  const handleCopyMagnet = () => {
    navigator.clipboard.writeText(torrent.magnetUri);
    // 顯示提示
  };

  return (
    <div className="torrent-card">
      {torrent.image && (
        <img src={torrent.image} alt={torrent.title} className="preview" />
      )}
      <div className="info">
        <h3>{torrent.title}</h3>
        <p className="description">{torrent.description}</p>
        <div className="meta">
          <span className="size">{formatBytes(torrent.totalSize)}</span>
          <span className="files">{torrent.files.length} 個檔案</span>
        </div>
        <div className="tags">
          {torrent.tags.map((tag) => (
            <span key={tag} className="tag">#{tag}</span>
          ))}
        </div>
        <div className="actions">
          <a href={torrent.magnetUri} className="magnet-btn">
            🧲 開啟 Magnet
          </a>
          <button onClick={handleCopyMagnet}>複製連結</button>
        </div>
        <div className="files-list">
          <h4>檔案列表</h4>
          {torrent.files.map((file, i) => (
            <div key={i} className="file">
              <span className="name">{file.name}</span>
              <span className="size">{formatBytes(file.size)}</span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

使用場景

開源軟體分發

  • Linux 發行版 ISO
  • 開源應用程式
  • 區塊鏈客戶端

內容創作

  • 創用 CC 授權內容
  • 公共領域作品
  • 教育資源

數據集分享

  • 公開數據集
  • 研究資料
  • 歷史存檔

最佳實踐

  • 提供多個 Tracker:增加下載可用性
  • 詳細描述:說明內容和來源
  • 標籤分類:幫助搜尋和發現
  • 提供校驗碼:在描述中包含 SHA256
  • 合法內容:只分享合法授權的內容
  • NIP-01:基本協議 - 事件格式
  • NIP-94:檔案中繼資料 - 檔案資訊
  • NIP-96:檔案儲存 - 檔案上傳

參考資源

已複製連結
已複製到剪貼簿