Java tutorial
/** * Copyright (C) 2011-2012 Turn, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.turn.ttorrent.common; import com.turn.ttorrent.bcodec.BDecoder; import com.turn.ttorrent.bcodec.BEValue; import com.turn.ttorrent.bcodec.BEncoder; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A torrent file tracked by the controller's BitTorrent tracker. * * <p> * This class represents an active torrent on the tracker. The torrent * information is kept in-memory, and is created from the byte blob one would * usually find in a <tt>.torrent</tt> file. * </p> * * <p> * Each torrent also keeps a repository of the peers seeding and leeching this * torrent from the tracker. * </p> * * @author mpetazzoni * @see <a href="http://wiki.theory.org/BitTorrentSpecification#Metainfo_File_Structure">Torrent meta-info file structure specification</a> */ public class Torrent { private static final Logger logger = LoggerFactory.getLogger(Torrent.class); /** Torrent file piece length (in bytes), we use 512 kB. */ public static final int DEFAULT_PIECE_LENGTH = 512 * 1024; public static final int PIECE_HASH_SIZE = 20; /** The query parameters encoding when parsing byte strings. */ public static final String BYTE_ENCODING = "ISO-8859-1"; /** * * @author dgiffin * @author mpetazzoni */ public static class TorrentFile { public final File file; public final long size; public TorrentFile(File file, long size) { this.file = file; this.size = size; } } protected final byte[] encoded; protected final byte[] encoded_info; protected final Map<String, BEValue> decoded; protected final Map<String, BEValue> decoded_info; private final byte[] info_hash; private final String hex_info_hash; private final List<List<URI>> trackers; private final Set<URI> allTrackers; private final Date creationDate; private final String comment; private final String createdBy; private final String name; private final long size; private final int pieceLength; protected final List<TorrentFile> files; private final boolean seeder; /** * Create a new torrent from meta-info binary data. * * Parses the meta-info data (which should be B-encoded as described in the * BitTorrent specification) and create a Torrent object from it. * * @param torrent The meta-info byte data. * @param seeder Whether we'll be seeding for this torrent or not. * @throws IOException When the info dictionary can't be read or * encoded and hashed back to create the torrent's SHA-1 hash. */ public Torrent(byte[] torrent, boolean seeder) throws IOException, NoSuchAlgorithmException { this.encoded = torrent; this.seeder = seeder; this.decoded = BDecoder.bdecode(new ByteArrayInputStream(this.encoded)).getMap(); this.decoded_info = this.decoded.get("info").getMap(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); BEncoder.bencode(this.decoded_info, baos); this.encoded_info = baos.toByteArray(); this.info_hash = Torrent.hash(this.encoded_info); this.hex_info_hash = Utils.bytesToHex(this.info_hash); /** * Parses the announce information from the decoded meta-info * structure. * * <p> * If the torrent doesn't define an announce-list, use the mandatory * announce field value as the single tracker in a single announce * tier. Otherwise, the announce-list must be parsed and the trackers * from each tier extracted. * </p> * * @see <a href="http://bittorrent.org/beps/bep_0012.html">BitTorrent BEP#0012 "Multitracker Metadata Extension"</a> */ try { this.trackers = new ArrayList<List<URI>>(); this.allTrackers = new HashSet<URI>(); if (this.decoded.containsKey("announce-list")) { List<BEValue> tiers = this.decoded.get("announce-list").getList(); for (BEValue tv : tiers) { List<BEValue> trackers = tv.getList(); if (trackers.isEmpty()) { continue; } List<URI> tier = new ArrayList<URI>(); for (BEValue tracker : trackers) { URI uri = new URI(tracker.getString()); // Make sure we're not adding duplicate trackers. if (!this.allTrackers.contains(uri)) { tier.add(uri); this.allTrackers.add(uri); } } // Only add the tier if it's not empty. if (!tier.isEmpty()) { this.trackers.add(tier); } } } else if (this.decoded.containsKey("announce")) { URI tracker = new URI(this.decoded.get("announce").getString()); this.allTrackers.add(tracker); // Build a single-tier announce list. List<URI> tier = new ArrayList<URI>(); tier.add(tracker); this.trackers.add(tier); } } catch (URISyntaxException use) { throw new IOException(use); } this.creationDate = this.decoded.containsKey("creation date") ? new Date(this.decoded.get("creation date").getLong() * 1000) : null; this.comment = this.decoded.containsKey("comment") ? this.decoded.get("comment").getString() : null; this.createdBy = this.decoded.containsKey("created by") ? this.decoded.get("created by").getString() : null; this.name = this.decoded_info.get("name").getString(); this.pieceLength = this.decoded_info.get("piece length").getInt(); this.files = new LinkedList<TorrentFile>(); // Parse multi-file torrent file information structure. if (this.decoded_info.containsKey("files")) { for (BEValue file : this.decoded_info.get("files").getList()) { Map<String, BEValue> fileInfo = file.getMap(); StringBuilder path = new StringBuilder(); for (BEValue pathElement : fileInfo.get("path").getList()) { path.append(File.separator).append(pathElement.getString()); } this.files.add( new TorrentFile(new File(this.name, path.toString()), fileInfo.get("length").getLong())); } } else { // For single-file torrents, the name of the torrent is // directly the name of the file. this.files.add(new TorrentFile(new File(this.name), this.decoded_info.get("length").getLong())); } // Calculate the total size of this torrent from its files' sizes. long size = 0; for (TorrentFile file : this.files) { size += file.size; } this.size = size; logger.info("{}-file torrent information:", this.isMultifile() ? "Multi" : "Single"); logger.info(" Torrent name: {}", this.name); logger.info(" Announced at:" + (this.trackers.size() == 0 ? " Seems to be trackerless" : "")); for (int i = 0; i < this.trackers.size(); i++) { List<URI> tier = this.trackers.get(i); for (int j = 0; j < tier.size(); j++) { logger.info(" {}{}", (j == 0 ? String.format("%2d. ", i + 1) : " "), tier.get(j)); } } if (this.creationDate != null) { logger.info(" Created on..: {}", this.creationDate); } if (this.comment != null) { logger.info(" Comment.....: {}", this.comment); } if (this.createdBy != null) { logger.info(" Created by..: {}", this.createdBy); } if (this.isMultifile()) { logger.info(" Found {} file(s) in multi-file torrent structure.", this.files.size()); int i = 0; for (TorrentFile file : this.files) { logger.debug(" {}. {} ({} byte(s))", new Object[] { String.format("%2d", ++i), file.file.getPath(), String.format("%,d", file.size) }); } } logger.info(" Pieces......: {} piece(s) ({} byte(s)/piece)", (this.size / this.decoded_info.get("piece length").getInt()) + 1, this.decoded_info.get("piece length").getInt()); logger.info(" Total size..: {} byte(s)", String.format("%,d", this.size)); } /** * Get this torrent's name. * * <p> * For a single-file torrent, this is usually the name of the file. For a * multi-file torrent, this is usually the name of a top-level directory * containing those files. * </p> */ public String getName() { return this.name; } /** * Get this torrent's comment string. */ public String getComment() { return this.comment; } /** * Get this torrent's creator (user, software, whatever...). */ public String getCreatedBy() { return this.createdBy; } /** * Get the total size of this torrent. */ public long getSize() { return this.size; } /** * Get the file names from this torrent. * * @return The list of relative filenames of all the files described in * this torrent. */ public List<String> getFilenames() { List<String> filenames = new LinkedList<String>(); for (TorrentFile file : this.files) { filenames.add(file.file.getPath()); } return filenames; } /** * Tells whether this torrent is multi-file or not. */ public boolean isMultifile() { return this.files.size() > 1; } /** * Return the hash of the B-encoded meta-info structure of this torrent. */ public byte[] getInfoHash() { return this.info_hash; } /** * Get this torrent's info hash (as an hexadecimal-coded string). */ public String getHexInfoHash() { return this.hex_info_hash; } /** * Return a human-readable representation of this torrent object. * * <p> * The torrent's name is used. * </p> */ public String toString() { return this.getName(); } /** * Return the B-encoded meta-info of this torrent. */ public byte[] getEncoded() { return this.encoded; } /** * Return the trackers for this torrent. */ public List<List<URI>> getAnnounceList() { return this.trackers; } /** * Returns the number of trackers for this torrent. */ public int getTrackerCount() { return this.allTrackers.size(); } /** * Tells whether we were an initial seeder for this torrent. */ public boolean isSeeder() { return this.seeder; } /** * Save this torrent meta-info structure into a .torrent file. * * @param output The stream to write to. * @throws IOException If an I/O error occurs while writing the file. */ public void save(OutputStream output) throws IOException { output.write(this.getEncoded()); } public static byte[] hash(byte[] data) throws NoSuchAlgorithmException { MessageDigest crypt; crypt = MessageDigest.getInstance("SHA-1"); crypt.reset(); crypt.update(data); return crypt.digest(); } /** * Return an hexadecimal representation of the bytes contained in the * given string, following the default, expected byte encoding. * * @param input The input string. */ public static String toHexString(String input) { try { byte[] bytes = input.getBytes(Torrent.BYTE_ENCODING); return Utils.bytesToHex(bytes); } catch (UnsupportedEncodingException uee) { return null; } } /** * Determine how many threads to use for the piece hashing. * * <p> * If the environment variable TTORRENT_HASHING_THREADS is set to an * integer value greater than 0, its value will be used. Otherwise, it * defaults to the number of processors detected by the Java Runtime. * </p> * * @return How many threads to use for concurrent piece hashing. */ protected static int getHashingThreadsCount() { String threads = System.getenv("TTORRENT_HASHING_THREADS"); if (threads != null) { try { int count = Integer.parseInt(threads); if (count > 0) { return count; } } catch (NumberFormatException nfe) { // Pass } } return Runtime.getRuntime().availableProcessors(); } /** Torrent loading ---------------------------------------------------- */ /** * Load a torrent from the given torrent file. * * <p> * This method assumes we are not a seeder and that local data needs to be * validated. * </p> * * @param torrent The abstract {@link File} object representing the * <tt>.torrent</tt> file to load. * @throws IOException When the torrent file cannot be read. */ public static Torrent load(File torrent) throws IOException, NoSuchAlgorithmException { return Torrent.load(torrent, false); } /** * Load a torrent from the given torrent file. * * @param torrent The abstract {@link File} object representing the * <tt>.torrent</tt> file to load. * @param seeder Whether we are a seeder for this torrent or not (disables * local data validation). * @throws IOException When the torrent file cannot be read. */ public static Torrent load(File torrent, boolean seeder) throws IOException, NoSuchAlgorithmException { byte[] data = FileUtils.readFileToByteArray(torrent); return new Torrent(data, seeder); } /** Torrent creation --------------------------------------------------- */ /** * Create a {@link Torrent} object for a file. * * <p> * Hash the given file to create the {@link Torrent} object representing * the Torrent metainfo about this file, needed for announcing and/or * sharing said file. * </p> * * @param source The file to use in the torrent. * @param announce The announce URI that will be used for this torrent. * @param createdBy The creator's name, or any string identifying the * torrent's creator. */ public static Torrent create(File source, URI announce, String createdBy) throws InterruptedException, IOException, NoSuchAlgorithmException { return Torrent.create(source, null, DEFAULT_PIECE_LENGTH, announce, null, createdBy); } /** * Create a {@link Torrent} object for a set of files. * * <p> * Hash the given files to create the multi-file {@link Torrent} object * representing the Torrent meta-info about them, needed for announcing * and/or sharing these files. Since we created the torrent, we're * considering we'll be a full initial seeder for it. * </p> * * @param parent The parent directory or location of the torrent files, * also used as the torrent's name. * @param files The files to add into this torrent. * @param announce The announce URI that will be used for this torrent. * @param createdBy The creator's name, or any string identifying the * torrent's creator. */ public static Torrent create(File parent, List<File> files, URI announce, String createdBy) throws InterruptedException, IOException, NoSuchAlgorithmException { return Torrent.create(parent, files, DEFAULT_PIECE_LENGTH, announce, null, createdBy); } /** * Create a {@link Torrent} object for a file. * * <p> * Hash the given file to create the {@link Torrent} object representing * the Torrent metainfo about this file, needed for announcing and/or * sharing said file. * </p> * * @param source The file to use in the torrent. * @param announceList The announce URIs organized as tiers that will * be used for this torrent * @param createdBy The creator's name, or any string identifying the * torrent's creator. */ public static Torrent create(File source, int pieceLength, List<List<URI>> announceList, String createdBy) throws InterruptedException, IOException, NoSuchAlgorithmException { return Torrent.create(source, null, pieceLength, null, announceList, createdBy); } /** * Create a {@link Torrent} object for a set of files. * * <p> * Hash the given files to create the multi-file {@link Torrent} object * representing the Torrent meta-info about them, needed for announcing * and/or sharing these files. Since we created the torrent, we're * considering we'll be a full initial seeder for it. * </p> * * @param source The parent directory or location of the torrent files, * also used as the torrent's name. * @param files The files to add into this torrent. * @param announceList The announce URIs organized as tiers that will * be used for this torrent * @param createdBy The creator's name, or any string identifying the * torrent's creator. */ public static Torrent create(File source, List<File> files, int pieceLength, List<List<URI>> announceList, String createdBy) throws InterruptedException, IOException, NoSuchAlgorithmException { return Torrent.create(source, files, pieceLength, null, announceList, createdBy); } /** * Helper method to create a {@link Torrent} object for a set of files. * * <p> * Hash the given files to create the multi-file {@link Torrent} object * representing the Torrent meta-info about them, needed for announcing * and/or sharing these files. Since we created the torrent, we're * considering we'll be a full initial seeder for it. * </p> * * @param parent The parent directory or location of the torrent files, * also used as the torrent's name. * @param files The files to add into this torrent. * @param announce The announce URI that will be used for this torrent. * @param announceList The announce URIs organized as tiers that will * be used for this torrent * @param createdBy The creator's name, or any string identifying the * torrent's creator. */ private static Torrent create(File parent, List<File> files, int pieceLength, URI announce, List<List<URI>> announceList, String createdBy) throws InterruptedException, IOException, NoSuchAlgorithmException { if (files == null || files.isEmpty()) { logger.info("Creating single-file torrent for {}...", parent.getName()); } else { logger.info("Creating {}-file torrent {}...", files.size(), parent.getName()); } Map<String, BEValue> torrent = new HashMap<String, BEValue>(); if (announce != null) { torrent.put("announce", new BEValue(announce.toString())); } if (announceList != null) { List<BEValue> tiers = new LinkedList<BEValue>(); for (List<URI> trackers : announceList) { List<BEValue> tierInfo = new LinkedList<BEValue>(); for (URI trackerURI : trackers) { tierInfo.add(new BEValue(trackerURI.toString())); } tiers.add(new BEValue(tierInfo)); } torrent.put("announce-list", new BEValue(tiers)); } torrent.put("creation date", new BEValue(new Date().getTime() / 1000)); torrent.put("created by", new BEValue(createdBy)); Map<String, BEValue> info = new TreeMap<String, BEValue>(); info.put("name", new BEValue(parent.getName())); info.put("piece length", new BEValue(pieceLength)); if (files == null || files.isEmpty()) { info.put("length", new BEValue(parent.length())); info.put("pieces", new BEValue(Torrent.hashFile(parent, pieceLength), Torrent.BYTE_ENCODING)); } else { List<BEValue> fileInfo = new LinkedList<BEValue>(); for (File file : files) { Map<String, BEValue> fileMap = new HashMap<String, BEValue>(); fileMap.put("length", new BEValue(file.length())); LinkedList<BEValue> filePath = new LinkedList<BEValue>(); while (file != null) { if (file.equals(parent)) { break; } filePath.addFirst(new BEValue(file.getName())); file = file.getParentFile(); } fileMap.put("path", new BEValue(filePath)); fileInfo.add(new BEValue(fileMap)); } info.put("files", new BEValue(fileInfo)); info.put("pieces", new BEValue(Torrent.hashFiles(files, pieceLength), Torrent.BYTE_ENCODING)); } torrent.put("info", new BEValue(info)); ByteArrayOutputStream baos = new ByteArrayOutputStream(); BEncoder.bencode(new BEValue(torrent), baos); return new Torrent(baos.toByteArray(), true); } /** * A {@link Callable} to hash a data chunk. * * @author mpetazzoni */ private static class CallableChunkHasher implements Callable<String> { private final MessageDigest md; private final ByteBuffer data; CallableChunkHasher(ByteBuffer buffer) throws NoSuchAlgorithmException { this.md = MessageDigest.getInstance("SHA-1"); this.data = ByteBuffer.allocate(buffer.remaining()); buffer.mark(); this.data.put(buffer); this.data.clear(); buffer.reset(); } @Override public String call() throws UnsupportedEncodingException { this.md.reset(); this.md.update(this.data.array()); return new String(md.digest(), Torrent.BYTE_ENCODING); } } /** * Return the concatenation of the SHA-1 hashes of a file's pieces. * * <p> * Hashes the given file piece by piece using the default Torrent piece * length (see {@link #PIECE_LENGTH}) and returns the concatenation of * these hashes, as a string. * </p> * * <p> * This is used for creating Torrent meta-info structures from a file. * </p> * * @param file The file to hash. */ private static String hashFile(File file, int pieceLenght) throws InterruptedException, IOException, NoSuchAlgorithmException { return Torrent.hashFiles(Arrays.asList(new File[] { file }), pieceLenght); } private static String hashFiles(List<File> files, int pieceLenght) throws InterruptedException, IOException, NoSuchAlgorithmException { int threads = getHashingThreadsCount(); ExecutorService executor = Executors.newFixedThreadPool(threads); ByteBuffer buffer = ByteBuffer.allocate(pieceLenght); List<Future<String>> results = new LinkedList<Future<String>>(); StringBuilder hashes = new StringBuilder(); long length = 0L; int pieces = 0; long start = System.nanoTime(); for (File file : files) { logger.info("Hashing data from {} with {} threads ({} pieces)...", new Object[] { file.getName(), threads, (int) (Math.ceil((double) file.length() / pieceLenght)) }); length += file.length(); FileInputStream fis = new FileInputStream(file); FileChannel channel = fis.getChannel(); int step = 10; try { while (channel.read(buffer) > 0) { if (buffer.remaining() == 0) { buffer.clear(); results.add(executor.submit(new CallableChunkHasher(buffer))); } if (results.size() >= threads) { pieces += accumulateHashes(hashes, results); } if (channel.position() / (double) channel.size() * 100f > step) { logger.info(" ... {}% complete", step); step += 10; } } } finally { channel.close(); fis.close(); } } // Hash the last bit, if any if (buffer.position() > 0) { buffer.limit(buffer.position()); buffer.position(0); results.add(executor.submit(new CallableChunkHasher(buffer))); } pieces += accumulateHashes(hashes, results); // Request orderly executor shutdown and wait for hashing tasks to // complete. executor.shutdown(); while (!executor.isTerminated()) { Thread.sleep(10); } long elapsed = System.nanoTime() - start; int expectedPieces = (int) (Math.ceil((double) length / pieceLenght)); logger.info("Hashed {} file(s) ({} bytes) in {} pieces ({} expected) in {}ms.", new Object[] { files.size(), length, pieces, expectedPieces, String.format("%.1f", elapsed / 1e6), }); return hashes.toString(); } /** * Accumulate the piece hashes into a given {@link StringBuilder}. * * @param hashes The {@link StringBuilder} to append hashes to. * @param results The list of {@link Future}s that will yield the piece * hashes. */ private static int accumulateHashes(StringBuilder hashes, List<Future<String>> results) throws InterruptedException, IOException { try { int pieces = results.size(); for (Future<String> chunk : results) { hashes.append(chunk.get()); } results.clear(); return pieces; } catch (ExecutionException ee) { throw new IOException("Error while hashing the torrent data!", ee); } } }