ektoplayer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +49 -0
  3. data/bin/ektoplayer +7 -0
  4. data/lib/ektoplayer.rb +10 -0
  5. data/lib/ektoplayer/application.rb +148 -0
  6. data/lib/ektoplayer/bindings.rb +230 -0
  7. data/lib/ektoplayer/browsepage.rb +138 -0
  8. data/lib/ektoplayer/client.rb +18 -0
  9. data/lib/ektoplayer/common.rb +91 -0
  10. data/lib/ektoplayer/config.rb +247 -0
  11. data/lib/ektoplayer/controllers/browser.rb +47 -0
  12. data/lib/ektoplayer/controllers/controller.rb +9 -0
  13. data/lib/ektoplayer/controllers/help.rb +21 -0
  14. data/lib/ektoplayer/controllers/info.rb +22 -0
  15. data/lib/ektoplayer/controllers/mainwindow.rb +40 -0
  16. data/lib/ektoplayer/controllers/playlist.rb +60 -0
  17. data/lib/ektoplayer/database.rb +199 -0
  18. data/lib/ektoplayer/events.rb +56 -0
  19. data/lib/ektoplayer/models/browser.rb +127 -0
  20. data/lib/ektoplayer/models/database.rb +49 -0
  21. data/lib/ektoplayer/models/model.rb +15 -0
  22. data/lib/ektoplayer/models/player.rb +28 -0
  23. data/lib/ektoplayer/models/playlist.rb +72 -0
  24. data/lib/ektoplayer/models/search.rb +42 -0
  25. data/lib/ektoplayer/models/trackloader.rb +17 -0
  26. data/lib/ektoplayer/mp3player.rb +151 -0
  27. data/lib/ektoplayer/operations/browser.rb +19 -0
  28. data/lib/ektoplayer/operations/operations.rb +26 -0
  29. data/lib/ektoplayer/operations/player.rb +11 -0
  30. data/lib/ektoplayer/operations/playlist.rb +67 -0
  31. data/lib/ektoplayer/theme.rb +102 -0
  32. data/lib/ektoplayer/trackloader.rb +146 -0
  33. data/lib/ektoplayer/ui.rb +404 -0
  34. data/lib/ektoplayer/ui/colors.rb +105 -0
  35. data/lib/ektoplayer/ui/widgets.rb +195 -0
  36. data/lib/ektoplayer/ui/widgets/container.rb +125 -0
  37. data/lib/ektoplayer/ui/widgets/labelwidget.rb +43 -0
  38. data/lib/ektoplayer/ui/widgets/listwidget.rb +332 -0
  39. data/lib/ektoplayer/ui/widgets/tabbedcontainer.rb +110 -0
  40. data/lib/ektoplayer/updater.rb +77 -0
  41. data/lib/ektoplayer/views/browser.rb +25 -0
  42. data/lib/ektoplayer/views/help.rb +46 -0
  43. data/lib/ektoplayer/views/info.rb +208 -0
  44. data/lib/ektoplayer/views/mainwindow.rb +64 -0
  45. data/lib/ektoplayer/views/playinginfo.rb +135 -0
  46. data/lib/ektoplayer/views/playlist.rb +39 -0
  47. data/lib/ektoplayer/views/progressbar.rb +51 -0
  48. data/lib/ektoplayer/views/splash.rb +99 -0
  49. data/lib/ektoplayer/views/trackrenderer.rb +137 -0
  50. data/lib/ektoplayer/views/volumemeter.rb +74 -0
  51. metadata +164 -0
