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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +38 -0
- data/bin/muzak +50 -0
- data/bin/muzakd +32 -0
- data/lib/muzak.rb +11 -0
- data/lib/muzak/album.rb +11 -0
- data/lib/muzak/cmd.rb +18 -0
- data/lib/muzak/cmd/config.rb +76 -0
- data/lib/muzak/cmd/index.rb +63 -0
- data/lib/muzak/cmd/meta.rb +19 -0
- data/lib/muzak/cmd/player.rb +79 -0
- data/lib/muzak/cmd/playlist.rb +101 -0
- data/lib/muzak/const.rb +14 -0
- data/lib/muzak/index.rb +84 -0
- data/lib/muzak/instance.rb +47 -0
- data/lib/muzak/player.rb +13 -0
- data/lib/muzak/player/mpv.rb +222 -0
- data/lib/muzak/player/stub_player.rb +69 -0
- data/lib/muzak/playlist.rb +45 -0
- data/lib/muzak/plugin.rb +20 -0
- data/lib/muzak/plugin/cava.rb +44 -0
- data/lib/muzak/plugin/notify.rb +16 -0
- data/lib/muzak/plugin/scrobble.rb +81 -0
- data/lib/muzak/plugin/stub_plugin.rb +25 -0
- data/lib/muzak/song.rb +44 -0
- data/lib/muzak/utils.rb +68 -0
- metadata +85 -0
data/lib/muzak/const.rb
ADDED
@@ -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
|
data/lib/muzak/index.rb
ADDED
@@ -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
|
data/lib/muzak/player.rb
ADDED
@@ -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
|