在开发BitTorrent相关应用时,准确解析.torrent文件并计算InfoHash是基础需求。本文介绍如何使用Java正确处理种子文件,特别是解决InfoHash计算中常见的编码问题,以及如何管理Tracker列表。

依赖配置

首先引入Bencode库处理种子文件的编码/解码:

<dependency>
    <groupId>com.dampcake</groupId>
    <artifactId>bencode</artifactId>
    <version>1.4.2</version>
</dependency>

InfoHash计算核心问题

种子文件InfoHash计算的关键在于字符集选择。虽然常规解析可使用UTF-8,但计算InfoHash必须使用ISO-8859-1(Latin-1)编码,否则会得到错误的哈希值。

完整代码实现

import com.dampcake.bencode.Bencode;
import com.dampcake.bencode.Type;

import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 种子文件解析工具类
 * 支持解析种子信息、计算InfoHash、修改Tracker等功能
 */
public class TorrentInfoHashCalculator {
    public static void main(String[] args) {
        try {
            // 测试用的种子文件路径
            File torrentFile = new File("课件.torrent");

            // 1. 解析并显示种子信息
            System.out.println("==== 1. 解析种子信息 ====");
            displayTorrentInfo(torrentFile);

            // 2. 计算InfoHash
            System.out.println("\n==== 2. 计算InfoHash ====");
            String infoHash = calculateInfoHash(torrentFile);
            System.out.println("InfoHash (大写): " + infoHash.toUpperCase());

            // 3. 修改Tracker的passkey
            System.out.println("\n==== 3. 修改Tracker的passkey ====");
            String passkey = "new-passkey-123456";
            File modifiedTorrentFile = modifyTorrentTracker(torrentFile, passkey);

            // 4. 显示修改后的种子信息
            System.out.println("\n==== 4. 修改后的种子信息 ====");
            displayTorrentInfo(modifiedTorrentFile);

            // 5. 验证修改后的InfoHash (应该与原始InfoHash相同)
            System.out.println("\n==== 5. 验证修改后的InfoHash ====");
            String newInfoHash = calculateInfoHash(modifiedTorrentFile);
            System.out.println("修改后的InfoHash (大写): " + newInfoHash.toUpperCase());
            System.out.println("InfoHash是否匹配: " + infoHash.equalsIgnoreCase(newInfoHash));

            // 6. 按层级添加Tracker
            System.out.println("\n==== 6. 按层级添加Tracker ====");

            // 创建分层的Tracker列表
            List<List<String>> trackerTiers = new ArrayList<>();

            // 第0层 (最高优先级)
            List<String> tier0 = new ArrayList<>();
            tier0.add("http://111.111.111.111:6969/new-passkey-123456/announce");
            trackerTiers.add(tier0);

            // 空行,代表层级分隔
            trackerTiers.add(new ArrayList<>());

            // 第1层
            List<String> tier1 = new ArrayList<>();
            tier1.add("http://tracker1.example.com/your-passkey/announce");
            tier1.add("http://tracker2.example.com/your-passkey/announce");
            tier1.add("http://tracker3.example.com/your-passkey/announce");
            trackerTiers.add(tier1);

            // 添加分层Tracker
            File tieredTrackersFile = addTrackersByTier(torrentFile, trackerTiers);

            // 7. 显示添加层级Tracker后的种子信息
            System.out.println("\n==== 7. 添加层级Tracker后的种子信息 ====");
            displayTorrentInfo(tieredTrackersFile);

            // 8. 生成磁力链接
            System.out.println("\n==== 8. 生成磁力链接 ====");
            String magnetLink = generateMagnetLink(torrentFile);
            System.out.println("磁力链接: " + magnetLink);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 解析并显示种子文件信息 - 支持中文
     */
    public static void displayTorrentInfo(File torrentFile) throws Exception {
        // 使用ISO-8859-1字符集读取种子文件,确保二进制数据完整性
        byte[] torrentData = Files.readAllBytes(torrentFile.toPath());
        Bencode bencode = new Bencode(StandardCharsets.ISO_8859_1);
        Map<String, Object> dict = bencode.decode(torrentData, Type.DICTIONARY);

        System.out.println("种子文件: " + torrentFile.getName());

        // 显示基本信息
        if (dict.containsKey("announce")) {
            System.out.println("主Tracker: " + dict.get("announce"));
        }

        if (dict.containsKey("created by")) {
            System.out.println("创建者: " + dict.get("created by"));
        }

        if (dict.containsKey("creation date")) {
            long creationDate = (Long) dict.get("creation date");
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            String dateStr = sdf.format(new Date(creationDate * 1000));
            System.out.println("创建日期: " + dateStr + " (" + creationDate + ")");
        }

        if (dict.containsKey("comment")) {
            String comment = (String) dict.get("comment");
            System.out.println("注释: " + isoToUtf8(comment));
        }

        // 显示Tracker列表
        if (dict.containsKey("announce-list")) {
            System.out.println("\nTracker列表:");
            List<List<Object>> announceList = (List<List<Object>>) dict.get("announce-list");
            int tierIndex = 0; // 使用单独的变量来追踪层级索引

            for (int i = 0; i < announceList.size(); i++) {
                List<Object> tier = announceList.get(i);
                if (tier.isEmpty()) {
                    System.out.println("  [空行 - 层级分隔符]");
                } else {
                    System.out.println("  层级 " + tierIndex + ":");
                    for (Object announce : tier) {
                        System.out.println("    - " + announce);
                    }
                    tierIndex++; // 只有实际的Tracker层级才增加索引
                }
            }
        }

        // 获取info字典信息
        Map<String, Object> info = (Map<String, Object>) dict.get("info");
        System.out.println("\n文件信息:");

        // 处理名称,优先使用UTF-8编码的名称字段
        String name;
        if (info.containsKey("name.utf-8")) {
            name = (String) info.get("name.utf-8");
        } else {
            String nameISO = (String) info.get("name");
            name = isoToUtf8(nameISO);
        }
        System.out.println("名称: " + name);

        if (info.containsKey("piece length")) {
            long pieceLength = (Long) info.get("piece length");
            System.out.println("分块大小: " + pieceLength + " 字节 (" + formatFileSize(pieceLength) + ")");
        }

        if (info.containsKey("pieces")) {
            // pieces包含所有文件块的SHA-1哈希值
            Object pieces = info.get("pieces");
            if (pieces instanceof byte[]) {
                byte[] piecesBytes = (byte[]) pieces;
                int pieceCount = piecesBytes.length / 20; // 每个SHA-1哈希值为20字节
                System.out.println("文件块数量: " + pieceCount);
            } else if (pieces instanceof String) {
                String piecesStr = (String) pieces;
                int pieceCount = piecesStr.length() / 20;
                System.out.println("文件块数量: " + pieceCount);
            }
        }

        // 检查是否为私有种子
        if (info.containsKey("private") && ((Number)info.get("private")).intValue() == 1) {
            System.out.println("私有种子: 是 (禁用DHT、PEX和LSD)");
        } else {
            System.out.println("私有种子: 否");
        }

        // 区分单文件和多文件种子
        if (info.containsKey("length")) {
            // 单文件种子
            long fileSize = (Long) info.get("length");
            System.out.println("种子类型: 单文件");
            System.out.println("文件大小: " + fileSize + " 字节 (" + formatFileSize(fileSize) + ")");
        } else if (info.containsKey("files")) {
            // 多文件种子
            List<Map<String, Object>> files = (List<Map<String, Object>>) info.get("files");
            long totalSize = 0;

            System.out.println("种子类型: 多文件");
            System.out.println("文件数量: " + files.size());

            System.out.println("\n文件列表:");
            int count = 0;
            for (Map<String, Object> file : files) {
                if (count++ >= 5 && files.size() > 6) {
                    System.out.println("... 还有 " + (files.size() - 5) + " 个文件未显示 ...");
                    break;
                }

                long fileSize = (Long) file.get("length");
                totalSize += fileSize;

                // 优先使用UTF-8编码的路径字段
                List<Object> pathElements;
                if (file.containsKey("path.utf-8")) {
                    pathElements = (List<Object>) file.get("path.utf-8");
                } else {
                    pathElements = (List<Object>) file.get("path");
                }

                StringBuilder pathBuilder = new StringBuilder();
                for (Object element : pathElements) {
                    if (pathBuilder.length() > 0) {
                        pathBuilder.append("/");
                    }
                    // 转换路径元素为UTF-8
                    String pathPart = isoToUtf8((String) element);
                    pathBuilder.append(pathPart);
                }

                System.out.println("  - " + pathBuilder.toString() + " (" + formatFileSize(fileSize) + ")");
            }

            System.out.println("\n总大小: " + totalSize + " 字节 (" + formatFileSize(totalSize) + ")");
        }
    }

    /**
     * 计算种子文件的InfoHash
     */
    public static String calculateInfoHash(File torrentFile) throws Exception {
        byte[] torrentData = Files.readAllBytes(torrentFile.toPath());

        // 使用ISO-8859-1字符集解析种子文件
        Bencode bencode = new Bencode(StandardCharsets.ISO_8859_1);
        Map<String, Object> dict = bencode.decode(torrentData, Type.DICTIONARY);

        // 提取info字典
        Map<String, Object> info = (Map<String, Object>) dict.get("info");

        // 对info字典进行bencode编码
        byte[] encodedInfo = bencode.encode(info);

        // 计算SHA-1哈希
        return sha1(encodedInfo);
    }

    /**
     * 修改种子文件的Tracker (更新passkey)
     */
    public static File modifyTorrentTracker(File torrentFile, String passkey) throws Exception {
        // 读取种子文件
        byte[] torrentData = Files.readAllBytes(torrentFile.toPath());

        // 使用ISO-8859-1字符集解析种子文件
        Bencode bencode = new Bencode(StandardCharsets.ISO_8859_1);
        Map<String, Object> dict = bencode.decode(torrentData, Type.DICTIONARY);

        // 获取原始announce URL
        String originalAnnounce = (String) dict.get("announce");
        System.out.println("原始主Tracker: " + originalAnnounce);

        // 修改announce URL
        if (originalAnnounce != null) {
            String newAnnounce = updatePasskey(originalAnnounce, passkey);
            dict.put("announce", newAnnounce);
            System.out.println("新的主Tracker: " + newAnnounce);
        }

        // 同时更新announce-list (如果存在)
        if (dict.containsKey("announce-list")) {
            List<List<Object>> announceList = (List<List<Object>>) dict.get("announce-list");
            System.out.println("\n更新Tracker列表:");

            for (int i = 0; i < announceList.size(); i++) {
                List<Object> tier = announceList.get(i);
                for (int j = 0; j < tier.size(); j++) {
                    String announceUrl = (String) tier.get(j);

                    // 只更新与原始announce URL类似的URL
                    if (isSimilarTracker(originalAnnounce, announceUrl)) {
                        String newUrl = updatePasskey(announceUrl, passkey);
                        tier.set(j, newUrl);
                        System.out.println("  从: " + announceUrl);
                        System.out.println("  到: " + newUrl);
                    }
                }
            }
        }

        // 保存修改后的种子文件
        String fileName = torrentFile.getName();
        String baseName = fileName.substring(0, fileName.lastIndexOf('.'));
        String extension = fileName.substring(fileName.lastIndexOf('.'));
        File outputFile = new File(torrentFile.getParent(), baseName + "_modified" + extension);

        // 重新编码并保存
        byte[] modifiedTorrentData = bencode.encode(dict);
        Files.write(outputFile.toPath(), modifiedTorrentData);

        System.out.println("\n修改后的种子文件已保存为: " + outputFile.getName());
        return outputFile;
    }

    /**
     * 向种子文件添加分层级的Tracker URL
     *
     * @param torrentFile 要修改的种子文件
     * @param trackerTiers 分层级的Tracker列表,每个子列表代表一个层级
     * @return 修改后的种子文件
     */
    public static File addTrackersByTier(File torrentFile, List<List<String>> trackerTiers) throws Exception {
        // 读取种子文件
        byte[] torrentData = Files.readAllBytes(torrentFile.toPath());

        // 使用ISO-8859-1字符集解析种子文件
        Bencode bencode = new Bencode(StandardCharsets.ISO_8859_1);
        Map<String, Object> dict = bencode.decode(torrentData, Type.DICTIONARY);

        // 获取原始announce URL
        String originalAnnounce = (String) dict.get("announce");
        System.out.println("原始主Tracker: " + originalAnnounce);

        // 准备announce-list - 不预先添加任何tier
        List<List<Object>> announceList = new ArrayList<>();

        // 遍历并添加所有tier
        System.out.println("\n按层级添加Tracker:");
        for (int i = 0; i < trackerTiers.size(); i++) {
            List<String> tierTrackers = trackerTiers.get(i);

            if (tierTrackers.isEmpty()) {
                // 空行代表层级分隔符 - 但我们不需要添加任何东西到announceList
                System.out.println("层级分隔符");
                continue; // 跳过空行,不添加到announceList
            }

            // 为非空tier创建一个新的tier列表
            List<Object> newTier = new ArrayList<>();
            System.out.println("层级 " + i + ":");

            for (String tracker : tierTrackers) {
                newTier.add(tracker);
                System.out.println("  + " + tracker);
            }

            // 添加非空tier到announceList
            if (!newTier.isEmpty()) {
                announceList.add(newTier);
            }
        }

        // 如果第一个tier非空,设置主announce
        if (!announceList.isEmpty() && !announceList.get(0).isEmpty()) {
            String mainAnnounce = (String) announceList.get(0).get(0);
            dict.put("announce", mainAnnounce);
            System.out.println("新的主Tracker: " + mainAnnounce);
        }

        // 更新announce-list
        dict.put("announce-list", announceList);

        // 保存修改后的种子文件
        String fileName = torrentFile.getName();
        String baseName = fileName.substring(0, fileName.lastIndexOf('.'));
        String extension = fileName.substring(fileName.lastIndexOf('.'));
        File outputFile = new File(torrentFile.getParent(), baseName + "_tiered_trackers" + extension);

        // 重新编码并保存
        byte[] modifiedTorrentData = bencode.encode(dict);
        Files.write(outputFile.toPath(), modifiedTorrentData);

        System.out.println("\n添加分层Tracker后的种子文件已保存为: " + outputFile.getName());
        return outputFile;
    }

    /**
     * 生成磁力链接
     */
    public static String generateMagnetLink(File torrentFile) throws Exception {
        // 计算InfoHash
        String infoHash = calculateInfoHash(torrentFile);

        // 读取种子文件获取名称
        byte[] torrentData = Files.readAllBytes(torrentFile.toPath());
        Bencode bencode = new Bencode(StandardCharsets.ISO_8859_1);
        Map<String, Object> dict = bencode.decode(torrentData, Type.DICTIONARY);
        Map<String, Object> info = (Map<String, Object>) dict.get("info");

        // 处理名称,优先使用UTF-8编码的名称字段
        String name;
        if (info.containsKey("name.utf-8")) {
            name = (String) info.get("name.utf-8");
        } else {
            String nameISO = (String) info.get("name");
            name = isoToUtf8(nameISO);
        }

        // 构建基本磁力链接
        StringBuilder magnetLink = new StringBuilder();
        magnetLink.append("magnet:?xt=urn:btih:").append(infoHash.toUpperCase());

        // 添加名称
        if (name != null && !name.isEmpty()) {
            magnetLink.append("&dn=").append(urlEncode(name));
        }

        // 添加Tracker
        if (dict.containsKey("announce")) {
            String announce = (String) dict.get("announce");
            magnetLink.append("&tr=").append(urlEncode(announce));
        }

        // 添加announce-list中的Tracker (最多添加5个)
        if (dict.containsKey("announce-list")) {
            List<List<Object>> announceList = (List<List<Object>>) dict.get("announce-list");
            int trackerCount = 0;

            for (List<Object> tier : announceList) {
                for (Object tracker : tier) {
                    if (trackerCount++ < 5) { // 限制最多添加5个Tracker
                        String announceUrl = (String) tracker;
                        magnetLink.append("&tr=").append(urlEncode(announceUrl));
                    }
                }
            }
        }

        return magnetLink.toString();
    }

    /**
     * 将ISO-8859-1编码的字符串转换为UTF-8
     */
    private static String isoToUtf8(String isoString) {
        try {
            byte[] bytes = isoString.getBytes("ISO-8859-1");
            return new String(bytes, "UTF-8");
        } catch (Exception e) {
            return isoString; // 转换失败则返回原始字符串
        }
    }

    /**
     * 更新Tracker URL中的passkey
     */
    private static String updatePasskey(String announceUrl, String passkey) {
        // 匹配Tracker URL的模式
        // 例如: http://tracker.example.com/oldpasskey/announce 或 http://tracker.example.com/announce
        Pattern pattern = Pattern.compile("(https?://[^/]+)(/[^/]+)?(/announce)");
        Matcher matcher = pattern.matcher(announceUrl);

        if (matcher.find()) {
            // 提取域名部分
            String domain = matcher.group(1);  // http://tracker.example.com
            String queryString = "";

            // 处理可能的查询参数
            if (announceUrl.contains("?")) {
                queryString = "?" + announceUrl.substring(announceUrl.indexOf("?") + 1);
            }

            // 构建新的URL,插入passkey
            return domain + "/" + passkey + "/announce" + queryString;
        }

        // 如果URL格式不匹配,返回原始URL
        return announceUrl;
    }

    /**
     * 判断两个Tracker URL是否相似
     */
    private static boolean isSimilarTracker(String url1, String url2) {
        if (url1 == null || url2 == null) {
            return false;
        }

        // 简单判断: 相同的主机名
        try {
            String host1 = new java.net.URL(url1).getHost();
            String host2 = new java.net.URL(url2).getHost();
            return host1.equals(host2);
        } catch (Exception e) {
            // 如果解析URL出错,返回false
            return false;
        }
    }

    /**
     * 计算SHA-1哈希值
     */
    private static String sha1(byte[] bytes) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-1");
            byte[] digest = md.digest(bytes);

            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
            }
            return sb.toString();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 格式化文件大小
     */
    private static String formatFileSize(long size) {
        final String[] units = new String[] { "B", "KB", "MB", "GB", "TB" };
        int digitGroups = (int) (Math.log10(size) / Math.log10(1024));
        return String.format("%.2f %s", size / Math.pow(1024, digitGroups), units[digitGroups]);
    }

    /**
     * URL编码字符串
     */
    private static String urlEncode(String s) {
        try {
            return java.net.URLEncoder.encode(s, "UTF-8")
                    .replace("+", "%20")  // 空格编码为%20而不是+
                    .replace("*", "%2A"); // 星号编码为%2A
        } catch (Exception e) {
            return s;
        }
    }
}

解析种子内容

此代码支持全面解析种子文件的各种信息:

