Torrent文件的解析與轉換

Torrent簡介

BitTorrent協議的種子文件(英語:Torrent file)可以保存一組文件的元數據。這種格式的文件被BitTorrent協議所定義。擴展名一般為「.torrent」。

.torrent種子文件本質上是文本文件,包含Tracker資訊和文件資訊兩部分。Tracker資訊主要是BT下載中需要用到的Tracker伺服器的地址和針對Tracker伺服器的設置,文件資訊是根據對目標文件的計算生成的,計算結果根據BitTorrent協議內的Bencode規則進行編碼。它的主要原理是需要把提供下載的文件虛擬分成大小相等的塊,塊大小必須為2k的整數次方(由於是虛擬分塊,硬碟上並不產生各個塊文件),並把每個塊的索引資訊和Hash驗證碼寫入種子文件中;所以,種子文件就是被下載文件的「索引」。

Torrent結構

Torrent文件內容都已Bencoding編碼類型進行存儲,整體上是一個字典結構,見下:

Torrent總體結構

鍵名稱 數據類型 可選項 鍵值含義
announce string required Tracker的Url
info dictionary required 該條映射到一個字典,該字典的鍵將取決於共享的一個或多個文件
announce-list array[] optional 備用Tracker的Url,以列表形式存在
comment string optional 備註
created by string optional 創建人或創建程式的資訊

Torrent單文件Info結構

鍵名稱 數據類型 可選項 鍵值含義
name string required 建議保存到的文件名稱
piceces byte[] required 每個文件塊的SHA-1的集成Hash。
piece length long required 每個文件塊的位元組數

Torrent多文件Info結構

鍵名稱 數據類型 可選項 鍵值含義
name string required 建議保存到的目錄名稱
piceces byte[] required 每個文件塊的SHA-1的集成Hash。
piece length long required 每個文件塊的位元組數
files array[] required 文件列表,列表存儲的內容是字典結構

files字典結構:

鍵名稱 數據類型 可選項 鍵值含義
path array[] required 一個對應子目錄名的字元串列表,最後一項是實際的文件名稱
length long required 文件的大小(以位元組為單位)

Torrent實際結構預覽

JSON序列化整個字典後,單文件和多文件的結構大致如下,注意:JSON內容省略了pieces摘要大部分內容,僅展示了開頭部分,另外由於本人序列化工具設置所致,所有的整型都會序列化成字元串類型。

  • 單文件結構

    {
        "creation date": "1581674765",
        "comment": "dynamic metainfo from client",
        "announce-list": [
            [
                "udp://tracker.leechers-paradise.org:6969/announce"
            ],
            [
                "udp://tracker.internetwarriors.net:1337/announce"
            ],
            [
                "udp://tracker.opentrackr.org:1337/announce"
            ],
            [
                "udp://tracker.coppersurfer.tk:6969/announce"
            ],
            [
                "udp://tracker.pirateparty.gr:6969/announce"
            ]
        ],
        "created by": "go.torrent",
        "announce": "udp://tracker.leechers-paradise.org:6969/announce",
        "info": {
            "pieces": "レJᅯ\ufff4ᅯ*f\nᄍ\ufff0... ...",
            "length": "54358058387",
            "name": "Frozen.II.2019.BDREMUX.2160p.HDR.seleZen.mkv",
            "piece length": "16777216"
        }
    }
    
  • 多文件結構

{
    "creation date": "1604347014",
    "comment": "Torrent downloaded from //YTS.MX",
    "announce-list": [
        [
            "udp://tracker.coppersurfer.tk:6969/announce"
        ],
        [
            "udp://9.rarbg.com:2710/announce"
        ],
        [
            "udp://p4p.arenabg.com:1337"
        ],
        [
            "udp://tracker.internetwarriors.net:1337"
        ],
        [
            "udp://tracker.opentrackr.org:1337/announce"
        ]
    ],
    "created by": "YTS.AG",
    "announce": "udp://tracker.coppersurfer.tk:6969/announce",
    "info": {
        "pieces": "ᆲimᅬヒ\u000b*゚ᆲト... ...",
        "name": "Love And Monsters (2020) [2160p] [4K] [WEB] [5.1] [YTS.MX]",
        "files": [
            {
                "path": [
                    "Love.And.Monsters.2020.2160p.4K.WEB.x265.10bit.mkv"
                ],
                "length": "5215702961"
            },
            {
                "path": [
                    "www.YTS.MX.jpg"
                ],
                "length": "53226"
            }
        ],
        "piece length": "524288"
    }
}

Torrent文件編碼

根據上文所說,Torrent文件均以Bencoding編碼進行存儲,故我們需要大致了解一下Bencoding編碼。

