====== Music Player Moode ====== ===== Hardware ===== |Raspberry Pi Zero W|| |Pimoroni Pirate Audio (Headphone)|https://shop.pimoroni.com/products/pirate-audio-headphone-amp| |Pimoroni Pirate Audio Case w/ Buttons|https://www.thingiverse.com/thing:5245754| |Pimoroni Pirate Audio Case orig w/o Buttons|https://www.printables.com/en/model/85009-pimoroni-pirate-audio-case| |Pimoroni Pirate Audio Case angled base|https://www.thingiverse.com/thing:5248110| |Pimoroni Pirate Audio Case open angled base|https://www.thingiverse.com/thing:4420857| |Alternative case ideas|https://www.yeggi.com/q/pimoroni+pirate+audio/| |External passive speakers with 3.5mm plug|| |3.5mm Headphone Volume Control|https://smile.amazon.co.uk/gp/product/B00Y1MYSYW/| {{raspberry-pi:pirateaudio_with_buttons_base_-_no_holes.stl}} {{raspberry-pi:pirateaudio_with_buttons_top_-_no_holes.stl}} {{raspberry-pi:pirateaudiobutton.stl}} ===== System installation 8.3.0 ===== Download Moode ISO https://moodeaudio.org/ https://github.com/moode-player/moode/releases/download/r830prod/image_2023-03-14-moode-r830-arm64-lite.zip Main install instructions with reference to auto-install: https://github.com/moode-player/moode/blob/master/www/setup.txt Then flash using Balena Etcher or similar, for Belana, unzipping is not required! https://www.balena.io/etcher/ sudo apt-get install belana-etcher-electron sudo apt-get install debian-keyring debian-archive-keyring apt-transport-https ca-certificates gnupg curl -1sLf "https://dl.cloudsmith.io/public/balena/etcher/gpg.70528471AFF9A051.key" | sudo apt-key add cat < Mount the SDCard which will make the boot partition accessible Copy the file /boot/moodecfg.ini.default to your PC, Mac or Linux client Rename it to moodecfg.ini Edit the settings as needed (wlan, country, volume steps, etc) Copy moodecfg.ini to /boot/ SSH Server enabled by default: username: pi password: moodeaudio moodecfg adjustments for UK and personal preferences: timezone = "Europe/London" keyboard = "gb" p3bt = "0" replaygain = "track" volume_normalization = "yes" volume_step_limit = "2" wlanssid = "xxx" wlanpwd = "xxx" wlancountry = "GB" first_use_help = "No" Config.txt adjustments for Pirate Audio on PiZeroW [pi0] # Disable the ACT LED on the Pi 1 and Zero dtparam=act_led_trigger=none dtparam=act_led_activelow=on [cm4] otg_mode=1 [pi4] hdmi_force_hotplug:0=1 hdmi_force_hotplug:1=1 [all] #Disable boot splash screen disable_splash=1 disable_overscan=1 hdmi_drive=2 #Disable HDMI on boot hdmi_blanking=2 hdmi_force_edid_audio=1 hdmi_force_hotplug=1 hdmi_group=1 # Uncomment some or all of these to enable the optional hardware interfaces dtparam=i2c_arm=on dtparam=i2s=on #Switch off onboard audio dtparam=audio=off #dtoverlay=disable-wifi dtoverlay=disable-bt #Configure Pimoroni Pirate audio DAC dtoverlay=i2s-mmap dtoverlay=hifiberry-dac gpio=25=op,dh #Enable SPI for display of Pimoroni Pirate Audio dtparam=spi=on #Set GPU memory to lowest value in /boot/config.txt: gpu_mem=16 possible further requirement for HDMI: Disable HDMI port on boot (power saving during headless operation) /usr/bin/tvservice -o (-p to re-enable) Add the line to /etc/rc.local to disable HDMI on boot. Now insert the SD-Card into the Pi and power it up. Then connect to web interface via http://moode/ or IP if known. Configure Moode: Audio Config: "HiFiBerry DAC" or "Pimoroni pHAT DAC" System: Disable ACT LED, Disable HDMI Enable GPIO button handler and set to: four buttons, active low connected to BCM 5, 6, 16, and 24 (A, B, X, Y respectively). Replace all spaces with commas in the command. See http://moodeaudio.org/forum/showthread.php?tid=1381&page=2&highlight=gpio BTN1: 5 mpc,toggle BTN2: 6 /var/www/vol.sh,-dn,5 BTN3: 16 mpc,next BTN4: 24 /var/www/vol.sh,-up,5 Debounce 1000ms #mpc,volume,-5 #mpc,volume,+5 #/var/www/vol.sh,-mute Configure vi via ssh: vi ~/.vimrc set tabstop=4 set shiftwidth=4 set softtabstop=4 set expandtab set nocompatible Remove Bluetooth config in Moode main menu when not in use: sudo vi /var/www/header.php remove line referencing 'blu-config.php' ===== Pirate Audio TFT Cover Art v0.0.6 ===== https://github.com/rusconi/TFT-MoodeCoverArt fork: https://github.com/pachisb/TFT-MoodeCoverArt Note: 16-25% CPU usage on RPI-0w ! Enable Metadata file in System -> Local Services in moode. #enable spi if not already enabled sudo raspi-config sudo reboot sudo apt-get update sudo apt-get install git python3-rpi.gpio python3-spidev python3-pip python3-pil python3-numpy libatlas-base-dev sudo pip3 install mediafile pyyaml RPI-ST7789 cd /home/pi git clone https://github.com/rusconi/TFT-MoodeCoverArt.git cd TFT-MoodeCoverArt/ vi config.yml chmod 777 *.sh #test python3 tft_moode_coverart.py #if it works, install service ./install_service.sh Bugfix: Wrap mf = MediaFile(fp) section in try/except and indent it else: if 'file' in metaDict: if len(metaDict['file']) > 0: fp = '/var/lib/mpd/music/' + metaDict['file'] try: mf = MediaFile(fp) if mf.art: cover = Image.open(BytesIO(mf.art)) return cover else: for it in covers: cp = os.path.dirname(fp) + '/' + it if path.exists(cp): cover = Image.open(cp) return cover except: pass return cover IP Address mod: #add to top import socket #add before 'def main():' def get_ip(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: # doesn't even have to be reachable s.connect(('10.255.255.255', 1)) IP = s.getsockname()[0] except Exception: IP = '127.0.0.1' finally: s.close() return IP # add in def main(): after variable initialisation #get ip address, add to image and show on display ip = get_ip() draw.rectangle((0,0,240,240), fill=(0,0,0)) txt = f"Visit http://{ip} to select content." mlw, mlh = draw.multiline_textsize(txt, font=font_m, spacing=4) draw.multiline_text(((WIDTH-mlw)//2, 20), txt, fill=(255,255,255), font=font_m, spacing=4, align="center") disp.display(img) Service [Unit] Description=TFT-MoodeCoverArt Display Requires=mpd.socket mpd.service After=mpd.socket mpd.service [Service] Type=simple ExecStart=/home/pi/TFT-MoodeCoverArt/tft-moodecoverart.py & ExecStartPre=/bin/sleep 15 #ExecStop=/home/pi/TFT-MoodeCoverArt/tft-moodecoverart.sh -q ExecStop=/home/pi/TFT-MoodeCoverArt/shutdown.py & Restart=on-abort StandardOutput=syslog StandardError=syslog [Install] WantedBy=multi-user.target ===== Boot logo on PirateAudio ===== #!/usr/bin/env python3 import time from PIL import ImageFont, Image, ImageDraw import os import ST7789 import sys # get the path of the script script_path = os.path.dirname(os.path.abspath(__file__)) # set script path as current directory os.chdir(script_path) # Create ST7789 LCD display class. disp = ST7789.ST7789( rotation=90, # Needed to display the right way up on Pirate Audio port=0, # SPI port cs=1, # SPI port Chip-select channel dc=9, # BCM pin used for data/command backlight=13, spi_speed_hz=80 * 1000 * 1000 ) # Initialize display. disp.begin() WIDTH = 240 HEIGHT = 240 font_s = ImageFont.truetype(script_path + '/fonts/Roboto-Medium.ttf', 20) font_m = ImageFont.truetype(script_path + '/fonts/Roboto-Medium.ttf', 24) font_l = ImageFont.truetype(script_path + '/fonts/Roboto-Medium.ttf', 30) def bootmessage(): print('bootmessage called') disp.set_backlight(True) img = Image.new('RGBA', (240, 240), color=(0, 0, 0, 25)) img = Image.open('images/default-cover-v6.jpg') draw = ImageDraw.Draw(img, 'RGBA') message = 'booting ...' draw.text((10, 200), message, font=font_m, fill=(255, 255, 255)) disp.display(img) sys.exit() try: bootmessage() except SystemExit: print('Systemexit called') pass chmod 755 /home/pi/TFT-MoodeCoverArt/boot.py [Unit] Description=TFT Boot Message Before=basic.target After=local-fs.target sysinit.target DefaultDependencies=no [Service] Type=oneshot ExecStart=/home/pi/TFT-MoodeCoverArt/boot.py [Install] WantedBy=basic.target systemctl enable tft-boot ===== Pirate Audio Cover related links ===== https://github.com/pimoroni/pidi/blob/master/pidi/client.py https://github.com/pimoroni/pidi-plugins/blob/master/pidi-display-pil/pidi_display_pil/__init__.py https://github.com/pimoroni/pidi-plugins/blob/master/pidi-display-pil/pidi_display_pil/__init__.py https://github.com/pimoroni/st7789-python https://github.com/pimoroni/pidi-spotify https://github.com/pimoroni/pirate-audio/issues/17 https://github.com/pimoroni/mopidy-pidi/blob/master/mopidy_pidi/frontend.py https://github.com/pimoroni/pirate-audio/blob/master/examples/backlight-pwm.py https://github.com/AnonTester/TFT-MoodeCoverArt http://moodeaudio.org/forum/showthread.php?tid=2210 https://ideatrash.net/2020/06/simple-smart-playlists-for-mpd-that-work.html https://bbs.archlinux.org/viewtopic.php?id=76385 https://github.com/Ax-LED/volumio-pirate-audio ===== Long Button Press MOD ===== Adjust gpio-buttons.py script as per following example for first configured button: if str(row['id']) == '1' and row['enabled'] == '1': sw_1_pin = int(row['pin']) sw_1_cmd = row['command'].split(',') sw_1_cmd = [x.strip() for x in sw_1_cmd] sw_1_cmd_2 = ["/var/www/command/sleeptimer.php","1800"] sw_1_cmd_3 = ["/var/www/command/sleeptimer.php","3600"] GPIO.setup(sw_1_pin, GPIO.IN, pull_up_down=GPIO.PUD_UP) def sw_1_event(channel): start_time = time.time() time.sleep(0.005) # edge debounce of 5 ms # only deal with valid edges while GPIO.input(channel) == 0: # wait for button up pass buttonTime = time.time() - start_time #calc button press print('time ' + str(buttonTime)) #if GPIO.input(channel) == 1: if buttonTime < 2: print('short press') subprocess.call(sw_1_cmd) elif 2 <= buttonTime < 4: # long press print('long press') subprocess.call(sw_1_cmd_2) elif buttonTime > 4: # very long press print('very long press') subprocess.call(sw_1_cmd_3) GPIO.add_event_detect(sw_1_pin, GPIO.FALLING, callback=sw_1_event, bouncetime=bounce_time) print(str(datetime.datetime.now())[:19] + ' sw_1: pin=' + str(sw_1_pin) + ', enabled=' + row['enabled'] + ', bounce_time=' + str(bounce_time) + ', cmd=' + row['command']) #!/usr/bin/php sudo chmod 755 /var/www/command/sleeptimer.php sudo killall -9 gpio_buttons.py sudo /var/www/daemon/gpio_buttons.py & Other notes: # https://learn.pimoroni.com/tutorial/sandyj/getting-started-with-pirate-audio #This will install python3 pip,wheel and pirate audio modules #Then add mopidy apt sources and install mopidy including mopidy-spotify #Then install mopidy-iris web interface and Pirate Audio plugins #And create system service to autostart mopidy SMB mount the manual way (/etc/rc.local or /etc/fstab): #Note experiment with rsize=61440 option and/or use nounix mount option //192.168.1.6/music /media/music cifs username=media,password=media,iocharset=utf8,noperm,file_mode=0644,dir_mode=0755,users,rsize=61440,nounix 0 0 sudo mkdir /media/music ===== Moode tips ===== http://moodeaudio.org/forum/showthread.php?tid=803 MPD settings /etc/mpd.conf that control whether to automatically update the database when files are changed. Refer to this link https://github.com/MusicPlayerDaemon/MPD/blob/master/doc/mpd.conf.5 for information. auto_update [yes or no] This specifies the whether to support automatic update of music database when files are changed in music_directory. The default is to disable autoupdate of database. auto_update_depth [N] Limit the depth of the directories being watched, 0 means only watch the music directory itself. There is no limit by default. in mean time you can ssh to moode and use this to add n last days .bash_profile function add-recents { find /media -type f -mtime -$1 | sed 's/\/media/USB/g' | mpc add } then 'add-recents 60' adds last 60 days off music Library update: http://moode/command/?cmd=libupd-submit.php or php /var/www/libupd-submit.php instead of mpc commands, it should update MPD and covers. You also can clear the library cache after the update: mpc -w update truncate /var/local/www/libcache.json --size 0 ===== Add/Remove Radio Stations ===== GUI interface to add radio stations via + icon in radio interface. Radio stations are stored as .pls files in /var/lib/mpd/music/RADIO Station logos are stored with same name as pls file in: /var/local/www/imagesw/radio-logos/Absolute Classic Rock.jpg /var/local/www/imagesw/radio-logos/thumbs/Absolute Classic Rock_sm.jpg /var/local/www/imagesw/radio-logos/thumbs/Absolute Classic Rock.jpg file "/var/local/www/imagesw/radio-logos/Absolute Classic Rock.jpg" /var/local/www/imagesw/radio-logos/Absolute Classic Rock.jpg: JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, baseline, precision 8, 225x225, components 3 file "/var/local/www/imagesw/radio-logos/thumbs/Absolute Classic Rock_sm.jpg" /var/local/www/imagesw/radio-logos/thumbs/Absolute Classic Rock_sm.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), density 96x96, segment length 16, comment: "CREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 75", baseline, precision 8, 80x80, components 3 file "/var/local/www/imagesw/radio-logos/thumbs/Absolute Classic Rock.jpg" /var/local/www/imagesw/radio-logos/thumbs/Absolute Classic Rock.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), density 96x96, segment length 16, comment: "CREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 75", baseline, precision 8, 200x200, components 3 Example pls file: [playlist] File1=http://edge-bauerall-01-gos2.sharp-stream.com/absoluteclassicrock.mp3?aw_0_1st.skey=1703523836&aw_0_1st.playerid=BMUK_RPi Title1=Absolute Classic Rock Length1=-1 NumberOfEntries=1 Version=2 ===== MPD Playlist Folder ===== /var/lib/mpd/playlists/ Generate playlists of recent music #!/bin/bash base_dir=/mnt/ music_dir=NAS/music/other/deemix\ Music/ playlistweek=/var/lib/mpd/playlists/New\ last\ week.m3u playlistmonth=/var/lib/mpd/playlists/New\ last\ month.m3u playlist2month=/var/lib/mpd/playlists/New\ last\ 2\ months.m3u if [ "$base_dir$music_dir" -nt "$playlist" ] || [ ! -f "$playlist" ]; then cd $base_dir find "$music_dir" -type f -mtime -7 -iname "*.mp3" -o -iname "*.ogg" > "$playlistweek" fi if [ "$base_dir$music_dir" -nt "$playlist" ] || [ ! -f "$playlist" ]; then cd $base_dir find "$music_dir" -type f -mtime -31 -iname "*.mp3" -o -iname "*.ogg" > "$playlistmonth" fi if [ "$base_dir$music_dir" -nt "$playlist" ] || [ ! -f "$playlist" ]; then cd $base_dir find "$music_dir" -type f -mtime -61 -iname "*.mp3" -o -iname "*.ogg" > "$playlist2month" fi chmod 755 ~/newm3u.sh ===== MPD Dynamic Playlist ===== https://bbs.archlinux.org/viewtopic.php?id=76385 I present to you, fellow Archers, two little programs. One I've had for months but haven't shared for some reason, and the other I finished a short while ago. MPDDP: MPD Dynamic Playlists is a program to generate, as the name would imply, a dynamic playlist for MPD. You specify rules about what tracks you want added, and it keeps adding them randomly. Source: #!/usr/bin/env python # MPDDP: MPD Dynamic Playlists # Call this and run it in the background (eg mpddp &>/dev/null &) # Configured in /etc/mpddp.conf, See /etc/mpddp.conf.example. import mpd, random, os, time, sys, string client = mpd.MPDClient() host = "" # The host MPD is operating upon port = 0 # The port MPD is operating upon playlistlen = 0 # The len of the playlist changeafter = 0 # The number of tracks before more are added/removed to/from the playlist clearinitially = '' # Whether to clear the playlist initially or not. saveonquit = '' # Whether to save/load the playlist on exit/start or not. update = '' # Whether to periodically check the config file / filesystem for changes. confdir = '/etc/mpddp.conf' # The path of the main MPDDP config file. savedir = '' # The folder where MPDDP saves and loads files. alltracks = [] # All the tracks that can be played. oldconfig = [] # The configuration as it was last loaded. def pickNewTrack(): # Pick and remove a track from the list, append it to the current list, and return the name. global client global alltracks index = random.randint(0, len(alltracks) - 1) track = alltracks[index] return track def addNewTrackToPlaylist(): # Pick a new track, update the lists, and add it to the playlist. global client global host global port global playlistlen client.connect(host, port) playlist = client.playlistinfo() client.disconnect() if len(playlist) < playlistlen: track = pickNewTrack() print "Adding", track client.connect(host, port) client.add(track) client.disconnect() def removeLastTrackFromPlaylist(): # Delete the oldest track from the playlist. global client global host global port client.connect(host, port) playlist = client.playlistinfo() client.delete(0) client.disconnect() print "Removing", playlist[0]['file'] def checkMPDPlaylist(): # Add enough tracks to the MPD playlist to repopulate it if it is almost empty. global client global host global port global playlistlen global alltracks client.connect(host, port) playlist = client.playlistinfo() client.disconnect() if len(playlist) < playlistlen: while len(playlist) < playlistlen: addNewTrackToPlaylist() def updatePlaylist(): # Update the MPD playlist, and the internal representation of it if necessary. checkMPDPlaylist() removeLastTrackFromPlaylist() addNewTrackToPlaylist() def getFilenamesFromMPDSPL(expression): os.system('mpdspl -s -n misc "' + expression + '" >/tmp/mpddp-mpdspl-temp.txt') a = open('/tmp/mpddp-mpdspl-temp.txt') ot = a.read() ot = ot.splitlines() a.close() os.remove('/tmp/mpddp-mpdspl-temp.txt') return ot def getFilenamesFromMPD(rules): # Gets the filenames from MPD of all files which match the specified rules. global client global host global port paths = [] playlists = [] smarts = [] nevers = [] tracks = [] for rule in rules: if rule[0] == 'path' and not rule[1] in paths: paths.append(rule[1]) elif rule[0] == 'playlist' and not rule[1] in playlists: playlists.append(rule[1]) elif rule[0] == 'smart' and not rule[1] in smarts: smarts.append(rule[1]) elif rule[0] == 'never' and not rule[1] in nevers: nevers.append(rule[1]) client.connect(host, port) for path in paths: temptracks = client.search("file", path) for track in temptracks: if isinstance(track, dict): track = track['file'] dontadd = False for never in nevers: if never in track: dontadd = True if dontadd == False and not track in tracks: tracks.append(track) for playlist in playlists: temptracks = client.listplaylist(playlist) for track in temptracks: if isinstance(track, dict): track = track['file'] dontadd = False for never in nevers: if never in track: dontadd = True if dontadd == False and not track in tracks: tracks.append(track) for smart in smarts: temptracks = getFilenamesFromMPDSPL(smart) for track in temptracks: dontadd = False for never in nevers: if never in track: dontadd = True if dontadd == False and not track in tracks: tracks.append(track) client.disconnect() return tracks def parseConfigIncludes(conf, path): # Parse a config file outconf = "" paths = path for line in conf: line = line.split("#") line = line[0] line = line.strip() if len(line) > 0: if line[0:7] == 'include': toinclude = line[8:].strip() toinclude = toinclude.replace("~", os.path.expanduser("~")) if not toinclude in paths: paths.append(toinclude) filehandler = open(toinclude) newconf = parseConfigIncludes(filehandler, paths) outconf = outconf + newconf filehandler.close() else: outconf = outconf + line + "\r\n" return outconf def parseConfigLine(line): # Parse a line from the configuration file and return what it means. line = line.split("#") line = line[0] line = line.strip() if len(line) > 0: if ("=" in line) and (not ":" in line): pline = line.split("=", 1) parsed = {'type' : pline[0].strip(), 'value' : pline[1].strip()} return parsed elif ":" in line: pline = line.split(":", 1) parsed = {'type' : 'rule', 'value' : [pline[0].strip(), pline[1].strip()]} return parsed else: return {'type' : 'unrecognised'} else: return {'type' : 'blankline'} def parseConfigFile(): # Open the configuration file and parse the rules. global confdir filehandler = open(confdir) conf = parseConfigIncludes(filehandler, [confdir]) output = {'rules' : [], 'server' : 'localhost', 'port' : 6600, 'playlistlen' : 15, 'changeafter' : 8, 'clearinitially' : 'yes', 'saveonquit' : 'no', 'savedir' : '/var/lib/mpddp/', 'update' : 'no'} for line in conf.splitlines(): result = parseConfigLine(line) if result['type'] == 'rule': output['rules'].append(result['value']) elif result['type'] == 'clearinitially' or result['type'] == 'saveonquit': if result['value'] == 'yes' or result['value'] == 'no': output[result['type']] = result['value'] else: print "Invalid value specified for", result['type'] elif result['type'] == 'port' or result['type'] == 'playlistlen' or result['type'] == 'changeafter': try: if (result['type'] == 'port' and int(result['value']) > 0 and int(result['value']) <= 65536) or not result['type'] == 'port': output[result['type']] = int(result['value']) else: print "Invalid value specified for", result['type'] except TypeError: print "Invalid value specified for", result['type'] elif result['type'] == 'server' or result['type'] == 'savedir' or result['type'] == 'update': output[result['type']] = result['value'] filehandler.close() return output def loadPlaylistFromSaved(): # Load the previously saved playlist, if it exists. Then fill any space remaining with newly-added tracks. global playlistlen global client global host global port global savedir loaded = [] try: filehandler = open(savedir + 'playlist') for line in filehandler: loaded.append(line) filehandler.close() client.connect(host, port) for track in loaded: track = track.strip() if len(track) > 0: try: client.add(track) print "Loading", track except mpd.CommandError: print "Error loading", track client.disconnect() for i in range(len(loaded), playlistlen): addNewTrackToPlaylist() except IOError: for i in range(0, playlistlen): addNewTrackToPlaylist() def populateLists(redoing): # Parse the configuration file, and grab the tracks from MPD to populate the lists. global playlistlen global changeafter global clearinitially global client global host global port global alltracks global saveonquit global savedir global update global oldconfig config = parseConfigFile() rules = config['rules'] if redoing == False or (redoing == True and not oldconfig == config): host = config['server'] port = config['port'] playlistlen = config['playlistlen'] changeafter = config['changeafter'] clearinitially = config['clearinitially'] saveonquit = config['saveonquit'] savedir = config['savedir'] update = config['update'] oldconfig = config print "Configuration updated:", config tracks = getFilenamesFromMPD(rules) if redoing == True: alltracks = [] if redoing == False or not alltracks == tracks: alltracks = tracks client.connect(host, port) if clearinitially == 'yes' and redoing == False: client.clear() client.random(0) client.disconnect() if redoing == False: if saveonquit == 'no': for i in range(0, playlistlen): addNewTrackToPlaylist() else: loadPlaylistFromSaved() client.connect(host, port) client.play() client.disconnect() def dieGracefully(): global saveonquit if saveonquit == 'yes': try: os.remove('/var/lib/mpddp/playlist') print "Removed old playlist..." except OSError: print "No old playlist to remove." try: os.remove('/tmp/killmpddp') print "Removed kill file..." except OSError: print "No kill file to remove." print "Saving playlist to", savedir, "playlist" filehandler = open(savedir + 'playlist', 'w') playlist = client.playlistinfo() for track in playlist: print "Writing", track['file'] filehandler.write(track['file'] + '\n') filehandler.close() print "Quitting..." sys.exit() # Execute the program main loop populateLists(False) loops = 0 try: while True: if os.path.exists('/tmp/killmpddp'): dieGracefully() client.connect(host, port) info = client.currentsong() status = client.status() playlist = client.playlistinfo() client.disconnect() if len(info) > 0: if int(status['song']) >= changeafter: for i in range(changeafter - 1, int(status['song'])): updatePlaylist() if len(playlist) < playlistlen: for i in range(len(playlist), playlistlen): addNewTrackToPlaylist() if loops == 59: if update == 'yes': populateLists(True) loops = 0 else: loops = loops + 1 time.sleep(1) except KeyboardInterrupt: dieGracefully() /etc/mpddp.conf.example: server = localhost # The server that MPD is operating upon. port = 6600 # The port that MPD is operating upon. playlistlen = 15 # The number of tracks to have in the playlist. changeafter = 8 # The number of tracks listened to initially before the add/remove loop begins. clearinitially = no # Whether to clear the playlist upon starting or not. saveonquit = no # Whether to save/load the playlist upon exit/start or not. savedir = /var/lib/mpddp/ # The directory to save/load the playlist. update = no # Periodically check to ensure that the config file hasn't been updated, and that no tracks have been added/removed. #include /home/USER/.mpddp # An additional config file to parse. I suggest you use this to specify tracks to listen to #path:PATH # Add all tracks where the file path contains PATH. #playlist:PLAYLIST # Add all the playlists where the name is PLAYLIST. #smart:RULES # Add all the tracks which match the smart playlist RULES. Requires MPDSPL to be in your $PATH. #never:STRING # Never add tracks where the file path contains STRING. And, for reference, here's a small chunk of my config file: # -*-conf-*- playlistlen = 50 # For full-screen changeafter = 26 #playlistlen = 19 # For half-screen #changeafter = 10 clearinitially = no # Playlists #playlist:Wordless #playlist:Steamcowboy # Musicals #smart:fp=/(Cats Original London Cast|Joseph and the Amazing Technicolour Dreamcoat|Les Misérables .*|Mary Poppins|Miss Saigon .*|Phantom Of The Opera .*|Sound of Music 40th Anniversary Special Edition, The).*\// #path:Cats Original London Cast/ #path:Joseph and the Amazing Technicolour Dreamcoat/ #path:Les Misérables Complete Symphonic Recording/ #path:Les Misérables Original Broadway Cast/ #path:Les Misérables Original Paris Cast/ #path:Mary Poppins/ #path:Miss Saigon CSR/ #path:Miss Saigon (Original London Cast)/ #path:Phantom Of The Opera 2004 Film, The/ #path:Phantom of the Opera Original London Cast, The/ #path:Sound of Music 40th Anniversary Special Edition, The/ MPDSPL: MPD Smart PlayLists makes smart playlists for MPD. See the "smart:" line in my config file above? that's an example of a MPDSPL playlist description. Playlist descriptions are in regex, and use keywords ("ar" for artist, "al" for album, etc). Source: #!/usr/bin/env python # A script to parse the MPD database into a list of dictionaries (or at least, it was going to be before I decided to finish it). # Now with patronising comments which assume almost no Python knowledge! # cPickle is a faster version of the pickle library. It is used to save data structures to a file. Like lists and dictionaries. os is needed for file stuff, sys for arguments, and re for regex. import cPickle, os, sys, re # Info about new playlists newname = "" newrules = [] # Place to look for the MPD database and config files, and the loaded MPD config (well, only the values useful to us). confpath = "/etc/mpd.conf" mpd = {"music_directory" : "", "playlist_directory" : "", "db_file" : "", "user" : ""} # There is an environmental variable XDG_CACHE_HOME which specifies where to save cache files. However, if not set, a default of ~/.cache should be used. cachehome = os.path.expanduser(os.environ['XDG_CACHE_HOME']) if cachehome == "": cachehome = os.environ['HOME'] + "/.cache" cachepath = cachehome + "/mpdspl/mpddb.cache" # $XDG_DATA_HOME specifies where to save data files. Like a record of playlists which have been created. If unset a default of ~/.local/share should be used. This is currently unused as there is no actual creation of playlists yet :p datahome = os.path.expanduser(os.environ['XDG_DATA_HOME']) if datahome == "": datahome = os.environ['HOME'] + "/.local/share/" datapath = datahome + "/mpdspl" # If the data directory does not exist, create it. if not os.path.isdir(datapath): os.mkdir(datapath) tracks = [] forceupdate = False simpleoutput = False # A nice little help function. Read on to see how it is called... def showhelp(): print "Usage: mpdspl [options]\n" print "A script to generate smart playlists for MPD. Currently does nothing of use :p\n" print "Options:" print " -f, --force - Force an update of the cache file and any playlists." print " -dFILE, --dbpath=FILE - Location of the database file." print " -cFILE, --cachepath=FILE - Location of the cache file." print " -CFILE, --confpath=FILE - Location of the MPD config file." print " -uUSER, --mpduser=USER - Location of the MPD config file." print " -n, --new [name] [rules] - Create a new playlist." print " -s, --simple - (used with -n) Only print the final track list (with paths relative to the MPD root dir) to STDOUT." print " -h, --help - Display this text and exit.\n" print "Playlist rules:" print " These are specified as a string of Python-compatible regular expressions separated by keywords, spaces, and slashes." print " They are matched by re.search, not re.match, and no special flags are passed, other than re.IGNORECASE when requested.\n" print " These keywords are:" print " ar = Artist" print " al = Album" print " ti = Title" print " tr = Track Number" print " ge = Genre" print " ye = Year" print " le = Length (seconds)" print " fp = File Path (relative to MPD root dir, including filename)" print " fn = File Name\n" print " Regular expressions are specified within slashes (/regex/)." print " If the first slash is preceeded by an 'i', the regular expression is interpreted as case-insensitive." print " If the final slash is succeeded by a 'n', the result of the match is negated.\n" print " For example, a rule for all tracks by 'Fred' or 'George', which have a title containing (case insensitive) 'The' and 'and', but not 'when' would be:" print " ar=/(Fred|George)/ ti=i/(the.*and|and.*the)/ ti=i/when/n\n" print "Notes:" print " Paths specified in the MPD config file containing a '~' will have the '~'s replaced by the user MPD runs as." print " If the user is not specified in the MPD config file, or by the -u parameter, it is assumed the user is root." print " Backslashes must be escaped in playlist rules.\n" sys.exit() # Parse the rules regex def parserules(rulestr): # rules will be our list of rules, bufferstr will be the buffer for our parser, and i will be a counter rules = [] bufferstr = "" i = 0 # We want to use the same identifiers as the track dictionaries: keywords = {"ar" : "Artist", "al" : "Album", "ti" : "Title", "tr" : "Track", "ge" : "Genre", "ye" : "Date", "le" : "Time", "fp" : "file", "fn" : "key"} # For every character in rulestr (we do it characterwise, hence needing a buffer) for c in rulestr: # Add the character to the buffer bufferstr += c # If the buffer matches one of our keywords, we have hit a new rule, and so create a blank dictionary, and clear the buffer. if bufferstr.strip() in ["ar", "al", "ti", "tr", "ge", "ye", "le", "fp", "fn"]: rules.append({"type" : keywords[bufferstr.strip()], "regex" : "", "compiled" : None, "inverse" : False, "negate" : False}) bufferstr = "" # If we're at the start of a blank case-insensitive regex, record that, and clear the buffer. elif bufferstr == "=i/": rules[i]["i"] = True bufferstr = "" # If not, just clear the buffer for the coming regex. elif bufferstr == "=/": bufferstr = "" # If at the end of a regex, stick it all (sans the trailing slash, they're just a nice separater for our parser) to the dictionary, increment the counter, and clear the buffer ready for the next rule. elif bufferstr[-1] == "/" and not bufferstr[-2] == "\\": rules[i]["regex"] = bufferstr[:-1] bufferstr = "" i += 1 # Get rid of the escape backslash if a forward slash has been used. elif bufferstr[-1] == "/" and not bufferstr[-2] == "\\": bufferstr[-2] = "" # If set to 'n' and the regex has been set, negate it. elif bufferstr == "n" and not rules[i - 1]["regex"] == "": bufferstr = "" rules[i - 1]["negate"] = True # This isn't needed. But it makes things faster and allows us to have case insensetivity. for rule in rules: regex = None if rule["inverse"]: # If case insensitive, compile it as such. regex = re.compile(rule["regex"], re.IGNORECASE) else: regex = re.compile(rule["regex"]) # Overwrite the regex string with the compiled object rule["compiled"] = regex return rules # Splitting things up into functions is good :D def parseargs(): # global lets us access variables specified outside our function. global forceupdate global mpd global confpath global cachepath global newname global newrules global simpleoutput newarg = 0 for argument in sys.argv: if not newarg == 0: # We're making a new playlist. If we're only on the first option after -n, that's the name. If the second, that's the description. if newarg == 2: newname = argument elif newarg == 1: newrules = parserules(argument) newarg -= 1 else: if argument == "-f" or argument == "--force": # If a "-f" or "--force" parameter is sent, force the cache to be updated even if it doesn't look like it needs to be. forceupdate = True elif argument[:2] == "-d" or argument[:9] == "--dbpath=": # Looks like their db is somewhere other than /var/lib/mpd/mpd.db... if argument[:2] == "-d": # Python can't work with ~, which has a reasonable chance of being used (eg: ~/.mpd/mpd.db"), so it needs to be expanded. mpd["db_file"] = os.path.expanduser(argument[2:]) elif argument[:9] == "--dbpath=": mpd["db_file"] = os.path.expanduser(argument[9:]) elif argument[:2] == "-c" or argument[:12] == "--cachepath=": # Silly person, not keeping their cache where XDG says it should be... if argument[:2] == "-c": cachepath = os.path.expanduser(argument[2:]) elif argument[:12] == "--cachepath=": cachepath = os.path.expanduser(argument[12:]) elif argument[:2] == "-C" or argument[:11] == "--confpath=": # Now any person which this code applies to is just awkward. if argument[:2] == "-C": confpath = os.path.expanduser(argument[2:]) elif argument[:11] == "--confpath=": confpath = os.path.expanduser(argument[11:]) elif argument[:2] == "-u" or argument[:10] == "--mpduser=": # As is any person to whom this applies... if argument[:2] == "-u": mpd["user"] = argument[2:] elif argument[:10] == "--mpdpath=": mpd["user"] = argument[10:] elif argument == "-n" or argument == "--new": # Do special treatment to the next 2 arguments newarg = 2 elif argument == "-s" or argument == "--simple": # Ooh, this means that (probably) MPDDP is being used! Yay! simpleoutput = True elif argument == "-h" or argument == "--help": showhelp() elif not argument == sys.argv[0]: # The first argument is the filename. Don't complain about not understanding it... # Ooh, stderr. I never actually knew how to send stuff through stderr in python. print >> sys.stderr, "Unrecognised parameter '" + argument + "'" sys.exit(1) # A function to parse a MPD database and make a huge list of tracks def parsedatabase(database): global tracks i = -1 parsing = False for line in database: # For every line in the database, remove any whitespace at the beginning and end so the script isn't buggered. line = line.strip() # If entering a songList, start parsing. If exiting one, stop. Fairly self explanatory. if not parsing and line == "songList begin": parsing = True elif parsing and line == "songList end": parsing = False # If we get a line to parse which is not a "songList begin" statement (because it's be stupid to do things with that) if parsing and not line == "songList begin": if line[0:5] == "key: ": i += 1 # Increment the counter and make an empty dictionary if we hit the beginning of a track tracks.append({"key" : "", "file" : "", "Time" : "", "Genre" : "", "Title" : "", "Artist" : "", "Date" : "", "Album" : "", "Track" : "", "mtime" : ""}) # Split the line by the first ": ", the string MPD uses, and stick the second part (the value) in the bit of the dictionary referred to by the first part (the key) splitted = line.split(": ", 1) tracks[i][splitted[0]] = splitted[1] # Grabbing stuff from the MPD config, a very important step def parsempdconf(): global confpath global mpd config = open(confpath, "r") # Don't load the user or db_file values if they've already been told to us holduser = not mpd["user"] == "" holddb = not mpd["db_file"] == "" for line in config: line = line.strip() if line[:15] == "music_directory": rest = line[15:].strip() mpd["music_directory"] = rest[1:-1] elif line[:18] == "playlist_directory": rest = line[18:].strip() mpd["playlist_directory"] = rest[1:-1] elif line[:7] == "db_file" and not holddb: rest = line[7:].strip() mpd["db_file"] = rest[1:-1] # The rest of the code in this function wouldn't be needed if I could assume nobody would use "~" in their MPD config... elif line[:4] == "user" and not holduser: rest = line[4:].strip() mpd["user"] = rest[1:-1] if mpd["user"] == "": mpd["user"] = "root" homedir = "/home/" + mpd["user"] if homedir == "/home/root": homedir = "/root" if "~" in mpd["music_directory"]: mpd["music_directory"] = mpd["music_directory"].replace("~", homedir) if "~" in mpd["playlist_directory"]: mpd["playlist_directory"] = mpd["playlist_directory"].replace("~", homedir) if "~" in mpd["db_file"]: mpd["db_file"] = mpd["db_file"].replace("~", homedir) def findtracks(): global tracks global newrules # matchingtracks will hold all tracks which match all of the criteria. matchingtracks = [] for track in tracks: # Initially assume a track *will* be added. addtrack = True for rule in newrules: # For every track, check it with every rule if rule["negate"]: if not re.search(rule["compiled"], track[rule["type"]]) == None: # If the regular expression matches the track, do not add it to the matchingtracks list. addtrack = False else: if re.search(rule["compiled"], track[rule["type"]]) == None: # If the regular expression does not match the track, do not add it to the matchingtracks list. addtrack = False if addtrack: # Add the track if appropriate matchingtracks.append(track) return matchingtracks def genplaylist(tracks): global mpd # Parse a list of track dictionaries into a playlist. Thankfully, m3u is a *very* simple format. playlist = "" for track in tracks: playlist += mpd["music_directory"] + "/" + track["file"] + "\n" return playlist # Save some random gubbage to a file def savegubbage(data, path): if not os.path.isdir(os.path.dirname(path)): os.mkdir(os.path.dirname(path)) # Open the file for writing in binary mode outfile = open(path, "wb") # Send the stuff to the file with the magic of cPickle cPickle.dump(data, outfile) # Close the file handler. Tidy u[p. outfile.close() # We might be running as someone other than the user, so make the file writable os.chmod(path, 438) def loadgubbage(path): infile = open(path, "rb") data = cPickle.load(infile) infile.close() return data def saveplaylist(): global newname global newrules global mpd global datapath global simpleoutput matchingtracks = findtracks() playlist = genplaylist(matchingtracks) if simpleoutput: for track in matchingtracks: print track["file"] else: print "Saving playlist '" + newname + "'." # Write the contents of the playlist to the m3u file newlist = open(mpd["playlist_directory"] + "/" + newname + ".m3u", "w") newlist.write(playlist) newlist.close() # Save as list object. This lets us load them all into a big list nicely. savegubbage([newname, newrules], datapath + "/" + newname) # Parse some options! parseargs() parsempdconf() # Check that the database is actually there before attempting to do stuff with it. if not os.path.exists(mpd["db_file"]): print >> sys.stderr, "The database file '" + mpd["db_file"] + "' could not be found." sys.exit(1) # If the cache file does not exist OR the database has been modified since the cache file has this has the side-effect of being able to touch the cache file to stop it from being updated. Good thing we have the -f option for any accidental touches (or if you copy the cache to a new location). if not os.path.exists(cachepath) or os.path.getmtime(mpd["db_file"]) > os.path.getmtime(cachepath) or forceupdate: if not simpleoutput: print "Updating database cache..." # If the cache directory does not exist, create it. The dirname function just removes the "/mpddb.cache" from the end. if not os.path.isdir(os.path.dirname(cachepath)): os.mkdir(os.path.dirname(cachepath)) database = open(mpd["db_file"], "r") # Now, parse that database! parsedatabase(database) # Save the parsed stuff to the cache file and close the database file handler. That's not strictly required, python will clean up when the script ends, but you can't unmount volumes with file handlers pointing to them, so it makes a mess. savegubbage(tracks, cachepath) database.close() if not simpleoutput: # Let's update those playlists! playlistfiles = os.listdir(datapath) playlists = [] for playlistfile in playlistfiles: playlists.append(loadgubbage(datapath + "/" + playlistfile)) # Backup the values first. oldnewname = newname oldnewrules = newrules # Now regenerate! for playlist in playlists: newname = playlist[0] newrules = playlist[1] saveplaylist() # And restore. newname = oldnewname newrules = oldnewrules else: # Oh, goodie, we don't need to go through all that arduous parsing as we have a valid cache file :D if not simpleoutput: print "Loading database cache..." # Open it for reading, load the stuff in the file into the tracks list, close the file handler, and have a party. tracks = loadgubbage(cachepath) # See if we're making a new playlist or not if not newname == "": # We are, go go go! saveplaylist() The source is full of simple comments because I was supposed to be helping a friend who's less proficient in Python make it but… I got bored of waiting tongue If your MPD playlists directory is somewhere which you don't have write access to, run it with `sudo -E` If you need any help with either, ask and ye shall receive. If what you need help with is covered in this post, mpdspl -h, or /etc/mpddp.conf.example, ask and ye shall be laughed at tongue ===== Github projects ===== https://github.com/TestDotCom/pirateplayer https://github.com/Bit-River/Home-Assistant-Gadget-using-Pirate-Audio https://github.com/kenhayward/PirateAudio https://github.com/duracell80/PirateAudio-MPD https://github.com/hakutai/PirateAudioVolumio https://github.com/createcodeandgo/audiobookplayer/tree/main/player https://github.com/placebo83/pirate-audio/blob/master/test.py https://raw.githubusercontent.com/jbenc/kodi-pirate-audio/master/script.service.pirate-audio/resources/lib/main.py https://github.com/mtbkapp/kids_pirate_audio https://github.com/Zurga/pimoroni_pirate_audio https://github.com/traveltrousers/pirate-audio-buttons https://github.com/tiradoe/pirate-audio-display https://github.com/DrewBatchelor/Pirate-Audio-Volumio-box https://github.com/promethee/pimoroni.pirate-audio.dual-mic https://www.bluetin.io/displays/simulate-oled-lcd-display-pc-pil-opencv/