-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmpd-random-playlist-album.py
executable file
·463 lines (395 loc) · 18.6 KB
/
mpd-random-playlist-album.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
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
#!/usr/bin/env python
# This script picks a random album from the MPD playlist.
# Copyright (C) 2009 Kyle MacLeod kyle.macleod is at gmail
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Description
-----------
This script picks a random album from the MPD playlist. Called with no
args it will choose the first song from a random album on the current playlist
and start playing from that point. Obviously, this only works if the playlist
is arranged as a list of albums. It's meant to provide a rudimentary album-level
shuffle function for MPD.
In daemon mode the script will monitor MPD and select a new album
in the playlist after the last song on an album has ended (see -d option).
Options:
-h|--help
-d|--daemon : Daemon mode. Monitors MPD for track changes. At end of album selects
a new random album from the playlist
-D|--debug : Print debug messages to stdout
-p|--passive : Testing only. Does not make any changes to the MPD playlist.
Dependencies:
* python2-mpd : still using the python2 mpd library (for now)
Limitations:
* The album switching is currently triggered when the last song on an album is
reached. If the user changes the current song selection during the last song
on an album then this script will kick in, randomly selecting a new album.
Unfortunately I don't see how to avoid this unless we were to time how long the
last song has been playing for, and compare it to the song length given by MPD.
Usage Notes:
------------
### Album Queue
A file specified by environment variable MPD_RANDOM_ALBUM_QUEUE_FILE [default=/tmp/mpd.albumq]
can be used to enqueue individual albums to be played in order.
Put album titles to be enqueued in $MPD_RANDOM_ALBUM_QUEUE_FILE, one line per album.
Album names are consumed as a queue, until the file is empty, after which the selector will
revert back to random.
By default, the given album string matches the first album against any
substring in the playlist album names (case-sensitive). For an exact match,
prefix the album name with a '!'.
An example /tmp/mpd.albumq:
Abbey Road
!Movement (Remastered)
### Temporarily Suspend (mpd.norandom file)
When the file specified by environment variable MPD_RANDOM_SUSPEND_FILE [default=/tmp/mpd.norandom]
is created, then this script ignores album changes.
You can use this to temporarily override album selection when the script is
running in daemon mode. e.g.:
touch /tmp/mpd.norandom
Examples
--------
Select a new album to play from the current playlist:
./mpd-random-playlist-album.py
Start a daemon, logging output to /tmp/mpd-random-playlist-album.log
(./mpd-random-playlist-album.py -d > /tmp/mpd-random-playlist-album.log 2>&1 ) &
"""
import getopt
import logging
import mpd
import os
import os.path
import random
import sys
import tempfile
import time
import traceback
# If this file exists then no random album is chosen. Used to easily disable the daemon
# e.g. touch /tmp/mpd.norandom && sleep 3600 && rm -f /tmp/mpd.norandom
MPD_RANDOM_SUSPEND_FILE = os.getenv('MPD_RANDOM_SUSPEND_FILE')
if MPD_RANDOM_SUSPEND_FILE is None:
MPD_RANDOM_SUSPEND_FILE = os.path.join(tempfile.gettempdir(), 'mpd.norandom')
# Album queue file. This file contains any number of lines. When an album is selected
# lines are processed in order; any match against the album names in the current playlist
# cause that album to be selected next. Lines are consumed as processed until the file is
# empty, after which the file is deleted.
MPD_RANDOM_ALBUM_QUEUE_FILE = os.getenv('MPD_RANDOM_ALBUM_QUEUE_FILE')
if MPD_RANDOM_ALBUM_QUEUE_FILE is None:
if os.path.exists(os.path.join(os.getenv('HOME'), '.config', 'mpd')):
MPD_RANDOM_ALBUM_QUEUE_FILE = os.path.join(os.getenv('HOME'), '.config', 'mpd', 'mpd.albumq')
elif os.path.exists(os.path.join(os.getenv('HOME'), '.mpd')):
MPD_RANDOM_ALBUM_QUEUE_FILE = os.path.join(os.getenv('HOME'), '.mpd', 'mpd.albumq')
else:
MPD_RANDOM_ALBUM_QUEUE_FILE = os.path.join(tempfile.gettempdir(), 'mpd.albumq')
# The archive file, derived from the album queue file. Maintains a history of the albumq.
# Set to '' via environment variable to disable.
MPD_RANDOM_ALBUM_QUEUE_ARCHIVE_FILE = os.getenv('MPD_RANDOM_ALBUM_QUEUE_ARCHIVE_FILE')
if MPD_RANDOM_ALBUM_QUEUE_ARCHIVE_FILE is None:
MPD_RANDOM_ALBUM_QUEUE_ARCHIVE_FILE = MPD_RANDOM_ALBUM_QUEUE_FILE + '.archive'
# This is used for testing purposes
PASSIVE_MODE = False
def script_help():
print(__doc__)
sys.exit(-1)
def song_info(song):
"""A helper to format song info.
"""
try:
return "[{}-{}-{}]".format(song['track'], song['title'], song['album'])
except:
return "[{}-{}]".format(song['artist'], song['album'])
def idle_loop(client, albumlist):
"""MPD idle loop. Used when we're in daemon mode.
"""
time_song_start = time.time()
while 1:
logging.debug("idle_loop: current song: {}".format(client.currentsong()))
try:
prevsong = client.currentsong()
at_last_song = albumlist.is_last_song_in_album(prevsong)
reasons = client.idle('player','playlist') # blocking
logging.debug("response from client.idle: {}".format(str(reasons)))
# streams come in with ['playlist', 'player'] on song change
# we only want to refresh the albumlist if only the playlist has changed:
if len(reasons) == 1 and 'playlist' in reasons:
# the playlist has changed
albumlist.refresh()
continue
if not at_last_song:
# Ignore everything unless we were at the last song on the current album.
# This is a hack so that we ignore the user changing the playlist. We're
# trying to detect the end of the album. The hole here is that if the
# user changes the current song during the last song on an album then
# we'll randomly select a new album for them. Unfortunately I don't see how
# to avoid this given the current MPD API.
continue
currsong = client.currentsong()
if currsong == None or len(currsong) < 1:
# handle end of playlist
logging.info("end of playlist detected")
albumlist.play_next_album(prevsong['album'])
elif currsong['pos'] != prevsong['pos']:
logging.debug("song change detected: prev: {} curr: {}".format(song_info(prevsong), song_info(currsong)))
if currsong['album'] != prevsong['album']:
# Check that we are at the end of the last song. This is to handle the case where the user
# changes the current song when we're at the last song in an album
if 'time' in prevsong:
time_elapsed = time.time() - time_song_start
song_length = int(prevsong['time'])
time_diff = song_length - time_elapsed
if abs(time_diff) < 5 or abs(time_diff) > song_length:
logging.debug("album changed detected: prev: {0} curr: {1}, time_diff: {2}-{3}={4}".format(prevsong['album'],
currsong['album'], song_length, time_elapsed, time_diff))
albumlist.play_next_album(prevsong['album'])
else:
logging.debug("user changed song at end of album; not selecting a different album, time_diff: {0}-{1}={2}".format(song_length,
time_elapsed, time_diff))
else:
albumlist.play_next_album(prevsong['album'])
# update the start time for the next song
time_song_start = time.time()
except:
logging.error("Unexpected error: {}\n{}".format(sys.exc_info()[0], traceback.format_exc()))
albumlist.play_next_album()
def connect_mpd():
"""Connect to mpd.
"""
client = mpd.MPDClient()
mpd_passwd = None
mpd_host = os.getenv('MPD_HOST')
if mpd_host is None:
mpd_host = 'localhost'
else:
splithost = mpd_host.split('@')
if len(splithost) > 1:
mpd_passwd = splithost[0]
mpd_host = splithost[1]
mpd_port = os.getenv('MPD_PORT')
if mpd_port is None:
mpd_port = 6600
client.connect(mpd_host, mpd_port)
if mpd_passwd is not None:
client.password(mpd_passwd)
logging.debug("MPD version: {}".format(client.mpd_version))
#logging.debug("client.commands(): %s" % client.commands())
return client
def go_mpd(client, is_daemon):
"""Top-level function, called from main(). Here is where we start to interact with mpd.
"""
albumlist = AlbumList(client)
albumlist.refresh()
if is_daemon:
idle_loop(client, albumlist)
else:
albumlist.play_next_album()
client.close()
client.disconnect()
def mpd_info(client):
"""Print some basic info obtained from mpd.
"""
albumlist = AlbumList(client)
albumlist.refresh()
print("Album List:\n")
albumlist.print_debug_info()
print("\nCurrent Song:\n")
currsong = client.currentsong()
print(currsong)
client.close()
client.disconnect()
def main():
try:
opts, args = getopt.getopt(sys.argv[1:], "hDpdi", ["help", "debug", "passive", "daemon", "info"])
except getopt.GetoptError:
# print help information and exit:
script_help()
return 2
arg_daemon=False
arg_loglevel = logging.INFO
arg_info = False
for o, a in opts:
if o in ("-h", "--help"):
script_help()
elif o in ("-D", "--debug"):
arg_loglevel = logging.DEBUG
elif o in ("-p", "--passive"):
global PASSIVE_MODE
PASSIVE_MODE = True
elif o in ("-i", "--info"):
arg_info = True
elif o in ("-d", "--daemon"):
arg_daemon = True
# configure logging
logging.basicConfig(level=arg_loglevel)
client = connect_mpd()
if PASSIVE_MODE:
print("PASSIVE_MODE: will not change playlist")
if arg_info:
return mpd_info(client)
go_mpd(client, arg_daemon)
return 0
class AlbumList:
"""Manages album information as queried from MPD.
"""
def __init__(self, client):
self._client = client
if not os.path.exists(MPD_RANDOM_ALBUM_QUEUE_FILE):
logging.info("Creating album queue file '{}'".format(MPD_RANDOM_ALBUM_QUEUE_FILE))
self._write_album_queue([])
def _create_album_list(self, plinfo):
"""Returns a list of albums from the playlist info."""
self._albums = []
for a in plinfo:
try:
if a['album'] not in self._albums:
self._albums.append(a['album'])
except KeyError:
logging.debug("createAlbumList, no album key, ignoring entry: {}".format(a))
def _create_last_song_list(self, plinfo):
"""Manages the _last_song_pos map, which maintains a last song position for each album.
"""
self._last_song_pos = {}
for a in self._albums:
entries = self._client.playlistfind("album", a)
# skip if size of entries is zero
if len(entries) == 0:
continue
elif len(entries) == 1:
logging.debug("Single file album={}: {}".format(a, song_info(entries[-1])))
else:
logging.debug("Last song for album={}: {}".format(a, song_info(entries[-1])))
# pick pos from last entry that is returned
self._last_song_pos[a] = entries[-1]['pos']
def _choose_random_album(self, current_album_name):
"""Selects a random album from the current playlist, doing its best to avoid choosing
the current album.
"""
if len(self._albums) < 1:
logging.warn("No albums found")
album_name = current_album_name
elif len(self._albums) == 1:
logging.debug("only one album found: {}".format(self._albums))
album_name = self._albums[0]
else:
for i in range(0,3):
# pick a random album from the list of album names we've built
new_album_index = random.choice(range(0, len(self._albums) - 1))
album_name = self._albums[new_album_index]
# If we've picked the same album as current then
# lets keep trying (a few times before giving up)
if album_name != current_album_name:
break
logging.info("picked album: {}".format(album_name))
return album_name
def _write_album_queue(self, album_q_list):
"""Writes the given album queue to file. Will write an empty file if list is empty."""
logging.debug("Album queue: writing '{}'".format(MPD_RANDOM_ALBUM_QUEUE_FILE))
with open(MPD_RANDOM_ALBUM_QUEUE_FILE, 'w') as f:
for l in album_q_list:
f.write(l)
def _write_album_queue_archive(self, album_name):
"""Writes the given album name to the archive file."""
if MPD_RANDOM_ALBUM_QUEUE_ARCHIVE_FILE is not None and MPD_RANDOM_ALBUM_QUEUE_ARCHIVE_FILE != '':
logging.debug("Album queue archive: writing '{}'".format(album_name))
with open(MPD_RANDOM_ALBUM_QUEUE_ARCHIVE_FILE, 'a') as f:
f.write(album_name + '\n')
def _process_album_queue(self):
"""Process the album queue file. Selects a matching album from the queue, or returns None if not found."""
if not os.path.exists(MPD_RANDOM_ALBUM_QUEUE_FILE):
logging.warn("Album queue file does not exist '{}'".format(MPD_RANDOM_ALBUM_QUEUE_FILE))
return None
logging.info("Album queue: Scanning '{}'".format(MPD_RANDOM_ALBUM_QUEUE_FILE))
with open(MPD_RANDOM_ALBUM_QUEUE_FILE) as f:
album_q_list = f.readlines()
if len(album_q_list) < 1:
return None
try:
while len(album_q_list) > 0:
queued_album = album_q_list.pop(0).strip()
for album_name in self._albums:
if queued_album.startswith('!'):
# exact match
if queued_album.lstrip('!') == album_name:
logging.info("Album queue: exact matched '{}'".format(queued_album))
self._write_album_queue_archive(queued_album)
return album_name
else:
# substring match (default)
if queued_album in album_name:
logging.info("Album queue: matched '{}' in '{}".format(queued_album, album_name))
self._write_album_queue_archive(queued_album)
return album_name
finally:
self._write_album_queue(album_q_list)
logging.info("Album queue: No matching album found from '{}'".format(MPD_RANDOM_ALBUM_QUEUE_FILE))
return None
def refresh(self):
"""Refreshes the album list.
"""
plinfo = self._client.playlistinfo()
self._create_album_list(plinfo)
self._create_last_song_list(plinfo)
def get_album_names(self):
"""Returns list of album names.
"""
return self._albums
def is_last_song_in_album(self, currentsong):
"""Given a song entry, returns 1 if song is last in album.
"""
if currentsong == None or len(currentsong) < 1:
return False
if 'album' not in currentsong:
logging.info("current song has no album, ignoring: {}".format(currentsong))
return False
try:
if currentsong['pos'] == self._last_song_pos[currentsong['album']]:
logging.info("is last song: {}".format(song_info(currentsong)))
return True
except KeyError:
logging.error("Caught KeyError current pos: {}, currentsong['album']: {}".format(currentsong['pos'],
currentsong['album']))
return False
logging.debug("not last song: {}, current pos: {} / last pos: {}".format(song_info(currentsong),
currentsong['pos'],
self._last_song_pos[currentsong['album']]))
return False
def play_next_album(self, current_album_name=None):
"""Plays a random album on the current playlist.
"""
if os.path.exists(MPD_RANDOM_SUSPEND_FILE):
logging.info("Suspended by presence of {}, not choosing next album".format(MPD_RANDOM_SUSPEND_FILE))
return
# choose next album, either by album queue or random
album_name = self._process_album_queue()
if album_name is None:
album_name = self._choose_random_album(current_album_name)
if album_name is None:
print("ERROR: could not find an album to play")
return
# Look for tracks in album. They are ordered by position in the playlist.
# NOTE: if the playlist is not sorted by album the results may be wonky.
entries = self._client.playlistfind("album", album_name)
if len(entries) < 1:
print("ERROR: could not find album '{}'".format(album_name))
return
logging.debug("found entry: {}".format(entries[0]))
if not PASSIVE_MODE:
# play at the playlist position of the first returned entry
self._client.play(entries[0]['pos'])
def print_debug_info(self):
print("Albums: {}".format(self._albums))
print("Last Song Positions: {}".format(self._last_song_pos))
###############################################################################
if __name__ == "__main__" or __name__ == "main":
sys.exit(main())
###############################################################################