torrents 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ authentication/*
data/.rspec ADDED
@@ -0,0 +1,5 @@
1
+ --color
2
+ -fs
3
+ -Ilib
4
+ -Ispec
5
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in torrents.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # Torrents
2
+
3
+ Search and download torrents from your favorite bittorrent tracker using Ruby.
4
+
5
+ Download and get information like:
6
+
7
+ - Subtitles, english and swedish.
8
+ - Movie information (if the torrent is a movie), actors, grade, original title, length, trailers and so on.
9
+ - A direct download link to the torrent.
10
+ - [IMDB](http://imdb.com) link
11
+
12
+ ## Which trackers are implemented at the moment?
13
+
14
+ ### Open trackers
15
+
16
+ - [The Pirate Bay](http://thepiratebay.org/)
17
+
18
+ ### Closed trackers
19
+
20
+ - [TTI](http://tti.nu/)
21
+ - [Torrentleech](http://www.torrentleech.org/)
22
+
23
+ ## How to use
24
+
25
+ ### Search for torrents
26
+
27
+ >> Torrents.the_pirate_bay.search("chuck").results
28
+
29
+ ### List recent torrents
30
+
31
+ >> Torrents.the_pirate_bay.results
32
+
33
+ ### List recent torrents - with category
34
+
35
+ >> Torrents.the_pirate_bay.category(:movies).results
36
+
37
+ ### Specify a page
38
+
39
+ The `page` method can be places anywhere before the `results` method.
40
+
41
+ It starts counting from `1` and goes up, no matter what is used on the site it self.
42
+
43
+ >> Torrents.the_pirate_bay.page(6).results
44
+
45
+ ### Specify some cookies
46
+
47
+ Some trackers requires cookies to work, even though [The Pirate Bay](http://thepiratebay.org/) is not one of them.
48
+
49
+ >> Torrents.the_pirate_bay.cookies(user_id: "123", hash: "c4656002ce46f9b418ce72daccfa5424").results
50
+
51
+ ## What methods to work with
52
+
53
+ ### The results method
54
+
55
+ As soon as you apply the `results` method on the query it will try to execute your request.
56
+ If you for example want to activate the debugger, define some cookies or specify a page, then you might do something like this.
57
+
58
+ $ Torrents.the_pirate_bay.page(5).debug(true).cookies(:my_cookie => "value").results
59
+
60
+ It will return a list of `Container::Torrent` object if the request was sucessfull, otherwise an empty list.
61
+
62
+ ### The find_by_details method
63
+
64
+ If you have access to a single details link and want to get some useful data from it, then `find_by_details` might fit you needs.
65
+
66
+ The method takes the url as an argument and returns a single `Container::Torrent` object.
67
+
68
+ $ Torrents.the_pirate_bay.find_by_details("http://thepiratebay.org/torrent/6173093/")
69
+
70
+ # What data to work with
71
+
72
+ ### The Container::Torrent class
73
+
74
+ The class has some nice accessors that might be useful.
75
+
76
+ - **title** (String) The title.
77
+ - **details** (String) The url to the details page.
78
+ - **seeders** (Fixnum) The amount of seeders.
79
+ - **dead?** (Boolean) Check to see if the torrent has no seeders. If it has no seeders, then `dead?` will be true.
80
+ - **torrent** (String) The url. This should be a direct link to the torrent.
81
+ - **id** (Fixnum) An unique id for the torrent. The id is only unique for this specific torrent, not all torrents.
82
+ - **tid** (String) The `tid` method, also known as `torrent id` is a *truly* unique identifier for all torrents. It is generated using a [MD5](http://sv.wikipedia.org/wiki/MD5) with the torrent domain and the `id` method as a seed.
83
+ - **torrent_id** (String) The same as the `tid` method.
84
+ - **imdb** (String) The imdb link for the torrent, if the details view contains one.
85
+ - **imdb_id** (String) The imdb id for the torrent, if the details view contain one. Example: tt0066026.
86
+ - **subtitle** ([Undertexter](https://github.com/oleander/Undertexter)) The subtitle for the torrent. Takes one argument, the language for the subtitle. Default is `:english`. Read more about it [here](https://github.com/oleander/Undertexter).
87
+ - **movie** ([MovieSearcher](https://github.com/oleander/MovieSearcher)) Read more about the returned object at the [MovieSearcher](https://github.com/oleander/MovieSearcher) project page.
88
+
89
+ **Note:** The `seeders`, `movie`, `subtitle`, `imdb_id` and `ìmdb` method will do another request to the tracker, which means that it will take a bit longer to load then the other methods.
90
+
91
+ ## What cookies to pass
92
+
93
+ Here is an example
94
+
95
+ $ Torrents.torrentleech.cookies({:member_id => "123", :pass_hash => "value", :PHPSESSID => "value"}).results
96
+
97
+ All values you pass to `cookies` must be of type string, like in the example above.
98
+
99
+ - Torrentleech
100
+ - member_id
101
+ - pass_hash
102
+ - PHPSESSID
103
+ - TTI
104
+ - hass
105
+ - pass
106
+ - uid
107
+
108
+ **Note:** The cookies you pass might be browser and IP-adress sensitive. Which means that it might only work in the current browser using the current Internet connection.
109
+
110
+ ## Error handling
111
+
112
+ I decided in the beginning of the project to rescue parse errors during the runtime and instead print them as warnings.
113
+
114
+ ### Why? - Lack of good selectors
115
+
116
+ The trackers parser, [this](https://github.com/oleander/Torrents/blob/master/lib/torrents/trackers/the_pirate_bay.rb) one for example, isn't always returning the right data.
117
+
118
+ Due to the lack of useful CSS selectors on the given tracker. It returns 32 rows, the first and the last containing the header and the footer of the table.
119
+ The unwanted results will be thrown away by the [validator](https://github.com/oleander/Torrents/blob/master/lib/torrents/container.rb#L141), but may raise errors during the run time.
120
+ The easiest way to solve it was to just isolate the tracker, if it raised an error we return nil.
121
+
122
+ ### Get the error messages
123
+
124
+ You can read errors in two ways.
125
+
126
+ Activate the debugger by adding the `debug` method to your query. The errors will be printed as warnings in the console.
127
+
128
+ $ Torrents.the_pirate_bay.debug(true).results
129
+
130
+ Request a list of errors using the `errors` method.
131
+
132
+ $ Torrents.the_pirate_bay.errors
133
+ >> ["...undefined method `attr' for nil:NilClass>...", "32 torrents where found, 2 where not valid", "..."]
134
+
135
+ ## How do access tracker X
136
+
137
+ Here is how to access an implemented tracker.
138
+ The first static method to apply is the name of the tracker in lower non camel cased letters.
139
+
140
+ The Pirate Bay becomes `the_pirate_bay`, TTI becomes `tti` and Torrentleech `torrentleech`.
141
+
142
+ Here is an example.
143
+
144
+ $ Torrents.torrentleech.cookies({:my_cookie => "value"}).results
145
+
146
+ Take a look at the [tests](https://github.com/oleander/Torrents/tree/master/spec/trackers) for all trackers to get to know more.
147
+
148
+ ## Add you own tracker
149
+
150
+ I'm about to write a wiki that describes how to add you own site.
151
+ Until then, take a look at the parser for [The Pirate Bay](https://github.com/oleander/Torrents/blob/master/lib/torrents/trackers/the_pirate_bay.rb).
152
+
153
+ All heavy lifting has already been done, so adding another tracker should be quite easy.
154
+
155
+ I'm using [Nokogiri](http://nokogiri.org/) to parse data from the site, which in most cases means that you don't have to mess with regular expressions.
156
+
157
+ Don't know Nokogiri? Take a look at [this](http://railscasts.com/episodes/190-screen-scraping-with-nokogiri) awesome screen cast by [Ryan Bates](https://github.com/ryanb).
158
+
159
+ ### The short version
160
+
161
+ 1. Create your own fork of the project.
162
+ 2. Create and implement a tracker file inside the [tracker directory](https://github.com/oleander/Torrents/tree/master/lib/torrents/trackers).
163
+ 3. Add a cached version of the tracker [here](https://github.com/oleander/Torrents/tree/master/spec/data). **Note:** Remember to remove sensitive data from the cache like user name and uid.
164
+ 4. Add tests for it, [here](https://github.com/oleander/Torrents/blob/master/spec/trackers/the_pirate_bay_spec.rb) is a skeleton for the Pirate Bay class to use as a start.
165
+ 5. Add the tracker to the readme.
166
+ 6. Do a pull request, if you want to share you implementation with the world.
167
+
168
+ You don't have to take care about exceptions, `Torrents` does that for you.
169
+
170
+ ## Disclaimer
171
+
172
+ Before you use `Torrents` make sure you have permission from the tracker in question to use their data.
173
+
174
+ ## How do install
175
+
176
+ [sudo] gem install torrents
177
+
178
+ ## How to use it in a rails 3 project
179
+
180
+ Add `gem 'torrents'` to your Gemfile and run `bundle`.
181
+
182
+ ## Requirements
183
+
184
+ Torrents is tested in OS X 10.6.6 using Ruby 1.9.2.
185
+
186
+ ## License
187
+
188
+ Torrents is released under the MIT license.
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
data/lib/torrents.rb ADDED
@@ -0,0 +1,140 @@
1
+ $:.push File.expand_path("../../lib/torrents", __FILE__)
2
+ $:.push File.expand_path("../../lib/torrents/trackers", __FILE__)
3
+
4
+ require 'rest_client'
5
+ require 'nokogiri'
6
+ require 'torrents/container'
7
+
8
+ class Torrents < Container::Shared
9
+ attr_accessor :page
10
+
11
+ def initialize
12
+ @torrents = []
13
+ @errors = []
14
+ @url = {
15
+ callback: lambda { |obj|
16
+ obj.send(:inner_recent_url)
17
+ },
18
+ search: {
19
+ value: ""
20
+ }
21
+ }
22
+ end
23
+
24
+ def exists?(tracker)
25
+ File.exists?(File.dirname(File.expand_path( __FILE__)) + "/torrents/trackers/" + tracker.to_s + ".rb")
26
+ end
27
+
28
+ def content
29
+ @content ||= Nokogiri::HTML(self.download(self.url))
30
+ end
31
+
32
+ # Set the default page
33
+ def inner_page
34
+ ((@page ||= 1) - 1 + self.inner_start_page_index).to_s
35
+ end
36
+
37
+ def url
38
+ @url[:callback].call(self).
39
+ gsub('<SEARCH>', @url[:search][:value]).
40
+ gsub('<PAGE>', self.inner_page)
41
+ end
42
+
43
+ # Makes this the {tracker} tracker
44
+ def add(tracker)
45
+ @tracker = tracker.to_s; self
46
+ end
47
+
48
+ def page(value)
49
+ @page = value
50
+ raise ArgumentError.new("To low page value, remember that the first page has the value 1") if self.inner_page.to_i < 0
51
+ self
52
+ end
53
+
54
+ def debugger(value)
55
+ @debug = value; self
56
+ end
57
+
58
+ # Set the search value
59
+ def search(value)
60
+ @url.merge!(:callback => lambda { |obj|
61
+ obj.send(:inner_search_url)
62
+ }, :search => {:value => value}); self
63
+ end
64
+
65
+ def category(cat)
66
+ @url.merge!(:callback => lambda { |obj|
67
+ obj.send(:inner_category_url, cat)
68
+ }); self
69
+ end
70
+
71
+ def cookies(args)
72
+ @cookies = args; self
73
+ end
74
+
75
+ # If the user is trying to do some funky stuff to the data
76
+ def method_missing(method, *args, &block)
77
+ return self.inner_call($1.to_sym, args.first) if method =~ /^inner_(.+)$/
78
+ super(method, args, block)
79
+ end
80
+
81
+ def self.method_missing(method, *args, &block)
82
+ this = Torrents.new
83
+ # Raises an exception if the site isn't in the trackers.yaml file
84
+ raise Exception.new("The site #{method} does not exist") unless this.exists?(method)
85
+
86
+ # Yes, I like return :)
87
+ return this.add(method)
88
+ end
89
+
90
+ # Returns a Container::Torrent object
91
+ # {details} (String) The details url for the torrent
92
+ def find_by_details(details)
93
+ self.create_torrent({
94
+ details: details,
95
+ tracker: @tracker
96
+ })
97
+ end
98
+
99
+ # Creates a torrent based on the ingoing arguments
100
+ # Is used by {find_by_details} and the {results} method
101
+ # Returns a Container::Torrent object
102
+ # {arguments} (Hash) The params to the Torrent constructor
103
+ # The debugger and cookie param is passed by default
104
+ def create_torrent(arguments)
105
+ arguments.merge!(:debug => @debug) if @debug
106
+ arguments.merge!(:cookies => @cookies) if @cookies
107
+ Container::Torrent.new(arguments)
108
+ end
109
+
110
+ # Returns errors from the application.
111
+ # Return type: A list of strings
112
+ def errors
113
+ self.results; @errors.uniq
114
+ end
115
+
116
+ def results
117
+ return @torrents if @torrents.any?
118
+ counter = 0
119
+ rejected = 0
120
+ self.inner_torrents(self.content).each do |tr|
121
+ counter += 1
122
+
123
+ torrent = self.create_torrent({
124
+ details: self.inner_details(tr),
125
+ torrent: self.inner_torrent(tr),
126
+ title: self.inner_title(tr).to_s.strip,
127
+ tracker: @tracker
128
+ })
129
+
130
+ if torrent.valid?
131
+ @torrents << torrent
132
+ else
133
+ rejected += 1
134
+ end
135
+ end
136
+
137
+ @errors << "#{counter} torrents where found, #{rejected} where not valid" unless rejected.zero?
138
+ return @torrents
139
+ end
140
+ end
@@ -0,0 +1,246 @@
1
+ module Container
2
+ require "rest_client"
3
+ require "nokogiri"
4
+ require 'rchardet19'
5
+ require "iconv"
6
+ require "classify"
7
+ require "digest/md5"
8
+ require "movie_searcher"
9
+ require "undertexter"
10
+
11
+ # Loads all trackers inside the trackers directory
12
+ Dir["#{File.dirname(File.expand_path(__FILE__))}/trackers/*.rb"].each {|rb| require "#{rb}"}
13
+
14
+ class Shared
15
+ include Trackers
16
+ # Downloads the URL, returns an empty string if an error occurred
17
+ # Here we try to convert the downloaded content to UTF8,
18
+ # if we"re at least 60% sure that the content that was downloaded actally is was we think
19
+ # The timeout is set to 10 seconds, after that time, an empty string will be returned
20
+ # {url} (String) The URL to download
21
+ def download(url)
22
+ begin
23
+ data = RestClient.get self.url_cleaner(url), {:timeout => 10, :cookies => @cookies}
24
+ cd = CharDet.detect(data)
25
+ return (cd["confidence"] > 0.6) ? (Iconv.conv(cd["encoding"] + "//IGNORE", "UTF-8", data) rescue data) : data
26
+ rescue
27
+ self.error("Something when wrong when trying to fetch #{url}", $!)
28
+ end
29
+
30
+ # The default value, if {RestClient} for some reason craches (like wrong encoding or a timeout)
31
+ return ""
32
+ end
33
+
34
+ # Prints a nice(er) error to the console if something went wrong
35
+ # This is only being called when trying to download or when trying to parse a page
36
+ # {messages} (String) The custom error to the user
37
+ # {error} (Exception) The actual error that was thrown
38
+ # TODO: Implement a real logger => http://www.ruby-doc.org/stdlib/libdoc/logger/rdoc/classes/Logger.html
39
+ def error(messages, error = "")
40
+ messages = messages.class == Array ? messages : [messages]
41
+ error = error.inspect[0..60]
42
+ @errors = [] unless @errors
43
+ @errors << "#{messages.join(", ")}\n#{error}"
44
+
45
+ return unless @debug
46
+
47
+ warn "An error in the Torrents gem occurred"
48
+ warn "==> " + messages.join("\n\t")
49
+ warn "==> " + error + " ..."
50
+ warn "\n\n"
51
+ end
52
+
53
+ # A middle caller that can handle errors for external trackers
54
+ # If the tracker that is being loaded in {load} crashes,
55
+ # then this method makes sure that the entire application won"t crash
56
+ # {method} (Hash) The method that is being called inside the trackers module
57
+ # {tr} (Nokogiri | Symbol) The object that contains the HTML content of the current row
58
+ def inner_call(method, option = nil)
59
+ begin
60
+ results = option.nil? ? self.load.send(method) : self.load.send(method, option) if self.valid_option?(method, option)
61
+ rescue
62
+ self.error("An error occurred in the #{@tracker} class at the #{method} method.", $!)
63
+ ensure
64
+ raise NotImplementedError.new("#{option} is not implemented yet") if results.nil? and method == :category_url
65
+ value = results.nil? ? self.default_values(method) : results
66
+ end
67
+
68
+ return value
69
+ end
70
+
71
+ # Returns default value if any of the below methods (:details for example) return an exception.
72
+ # If the method for some reason isn't implemented (is not in the hash below), then it will return an empty string
73
+ # {method} (Hash) The method that raised an exception
74
+ def default_values(method)
75
+ # warn "Something went wrong, we can't find the #{method} tag, using default values"
76
+ {torrent: "", torrents: [], seeders: 1, title: "", details: "", id: 0}[method] || ""
77
+ end
78
+
79
+ # Creating a singleton of the {tracker} class
80
+ def load
81
+ @load ||= eval("#{Classify.new.camelize(@tracker)}.new")
82
+ end
83
+
84
+ # Check to see if the ingoing arguments to the tracker if valid.
85
+ # If something goes wrong after the parser has been implemented, then it (the tracker) wont crash.
86
+ # Insted we write to a log file, so that the user can figure out the problem afterwards.
87
+ # {method} (Symbol) That method that is being called
88
+ # {option} (Object) That params to the {method}, can be anything, including {nil}
89
+ # Returns a boolean, {true} if the {method} can handle the {option} params, {false} otherwise.
90
+ def valid_option?(method, option)
91
+ case method
92
+ when :details, :title, :torrent
93
+ option.instance_of?(Nokogiri::XML::Element)
94
+ when :category_url
95
+ option.instance_of?(Symbol)
96
+ when :torrents, :seeders
97
+ option.instance_of?(Nokogiri::HTML::Document)
98
+ when :id
99
+ option.instance_of?(String)
100
+ else
101
+ true
102
+ end
103
+ end
104
+ # Cleans up the URL
105
+ # The ingoing param to the {open | RestClient} method can handle the special characters below.
106
+ # The only way to download the content that the URL points to is to escape those characters.
107
+ # Read more about it here => http://stackoverflow.com/questions/4999322/escape-and-download-url-using-ruby
108
+ # {url} (String) The url to escape
109
+ # Returns an escaped string
110
+ def url_cleaner(url)
111
+ url.gsub(/\{|\}|\||\\|\^|\[|\]|\`|\s+/) { |m| CGI::escape(m) }
112
+ end
113
+ end
114
+
115
+ class Torrent < Shared
116
+ attr_accessor :details
117
+
118
+ def initialize(args)
119
+ args.keys.each { |name| instance_variable_set "@" + name.to_s, args[name] }
120
+ @errors = [] unless @errors
121
+ @debug = false unless @debug
122
+ end
123
+
124
+ # Is the torrent dead?
125
+ # The definition of dead is; no seeders
126
+ # Returns a boolean
127
+ def dead?
128
+ self.seeders <= 0
129
+ end
130
+
131
+ # Returns the amount of seeders for the current torrent
132
+ # If the seeder-tag isn't found, the value one (1) will be returned.
133
+ # Returns an integer from 0 to inf
134
+ def seeders
135
+ @seeders ||= self.inner_call(:seeders, self.content).to_i
136
+ end
137
+
138
+ # Is the torrent valid?
139
+ # The definition of valid:
140
+ # Non of the accessors
141
+ # => is nil
142
+ # => contains htmltags
143
+ # => starts or ends with whitespace
144
+ # It must also stand up to the following requirements
145
+ # => The details and torrent url must be valid
146
+ # => The id for the torrent must only contain integers.
147
+ # Returns {true} or {false}
148
+ def valid?
149
+ [:details, :torrent, :title, :id].each do |method|
150
+ data = self.send(method)
151
+ return false if self.send(method).nil? or
152
+ data.to_s.empty? or
153
+ data.to_s.match(/<\/?[^>]*>/) or
154
+ data.to_s.strip != data.to_s
155
+ end
156
+
157
+ return [
158
+ !! self.valid_url?(self.details),
159
+ !! self.valid_torrent?(self.torrent),
160
+ !! self.inner_call(:id, self.details).to_s.match(/^\d+$/)
161
+ ].all?
162
+ end
163
+
164
+ # Downloads the detailed view for this torrent
165
+ # Returns an Nokogiri object
166
+ def content
167
+ @content ||= Nokogiri::HTML self.download(@details)
168
+ end
169
+
170
+ # Check to see if the ingoing param is a valid url or not
171
+ def valid_url?(url)
172
+ !! url.match(/(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/i)
173
+ end
174
+
175
+ # Check to see if the ingoing param is a valid torrent url or not
176
+ # The url has to be a valid url and has to end with .torrent
177
+ def valid_torrent?(torrent)
178
+ torrent.match(/\.torrent$/) and self.valid_url?(torrent)
179
+ end
180
+
181
+ # Generates an id using the details url
182
+ def id
183
+ @id ||= self.inner_call(:id, self.details).to_i
184
+ end
185
+
186
+ # Returns the domain for the torrent, without http or www
187
+ # If the domain for some reason isn't found, it will use an empty string
188
+ def domain
189
+ @domain ||= self.details.match(/(ftp|http|https):\/\/([w]+\.)?(.+?\.[a-z]{2,3})/i).to_a[3] || ""
190
+ end
191
+
192
+ # Returns a unique id for the torrent based on the domain and the id of the torrent
193
+ def tid
194
+ @tid ||= Digest::MD5.hexdigest("#{domain}#{id}")
195
+ end
196
+
197
+ # Just a mirror method for {tid}, just in case someone don't like the method name tid
198
+ def torrent_id
199
+ @torrent_id ||= self.tid
200
+ end
201
+
202
+ # Returns the full url to the related imdb page
203
+ # The link is parsed from the details view
204
+ # Example: http://www.imdb.com/title/tt0066026
205
+ # Return type: String or nil
206
+ def imdb
207
+ @imdb ||= self.content.to_s.match(/((http:\/\/)?([w]{3}\.)?imdb.com\/title\/tt\d+)/i).to_a[1]
208
+ end
209
+
210
+ # Returns the imdb id for the torrent, including the tt at the beginning
211
+ # Example: tt0066026
212
+ # Return type: String or nil
213
+ def imdb_id
214
+ @imdb_id ||= self.imdb.to_s.match(/(tt\d+)/).to_a[1]
215
+ end
216
+
217
+ # Returns an movie_searcher object based on the imdb_id, if it exists, otherwise the torrent title
218
+ # Read more about it here: https://github.com/oleander/MovieSearcher
219
+ # Return type: A MovieSearcher object or nil
220
+ def movie
221
+ self.imdb_id.nil? ? MovieSearcher.find_by_release_name(self.title, :options => {:details => true}) : MovieSearcher.find_movie_by_id(self.imdb_id)
222
+ end
223
+
224
+ # Returns the title for the torrent
225
+ # If the title has't been set from the Torrents class, we will download the details page try to find it there.
226
+ # Return type: String or nil
227
+ def title
228
+ @title ||= self.inner_call(:details_title, self.content)
229
+ @title = @title.strip unless @title.nil?
230
+ end
231
+
232
+ # Returns a Undertexter object, if we found a imdb_id, otherwise nil
233
+ # Read more about it here: https://github.com/oleander/Undertexter
234
+ # Return type: A single Undertexter object or nil
235
+ def subtitle(option = :english)
236
+ @subtitle ||= Undertexter.find(self.imdb_id, language: option).based_on(self.title)
237
+ end
238
+
239
+ # Returns the torrent for the torrent
240
+ # If the torrent has't been set from the Torrents class, we will download the details page try to find it there.
241
+ # Return type: String or nil
242
+ def torrent
243
+ @torrent ||= self.inner_call(:details_torrent, self.content)
244
+ end
245
+ end
246
+ end