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