nyaa 0.3.0 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/nyaa/cli.rb ADDED
@@ -0,0 +1,75 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module Nyaa
3
+ class CLI
4
+ def self.parse(args)
5
+ @config = {}
6
+
7
+ opts = OptionParser.new do |opt|
8
+ opt.banner = 'Usage: nyaa [options] QUERY'
9
+ opt.separator ''
10
+ opt.separator 'Specific options:'
11
+
12
+ @config[:category] = 'anime_english'
13
+ opt.on('-c', '--category=CATEGORY', 'Select a search category (default: anime_english)') do |cat|
14
+ @config[:category] = cat
15
+ end
16
+
17
+ @config[:filter] = 'show_all'
18
+ opt.on('-f', '--filter=FILTER', 'Filter for search query (default: show_all)') do |filter|
19
+ @config[:filter] = filter
20
+ end
21
+
22
+ @config[:output] = Dir.pwd
23
+ opt.on('-o', '--output-path=PATH', 'Output directory for downloads') do |output|
24
+ @config[:output] = output
25
+ end
26
+
27
+ opt.on('-b', '--batch', 'Batch mode for scripting (first page only)') do |batch|
28
+ @config[:batch] = batch
29
+ end
30
+
31
+ opt.on('-t', '--classic', 'Use the old interface (deprecated)') do |classic|
32
+ @config[:classic] = classic
33
+ end
34
+
35
+ opt.on('-v', '--version', 'Print version info') do
36
+ puts "nyaa #{Nyaa::VERSION}"
37
+ puts "Copyright (c) 2013 David Palma"
38
+ puts "https://github.com/mistofvongola/nyaa"
39
+ exit
40
+ end
41
+
42
+ opt.on_tail '-h', '--help', 'Show usage info' do
43
+ puts opts
44
+ puts %s{
45
+ Categories:
46
+ anime_all, anime_raw, anime_english, anime_nonenglish, anime_music_video
47
+ books_all, books_raw, books_english, books_nonenglish
48
+ live_all, live_raw, live_english, live_nonenglish, live_promo
49
+ audio_all, audio_lossless, audio_lossy
50
+ pictures_all, pictures_photos, pictures_graphics,
51
+ software_all, software_apps, software_games
52
+
53
+ Filters:
54
+ show_all, filter_remakes, trusted_only, aplus_only}
55
+ exit
56
+ end
57
+ end
58
+
59
+ # grab leftovers
60
+ @config[:query] = opts.parse!(args).join(' ')
61
+
62
+ unless Nyaa::CATS.has_key?(@config[:category].to_sym)
63
+ puts "#{@config[:category]} is not a valid category"
64
+ exit 1
65
+ end
66
+
67
+ unless Nyaa::FILS.has_key?(@config[:filter].to_sym)
68
+ puts "#{@config[:filter]} is not a valid filter"
69
+ exit 1
70
+ end
71
+
72
+ @config
73
+ end # parse
74
+ end # CLI
75
+ end # Nyaa
@@ -0,0 +1,124 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module Nyaa
3
+
4
+ BASE_URL = 'http://www.nyaa.eu/?page=search'
5
+ PSIZE = 100
6
+
7
+ CATS = {
8
+ :all_categories => {
9
+ :id => '0_0',
10
+ :title => 'All Categories'
11
+ },
12
+ :anime_all => {
13
+ :id => '1_0',
14
+ :title => 'Anime'
15
+ },
16
+ :anime_raw => {
17
+ :id => '1_11',
18
+ :title => 'Anime >> Raw Anime'
19
+ },
20
+ :anime_english => {
21
+ :id => '1_37',
22
+ :title => 'Anime >> English-translated Anime'
23
+ },
24
+ :anime_nonenglish => {
25
+ :id => '1_38',
26
+ :title => 'Anime >> Non-English-translated Anime'
27
+ },
28
+ :anime_music_video => {
29
+ :id => '1_32',
30
+ :title => 'Anime >> Anime Music Video'
31
+ },
32
+ :books_all => {
33
+ :id => '2_0',
34
+ :title => 'Books'
35
+ },
36
+ :books_raw => {
37
+ :id => '2_13',
38
+ :title => 'Books >> Raw Books'
39
+ },
40
+ :books_english => {
41
+ :id => '2_12',
42
+ :title => 'Books >> English-scanlated Books'
43
+ },
44
+ :books_nonenglish => {
45
+ :id => '2_39',
46
+ :title => 'Books >> Non-English-scanlated Books'
47
+ },
48
+ :audio_all => {
49
+ :id => '3_0',
50
+ :title => 'Audio'
51
+ },
52
+ :audio_lossless => {
53
+ :id => '3_14',
54
+ :title => 'Audio >> Lossless Audio'
55
+ },
56
+ :audio_lossy => {
57
+ :id => '3_15',
58
+ :title => 'Audio >> Lossy Audio'
59
+ },
60
+ :pictures_all => {
61
+ :id => '4_0',
62
+ :title => 'Pictures'
63
+ },
64
+ :pictures_photos => {
65
+ :id => '4_17',
66
+ :title => 'Pictures >> Photos'
67
+ },
68
+ :pictures_graphics => {
69
+ :id => '4_18',
70
+ :title => 'Pictures >> Graphics'
71
+ },
72
+ :live_all => {
73
+ :id => '5_0',
74
+ :title => 'Live Action'
75
+ },
76
+ :live_raw => {
77
+ :id => '5_20',
78
+ :title => 'Live Action >> Raw Live Action'
79
+ },
80
+ :live_english => {
81
+ :id => '5_19',
82
+ :title => 'Live Action >> English-translated Live Action'
83
+ },
84
+ :live_nonenglish => {
85
+ :id => '5_21',
86
+ :title => 'Live Action >> Non-English-translated Live Action'
87
+ },
88
+ :live_promo => {
89
+ :id => '5_22',
90
+ :title => 'Live Action >> Live Action Promotional Video'
91
+ },
92
+ :software_all => {
93
+ :id => '6_0',
94
+ :title => 'Software'
95
+ },
96
+ :software_apps => {
97
+ :id => '6_23',
98
+ :title => 'Software >> Applications'
99
+ },
100
+ :software_games => {
101
+ :id => '6_24',
102
+ :title => 'Software >> Games'
103
+ },
104
+ }
105
+
106
+ FILS = {
107
+ :show_all => {
108
+ :id => '0',
109
+ :title => 'Show all'
110
+ },
111
+ :filter_remakes => {
112
+ :id => '1',
113
+ :title => 'Filter Remakes'
114
+ },
115
+ :trusted_only => {
116
+ :id => '2',
117
+ :title => 'Trusted only'
118
+ },
119
+ :aplus_only => {
120
+ :id => '3',
121
+ :title => 'A+ only'
122
+ },
123
+ }
124
+ end
@@ -0,0 +1,65 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module Nyaa
3
+ class Downloader
4
+ attr_accessor :target, :destination, :retries
5
+ attr_accessor :response, :filename
6
+
7
+ def initialize(url, path, retries = 3)
8
+ self.target = url
9
+ self.destination = sane_dir(path)
10
+ self.retries = retries
11
+ self.response = request
12
+ self.filename = name_from_disposition
13
+
14
+ @fail = nil
15
+ end
16
+
17
+ def save
18
+ unless @fail
19
+ File.open("#{self.destination}/#{filename}", 'w') do |f|
20
+ f.write(self.response.body)
21
+ end
22
+ end
23
+ end
24
+
25
+ def failed?
26
+ @fail
27
+ end
28
+
29
+ private
30
+
31
+ def request
32
+ begin
33
+ response = RestClient.get(self.target)
34
+ rescue StandardError => e
35
+ if retries > 0
36
+ retries -= 1
37
+ sleep = 1
38
+ retry
39
+ end
40
+ @fail = true
41
+ end
42
+ @fail = false
43
+ response
44
+ end
45
+
46
+ # Filename from Content Disposition Header Field
47
+ # http://www.ietf.org/rfc/rfc2183.txt
48
+ def name_from_disposition
49
+ disp = self.response.headers[:content_disposition]
50
+ disp_filename = disp.split(/;\s+/).select { |v| v =~ /filename\s*=/ }[0]
51
+ re = /([""'])(?:(?=(\\?))\2.)*?\1/
52
+ if re.match(disp_filename)
53
+ filename = re.match(disp_filename).to_s.gsub(/\A['"]+|['"]+\Z/, "")
54
+ else
55
+ nil
56
+ end
57
+ end
58
+
59
+ def sane_dir(path)
60
+ path = Dir.pwd if path.nil? || !File.writable?(path)
61
+ FileUtils.mkdir_p path unless File.directory?(path)
62
+ path
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,113 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module Nyaa
3
+ class Search
4
+ attr_accessor :query, :category, :filter
5
+ attr_accessor :offset, :count, :results
6
+ attr_accessor :runid, :cachedir
7
+
8
+ def initialize(query, cat = nil, fil = nil)
9
+ self.query = URI.escape(query)
10
+ self.category = cat ? CATS[cat.to_sym][:id] : '0_0'
11
+ self.filter = fil ? FILS[fil.to_sym][:id] : '0'
12
+ self.offset = 0
13
+ self.results = []
14
+ self.count = 1.0/0.0
15
+
16
+ self.runid = Time.new.to_i
17
+ self.cachedir = File.expand_path('~/.nyaa/cache')
18
+ FileUtils.mkdir_p(cachedir) unless File.directory?(cachedir)
19
+ end
20
+
21
+ # Returns current batch (page) results
22
+ def get_results
23
+ if self.offset.zero?
24
+ batch = []
25
+ elsif self.offset == 1
26
+ batch = self.results[0, 100]
27
+ else # self.offset > 1
28
+ batch = self.results[(self.offset - 1) * 100, 100]
29
+ end
30
+ batch
31
+ end
32
+
33
+ def more
34
+ self.offset += 1
35
+ if self.results.length < self.count
36
+ extract(self.offset)
37
+ else
38
+ self.results = []
39
+ puts "No more results"
40
+ end
41
+ self
42
+ end
43
+
44
+ def cached(page)
45
+ cachefile = "#{self.cachedir}/cache_#{self.runid}_p#{page}"
46
+ p cachefile
47
+ return nil unless File.exists?(cachefile)
48
+
49
+ File.open(cachefile, 'rb') do |file|
50
+ begin
51
+ results = Marshal.load(file)
52
+ rescue => e
53
+ puts "ERROR: Failed to load #{cachefile}"
54
+ puts "#{e.backtrace}: #{e.message} (#{e.class})"
55
+ end
56
+ end
57
+ results
58
+ end
59
+
60
+ def purge
61
+ FileUtils.rm_rf Dir.glob("#{self.cachedir}/*")
62
+ end
63
+
64
+ private
65
+
66
+ def dump(page, results)
67
+ cachefile = "cache_#{self.runid}_p#{page}"
68
+ File.open("#{self.cachedir}/#{cachefile}", 'wb') do |file|
69
+ begin
70
+ Marshal.dump(results, file)
71
+ rescue => e
72
+ puts "ERROR: Failed to dump #{cachefile}"
73
+ puts "#{e.backtrace}: #{e.message} (#{e.class})"
74
+ ensure
75
+ file.close
76
+ end
77
+ end
78
+ end
79
+
80
+ def dump_json(page, results)
81
+ cachefile = "cache_#{self.runid}_p#{page}.json"
82
+ File.open("#{self.cachedir}/#{cachefile}", 'w') do |file|
83
+ begin
84
+ batch = []
85
+ results.each { |r| batch << r.to_hash }
86
+ file.write(batch.to_json)
87
+ rescue => e
88
+ puts "ERROR: Failed to dump #{cachefile}"
89
+ puts "#{e.backtrace}: #{e.message} (#{e.class})"
90
+ ensure
91
+ file.close
92
+ end
93
+ end
94
+ end
95
+
96
+ def extract(page)
97
+ raw = fetch(page)
98
+ doc = Nokogiri::HTML(raw)
99
+ self.count = doc.css('span.notice').text.match(/\d+/).to_s.to_i
100
+ rows = doc.css('div#main div.content table.tlist tr.tlistrow')
101
+ rows.each { |row| self.results << Torrent.new(row) }
102
+ dump(page, self.results)
103
+ #dump_json(page, self.results)
104
+ end
105
+
106
+ def fetch(page)
107
+ url = "#{BASE_URL}&offset=#{page}"
108
+ url << "&cats=#{self.category}&filter=#{self.filter}"
109
+ url << "&term=#{self.query}" unless self.query.empty?
110
+ open(url).read
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,65 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module Nyaa
3
+
4
+ class Torrent
5
+ attr_accessor :tid, :name, :info, :link
6
+ attr_accessor :filesize, :seeders, :leechers
7
+ attr_accessor :category, :status, :downloads, :comments
8
+ attr_accessor :health, :bytes
9
+
10
+ def initialize (row = nil)
11
+ self.tid = row.css('td.tlistname').at('a')['href'][/tid=\d+/].gsub(/\D/,'')
12
+ self.name = row.css('td.tlistname').at('a').text.strip
13
+ self.info = row.css('td.tlistname').at('a')['href']
14
+ self.link = row.css('td.tlistdownload').at('a')['href']
15
+ self.filesize = row.css('td.tlistsize').text
16
+ self.seeders = row.css('td.tlistsn').text.to_i
17
+ self.leechers = row.css('td.tlistln').text.to_i
18
+
19
+ self.status = state(row.values[0])
20
+ self.category = row.css('td.tlisticon').at('a')['title']
21
+ self.downloads = row.css('td.tlistdn').text
22
+ self.comments = row.css('td.tlistmn').text
23
+ end
24
+
25
+
26
+ def health( leech_weight = 0.5, seed_weight = 1.0 )
27
+ seeders.zero? ? 0 : seeders * seed_weight + leechers * leech_weight
28
+ end
29
+
30
+ def bytes
31
+ match = filesize.match(/([\d.]+)(.*)/)
32
+ if match
33
+ raw_size = match[1].to_f
34
+ case match[2].strip
35
+ when /gib/i then raw_size * 1000000000
36
+ when /mib/i then raw_size * 1000000
37
+ when /kib/i then raw_size * 1000
38
+ else nil
39
+ end
40
+ else
41
+ nil
42
+ end
43
+ end
44
+
45
+ def state(value)
46
+ case value
47
+ when 'trusted tlistrow' then status = 'Trusted'
48
+ when 'remake tlistrow' then status = 'Remake'
49
+ when 'aplus tlistrow' then status = 'A+'
50
+ when 'tlistrow' then status = 'Normal'
51
+ else status = 'Normal'
52
+ end
53
+ status
54
+ end
55
+
56
+ def to_hash
57
+ hash = {}
58
+ instance_variables.each do |var|
59
+ hash[var.to_s.delete("@")] = instance_variable_get(var)
60
+ end
61
+ hash
62
+ end
63
+
64
+ end
65
+ end
data/lib/nyaa/ui.rb ADDED
@@ -0,0 +1,260 @@
1
+ # -*- encoding : utf-8 -*-
2
+ include Curses
3
+
4
+ module Nyaa
5
+ class UI
6
+ attr_accessor :menusize, :page
7
+
8
+ def initialize (config, search)
9
+ @config = config
10
+ @status = { :text => 'Ready.', :type => :default }
11
+ setup_curses
12
+
13
+ # columns
14
+ @info_columns = %w[ Size SE LE ]
15
+ @info_column_width = 10
16
+ @name_column_width = 0
17
+
18
+ # commands
19
+ @commands = {
20
+ '?' => 'help',
21
+ 'g' => 'get',
22
+ 'i' => 'info',
23
+ 'n' => 'next',
24
+ 'p' => 'prev',
25
+ 'q' => 'quit',
26
+ }
27
+
28
+ @search = search
29
+ @torrents = @search.more.results
30
+ @num_torrents = @torrents.size
31
+ harvester # start bg harvester
32
+
33
+ @menusize = lines - 4 # lines - header + footer + status
34
+ @page = 1
35
+ @num_pages = (@search.count/@menusize.to_f).ceil
36
+ @offset = 0
37
+ # TODO: on hold state: prevent paging forward when waiting on results
38
+ end
39
+
40
+ def header
41
+ # pad info columns to column width
42
+ @info_columns = @info_columns.map do |column|
43
+ sprintf("%#{@info_column_width}s", column)
44
+ end
45
+ # key column width is whatever is leftover
46
+ @name_column_width = cols - (@info_columns.length * @info_column_width)
47
+
48
+ # header bar
49
+ cat = CATS[@config[:category].to_sym][:title]
50
+ header_text = sprintf " %-#{@name_column_width-1}s%s", "Nyaa - #{cat}", @info_columns.join
51
+ attrset(color_pair(1))
52
+ setpos(0,0)
53
+ addstr(sprintf "%-#{cols}s", header_text)
54
+ end
55
+
56
+ def footer
57
+ footer_text = @commands.map { |k,v| "#{k}: #{v}" }.join(' ')
58
+ attrset(color_pair(2))
59
+ setpos(lines - 1, 0)
60
+ addstr(sprintf " %-#{cols}s", footer_text)
61
+
62
+ search_summary = sprintf " %-14s %-14s %-14s",
63
+ "view: [#{@offset+1}-#{@offset+@menusize}]/#{@search.count}",
64
+ "recv: #{@num_torrents}/#{@search.count}",
65
+ "page: #{@page}/#{@num_pages}"
66
+ attrset(color_pair(2))
67
+ setpos(lines - 2, 0)
68
+ addstr(sprintf "%-#{cols}s", search_summary)
69
+ end
70
+
71
+ def status(text = nil, type = nil)
72
+ @status[:text] = text if text
73
+ @status[:type] = type
74
+
75
+ case @status[:type]
76
+ when :success then profile = 8
77
+ when :failure then profile = 9
78
+ else profile = 1
79
+ end
80
+
81
+ status_text = sprintf " Status: %-s", @status[:text]
82
+ attrset(color_pair(profile))
83
+ setpos(lines-3,0)
84
+ addstr(sprintf "%-#{cols}s", status_text)
85
+ refresh
86
+ end
87
+
88
+ def menu(highlight)
89
+ xpos = 1
90
+ attrset(color_pair(0))
91
+ setpos(xpos, 0)
92
+
93
+ (0..@menusize-1).each do |i|
94
+ if i < @search.count - @offset
95
+ if @torrents[@offset + i].nil?
96
+ status("Fetching more results, try again.", :failure)
97
+ else
98
+ line_text = sprintf("% -#{@name_column_width}s %9s %9s %9s",
99
+ truncate("#{@torrents[@offset + i].name}", @name_column_width),
100
+ @torrents[@offset + i].filesize,
101
+ @torrents[@offset + i].seeders,
102
+ @torrents[@offset + i].leechers)
103
+
104
+ attrset(color_pair(torrent_status(@torrents[@offset + i])))
105
+ setpos(xpos, 0)
106
+ # highlight the present choice
107
+ if highlight == i + 1
108
+ attron(A_STANDOUT)
109
+ addstr(line_text)
110
+ attroff(A_STANDOUT)
111
+ else
112
+ addstr(line_text)
113
+ end
114
+ xpos += 1
115
+ end
116
+ else
117
+ # blank lines if there's < @menusize of results
118
+ line_text = " "*cols
119
+ addstr(line_text)
120
+ xpos += 1
121
+ end
122
+ end
123
+ end
124
+
125
+ # TODO: Pop up help window
126
+ def help
127
+ end
128
+
129
+ def move(cursor, increment)
130
+ if increment < 0 # negative, retreat!
131
+ if cursor == 1
132
+ if @offset == 0
133
+ cursor = 1
134
+ else
135
+ prev_page
136
+ cursor = menusize
137
+ end
138
+ else
139
+ cursor = cursor + increment
140
+ end
141
+ else # non-negative, advance!
142
+ if cursor == menusize
143
+ next_page
144
+ cursor = 1
145
+ else
146
+ cursor = cursor + increment
147
+ end
148
+ end
149
+ end
150
+
151
+ def get(choice)
152
+ torrent = @torrents[@offset + choice - 1]
153
+ download = Downloader.new(torrent.link, @config[:output])
154
+ download.save
155
+ unless download.failed?
156
+ status("Downloaded successful: #{torrent.tid}", :success)
157
+ else
158
+ status("Download failed (3 attempts): #{torrent.tid}", :failure)
159
+ end
160
+ end
161
+
162
+ def open(choice)
163
+ torrent = @torrents[@offset + choice - 1]
164
+ link = "#{torrent.info}"
165
+ if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/ then
166
+ system("start #{link}")
167
+ elsif RbConfig::CONFIG['host_os'] =~ /darwin/ then
168
+ system("open '#{link}'", [:out, :err]=>'/dev/null')
169
+ elsif RbConfig::CONFIG['host_os'] =~ /linux/ then
170
+ system("xdg-open '#{link}'", [:out, :err]=>'/dev/null')
171
+ end
172
+ status("Opened '#{link}'", :success)
173
+ end
174
+
175
+ def next_page
176
+ status("Ready.")
177
+ unless @page + 1 > @num_pages
178
+ @page += 1
179
+ end
180
+
181
+ unless @offset + @menusize > @num_torrents
182
+ @offset += @menusize
183
+ end
184
+ end
185
+
186
+ def prev_page
187
+ unless @page - 1 < 1
188
+ @page += -1
189
+ end
190
+
191
+ unless @offset == 0
192
+ @offset -= @menusize
193
+ end
194
+ end
195
+
196
+ def resize_handler(cursor)
197
+ @menusize = lines - 4
198
+ menu(cursor)
199
+ refresh
200
+ end
201
+
202
+ def harvester
203
+ Thread.new do
204
+ until @num_torrents == @search.count
205
+ @torrents = @search.more.results
206
+ @num_torrents = @torrents.size
207
+ sleep 2
208
+ end
209
+ Thread.kill
210
+ end
211
+ end
212
+
213
+ private
214
+
215
+ def setup_curses
216
+ noecho # don't show typed keys
217
+ init_screen # start curses mode
218
+ cbreak # disable line buffering
219
+ curs_set(0) # make cursor invisible
220
+ stdscr.keypad(true) # enable arrow keys
221
+
222
+ # set keyboard input timeout - sneaky way to manage refresh rate
223
+ timeout = 500 # milliseconds
224
+
225
+ if can_change_color?
226
+ start_color
227
+ init_pair(0, COLOR_WHITE, COLOR_BLACK) # ui:default
228
+ init_pair(1, COLOR_BLACK, COLOR_CYAN) # ui:header
229
+ init_pair(2, COLOR_BLACK, COLOR_YELLOW) # ui:footer
230
+ init_pair(3, COLOR_MAGENTA, COLOR_BLACK) # ui:selected
231
+
232
+ init_pair(4, COLOR_WHITE, COLOR_BLACK) # torrent:normal
233
+ init_pair(5, COLOR_GREEN, COLOR_BLACK) # torrent:trusted
234
+ init_pair(6, COLOR_BLUE, COLOR_BLACK) # torrent:aplus
235
+ init_pair(7, COLOR_RED, COLOR_BLACK) # torrent:remake
236
+
237
+ init_pair(8, COLOR_BLACK, COLOR_GREEN) # type:success
238
+ init_pair(9, COLOR_BLACK, COLOR_RED) # type:failure
239
+ end
240
+ end
241
+
242
+ def torrent_status(torrent)
243
+ case torrent.status
244
+ when 'Trusted' then 5
245
+ when 'A+' then 6
246
+ when 'Remake' then 7
247
+ else 4
248
+ end
249
+ end
250
+
251
+ def truncate(text, width)
252
+ if text.length > width
253
+ truncated = "#{text[0..width-4]}..."
254
+ else
255
+ text
256
+ end
257
+ end
258
+
259
+ end # UI
260
+ end # Nyaa