kat 1.0.0 → 2.0.0
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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/README.md +45 -2
- data/Rakefile +1 -1
- data/bin/kat +2 -124
- data/lib/kat.rb +2 -247
- data/lib/kat/app.rb +276 -0
- data/lib/kat/colour.rb +77 -0
- data/lib/kat/field_map.rb +122 -0
- data/lib/kat/options.rb +59 -0
- data/lib/kat/search.rb +307 -0
- data/lib/kat/version.rb +5 -3
- data/test/kat/test_app.rb +121 -0
- data/test/kat/test_colour.rb +136 -0
- data/test/kat/test_field_map.rb +46 -0
- data/test/kat/test_options.rb +77 -0
- data/test/{kat_test.rb → kat/test_search.rb} +22 -24
- metadata +17 -4
data/lib/kat/app.rb
ADDED
@@ -0,0 +1,276 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../kat'
|
2
|
+
require File.dirname(__FILE__) + '/options'
|
3
|
+
require File.dirname(__FILE__) + '/colour'
|
4
|
+
|
5
|
+
require 'highline'
|
6
|
+
require 'yaml'
|
7
|
+
|
8
|
+
module Kat
|
9
|
+
|
10
|
+
class << self
|
11
|
+
#
|
12
|
+
# Convenience method for the App class
|
13
|
+
#
|
14
|
+
def app(args = ARGV)
|
15
|
+
App.new(args).main
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class App
|
20
|
+
MIN_WIDTH = 80
|
21
|
+
|
22
|
+
# The current page number (0-based)
|
23
|
+
attr_accessor :page
|
24
|
+
|
25
|
+
# The +Kat::Search+ search object
|
26
|
+
attr_reader :kat
|
27
|
+
|
28
|
+
# The app options hash
|
29
|
+
attr_reader :options
|
30
|
+
|
31
|
+
#
|
32
|
+
# Create a new +Kat::App+ object, using command-line switches as options by default
|
33
|
+
#
|
34
|
+
def initialize(args = ARGV)
|
35
|
+
@kat = nil
|
36
|
+
@page = 0
|
37
|
+
@window_width = 0
|
38
|
+
@show_info = !hide_info?
|
39
|
+
@h = HighLine.new
|
40
|
+
|
41
|
+
init_options args
|
42
|
+
init_search
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# Initialise the app's options
|
47
|
+
#
|
48
|
+
def init_options(args = nil)
|
49
|
+
@options = {}
|
50
|
+
@args = case args
|
51
|
+
when nil then []
|
52
|
+
when String then args.split
|
53
|
+
else args
|
54
|
+
end
|
55
|
+
|
56
|
+
load_config
|
57
|
+
|
58
|
+
Kat.options(@args).tap { |o|
|
59
|
+
@options.merge!(o) { |k, ov, nv| o["#{ k }_given".intern] ? nv : ov }
|
60
|
+
}
|
61
|
+
|
62
|
+
Kat::Colour.colour = @options[:colour]
|
63
|
+
end
|
64
|
+
|
65
|
+
#
|
66
|
+
# Initialise the +Kat::Search+ object with the query and it's options
|
67
|
+
#
|
68
|
+
def init_search
|
69
|
+
@kat ||= Kat.search
|
70
|
+
@kat.query = @args.join(' ')
|
71
|
+
@kat.options = @options
|
72
|
+
end
|
73
|
+
|
74
|
+
#
|
75
|
+
# The main method. Prints a list of options for categories, platforms,
|
76
|
+
# languages or times if the user has asked for them, otherwise will loop
|
77
|
+
# over the running method until the user quits (or there's no results).
|
78
|
+
#
|
79
|
+
def main
|
80
|
+
puts VERSION_STR
|
81
|
+
|
82
|
+
Kat::Search.selects.select { |k, v| @options[v[:select]] }.tap { |lists|
|
83
|
+
if lists.empty?
|
84
|
+
while running; end
|
85
|
+
else
|
86
|
+
puts format_lists lists
|
87
|
+
end
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
#
|
94
|
+
# Get the width of the terminal window
|
95
|
+
#
|
96
|
+
def set_window_width
|
97
|
+
@window_width = @h.terminal_size[0]
|
98
|
+
end
|
99
|
+
|
100
|
+
#
|
101
|
+
# Hide extra info if window width is 80 chars or less
|
102
|
+
#
|
103
|
+
def hide_info?
|
104
|
+
@window_width < 81
|
105
|
+
end
|
106
|
+
|
107
|
+
#
|
108
|
+
# Is there a next page?
|
109
|
+
#
|
110
|
+
def next?
|
111
|
+
@page < @kat.pages - 1
|
112
|
+
end
|
113
|
+
|
114
|
+
#
|
115
|
+
# Is there a previous page?
|
116
|
+
#
|
117
|
+
def prev?
|
118
|
+
@page > 0
|
119
|
+
end
|
120
|
+
|
121
|
+
#
|
122
|
+
# Do the search, output the results and prompt the user for what to do next.
|
123
|
+
# Returns false on error or the user enters 'q', otherwise returns true to
|
124
|
+
# signify to the main loop it should run again.
|
125
|
+
#
|
126
|
+
def running
|
127
|
+
puts
|
128
|
+
set_window_width
|
129
|
+
|
130
|
+
searching = true
|
131
|
+
[
|
132
|
+
-> {
|
133
|
+
@kat.search @page
|
134
|
+
searching = false
|
135
|
+
},
|
136
|
+
|
137
|
+
-> {
|
138
|
+
i = 0
|
139
|
+
while searching do
|
140
|
+
print "\rSearching...".yellow + '\\|/-'[i % 4]
|
141
|
+
i += 1
|
142
|
+
sleep 0.1
|
143
|
+
end
|
144
|
+
}
|
145
|
+
].map { |w| Thread.new { w.call } }.each(&:join)
|
146
|
+
|
147
|
+
unless @kat.results[@page] && !@kat.error
|
148
|
+
puts "\rNo results ".red
|
149
|
+
puts @kat.error[:error] if @kat.error
|
150
|
+
return false
|
151
|
+
end
|
152
|
+
|
153
|
+
puts format_results
|
154
|
+
|
155
|
+
case (answer = prompt)
|
156
|
+
when 'i' then @show_info = !@show_info
|
157
|
+
when 'n' then @page += 1 if next?
|
158
|
+
when 'p' then @page -= 1 if prev?
|
159
|
+
when 'q' then return false
|
160
|
+
else
|
161
|
+
if (1..@kat.results[@page].size).include? (answer = answer.to_i)
|
162
|
+
print "\nDownloading".yellow <<
|
163
|
+
": #{ @kat.results[@page][answer - 1][:title] }... "
|
164
|
+
puts download @kat.results[@page][answer - 1]
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
true
|
169
|
+
end
|
170
|
+
|
171
|
+
#
|
172
|
+
# Format a list of options
|
173
|
+
#
|
174
|
+
def format_lists(lists)
|
175
|
+
lists.inject([nil]) { |buf, (k, v)|
|
176
|
+
opts = Kat::Search.send(v[:select])
|
177
|
+
buf << v[:select].to_s.capitalize
|
178
|
+
buf << nil unless Array === opts.values.first
|
179
|
+
width = opts.keys.sort { |a, b| b.size <=> a.size }.first.size
|
180
|
+
opts.each { |k, v|
|
181
|
+
buf += if Array === v
|
182
|
+
[nil, "%#{ width }s => #{ v.shift }" % k] +
|
183
|
+
v.map { |e| ' ' * (width + 4) + e }
|
184
|
+
else
|
185
|
+
["%-#{ width }s => #{ v }" % k]
|
186
|
+
end
|
187
|
+
}
|
188
|
+
buf << nil
|
189
|
+
}
|
190
|
+
end
|
191
|
+
|
192
|
+
#
|
193
|
+
# Format the list of results with header information
|
194
|
+
#
|
195
|
+
def format_results
|
196
|
+
main_width = @window_width - (!hide_info? || @show_info ? 42 : 4)
|
197
|
+
|
198
|
+
buf = ["\r%-#{ main_width + 5 }s#{ ' Size Age Seeds Leeches' if !hide_info? || @show_info }" %
|
199
|
+
"Page #{ page + 1 } of #{ @kat.pages }", nil].yellow!
|
200
|
+
|
201
|
+
@kat.results[@page].each_with_index { |t, i|
|
202
|
+
age = t[:age].split "\xC2\xA0"
|
203
|
+
age = "%3d %-6s" % age
|
204
|
+
# Filter out the crap that invariably infests torrent names
|
205
|
+
title = t[:title].codepoints.map { |c| c > 31 && c < 127 ? c.chr : '?' }.join[0...main_width]
|
206
|
+
buf << ("%2d. %-#{ main_width }s#{ ' %10s %10s %7d %7d' if !hide_info? or @show_info }" %
|
207
|
+
[i + 1, title, t[:size], age, t[:seeds], t[:leeches]]).tap { |s| s.red! if t[:seeds] == 0 }
|
208
|
+
}
|
209
|
+
|
210
|
+
buf << nil
|
211
|
+
end
|
212
|
+
|
213
|
+
#
|
214
|
+
# Create a regex to validate the user's input
|
215
|
+
#
|
216
|
+
def validation_regex
|
217
|
+
n = @kat.results[@page].size
|
218
|
+
commands = "[#{ 'i' if hide_info? }#{ 'n' if next? }#{ 'p' if prev? }q]|"
|
219
|
+
_01to09 = "[1-#{ [n, 9].min }]"
|
220
|
+
_10to19 = "#{ "|1[0-#{ [n - 10, 9].min }]" if n > 9 }"
|
221
|
+
_20to25 = "#{ "|2[0-#{ n - 20 }]" if n > 19 }"
|
222
|
+
|
223
|
+
/^(#{ commands }#{ _01to09 }#{ _10to19 }#{ _20to25 })$/
|
224
|
+
end
|
225
|
+
|
226
|
+
#
|
227
|
+
# Set the prompt after the results list has been printed
|
228
|
+
#
|
229
|
+
def prompt
|
230
|
+
n = @kat.results[@page].size
|
231
|
+
@h.ask("1#{ "-#{n}" if n > 1}".cyan(true) << ' to download' <<
|
232
|
+
"#{ ', ' << '(n)'.cyan(true) << 'ext' if next? }" <<
|
233
|
+
"#{ ', ' << '(p)'.cyan(true) << 'rev' if prev? }" <<
|
234
|
+
"#{ ", #{ @show_info ? 'hide' : 'show' } " << '(i)'.cyan(true) << 'nfo' if hide_info? }" <<
|
235
|
+
', ' << '(q)'.cyan(true) << 'uit: ') { |q|
|
236
|
+
q.responses[:not_valid] = 'Invalid option.'
|
237
|
+
q.validate = validation_regex
|
238
|
+
}
|
239
|
+
end
|
240
|
+
|
241
|
+
#
|
242
|
+
# Download the torrent to either the output directory or the working directory
|
243
|
+
#
|
244
|
+
def download(torrent)
|
245
|
+
uri = URI(URI::encode torrent[:download])
|
246
|
+
uri.query = nil
|
247
|
+
file = "#{ @options[:output] || '.' }/" <<
|
248
|
+
"#{ torrent[:title].tr(' ', ?.).gsub(/[^a-z0-9()_.-]/i, '') }.torrent"
|
249
|
+
|
250
|
+
fail '404 File Not Found' if (res = Net::HTTP.start(uri.host) { |http|
|
251
|
+
http.get uri
|
252
|
+
}).code == '404'
|
253
|
+
|
254
|
+
File.open(File.expand_path(file), 'w') { |f| f.write res.body }
|
255
|
+
|
256
|
+
:done.green
|
257
|
+
rescue => e
|
258
|
+
[:failed, e.message].red
|
259
|
+
end
|
260
|
+
|
261
|
+
#
|
262
|
+
# Load options from ~/.katrc if it exists
|
263
|
+
#
|
264
|
+
def load_config
|
265
|
+
config = File.join(ENV['HOME'], '.katrc')
|
266
|
+
|
267
|
+
@options = (symbolise = -> h {
|
268
|
+
Hash === h ? Hash[h.map { |k, v| [k.intern, symbolise[v]] }] : h
|
269
|
+
})[YAML.load_file config] if File.readable? config
|
270
|
+
rescue => e
|
271
|
+
warn "Failed to load #{config}: #{e}"
|
272
|
+
end
|
273
|
+
|
274
|
+
end
|
275
|
+
|
276
|
+
end
|
data/lib/kat/colour.rb
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
module Kat
|
2
|
+
|
3
|
+
module Colour
|
4
|
+
|
5
|
+
COLOURS = %w(black red green yellow blue magenta cyan white)
|
6
|
+
|
7
|
+
class << self
|
8
|
+
# From AwesomePrint.colorize? by Michael Dvorkin
|
9
|
+
# https://github.com/michaeldv/awesome_print/blob/master/lib/awesome_print/inspector.rb
|
10
|
+
def capable?
|
11
|
+
STDOUT.tty? && (ENV['TERM'] && ENV['TERM'] != 'dumb' || ENV['ANSICON'])
|
12
|
+
end
|
13
|
+
|
14
|
+
def colour=(f)
|
15
|
+
@@colour = f && capable?
|
16
|
+
end
|
17
|
+
|
18
|
+
def colour?
|
19
|
+
@@colour
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
@@colour = capable?
|
24
|
+
|
25
|
+
def colour?
|
26
|
+
@@colour
|
27
|
+
end
|
28
|
+
|
29
|
+
COLOURS.each { |c|
|
30
|
+
define_method(c) { |*args| colour c, args[0] }
|
31
|
+
define_method("#{ c }!") { |*args| colour! c, args[0] }
|
32
|
+
}
|
33
|
+
|
34
|
+
def uncolour
|
35
|
+
case self
|
36
|
+
when String then gsub /\e\[[0-9;]+?m(.*?)\e\[0m/, '\\1'
|
37
|
+
when Array then map { |e| e.uncolour if e }
|
38
|
+
else self
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def uncolour!
|
43
|
+
case self
|
44
|
+
when String then replace uncolour
|
45
|
+
when Array then each { |e| e.uncolour! if e }
|
46
|
+
end
|
47
|
+
|
48
|
+
self
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def colour(name, intense = false)
|
54
|
+
return case self
|
55
|
+
when String, Symbol then "\e[#{ intense ? 1 : 0 };#{ 30 + COLOURS.index(name) }m#{ self }\e[0m"
|
56
|
+
when Array then map { |e| e.send name.to_s, intense if e }
|
57
|
+
end if colour?
|
58
|
+
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
def colour!(name, intense = false)
|
63
|
+
case self
|
64
|
+
when String then replace send(name.to_s, intense)
|
65
|
+
when Array then each { |e| e.send "#{ name.to_s }!", intense if e }
|
66
|
+
end if colour?
|
67
|
+
|
68
|
+
self
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
class String; include Kat::Colour end
|
76
|
+
class Symbol; include Kat::Colour end
|
77
|
+
class Array; include Kat::Colour end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Kat
|
4
|
+
|
5
|
+
FIELD_MAP = (symbolise = -> h {
|
6
|
+
case h
|
7
|
+
when Hash then Hash[h.map { |k, v| [k.to_sym, k == 'desc' ? v : symbolise[v]] }]
|
8
|
+
when String then h.to_sym
|
9
|
+
else h
|
10
|
+
end
|
11
|
+
})[YAML.load(<<-FIELD_MAP
|
12
|
+
---
|
13
|
+
exact:
|
14
|
+
type: string
|
15
|
+
desc: Exact phrase
|
16
|
+
|
17
|
+
or:
|
18
|
+
type: string
|
19
|
+
desc: Optional words
|
20
|
+
multi: true
|
21
|
+
|
22
|
+
without:
|
23
|
+
type: string
|
24
|
+
desc: Without this word
|
25
|
+
multi: true
|
26
|
+
|
27
|
+
sort:
|
28
|
+
type: string
|
29
|
+
desc: Sort field (size, files, added, seeds, leeches)
|
30
|
+
|
31
|
+
asc:
|
32
|
+
desc: Ascending sort order (descending is default)
|
33
|
+
|
34
|
+
category:
|
35
|
+
select: categories
|
36
|
+
type: string
|
37
|
+
desc: Category
|
38
|
+
short: c
|
39
|
+
|
40
|
+
added:
|
41
|
+
select: times
|
42
|
+
sort: time_add
|
43
|
+
type: string
|
44
|
+
desc: Age of the torrent
|
45
|
+
id: age
|
46
|
+
short: a
|
47
|
+
|
48
|
+
size:
|
49
|
+
sort: size
|
50
|
+
|
51
|
+
user:
|
52
|
+
input: true
|
53
|
+
type: string
|
54
|
+
desc: Uploader
|
55
|
+
|
56
|
+
files:
|
57
|
+
input: true
|
58
|
+
sort: files_count
|
59
|
+
type: int
|
60
|
+
desc: Number of files
|
61
|
+
|
62
|
+
imdb:
|
63
|
+
input: true
|
64
|
+
type: int
|
65
|
+
desc: IMDB ID
|
66
|
+
|
67
|
+
seeds:
|
68
|
+
input: true
|
69
|
+
sort: seeders
|
70
|
+
type: int
|
71
|
+
desc: Min no of seeders
|
72
|
+
short: s
|
73
|
+
|
74
|
+
leeches:
|
75
|
+
sort: leechers
|
76
|
+
|
77
|
+
season:
|
78
|
+
input: true
|
79
|
+
type: int
|
80
|
+
desc: Television season
|
81
|
+
|
82
|
+
episode:
|
83
|
+
input: true
|
84
|
+
type: int
|
85
|
+
desc: Television episode
|
86
|
+
short: e
|
87
|
+
|
88
|
+
language:
|
89
|
+
select: languages
|
90
|
+
type: int
|
91
|
+
desc: Language
|
92
|
+
id: lang_id
|
93
|
+
|
94
|
+
platform:
|
95
|
+
select: platforms
|
96
|
+
type: int
|
97
|
+
desc: Game platform
|
98
|
+
id: platform_id
|
99
|
+
|
100
|
+
safe:
|
101
|
+
check: true
|
102
|
+
desc: Family safe filter
|
103
|
+
short: none
|
104
|
+
|
105
|
+
verified:
|
106
|
+
check: true
|
107
|
+
desc: Verified torrent
|
108
|
+
short: none
|
109
|
+
|
110
|
+
output:
|
111
|
+
type: string
|
112
|
+
desc: Directory to save torrents in
|
113
|
+
short: o
|
114
|
+
|
115
|
+
colour:
|
116
|
+
type: boolean
|
117
|
+
desc: Output with colour
|
118
|
+
short: none
|
119
|
+
FIELD_MAP
|
120
|
+
)].freeze
|
121
|
+
|
122
|
+
end
|