  1. 基本信息:Tracker地址、创建者、创建时间、注释等
  2. Tracker列表:支持分层级显示Tracker,包括空行分隔符
  3. 文件信息:区分单文件和多文件种子,显示文件大小、路径等
  4. 技术细节:显示分块大小、是否为私有种子等

特殊功能

修改Tracker Passkey

代码支持智能修改Tracker URL中的passkey部分,保持URL的其他部分不变:

String passkey = "your-new-passkey";
File modifiedFile = modifyTorrentTracker(torrentFile, passkey);

这个功能会自动识别Tracker URL的格式,仅替换passkey部分。

设置Tracker层级

支持qBittorrent风格的Tracker层级设置,包括空行分隔符:

List<List<String>> trackerTiers = new ArrayList<>();

// 第0层 (最高优先级)
List<String> tier0 = new ArrayList<>();
tier0.add("http://main-tracker.com/passkey/announce");
trackerTiers.add(tier0);

// 空行分隔符
trackerTiers.add(new ArrayList<>());

// 第1层 (次优先级)
List<String> tier1 = new ArrayList<>();
tier1.add("http://backup1.com/passkey/announce");
tier1.add("http://backup2.com/passkey/announce");
trackerTiers.add(tier1);

File tieredFile = addTrackersByTier(torrentFile, trackerTiers);

这种方式添加的Tracker会按照层级优先级使用,符合qBittorrent的层级规范。

生成磁力链接

基于种子文件生成磁力链接:

String magnetLink = generateMagnetLink(torrentFile);
// 结果: magnet:?xt=urn:btih:A1B2C3...&dn=文件名&tr=http://tracker...

关键技术要点

  1. 字符集处理:始终使用ISO-8859-1字符集解析和编码种子文件,确保二进制数据完整性
  2. InfoHash计算:使用ISO-8859-1编码info字典,然后计算SHA-1哈希
  3. Tracker层级:处理空行分隔符,确保层级结构符合客户端规范
  4. 兼容性:生成的种子文件可被qBittorrent等主流客户端正确识别和使用

注意事项

  1. 修改种子文件时,切勿直接修改info字典内容,否则会导致InfoHash变化
  2. 确保使用ISO-8859-1字符集处理二进制数据,特别是pieces字段,以避免数据损坏
  3. 使用专门的方法修改Tracker URL,不要简单替换字符串,以处理各种URL格式
  4. 添加Tracker时注意层级结构,空行在qBittorrent中代表层级分隔
  5. 生成的种子文件需要经过客户端验证,确保没有"incorrect number of piece hashes"等错误