├── .gitattributes ├── .gitignore ├── .travis.yml ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Bee.php ├── File.php └── Torrent.php └── tests ├── 1992-06-15.torrent ├── BeeTest.php ├── TorrentTest.php ├── TorrentTestMultiFile.php ├── multifile.torrent └── ubuntu-13.10-desktop-amd64.iso.torrent /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.torrent binary -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | composer.phar 3 | .idea 4 | vendor -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.0 5 | 6 | sudo: false 7 | 8 | cache: 9 | directories: 10 | - vendor 11 | 12 | branches: 13 | only: 14 | - master 15 | 16 | install: 17 | - composer install --no-dev 18 | 19 | script: 20 | - phpunit -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Features 2 | ================= 3 | Extract data from .torrent files and change them 4 | 5 | Getting started 6 | ================= 7 | Install using composer. Add the following to your composer.json 8 | 9 | ```json 10 | { 11 | "repositories": [ 12 | { 13 | "type": "vcs", 14 | "url": "https://github.com/Devristo/torrent" 15 | } 16 | ], 17 | "require": { 18 | "devristo/torrent": "dev-master" 19 | } 20 | } 21 | ``` 22 | 23 | Reading Torrent Files 24 | ========================= 25 | 26 | ```php 27 | use Devristo\Torrent\Torrent; 28 | 29 | $torrent = Torrent::fromFile('ubuntu-13.10-desktop-amd64.iso.torrent'); 30 | echo $torrent->getInfoHash(false) // echoes e3811b9539cacff680e418124272177c47477157 31 | 32 | ``` 33 | 34 | Modifying Torrent Files 35 | ========================== 36 | ```php 37 | $torrent->setPrivate(true); 38 | $torrent->setComment("Downloaded from example.org"); 39 | 40 | file_put_contents("private-tracker.torrent", $torrent->serialize()); 41 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devristo/torrent", 3 | "description": "Torrent file decoder / encoder", 4 | "license": "BSD", 5 | "authors": [ 6 | { 7 | "name": "Chris", 8 | "email": "risto@live.nl" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": {"Devristo\\Torrent\\": "src/"} 13 | }, 14 | "minimum-stability": "stable", 15 | "require-dev": { 16 | "phpunit/phpunit": ">=3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | ./tests/ 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Bee.php: -------------------------------------------------------------------------------- 1 | encode($item); 26 | 27 | $encoded .= 'e'; 28 | 29 | return $encoded; 30 | } 31 | 32 | private function encode_dict(array $dict){ 33 | $encoded = 'd'; 34 | 35 | ksort($dict, SORT_STRING); 36 | 37 | foreach($dict as $key => $value){ 38 | $encoded .= $this->encode_string($key); 39 | $encoded .= $this->encode($value); 40 | } 41 | 42 | $encoded .= 'e'; 43 | return $encoded; 44 | } 45 | 46 | private function is_list(array $arr){ 47 | for (reset($arr); is_int(key($arr)); next($arr)); 48 | return is_null(key($arr)); 49 | } 50 | 51 | private function is_dict(array $arr){ 52 | for (reset($arr); is_int(key($arr)) || is_string(key($arr)); next($arr)); 53 | return is_null(key($arr)); 54 | } 55 | 56 | /** 57 | * @param $object 58 | * @return string 59 | */ 60 | public function encode($object){ 61 | if(is_int($object) || ctype_digit($object)) 62 | return $this->encode_int($object); 63 | elseif(is_string($object)) 64 | return $this->encode_string($object); 65 | elseif(is_array($object)) 66 | if($this->is_list($object)) 67 | return $this->encode_list($object); 68 | elseif($this->is_dict($object)) 69 | return $this->encode_dict($object); 70 | else throw new \InvalidArgumentException("Input is not valid"); 71 | else throw new \InvalidArgumentException("Input is not valid"); 72 | } 73 | 74 | 75 | public function eatInt(&$string, &$pos){ 76 | // Eat the i 77 | $pos++; 78 | 79 | $i = $pos; 80 | while($i < strlen($string)){ 81 | if(ctype_digit($string[$i]) || $string[$i] == '-') 82 | $i++; 83 | elseif($string[$i] == 'e'){ 84 | $result = substr($string, $pos, $i-$pos); 85 | $pos = $i+1; 86 | return $result; 87 | }else 88 | break; 89 | } 90 | 91 | throw new \InvalidArgumentException("Invalid int format"); 92 | } 93 | 94 | public function eatList(&$string, &$pos){ 95 | // Eat the l 96 | $pos++; 97 | 98 | $i = $pos; 99 | $items = array(); 100 | while($i < strlen($string)){ 101 | 102 | if($string[$i] == 'e'){ 103 | $pos = $i+1; 104 | return $items; 105 | }else { 106 | $items[] = $this->decode($string, $i); 107 | } 108 | } 109 | 110 | throw new \InvalidArgumentException("Invalid list format"); 111 | } 112 | 113 | public function eatDict(&$string, &$pos){ 114 | // Eat the d 115 | $pos++; 116 | 117 | $i = $pos; 118 | $items = array(); 119 | while($i < strlen($string)){ 120 | 121 | if($string[$i] == 'e'){ 122 | $pos = $i+1; 123 | return $items; 124 | }else { 125 | $key = $this->decode($string, $i); 126 | $value = $this->decode($string, $i); 127 | 128 | $items[$key] = $value; 129 | } 130 | } 131 | 132 | throw new \InvalidArgumentException("Invalid dict format"); 133 | } 134 | 135 | public function eatString(&$string, &$pos){ 136 | $i = $pos; 137 | while($i < strlen($string)){ 138 | if(ctype_digit($string[$i])) 139 | $i++; 140 | elseif($string[$i] == ':'){ 141 | $length = (int)substr($string, $pos, $i-$pos); 142 | 143 | if($length + $i < strlen($string)){ 144 | $result = substr($string, $i+1, $length); 145 | $pos = $length + $i + 1; 146 | return $result; 147 | }else break; 148 | 149 | }else 150 | break; 151 | } 152 | throw new \InvalidArgumentException("Invalid string format"); 153 | } 154 | 155 | /** 156 | * @param $string 157 | * @param int $pos 158 | * @return mixed 159 | */ 160 | public function decode($string, &$pos=0){ 161 | while($pos < strlen($string)){ 162 | switch($string[$pos]){ 163 | case 'i': 164 | return $this->eatInt($string, $pos); 165 | case 'l': 166 | return $this->eatList($string, $pos); 167 | case 'd': 168 | return $this->eatDict($string, $pos); 169 | default: 170 | if(ctype_digit($string[$pos])) 171 | return $this->eatString($string, $pos); 172 | else throw new \InvalidArgumentException("Invalid input format"); 173 | 174 | } 175 | } 176 | } 177 | } -------------------------------------------------------------------------------- /src/File.php: -------------------------------------------------------------------------------- 1 | data = $data; 16 | 17 | if(!array_key_exists('length', $data) || !array_key_exists('name', $data) &&! array_key_exists('path', $data)) 18 | throw new \InvalidArgumentException("Invalid file data structure"); 19 | } 20 | 21 | public function getName(){ 22 | if(array_key_exists('path', $this->data)){ 23 | return $this->data['path'][count($this->data['path'])-1]; 24 | } else { 25 | return $this->data['name']; 26 | } 27 | } 28 | 29 | public function getPath(){ 30 | if(array_key_exists('path', $this->data)){ 31 | return join("/", $this->data['path']); 32 | } else { 33 | return $this->data['name']; 34 | } 35 | } 36 | 37 | public function getParentDirectories(){ 38 | return array_key_exists('path', $this->data) ? array_slice($this->data['path'], 0, -1) : array(); 39 | } 40 | 41 | public function getSize(){ 42 | return $this->data['length']; 43 | } 44 | 45 | public function getMd5Sum(){ 46 | return array_key_exists('md5sum', $this->data) ? $this->data['md5sum'] : null; 47 | } 48 | 49 | public function __toString(){ 50 | return $this->getName(); 51 | } 52 | } -------------------------------------------------------------------------------- /src/Torrent.php: -------------------------------------------------------------------------------- 1 | data = $data; 21 | 22 | if(!$this->isValid()) 23 | throw new \InvalidArgumentException("Invalid torrent data structure"); 24 | 25 | if(!array_key_exists('files', $this->data['info'])){ 26 | $this->files = array(new File($this->data['info'])); 27 | } else { 28 | $this->files = array(); 29 | 30 | foreach($this->data['info']['files'] as &$data){ 31 | $this->files[] = new File($data); 32 | } 33 | } 34 | } 35 | 36 | public function getInfoHash($rawOutput=true){ 37 | $bee = new Bee(); 38 | return sha1($bee->encode($this->data['info']), $rawOutput); 39 | } 40 | 41 | protected function isValid(){ 42 | $hasKeys = function(array $keys, array $data){ 43 | return count(array_diff($keys, array_keys($data))) === 0; 44 | }; 45 | 46 | if(!$hasKeys(array('info'), $this->data)) 47 | return false; 48 | 49 | if(!$hasKeys(array('piece length', 'pieces'), $this->data['info'])) 50 | return false; 51 | 52 | return true; 53 | } 54 | 55 | public function getAnnounce(){ 56 | return array_key_exists('announce', $this->data) ? $this->data['announce'] : null; 57 | } 58 | 59 | public function setAnnounce($url){ 60 | $this->data['announce'] = $url; 61 | } 62 | 63 | public function setAnnounceList(array $urls){ 64 | foreach($urls as $url) 65 | if(!is_array($url)) 66 | throw new \InvalidArgumentException("Announce list should be an array of arrays"); 67 | 68 | 69 | $this->data['announce-list'] = $urls; 70 | } 71 | 72 | public function getAnnounceList(){ 73 | return array_key_exists('announce-list', $this->data) ? $this->data['announce-list'] : array(); 74 | } 75 | 76 | public function getCreationDate(){ 77 | if(!array_key_exists('creation date', $this->data)) 78 | return null; 79 | 80 | $dt = new \DateTime(); 81 | $dt->setTimestamp($this->data['creation date']); 82 | 83 | return $dt; 84 | } 85 | 86 | public function getComment(){ 87 | if(!array_key_exists('comment', $this->data)) 88 | return null; 89 | 90 | return $this->data['comment']; 91 | } 92 | 93 | public function setComment($comment){ 94 | $this->data['comment'] = $comment; 95 | } 96 | 97 | public function getCreatedBy(){ 98 | if(!array_key_exists('created by', $this->data)) 99 | return null; 100 | 101 | return $this->data['created by']; 102 | } 103 | 104 | public function getName(){ 105 | return $this->data['info']['name']; 106 | } 107 | 108 | public function setName($name){ 109 | $this->data['info']['name'] = $name; 110 | } 111 | 112 | public function setPrivate($val){ 113 | $this->data['info']['private'] = $val ? 1 : 0; 114 | } 115 | 116 | public function getNumPieces(){ 117 | return ceil($this->getSize() / $this->getPieceSize()); 118 | } 119 | 120 | public function getPieces(){ 121 | return str_split($this->data['info']['pieces'], 20); 122 | } 123 | 124 | public function getPieceSize(){ 125 | return $this->data['info']['piece length']; 126 | } 127 | 128 | public function isPrivate(){ 129 | return array_key_exists('private', $this->data['info']); 130 | } 131 | 132 | public function getSize(){ 133 | $length = 0; 134 | foreach($this->files as $file) 135 | $length += $file->getSize(); 136 | 137 | return $length; 138 | } 139 | 140 | public function getFiles(){ 141 | return $this->files; 142 | } 143 | 144 | public function getFileTree(){ 145 | $tree = array(); 146 | 147 | // Create the tree 148 | foreach($this->getFiles() as $file){ 149 | $start = &$tree; 150 | foreach($file->getParentDirectories() as $dir){ 151 | $start = &$start[$dir]; 152 | } 153 | 154 | $start[$file->getName()] = $file; 155 | } 156 | 157 | 158 | // Sort the tree, recursively, depth first search 159 | $to_sort = array(&$tree); 160 | while(count($to_sort)){ 161 | $array = &$to_sort[count($to_sort)-1]; 162 | array_pop($to_sort); 163 | 164 | // Sort current 'view' 165 | uksort($array, function($keyA, $keyB) use($array){ 166 | $a = $array[$keyA]; 167 | $b = $array[$keyB]; 168 | 169 | // Order 2 directories according to their name 170 | if(is_array($a) && is_array($b)) 171 | return strcasecmp($keyA, $keyB); 172 | 173 | // Order the directory above the file 174 | elseif(is_array($a) && !is_array($b)) 175 | return -1; 176 | 177 | // Order the directory above the file 178 | elseif(!is_array($a) && is_array($b)) 179 | return 1; 180 | 181 | // Order 2 files according to their name 182 | else 183 | return strcasecmp($a->getName(), $b->getName()); 184 | }); 185 | 186 | foreach($array as $k => $item) 187 | if(is_array($item)) 188 | $to_sort[] = &$array[$k]; 189 | } 190 | return $tree; 191 | } 192 | 193 | public function toArray(){ 194 | return $this->data; 195 | } 196 | 197 | public function serialize(){ 198 | $bee = new Bee(); 199 | return $bee->encode($this->data); 200 | } 201 | 202 | public static function fromFile($filename){ 203 | $contents = file_get_contents($filename); 204 | 205 | return self::fromString($contents); 206 | } 207 | 208 | public static function fromString($string){ 209 | $bee = new Bee(); 210 | $decoded = $bee->decode($string); 211 | $torrent = new Torrent($decoded); 212 | 213 | return $torrent; 214 | } 215 | } -------------------------------------------------------------------------------- /tests/1992-06-15.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Devristo/torrent/f509e954bf58691e0cba9bbd4a2e75134a74b28a/tests/1992-06-15.torrent -------------------------------------------------------------------------------- /tests/BeeTest.php: -------------------------------------------------------------------------------- 1 | encode($input); 16 | 17 | $this->assertEquals($expected, $result); 18 | } 19 | 20 | public function test_encode_int(){ 21 | $input = "4154548412"; 22 | $expected = "i{$input}e"; 23 | 24 | $bee = new \Devristo\Torrent\Bee(); 25 | $result = $bee->encode($input); 26 | 27 | $this->assertEquals($expected, $result); 28 | } 29 | 30 | public function test_decode_int(){ 31 | $expected = 41545482; 32 | $input = "i{$expected}e"; 33 | 34 | $bee = new \Devristo\Torrent\Bee(); 35 | $result = $bee->decode($input); 36 | 37 | $this->assertEquals($expected, $result); 38 | } 39 | 40 | public function test_encode_list(){ 41 | $input = array(1,2,3,4); 42 | $expected = "li1ei2ei3ei4ee"; 43 | 44 | $bee = new \Devristo\Torrent\Bee(); 45 | $result = $bee->encode($input); 46 | 47 | $this->assertEquals($expected, $result); 48 | } 49 | 50 | public function test_decode_list(){ 51 | $expected = array(1,2,3,4); 52 | $input = "li1ei2ei3ei4ee"; 53 | 54 | $bee = new \Devristo\Torrent\Bee(); 55 | $result = $bee->decode($input); 56 | 57 | $this->assertEquals($expected, $result); 58 | } 59 | 60 | public function test_decode_string(){ 61 | $expected = "hello world"; 62 | $input = strlen($expected).":$expected"; 63 | 64 | $bee = new \Devristo\Torrent\Bee(); 65 | $result = $bee->decode($input); 66 | 67 | $this->assertEquals($expected, $result); 68 | } 69 | 70 | public function test_decode_dict_strings_1(){ 71 | $input = "d3:cow3:moo4:spam4:eggse"; 72 | $expected = array( 73 | 'cow' => 'moo', 74 | 'spam' => 'eggs', 75 | ); 76 | 77 | $bee = new \Devristo\Torrent\Bee(); 78 | $result = $bee->decode($input); 79 | 80 | $this->assertEquals($expected, $result); 81 | } 82 | 83 | public function test_decode_dict_list(){ 84 | $input = "d4:spaml1:a1:bee"; 85 | $expected = array( 86 | 'spam' => array('a', 'b') 87 | ); 88 | 89 | $bee = new \Devristo\Torrent\Bee(); 90 | $result = $bee->decode($input); 91 | 92 | $this->assertEquals($expected, $result); 93 | } 94 | 95 | public function test_decode_dict_strings_2(){ 96 | $input = "d9:publisher3:bob17:publisher-webpage15:www.example.com18:publisher.location4:homee"; 97 | $expected = array( 98 | "publisher" => "bob", "publisher-webpage" => "www.example.com", "publisher.location" => "home" 99 | ); 100 | 101 | $bee = new \Devristo\Torrent\Bee(); 102 | $result = $bee->decode($input); 103 | 104 | $this->assertEquals($expected, $result); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/TorrentTest.php: -------------------------------------------------------------------------------- 1 | assertNull($torrent->getAnnounce()); 20 | } 21 | 22 | public function test_file(){ 23 | $this->torrent = Torrent::fromFile(__DIR__.'/ubuntu-13.10-desktop-amd64.iso.torrent'); 24 | 25 | $this->assertEquals('ubuntu-13.10-desktop-amd64.iso', $this->torrent->getFiles()[0]->getPath()); 26 | $this->assertEquals(925892608, $this->torrent->getFiles()[0]->getSize()); 27 | $this->assertEquals(null, $this->torrent->getFiles()[0]->getMd5Sum()); 28 | } 29 | 30 | public function test_main_details(){ 31 | $this->torrent = Torrent::fromFile(__DIR__.'/ubuntu-13.10-desktop-amd64.iso.torrent'); 32 | 33 | $this->assertEquals('http://torrent.ubuntu.com:6969/announce', $this->torrent->getAnnounce()); 34 | $this->assertEquals(array( 35 | array('http://torrent.ubuntu.com:6969/announce'), 36 | array('http://ipv6.torrent.ubuntu.com:6969/announce') 37 | ), $this->torrent->getAnnounceList()); 38 | 39 | 40 | $this->assertEquals(925892608, $this->torrent->getSize()); 41 | $this->assertEquals("Ubuntu CD releases.ubuntu.com", $this->torrent->getComment()); 42 | $this->assertEquals("e3811b9539cacff680e418124272177c47477157", $this->torrent->getInfoHash(false)); 43 | $this->assertEquals(hex2bin("e3811b9539cacff680e418124272177c47477157"), $this->torrent->getInfoHash()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/TorrentTestMultiFile.php: -------------------------------------------------------------------------------- 1 | torrent = Torrent::fromFile('multifile.torrent'); 19 | } 20 | 21 | public function test_file(){ 22 | $this->assertEquals('2010-08-10 - Krass 360° in Frankfurt (MULTICAM BD).mkv', $this->torrent->getFiles()[0]->getPath()); 23 | $this->assertEquals(12262451114, $this->torrent->getFiles()[0]->getSize()); 24 | $this->assertEquals(null, $this->torrent->getFiles()[0]->getMd5Sum()); 25 | 26 | $this->assertEquals('info.txt', $this->torrent->getFiles()[1]->getPath()); 27 | $this->assertEquals(1660, $this->torrent->getFiles()[1]->getSize()); 28 | $this->assertEquals(null, $this->torrent->getFiles()[1]->getMd5Sum()); 29 | } 30 | 31 | public function test_private(){ 32 | $this->assertEquals(true, $this->torrent->isPrivate()); 33 | $this->assertEquals("618b40bddf786d3af341f2bb00e441b355ecc953", $this->torrent->getInfoHash(false)); 34 | $this->torrent->setPrivate(false); 35 | $this->assertEquals("d25e9ed17d4481488aac880bf5349413f76dbb67", $this->torrent->getInfoHash(false)); 36 | } 37 | 38 | public function test_main_details(){ 39 | $this->assertEquals('http://tracker.u2start.com/132/ffffffffffffffffffffffffffffffffffffffff/announce/', $this->torrent->getAnnounce()); 40 | $this->assertEquals(array( 41 | array("udp://tracker.u2start.com:8080/132/ffffffffffffffffffffffffffffffffffffffff/announce/"), 42 | array("http://tracker.u2start.com/132/ffffffffffffffffffffffffffffffffffffffff/announce/") 43 | ), $this->torrent->getAnnounceList()); 44 | 45 | 46 | $this->assertEquals(12262452774, $this->torrent->getSize()); 47 | $this->assertEquals("Powered By:\nu2start.com - Share Your Passion", $this->torrent->getComment()); 48 | $this->assertEquals("618b40bddf786d3af341f2bb00e441b355ecc953", $this->torrent->getInfoHash(false)); 49 | $this->assertEquals(hex2bin("618b40bddf786d3af341f2bb00e441b355ecc953"), $this->torrent->getInfoHash()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/multifile.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Devristo/torrent/f509e954bf58691e0cba9bbd4a2e75134a74b28a/tests/multifile.torrent -------------------------------------------------------------------------------- /tests/ubuntu-13.10-desktop-amd64.iso.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Devristo/torrent/f509e954bf58691e0cba9bbd4a2e75134a74b28a/tests/ubuntu-13.10-desktop-amd64.iso.torrent --------------------------------------------------------------------------------