muzak 0.0.1

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.
@@ -0,0 +1,14 @@
1
+ module Muzak
2
+ VERSION = "0.0.1".freeze
3
+
4
+ CONFIG_DIR = File.expand_path("~/.config/muzak").freeze
5
+ CONFIG_FILE = File.join(CONFIG_DIR, "muzak.yml").freeze
6
+ INDEX_FILE = File.join(CONFIG_DIR, "index.yml").freeze
7
+ PLAYLIST_DIR = File.join(CONFIG_DIR, "playlists").freeze
8
+
9
+ PLUGIN_EVENTS = [
10
+ :player_activated,
11
+ :player_deactivated,
12
+ :song_loaded
13
+ ]
14
+ end
@@ -0,0 +1,84 @@
1
+ module Muzak
2
+ class Index
3
+ include Utils
4
+ attr_accessor :hash
5
+
6
+ def self.load_index(file)
7
+ instance = allocate
8
+ instance.hash = YAML::load_file(file)
9
+
10
+ instance
11
+ end
12
+
13
+ def initialize(tree)
14
+ @hash = {
15
+ "timestamp" => Time.now.to_i,
16
+ "artists" => {}
17
+ }
18
+
19
+ Dir.entries(tree).each do |artist|
20
+ next unless File.directory?(File.join(tree, artist))
21
+ next if artist.start_with?(".")
22
+
23
+ @hash["artists"][artist] = {}
24
+ @hash["artists"][artist]["albums"] = {}
25
+
26
+ Dir.entries(File.join(tree, artist)).each do |album|
27
+ next if album.start_with?(".")
28
+
29
+ @hash["artists"][artist]["albums"][album] = {}
30
+ @hash["artists"][artist]["albums"][album]["songs"] = []
31
+
32
+ Dir.glob(File.join(tree, artist, album, "**")) do |file|
33
+ @hash["artists"][artist]["albums"][album]["cover"] = file if album_art?(file)
34
+ @hash["artists"][artist]["albums"][album]["songs"] << file if music?(file)
35
+ end
36
+
37
+ @hash["artists"][artist]["albums"][album]["songs"].sort!
38
+ end
39
+ end
40
+ end
41
+
42
+ def artists
43
+ @hash["artists"].keys
44
+ end
45
+
46
+ def albums
47
+ albums_hash = {}
48
+
49
+ artists.each do |a|
50
+ @hash["artists"][a]["albums"].keys.each do |ak|
51
+ albums_hash[ak] = @hash["artists"][a]["albums"][ak]
52
+ end
53
+ end
54
+
55
+ albums_hash
56
+ end
57
+
58
+ def album_names
59
+ artists.map { |a| @hash["artists"][a]["albums"].keys }.flatten
60
+ end
61
+
62
+ def albums_by(artist)
63
+ error "no such artist: '#{artist}'" unless @hash["artists"].key?(artist)
64
+
65
+ begin
66
+ @hash["artists"][artist]["albums"]
67
+ rescue Exception => e
68
+ {}
69
+ end
70
+ end
71
+
72
+ def songs_by(artist)
73
+ error "no such artist: '#{artist}'" unless @hash["artists"].key?(artist)
74
+
75
+ begin
76
+ albums_by(artist).map do |_, album|
77
+ album["songs"].map { |s| File.basename(s) }.sort
78
+ end.flatten
79
+ rescue Exception => e
80
+ []
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,47 @@
1
+ module Muzak
2
+ class Instance
3
+ include Cmd
4
+ include Utils
5
+
6
+ def method_missing(meth, *args)
7
+ warn "unknown command: #{Cmd.resolve_method(meth)}"
8
+ help
9
+ end
10
+
11
+ attr_reader :config, :player, :index, :playlist
12
+
13
+ def initialize(opts = {})
14
+ $debug = opts[:debug]
15
+ $verbose = opts[:verbose]
16
+
17
+ debug "muzak is starting..."
18
+
19
+ _config_init unless _config_available?
20
+ config_load
21
+
22
+ index_build unless _index_available?
23
+ index_load
24
+
25
+ @player = Player::PLAYER_MAP[@config["player"]].new(self)
26
+
27
+ playlist_load @config["default-playlist"] if @config["default-playlist"]
28
+
29
+ @plugins = initialize_plugins!
30
+ end
31
+
32
+ def initialize_plugins!
33
+ pks = Plugin.plugin_classes.select { |pk| _config_plugin? pk.plugin_name }
34
+ pks.map { |pk| pk.new(self) }
35
+ end
36
+
37
+ def event(type, *args)
38
+ return unless PLUGIN_EVENTS.include?(type)
39
+
40
+ @plugins.each do |plugin|
41
+ Thread.new do
42
+ plugin.send(type, *args)
43
+ end.join
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,13 @@
1
+ # we have to require StubPlayer first because ruby's module resolution is bad
2
+ require_relative "player/stub_player"
3
+
4
+ Dir.glob(File.join(__dir__, "player/*")) { |file| require_relative file }
5
+
6
+ module Muzak
7
+ module Player
8
+ PLAYER_MAP = {
9
+ "stub" => Player::StubPlayer,
10
+ "mpv" => Player::MPV
11
+ }
12
+ end
13
+ end
@@ -0,0 +1,222 @@
1
+ require "tempfile"
2
+ require "socket"
3
+ require "json"
4
+ require "thread"
5
+
6
+ module Muzak
7
+ module Player
8
+ class MPV < StubPlayer
9
+ def running?
10
+ begin
11
+ !!@pid && Process.waitpid(@pid, Process::WNOHANG).nil?
12
+ rescue Errno::ECHILD
13
+ false
14
+ end
15
+ end
16
+
17
+ def activate!
18
+ return if running?
19
+
20
+ debug "activating #{self.class}"
21
+
22
+ @sock_path = Dir::Tmpname.make_tmpname("/tmp/mpv", ".sock")
23
+ mpv_args = [
24
+ "--idle",
25
+ "--no-osc",
26
+ "--no-osd-bar",
27
+ "--no-input-default-bindings",
28
+ "--no-input-cursor",
29
+ "--no-terminal",
30
+ "--load-scripts=no", # autoload and other scripts with clobber our mpv management
31
+ "--input-ipc-server=%{socket}" % { socket: @sock_path }
32
+ ]
33
+
34
+ mpv_args << "--geometry=#{instance.config["art-geometry"]}" if instance.config["art-geometry"]
35
+
36
+ @pid = Process.spawn("mpv", *mpv_args)
37
+
38
+ until File.exists?(@sock_path)
39
+ sleep 0.1
40
+ end
41
+
42
+ @socket = UNIXSocket.new(@sock_path)
43
+
44
+ @command_queue = Queue.new
45
+ @result_queue = Queue.new
46
+ @event_queue = Queue.new
47
+
48
+ @command_thread = Thread.new { pump_commands! }
49
+ @results_thread = Thread.new { pump_results! }
50
+ @events_thread = Thread.new { dispatch_events! }
51
+
52
+ instance.event :player_activated
53
+ end
54
+
55
+ def deactivate!
56
+ return unless running?
57
+
58
+ debug "deactivating #{self.class}"
59
+
60
+ command "quit"
61
+
62
+ Process.kill :TERM, @pid
63
+ Process.wait @pid
64
+ @pid = nil
65
+
66
+ @socket.close
67
+ ensure
68
+ instance.event :player_deactivated
69
+ File.delete(@sock_path) if @sock_path && File.exists?(@sock_path)
70
+ end
71
+
72
+ def play
73
+ return unless running?
74
+
75
+ set_property "pause", false
76
+ end
77
+
78
+ def pause
79
+ return unless running?
80
+
81
+ set_property "pause", true
82
+ end
83
+
84
+ def playing?
85
+ return false unless running?
86
+
87
+ !get_property "pause"
88
+ end
89
+
90
+ def next_song
91
+ command "playlist-next"
92
+ end
93
+
94
+ def previous_song
95
+ command "playlist-prev"
96
+ end
97
+
98
+ def enqueue_song(song)
99
+ activate! unless running?
100
+
101
+ load_song song, song.best_guess_album_art
102
+ end
103
+
104
+ def enqueue_album(album)
105
+ activate! unless running?
106
+
107
+ album.songs.each do |song|
108
+ load_song song, album.cover_art
109
+ end
110
+ end
111
+
112
+ def enqueue_playlist(playlist)
113
+ activate! unless running?
114
+
115
+ playlist.songs.each do |song|
116
+ load_song song, song.best_guess_album_art
117
+ end
118
+ end
119
+
120
+ def list_queue
121
+ entries = get_property "playlist/count"
122
+
123
+ playlist = []
124
+
125
+ entries.times do |i|
126
+ playlist << Song.new(get_property("playlist/#{i}/filename"))
127
+ end
128
+
129
+ playlist
130
+ end
131
+
132
+ def shuffle_queue
133
+ command "playlist-shuffle"
134
+ end
135
+
136
+ def clear_queue
137
+ command "playlist-clear"
138
+ end
139
+
140
+ def now_playing
141
+ Song.new(get_property "path")
142
+ end
143
+
144
+ private
145
+
146
+ def load_song(song, art)
147
+ cmds = ["loadfile", song.path, "append-play"]
148
+ cmds << "external-file=#{art}" if art
149
+ command *cmds
150
+ end
151
+
152
+ def pump_commands!
153
+ loop do
154
+ begin
155
+ @socket.puts(@command_queue.pop)
156
+ rescue EOFError # the player is deactivating
157
+ Thread.exit
158
+ end
159
+ end
160
+ end
161
+
162
+ def pump_results!
163
+ loop do
164
+ begin
165
+ response = JSON.parse(@socket.readline)
166
+
167
+ if response["event"]
168
+ @event_queue << response["event"]
169
+ else
170
+ @result_queue << response
171
+ end
172
+ rescue EOFError # the player is deactivating
173
+ Thread.exit
174
+ end
175
+ end
176
+ end
177
+
178
+ def dispatch_events!
179
+ loop do
180
+ event = @event_queue.pop
181
+
182
+ Thread.new do
183
+ case event
184
+ when "file-loaded"
185
+ # this really isn't ideal, since we already have access
186
+ # to Song objects earlier in the object's lifetime.
187
+ # the "correct" way to do this would be to sync an external
188
+ # playlist with mpv's internal one and access that instead
189
+ # of re-creating the Song from mpv properties.
190
+ # another idea: serialize Song objects into mpv's properties
191
+ # somehow.
192
+ song = Song.new(get_property "path")
193
+ instance.event :song_loaded, song
194
+ end
195
+ end
196
+ end
197
+ end
198
+
199
+ def command(*args)
200
+ return unless running?
201
+
202
+ payload = {
203
+ "command" => args
204
+ }
205
+
206
+ debug "mpv payload: #{payload.to_s}"
207
+
208
+ @command_queue << JSON.generate(payload)
209
+
210
+ @result_queue.pop
211
+ end
212
+
213
+ def set_property(*args)
214
+ command "set_property", *args
215
+ end
216
+
217
+ def get_property(*args)
218
+ command("get_property", *args)["data"]
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,69 @@
1
+ module Muzak
2
+ module Player
3
+ class StubPlayer
4
+ include Utils
5
+
6
+ attr_reader :instance
7
+
8
+ def initialize(instance)
9
+ @instance = instance
10
+ end
11
+
12
+ def running?
13
+ debug "#running?"
14
+ end
15
+
16
+ def activate!
17
+ debug "#activate!"
18
+ end
19
+
20
+ def deactivate!
21
+ debug "#deactivate!"
22
+ end
23
+
24
+ def play
25
+ debug "#play"
26
+ end
27
+
28
+ def pause
29
+ debug "#pause"
30
+ end
31
+
32
+ def next_song
33
+ debug "#next_song"
34
+ end
35
+
36
+ def previous_song
37
+ debug "#previous_song"
38
+ end
39
+
40
+ def enqueue_song(song)
41
+ debug "#enqueue_song"
42
+ end
43
+
44
+ def enqueue_album(album)
45
+ debug "#enqueue_album"
46
+ end
47
+
48
+ def enqueue_playlist(playlist)
49
+ debug "#enqueue_playlist"
50
+ end
51
+
52
+ def list_queue
53
+ debug "#list_queue"
54
+ end
55
+
56
+ def shuffle_queue
57
+ debug "#shuffle_queue"
58
+ end
59
+
60
+ def clear_queue
61
+ debug "#clear_queue"
62
+ end
63
+
64
+ def now_playing
65
+ debug "#now_playing"
66
+ end
67
+ end
68
+ end
69
+ end