@@ -0,0 +1,138 @@
1
+ #!/bin/ruby
2
+
3
+ require 'nokogiri'
4
+ require 'base64'
5
+ require 'scanf'
6
+ require 'open-uri'
7
+
8
+ module Ektoplayer
9
+ class BrowsePage
10
+ ALBUM_KEYS = %w(url title artist date category styles cover_url description
11
+ download_count released_by released_by_url posted_by posted_by_url
12
+ archive_urls rating votes tracks).map(&:to_sym).freeze
13
+
14
+ TRACK_KEYS = %w(url album_url number title remix artist bpm).map(&:to_sym).freeze
15
+
16
+ attr_reader :albums, :page_urls, :current_page_index
17
+
18
+ def self.parse(src)
19
+ BrowsePage.new(src)
20
+ end
21
+
22
+ def styles; @@styles or [] end
23
+ def first_page_url; @page_urls[0] end
24
+ def last_page_url; @page_urls[-1] end
25
+ def current_page_url; @page_urls[@current_page_index] end
26
+
27
+ def prev_page_url
28
+ @page_urls[@current_page_index - 1] if @current_page_index > 0
29
+ end
30
+
31
+ def next_page_url
32
+ @page_urls[@current_page_index + 1] if @current_page_index + 1 < @page_urls.size
33
+ end
34
+
35
+ def initialize(src)
36
+ doc = Nokogiri::HTML(open(src))
37
+ @albums = []
38
+
39
+ @page_urls = []
40
+ doc.css('.wp-pagenavi option').each_with_index do |option, i|
41
+ @current_page_index = i if option[:selected]
42
+ @page_urls << option[:value]
43
+ end
44
+
45
+ @@styles ||= begin
46
+ doc.xpath('//a[contains(@href, "http") and contains(@href, "/style/")]').map do |a|
47
+ [ a.text, a[:href] ]
48
+ end.to_h
49
+ end
50
+
51
+ doc.xpath('//div[starts-with(@id, "post-")]').each do |post|
52
+ album = { tracks: [] }
53
+ album[:date] = post.at_css('.d').text rescue nil
54
+ album[:category] = post.at_css('.c a').text rescue nil
55
+
56
+ album[:styles] = []
57
+ post.css('.style a').map do |a|
58
+ @@styles[a.text] = a[:href]
59
+ album[:styles] << a.text
60
+ end
61
+
62
+ album[:cover_url] = post.at_css('.cover')[:src] rescue nil
63
+ album[:description] = post.at_css(?p).to_html rescue ''
64
+ album[:download_count] = post.at_css('.dc strong').text.delete(?,).to_i rescue 0
65
+
66
+ post.css('h1 a').each do |a|
67
+ album[:title] = a.text
68
+ album[:url] = a[:href]
69
+ end
70
+
71
+ post.xpath('.//a[@rel="tag"]').each do |a|
72
+ album[:released_by] = a.text
73
+ album[:released_by_url] = a[:href]
74
+ end
75
+
76
+ post.xpath('.//a[@rel="author external"]').each do |a|
77
+ album[:posted_by] = a.text
78
+ album[:posted_by_url] = a[:href]
79
+ end
80
+
81
+ album[:archive_urls] = post.css('.dll a').map do |a|
82
+ [ a.text.split[0] , a[:href] ]
83
+ end.to_h
84
+
85
+ begin
86
+ post.at_css('.postmetadata .d').
87
+ text.scanf('Rated %f%% with %d votes').
88
+ each_slice(2) { |r,v| album[:rating], album[:votes] = r, v }
89
+ rescue
90
+ album[:rating], album[:votes] = 0, 0
91
+ end
92
+
93
+ begin
94
+ base64_tracklist = post.at_css(:script).text.scan(/soundFile:"(.*)"/)[0][0]
95
+ tracklist_urls = Base64.decode64(base64_tracklist).split(?,)
96
+ rescue
97
+ # Sometimes there are no tracks:
98
+ # http://www.ektoplazm.com/free-music/dj-basilisk-the-colours-of-ektoplazm
99
+ tracklist_urls = []
100
+ end
101
+
102
+ post.css('.tl').each do |album_track_list|
103
+ track = nil
104
+ album_track_list.css(:span).each do |ti|
105
+ case ti[:class]
106
+ when ?n
107
+ album[:tracks] << track if track and track[:url]
108
+ track = { url: tracklist_urls.shift }
109
+ track[:number] = ti.text.to_i
110
+ when ?t then track[:title] = ti.text
111
+ when ?a then track[:artist] = ti.text
112
+ when ?r then track[:remix] = ti.text
113
+ when ?d then track[:bpm] = ti.text.scan(/\d+/)[0].to_i rescue nil
114
+ end
115
+ end
116
+
117
+ album[:tracks] << track if track and track[:url]
118
+ end
119
+
120
+ # extract artist name out ouf album title, set missing artist on album tracks
121
+ unless album[:tracks].all? { |t| t.key?(:artist) }
122
+ if album[:title].include?' – '
123
+ album[:artist], album[:title] = album[:title].split(' – ', 2)
124
+ album[:tracks].each { |t| t[:artist] ||= album[:artist] }
125
+ else
126
+ album[:tracks].each { |t| t[:artist] ||= 'Unknown Artist' }
127
+ end
128
+ end
129
+
130
+ @albums << album
131
+ end
132
+
133
+ Application.log(self, "completed; #{src} #{@albums.size} albums found")
134
+ rescue
135
+ Application.log(self, src, $!)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,18 @@
1
+ require 'fileutils'
2
+
3
+ require_relative 'database'
4
+ require_relative 'trackloader'
5
+
6
+ module Ektoplayer
7
+ class Client
8
+ attr_reader :database
9
+ attr_reader :trackloader
10
+
11
+ def initialize
12
+ db_file = Config.get(:database_file)
13
+ FileUtils::touch(db_file) unless File.file? db_file
14
+ @database = Database.new(db_file)
15
+ @trackloader = Trackloader.new(@database)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,91 @@
1
+ require 'zip'
2
+
3
+ class Object
4
+ alias :frz :freeze
5
+ end
6
+
7
+ class Dir
8
+ def Dir.size(path)
9
+ Dir.glob(File.join(path, '**', ?*)).map { |f| File.size(f) }.sum
10
+ end
11
+ end
12
+
13
+ class String
14
+ def chunks(n)
15
+ return [self] if n < 1
16
+
17
+ if (chunk_size = self.size / n) < 1
18
+ return [self]
19
+ end
20
+
21
+ (n - 1).times.map do |i|
22
+ self.slice((chunk_size * i)..(chunk_size * (i + 1) - 1))
23
+ end + [
24
+ self.slice((chunk_size * (n-1))..-1)
25
+ ]
26
+ end
27
+ end
28
+
29
+ module Common
30
+ def self.open_url_extern(url)
31
+ if url =~ /\.(jpe?g|png)$/i
32
+ Common.open_image_extern(url)
33
+ else
34
+ fork { exec('xdg-open', url) }
35
+ end
36
+ end
37
+
38
+ def self.open_image_extern(url)
39
+ fork do
40
+ exec('feh', url) rescue (
41
+ exec('display', url) rescue (
42
+ exec('xdg-open', url)
43
+ )
44
+ )
45
+ end
46
+ end
47
+
48
+ def self.extract_zip(zip_file, dest)
49
+ Zip::File.open(zip_file) do |zip_obj|
50
+ zip_obj.each do |f|
51
+ f.extract(File.join(dest, f.name))
52
+ end
53
+ end
54
+ end
55
+
56
+ def self.with_hash_zip(keys, values)
57
+ hash = {}
58
+
59
+ keys.size.times do |i|
60
+ hash[keys[i]] = values[i]
61
+ end
62
+
63
+ yield hash
64
+
65
+ keys.clear << hash.keys
66
+ values.clear << hash.values
67
+ end
68
+
69
+ def self.to_time(secs)
70
+ return '00:00' unless secs or secs == 0
71
+ "%0.2d:%0.2d" % [(secs / 60), (secs % 60)]
72
+ end
73
+
74
+ def self.mksingleton(cls)
75
+ unless cls.singleton_methods.include? :_get_instance
76
+ cls.define_singleton_method(:_get_instance) do
77
+ unless cls.class_variable_defined? :@@_class_instance
78
+ cls.class_variable_set :@@_class_instance, cls.new
79
+ end
80
+
81
+ cls.class_variable_get :@@_class_instance
82
+ end
83
+
84
+ (cls.instance_methods - Object.instance_methods).each do |method|
85
+ cls.define_singleton_method(method) do |*args|
86
+ cls._get_instance.send(method, *args)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,247 @@
1
+ require 'shellwords'
2
+ require 'nokogiri'
3
+
4
+ module Ektoplayer
5
+ class ColumnFormat
6
+ def self.parse_column_format(format)
7
+ self.parse_simple_format(format).
8
+ select { |f| f[:tag] != 'text' }.
9
+ map do |fmt|
10
+ fmt[:size] = fmt[:size].to_i if fmt[:size]
11
+ fmt[:rel] = fmt[:rel].to_i if fmt[:rel]
12
+ fmt[:justify] = fmt[:justify].to_sym if fmt[:justify]
13
+
14
+ begin
15
+ fail 'Missing size= or rel=' if (!fmt[:size] and !fmt[:rel])
16
+ fail 'size= and rel= are mutually exclusive' if (fmt[:size] and fmt[:rel])
17
+ rescue
18
+ fail "column: #{fmt[:tag]}: #{$!}"
19
+ end
20
+
21
+ fmt
22
+ end
23
+ end
24
+
25
+ def self.parse_simple_format(format)
26
+ self._parse_markup(format).map do |fmt|
27
+ attrs = []
28
+ attrs << :bold if fmt[:bold]
29
+ attrs << :blink if fmt[:blink]
30
+ attrs << :standout if fmt[:standout]
31
+ attrs << :underline if fmt[:underline]
32
+ fmt[:curses_attrs] = [ (fmt[:fg] and fmt[:fg].to_sym), (fmt[:bg] and fmt[:bg].to_sym), *attrs]
33
+ fmt
34
+ end
35
+ end
36
+
37
+ def self._parse_markup(format)
38
+ Nokogiri::XML("<f>#{format}</f>").first_element_child.
39
+ children.map do |fmt|
40
+ fmt1 = fmt.attributes.map do |name,a|
41
+ [name.to_sym, a.value]
42
+ end.to_h.update(tag: fmt.name)
43
+ fmt1[:text] = fmt.text if fmt1[:tag] == 'text'
44
+ fmt1
45
+ end
46
+ end
47
+ end
48
+
49
+ class Config
50
+ CONFIG_DIR = File.join(Dir.home, '.config', 'ektoplayer').freeze
51
+ CONFIG_FILE = File.join(CONFIG_DIR, 'ektoplayer.rc').freeze
52
+
53
+ DEFAULT_PLAYLIST_FORMAT = (
54
+ '<number size="3" fg="magenta" />' +
55
+ '<artist rel="33" fg="blue" />' +
56
+ '<album rel="33" fg="red" />' +
57
+ '<title rel="33" fg="yellow" />' +
58
+ '<styles rel="20" fg="cyan" />' +
59
+ '<bpm size="4" fg="green" justify="right" />').freeze
60
+
61
+ DEFAULT_PLAYINGINFO_FORMAT1 =
62
+ '<text fg="black">──┤ </text><title bold="on" fg="yellow" /><text fg="black"> ├──</text>'.freeze
63
+
64
+ DEFAULT_PLAYINGINFO_FORMAT2 =
65
+ '<artist bold="on" fg="blue" /><text> - </text><album bold="on" fg="red" /><text> (</text><date fg="cyan" /><text>)</text>'.freeze
66
+
67
+ def register(key, description, default, method=nil)
68
+ @doc[key.to_sym] = description.squeeze(' ').freeze
69
+
70
+ if method
71
+ @cast[key.to_sym] = method if method
72
+ @options[key.to_sym] = method.(default).freeze
73
+ else
74
+ @options[key.to_sym] = default.freeze
75
+ end
76
+ end
77
+ alias :reg :register
78
+
79
+ def initialize
80
+ @options = Hash.new { |h,k| fail "Unknown option #{k}" }
81
+ @doc, @cast = {}, {}
82
+
83
+ reg :database_file, 'File to store metadata',
84
+ File.join(CONFIG_DIR, 'meta.db'),
85
+ File.method(:expand_path)
86
+
87
+ reg :log_file, 'Log file',
88
+ File.join(CONFIG_DIR, 'ektoplayer.log'),
89
+ File.method(:expand_path)
90
+
91
+ reg :temp_dir, %{Where to temporary store mp3 files. Directory will be created
92
+ if it does not exist (parent directories will NOT be created},
93
+ '/tmp/.ektoplazm',
94
+ File.method(:expand_path)
95
+
96
+ reg :cache_dir,
97
+ 'Directory for storing mp3 files',
98
+ File.join(Dir.home, '.cache', 'ektoplayer'),
99
+ File.method(:expand_path)
100
+
101
+ reg :archive_dir,
102
+ 'Where to search for downloaded MP3 archives',
103
+ File.join(CONFIG_DIR, 'archives'),
104
+ File.method(:expand_path)
105
+
106
+ reg :download_dir,
107
+ 'Where to store downloaded MP3 archives', '/tmp',
108
+ File.method(:expand_path)
109
+
110
+ reg :auto_extract_to_archive_dir,
111
+ %{Enable/disable automatic extraction of downloaded MP3
112
+ archives from download_dir to archive_dir}, true
113
+
114
+ reg :delete_after_extraction,
115
+ %{In combination with auto_extract_to_archive_dir:
116
+ Delete zip archive after successful extraction}, true
117
+
118
+ reg :playlist_load_newest,
119
+ %{How many tracks from database should be added to
120
+ the playlist on application start}, 100
121
+
122
+ reg :use_cache,
123
+ 'Enable/disable local mp3 cache', true
124
+
125
+ reg :small_update_pages,
126
+ 'How many pages should be fetched after start', 5
127
+
128
+ reg :use_colors,
129
+ 'Choose color capabilities. auto|mono|8|256', 'auto',
130
+ lambda { |v|
131
+ { 'auto' => :auto, 'mono' => 0,
132
+ '8' => 8, '256' => 256 }[v] or fail 'invalid value'
133
+ }
134
+
135
+ reg :threads,
136
+ 'Number of donwload threads during database update',
137
+ 20, lambda { |v| fail if Integer(v) < 1; Integer(v) }
138
+
139
+ reg 'browser.format', 'Format of browser columns',
140
+ DEFAULT_PLAYLIST_FORMAT, ColumnFormat.method(:parse_column_format)
141
+
142
+ reg 'playlist.format', 'Format of playlist columns',
143
+ DEFAULT_PLAYLIST_FORMAT, ColumnFormat.method(:parse_column_format)
144
+
145
+ # - Progressbar
146
+ reg 'progressbar.display',
147
+ 'Enable/disable progressbar', true
148
+
149
+ reg 'progressbar.download_char',
150
+ 'Character used for displaying download progress', ?-
151
+
152
+ reg 'progressbar.progress_char',
153
+ 'Character used for displaying playing progress', '─'
154
+
155
+ reg 'progressbar.rest_char',
156
+ 'Character used for the rest of the line', '─'
157
+
158
+ # - Volumemeter
159
+ reg 'volumemeter.display',
160
+ 'Enable/disable volumemeter', true
161
+
162
+ reg 'volumemeter.level_char',
163
+ 'Character used for displaying volume level', '·'
164
+
165
+ reg 'volumemeter.rest_char',
166
+ 'Character used for the rest of the line', '·'
167
+
168
+ # - Playinginfo
169
+ reg 'playinginfo.display',
170
+ 'Enable/display playinginfo', true
171
+
172
+ reg 'playinginfo.format1',
173
+ 'Format of first line in playinginfo', DEFAULT_PLAYINGINFO_FORMAT1,
174
+ ColumnFormat.method(:parse_simple_format)
175
+
176
+ reg 'playinginfo.format2',
177
+ 'Format of second line in playinginfo', DEFAULT_PLAYINGINFO_FORMAT2,
178
+ ColumnFormat.method(:parse_simple_format)
179
+
180
+ # - Tabs
181
+ reg 'tabs.show_tabbar',
182
+ 'Enable/disable tabbar', true
183
+
184
+ reg 'tabs.widgets', 'Specify widget order of tabbar (left to right)',
185
+ 'splash,playlist,browser,info,help',
186
+ lambda { |v| v.split(/\s*,\s*/).map(&:to_sym) }
187
+
188
+ reg 'main.widgets', 'Specify widgets to show (up to down)',
189
+ 'playinginfo,progressbar,tabs,volumemeter',
190
+ lambda { |v| v.split(/\s*,\s*/).map(&:to_sym) }
191
+ end
192
+
193
+ def get(key) @options[key] end
194
+ def [](key) @options[key] end
195
+
196
+ def set(option, value)
197
+ option = option.to_sym
198
+ current_value = get(option)
199
+
200
+ if cast = @cast[option]
201
+ @options[option] = cast.call(value)
202
+ else
203
+ if current_value.is_a?Integer
204
+ @options[option] = Integer(value)
205
+ elsif current_value.is_a?Float
206
+ @options[option] = Float(value)
207
+ elsif current_value.is_a?TrueClass or current_value.is_a?FalseClass
208
+ fail 'invalid bool' unless %w(true false).include? value
209
+ @options[option] = (value == 'true')
210
+ else
211
+ @options[option] = value
212
+ end
213
+ end
214
+
215
+ @options[option].freeze
216
+ rescue
217
+ fail "Invalid value '#{value}' for '#{option}': #{$!}"
218
+ end
219
+
220
+ def parse(file, bindings, theme)
221
+ callbacks = {
222
+ 'set': self.method(:set),
223
+ 'bind': bindings.method(:bind),
224
+ 'unbind': bindings.method(:unbind),
225
+ 'color': theme.method(:color),
226
+ 'color_256': theme.method(:color_256),
227
+ 'color_mono': theme.method(:color_mono)
228
+ }
229
+ callbacks.default_proc = proc { |h,k| fail "unknown command: #{k}" }
230
+ callbacks.freeze
231
+
232
+ open(file, ?r).readlines.each do |line|
233
+ line.chomp!
234
+ next if line.empty? or line.start_with?(?#)
235
+ command, *args = line.shellsplit
236
+
237
+ begin
238
+ cb = callbacks[command.to_sym]
239
+ fail "missing arguments for #{command}" if args.size != cb.arity
240
+ cb.call(*args)
241
+ rescue
242
+ fail "#{file}:#{$.}: #{$!}"
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end