nyaa 0.3.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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