-
Notifications
You must be signed in to change notification settings - Fork 406
/
Copy pathinterfacing_mpd.py
298 lines (235 loc) · 10.5 KB
/
interfacing_mpd.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# Copyright: 2022
# SPDX License Identifier: MIT License
import asyncio
import logging
import os.path
import re
import jukebox.plugs as plugin
import jukebox.cfghandler
from mpd.asyncio import MPDClient
logger = logging.getLogger('jb.mpd')
cfg = jukebox.cfghandler.get_handler('jukebox')
def sanitize(path: str):
return os.path.normpath(path).lstrip('./')
class MPDBackend:
def __init__(self, event_loop):
self.client = MPDClient()
self.loop = event_loop
self.host = 'localhost'
self.port = '6600'
self._flavors = {'folder': self.get_files,
'file': self.get_track,
'album': self.get_album_from_uri,
'podcast': self.get_podcast,
'livestream': self.get_livestream}
self._active_uri = ''
# TODO: If connect fails on first try this is non recoverable
self.connect()
# Start the status listener in an endless loop in the event loop
asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop)
# ------------------------------------------------------------------------------------------------------
# Bring calls to client functions from the synchronous part into the async domain
# Async function of the MPD client return a asyncio.future as a result
# That means we must
# - first await the function execution in the event loop
# _run_cmd_async: an async function
# - second then wait for the future result to be available in the sync domain
# _run_cmd: a sync function that schedules the async function in the event loop for execution
# and wait for the future result by calling ..., self.loop).result()
# Since this must be done for every command crossing the async/sync domain, we keep it generic and
# pass method and arguments to these two wrapper functions that do the scheduling and waiting
async def _run_cmd_async(self, afunc, *args, **kwargs):
return await afunc(*args, **kwargs)
def _run_cmd(self, afunc, *args, **kwargs):
return asyncio.run_coroutine_threadsafe(self._run_cmd_async(afunc, *args, **kwargs), self.loop).result()
# -----------------------------------------------------
# Check and update statues
async def _connect(self):
return await self.client.connect(self.host, self.port)
def connect(self):
# May raise: mpd.base.ConnectionError: Can not send command to disconnected client
result = asyncio.run_coroutine_threadsafe(self._connect(), self.loop).result()
logger.debug(f"Connected to MPD version {self.client.mpd_version} @ {self.host}:{self.port}")
return result
# -----------------------------------------------------
# Check and update statues
async def _status_listener(self):
"""The endless status listener: updates the status whenever there is a change in one MPD subsystem"""
# Calls to logger do not work
# logger.debug("MPD Status Listener started")
async for subsystem in self.client.idle():
# logger.debug("MPD: Idle change in", subsystem)
s = await self.client.status()
# logger.debug(f"MPD: New Status: {s.result()}")
print(f"MPD: New Status: {type(s)} // {s}")
# Now, do something with it ...
async def _status(self):
return await self.client.status()
@plugin.tag
def status(self):
"""Refresh the current MPD status (by a manual, sync trigger)"""
f = asyncio.run_coroutine_threadsafe(self._status(), self.loop).result()
print(f"Status: {f}")
# Put it into unified structure and notify global player control
# -----------------------------------------------------
# Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue")
def next(self):
return self._run_cmd(self.client.next)
def prev(self):
return self._run_cmd(self.client.prev)
@plugin.tag
def play(self, idx=None):
"""
If idx /= None, start playing song idx from queue
If stopped, start with first song in queue
If paused, resume playback at current position
"""
# self.client.play() continues playing at current position
if idx is None:
return self._run_cmd(self.client.play)
else:
return self._run_cmd(self.client.play, idx)
def toggle(self):
"""Toggle between playback / pause"""
return self._run_cmd(self.client.pause)
def pause(self):
"""Pause playback if playing
This is what you want as card removal action: pause the playback, so it can be resumed when card is placed
on the reader again. What happens on re-placement depends on configured second swipe option
"""
return self._run_cmd(self.client.pause, 1)
def stop(self):
return self._run_cmd(self.client.stop)
@plugin.tag
def get_queue(self):
return self._run_cmd(self.client.playlistinfo)
# -----------------------------------------------------
# Volume control (for developing only)
async def _volume(self, value):
return await self.client.setvol(value)
@plugin.tag
def set_volume(self, value):
return asyncio.run_coroutine_threadsafe(self._volume(value), self.loop).result()
# ----------------------------------
# Stuff that replaces the current playlist and starts a new playback for URI
@plugin.tag
def play_uri(self, uri: str, **kwargs):
"""Decode URI and forward play call
mpd:folder:path/to/folder
--> Build playlist from $MUSICLIB_DIR/path/to/folder/*
mpd:file:path/to/file.mp3
--> Plays single file
mpd:album:Feuerwehr:albumartist:Benjamin
-> Searches MPD database for album Feuerwehr from artist Benjamin
Conceptual at the moment (i.e. means it will likely change):
mpd:podcast:path/to/file.yaml
--> Reads local file: $PODCAST_FOLDER/path/to/file.yaml
--> which contains: https://cool-stuff.de/podcast.xml
mpd:livestream:path/to/file.yaml
--> Reads local file: $LIVESTREAM_FOLDER/path/to/file.yaml
--> which contains: https://hot-stuff.de/livestream.mp3
Why go via a local file? We need to have a database with all podcasts that we can pull out and display
to the user so he can select "play this one"
"""
self.clear()
# Clear the active uri before retrieving the track list, to avoid stale active uri in case something goes wrong
self._active_uri = ''
tracklist = self.get_from_uri(uri, **kwargs)
self._active_uri = uri
self.enqueue(tracklist)
self._restore_state()
self.play()
def clear(self):
return self._run_cmd(self.client.clear)
async def _enqueue(self, tracklist):
for entry in tracklist:
path = entry.get('file')
if path is not None:
await self.client.add(path)
def enqueue(self, tracklist):
return asyncio.run_coroutine_threadsafe(self._enqueue(tracklist), self.loop).result()
# ----------------------------------
# Get track lists
@plugin.tag
def get_from_uri(self, uri: str, **kwargs):
player_type, list_type, path = uri.split(':', 2)
if player_type != 'mpd':
raise KeyError(f"URI prefix must be 'mpd' not '{player_type}")
func = self._flavors.get(list_type)
if func is None:
raise KeyError(f"URI flavor '{list_type}' unknown. Must be one of: {self._flavors.keys()}.")
return func(path, **kwargs)
@plugin.tag
def get_files(self, path, recursive=False):
"""
List file meta data for single file or all files of folder
:returns: List of file(s) and directories including meta data
"""
path = sanitize(path)
if os.path.isfile(path):
files = self._run_cmd(self.client.find, 'file', path)
elif not recursive:
files = self._run_cmd(self.client.lsinfo, path)
else:
files = self._run_cmd(self.client.find, 'base', path)
return files
@plugin.tag
def get_track(self, path):
playlist = self._run_cmd(self.client.find, 'file', path)
if len(playlist) != 1:
raise ValueError(f"Path decodes to more than one file: '{path}'")
file = playlist[0].get('file')
if file is None:
raise ValueError(f"Not a music file: '{path}'")
return playlist
# ----------------------------------
# Get albums / album tracks
@plugin.tag
def get_albums(self):
"""Returns all albums in database"""
# return asyncio.run_coroutine_threadsafe(self._get_albums(), self.loop).result()
return self._run_cmd(self.client.list, 'album', 'group', 'albumartist')
@plugin.tag
def get_album_tracks(self, album_artist, album):
"""Returns all songs of an album"""
return self._run_cmd(self.client.find, 'albumartist', album_artist, 'album', album)
def get_album_from_uri(self, uri: str):
"""Accepts full or partial uri (partial means without leading 'mpd:')"""
p = re.match(r"(mpd:)?album:(.*):albumartist:(.*)", uri)
if not p:
raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'")
return self.get_album_tracks(album_artist=p.group(3), album=p.group(2))
# ----------------------------------
# Get podcasts / livestreams
def _get_podcast_items(self, path):
"""Decode playlist of one podcast file"""
pass
@plugin.tag
def get_podcast(self, path):
"""
If :attr:`path is a
* directory: List all stored podcasts in directory
* file: List podcast playlist
"""
pass
def _get_livestream_items(self, path):
"""Decode playlist of one livestream file"""
pass
@plugin.tag
def get_livestream(self, path):
"""
If :attr:`path is a
* directory: List all stored livestreams in directory
* file: List livestream playlist
"""
pass
# -----------------------------------------------------
# Queue / URI state (save + restore e.g. random, resume, ...)
def save_state(self):
"""Save the configuration and state of the current URI playback to the URIs state file"""
pass
def _restore_state(self):
"""
Restore the configuration state and last played status for current active URI
"""
pass