Bencoding以四種基本類型數據構成:

  • string : 字元串
  • intergers : 整數類型
  • lists:列表類型
  • dictionary:字典類型

字元串類型

字元串類型由以下結構表示:字元串長度:字元串原文,例如:42:udp://tracker.pirateparty.gr:6969/announce

整形類型

整型類型由以下結構表示:i<整形數據>e,例如i1234e,則表明的整形數據為1234。

列表類型

列表類型由以下結構表示:l<列表數據>e,即列表以字母l開頭,以字母e結束,中間的均為列表中的數據,中間的值可以為任意的四種類型之一。

字典類型

字典類型由以下結構表示:d<字典數據>e,即字典由字母d開頭,以字母e結束,中間的均為字典中的數據,中間的值可以為任意的四種類型之一。

實際組合解析

根據上述描述來看看實際的內容解析,我們以下方的數據為例:

d8:announce49:udp://tracker.leechers-paradise.org:6969/announce13:announce-listll49:udp://tracker.leechers-paradise.org:6969/announceel48:udp://tracker.internetwarriors.net:1337/announceeee

大家可以先嘗試根據上面的內容對這一串內容進行解析,我將這一串數據拆分開來方便大家理解和查看,可以明顯看出其由一個擁有兩個鍵值的字典,其中一個鍵為announce,另一個鍵為announce-list,兩者的值一個為udp://tracker.leechers-paradise.org:6969/announce,一個為列表,列表內還嵌套了一層列表。

d
  8:announce  
  49:udp://tracker.leechers-paradise.org:6969/announce
  13:announce-list
      l
         l
           49:udp://tracker.leechers-paradise.org:6969/announce
         e
         l
           48:udp://tracker.internetwarriors.net:1337/announce
         e
      e
e

Torrent文件解析

根據上文對Torrent文件編碼的了解,那麼我們使用程式碼對Torrent文件就很簡單了。我們只需要讀取種子位元組流,判斷具體是哪種類型並進行相應轉換即可。

即:讀取文件位元組,判斷位元組屬於哪一種類型:0-9 : 字元串類型、i:整形數據、l:列表數據、d:字典數據

再根據每個數據具體類型獲取該數據的內容,再讀取下一個文件位元組獲取下一個數據類型即可,根據這個分析,偽程式碼如下:

獲取字元串值

// 當讀取到位元組對應的內容為0-9時進入該方法
String readString(byte[] info,int offset) {
	// 讀取『:』以前的數據,即字元串長度
	int length = readLength(info,offset);
	// 根據字元串長度,獲取實際字元串內容
    string data = readData(info,length,offset);
    // 返回讀取到的字元串內容,整個讀取過程中讀過的偏移量要累加到offset
    return data;
}

獲取整數類型

這裡有一個注意項,考慮到數據邊界問題,例如java等語言,推薦使用Long類型,以防數據越界。

// 當讀取到的位元組對應的內容為i時,進入該方法
Long readInt(byte[] info,int offset) {
	// 讀取第一個'e'之前的數據,包括'e'
    string data = readInt(info,offset)
    return Long.valueOf(data);
}

獲取列表類型

因為列表類型中可以夾雜所有四種類型中任意要給即需要用到上面兩個方法。

// 當讀取到的位元組對應的內容為l時,進入該方法
List readList(byte[] info,int offset){
    List list = new List();
    // 讀取到第一個'e'為止
    while(info[offset] != 'e'){
        swtich(info[offset]){
            // 如果是列表,讀取列表並向列表添加
            case 'l':
              list.add(readList(info,offset));
              break;
            // 如果是字典,讀取字典並向列表添加
            case 'd':
              list.add(readDictionary(info,offset));
              break;
            // 如果是整形數據,讀取數據並向列表添加
            case 'i':
              list.add(readInt(info,offset));
              break;
            // 如果是字元串,讀取字元串數據並向列表添加
            case '0-9':
              list.add(readString(info,offset));
        }
    }
    // offset向前移一位,把列表的結束符'e'移動為已讀
    offset++;
    return list;
}

讀取字典類型

讀取字典類型與列表十分相似,唯一不同的就是需要區分鍵值,字典的鍵只可能為字元串,故依次來判斷。

