ektoplayer 0.1.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.
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