// 當讀取到的位元組對應的內容為d時,進入該方法
Dictionary readDictionary(byte[] info,int offset){
    Dictionary dic = new Dictionary();
    // key為null時,字元串為鍵,否則為值 
    String key = null;
    // 讀取到第一個'e'為止
    while(info[offset] != 'e'){
        swtich(info[offset]){
            // 如果是列表,讀取列表並向字典添加,添加列表時肯定存在鍵,直接添加並將鍵置空
            case 'l':
              dic.put(key,readList(info,offset));
              key = null;
              break;
            // 如果是字典,讀取字典並向字典添加,添加字典時肯定存在鍵,直接添加並將鍵置空
            case 'd':
              dic.put(key,readDictionary(info,offset));
              key = null;
              break;
            // 如果是整形數據,讀取數據並向字典添加,添加整形數據時肯定存在鍵,直接添加並將鍵置空
            case 'i':
              dic.put(key,readInt(info,offset));
              key = null;
              break;
            // 如果是字元串
            case '0-9':
              string data = readString(info,offset);
              // key為null時,字元串為鍵,否則為值 
              if(key == null){
                  key = data;
              }else{
                  dic.put(key,data);
                  key = null;
              }
        }
    }
    // offset向前移一位,把列表的結束符'e'移動為已讀
    offset++;
    return dic;
}

Torrent文件與Magnet

磁力鏈接與Torrent文件是可以相互轉換的,此文只討論根據Torrent文件如何轉換為Magnet磁力鏈接。

Magnet概述

磁力鏈接由一組參數組成,參數間的順序沒有講究,其格式與在HTTP鏈接末尾的查詢字元串相同。最常見的參數是”xt”,是”exact topic”的縮寫,通常是一個特定文件的內容散列函數值形成的URN,例如:

magnet:?xt=urn:bith:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C

注意,雖然這個鏈接指向一個特定文件,但是客戶端應用程式仍然必須進行搜索來確定哪裡,如果有,能夠獲取那個文件(即通過DHT進行搜索,這樣就實現了Magnet到Torrent的轉換,本文不討論)。

部分欄位名見下方表格:

欄位名 含義
magnet 協議名
xt exact topic的縮寫,包含文件哈希值的統一資源名稱。BTIH(BitTorrent Info Hash)表示哈希方法名,這裡還可以使用ED2K,AICH,SHA1和MD5等。這個值是文件的標識符,是不可缺少的。
dn display name的縮寫,表示向用戶顯示的文件名。這一項是選填的。
tr tracker的縮寫,表示tracker伺服器的地址。這一項也是選填的。
bith BitTorrent info hash,種子散列函數

Torrent轉換為Magnet

  • dn : 向用戶顯示的文件名

即為Torrent文件中,Info字典下的name鍵所對應的值

  • tr : tracker伺服器地址

即為Torrent文件中,announce以及announce-list兩個鍵所對應的值

  • bitch : 種子散列值

即為Torrent文件中,info對應的字典的SHA1哈希值(Hex)
根據下圖,為4:infod,以d的地址作為哈希原文的起始索引,則為Adress:00 01A3
開始offset
到整個info結束,以e的地址作為哈希原文的終止索引地址,則為Adress:03 0BE7
結束offset

根據上述可知:

magnet = 'magnet:?xt=urn:btih:'+Hex(Sha1(info))+'&dn='+encode(name)+'&tr='+encode(announce)

結合上一部分的實現,我們可以在讀取info時記錄startindex和endindex,即:

Dictionary readDictionary(byte[] info,int offset){
	//...
    case 'd':
      bool record = key == 'info';
      if(record){
          startindex = offset;
      }
      readDictoinary(info,offset);
      if(record){
          endindex = offset
      }
}

string getBith(byte[] info,int start,int end){
  // 獲取info中從start到end的位元組數組,並對其進行摘要計算
  byte[] infoByte = new byte[infoEnd - infoStart + 1];
  System.arraycopy(torrentBytes, infoStart, infoByte, 0, infoEnd - infoStart + 1);
  return Hex.toHex(Sha1.toSha1(infoByte));
}

具體實現

本人通過Java實現了以上部分邏輯(Torrent文件解析以及Magnet鏈接生成),若有需要參考的讀者可以到以下網址獲取相關內容:

工具類目錄://github.com/Rekent/common-utils/tree/master/src/main/java/com/rekent/tools/utils/torrent

解析類源碼://github.com/Rekent/common-utils/blob/master/src/main/java/com/rekent/tools/utils/torrent/TorrentFileResovler.java

依賴jar包://github.com/Rekent/common-utils/releases/tag/v0.0.3

調用方式:

public void testResolve() throws Exception {
		String path = "C:\\Users\\Refkent\\Downloads\\Test.torrent";
		TorrentFile torrentFile = TorrentFileUtils.resolve(path);
		System.out.println(torrentFile.print());
		System.out.println(torrentFile.getHash());
		System.out.println(torrentFile.getMagnetUri());
}

Reference