Support “albumart” command (close #30)

Load the album covers using MPD’s new “albumart” command instead of
reading the covers from the harddrive. Remove the corresponding UI
elements and configuration option.
This commit is contained in:
coderkun 2019-09-22 18:41:02 +02:00
commit bb8b816e8f
5 changed files with 266 additions and 230 deletions

View file

@ -1,16 +1,14 @@
#!/usr/bin/env python3
import concurrent.futures
import configparser
import glob
import logging
import os
import queue
import re
import socket
import sys
import threading
import urllib.request
from mcg.utils import SortOrder
from mcg.utils import Utils
@ -21,14 +19,13 @@ from mcg.utils import Utils
class MPDException(Exception):
def __init__(self, error):
super(MPDException, self).__init__(self._parse_error(error))
self._error = error
def _parse_error(self, error):
if error:
parts = re.match("\[(\d+)@(\d+)\]\s\{(\w+)\}\s(.*)", error)
if parts:
self._error = int(parts.group(1))
self._error_number = int(parts.group(1))
self._command_number = int(parts.group(2))
self._command_name = parts.group(3)
return parts.group(4)
@ -61,6 +58,18 @@ class CommandException(MPDException):
class Future(concurrent.futures.Future):
def __init__(self, signal):
concurrent.futures.Future.__init__(self)
self._signal = signal
def get_signal(self):
return self._signal
class Base():
def __init__(self):
self._callbacks = {}
@ -88,6 +97,10 @@ class Base():
callback(*data)
def _callback_future(self, future):
self._callback(future.get_signal(), *future.result())
class Client(Base):
@ -104,6 +117,8 @@ class Client(Base):
PROTOCOL_ERROR = 'ACK '
# Protocol: error: permission
PROTOCOL_ERROR_PERMISSION = 4
# Protocol: error: no exists
PROTOCOL_ERROR_NOEXISTS = 50
# Signal: connection status
SIGNAL_CONNECTION = 'connection'
# Signal: status
@ -120,6 +135,8 @@ class Client(Base):
SIGNAL_LOAD_PLAYLIST = 'load-playlist'
# Signal: load audio output devices
SIGNAL_LOAD_OUTPUT_DEVICES = 'load-output-devices'
# Signal: load albumart
SIGNAL_LOAD_ALBUMART = 'albumart'
# Signal: custom (dummy) event to trigger callback
SIGNAL_CUSTOM = 'custom'
# Signal: error
@ -143,7 +160,6 @@ class Client(Base):
self._host = None
self._albums = {}
self._playlist = []
self._image_dir = ""
self._state = None
@ -153,13 +169,12 @@ class Client(Base):
# Client commands
def connect(self, host, port, password=None, image_dir=""):
def connect(self, host, port, password=None):
"""Connect to MPD with the given host, port and password or with
standard values.
"""
self._logger.info("connect")
self._host = host
self._image_dir = image_dir
self._add_action(self._connect, host, port, password)
self._stop.clear()
self._start_worker()
@ -184,19 +199,19 @@ class Client(Base):
def get_status(self):
"""Determine the current status."""
self._logger.info("get status")
self._add_action(self._get_status)
self._add_action_signal(Client.SIGNAL_STATUS, self._get_status)
def get_stats(self):
"""Load statistics."""
self._logger.info("get stats")
self._add_action(self._get_stats)
self._add_action_signal(Client.SIGNAL_STATS, self._get_stats)
def get_output_devices(self):
"""Determine the list of audio output devices."""
self._logger.info("get output devices")
self._add_action(self._get_output_devices)
self._add_action_signal(Client.SIGNAL_LOAD_OUTPUT_DEVICES, self._get_output_devices)
def enable_output_device(self, device, enabled):
@ -208,7 +223,7 @@ class Client(Base):
def load_albums(self):
self._logger.info("load albums")
self._add_action(self._load_albums)
self._add_action_signal(Client.SIGNAL_LOAD_ALBUMS, self._load_albums)
def update(self):
@ -218,7 +233,7 @@ class Client(Base):
def load_playlist(self):
self._logger.info("load playlist")
self._add_action(self._load_playlist)
self._add_action_signal(Client.SIGNAL_LOAD_PLAYLIST, self._load_playlist)
def clear_playlist(self):
@ -285,9 +300,22 @@ class Client(Base):
self._add_action(self._set_volume, volume)
def get_albumart(self, album):
self._logger.info("get albumart")
self._add_action_signal(Client.SIGNAL_LOAD_ALBUMART, self._get_albumart, album)
def get_albumart_now(self, album):
self._logger.info("get albumart now")
future = concurrent.futures.Future()
self._add_action_future(future, self._get_albumart, album)
(_, albumart) = future.result()
return albumart
def get_custom(self, name):
self._logger.info("get custom \"%s\"", name)
self._add_action(self._get_custom, name)
self._add_action_signal(Client.SIGNAL_CUSTOM, self._get_custom, name)
# Private methods
@ -442,7 +470,7 @@ class Client(Base):
bitrate = None
if 'bitrate' in status:
bitrate = status['bitrate']
self._callback(Client.SIGNAL_STATUS, state, album, pos, time, volume, file, audio, bitrate, error)
return (state, album, pos, time, volume, file, audio, bitrate, error)
def _get_stats(self):
@ -475,7 +503,7 @@ class Client(Base):
uptime = 0
if 'uptime' in stats:
uptime = stats['uptime']
self._callback(Client.SIGNAL_STATS, artists, albums, songs, dbplaytime, playtime, uptime)
return (artists, albums, songs, dbplaytime, playtime, uptime)
def _get_output_devices(self):
@ -485,7 +513,7 @@ class Client(Base):
device = OutputDevice(output['outputid'], output['outputname'])
device.set_enabled(int(output['outputenabled']) == 1)
devices.append(device)
self._callback(Client.SIGNAL_LOAD_OUTPUT_DEVICES, devices)
return (devices, )
def _enable_output_device(self, device, enabled):
@ -513,7 +541,7 @@ class Client(Base):
if track:
self._logger.debug("track: %r", track)
album.add_track(track)
self._callback(Client.SIGNAL_LOAD_ALBUMS, self._albums)
return (self._albums, )
def _update(self):
@ -536,7 +564,7 @@ class Client(Base):
self._logger.debug("album: %r", album)
if track:
album.add_track(track)
self._callback(Client.SIGNAL_LOAD_PLAYLIST, self._playlist)
return (self._playlist, )
def _clear_playlist(self):
@ -615,8 +643,62 @@ class Client(Base):
self._call('setvol', volume)
def _get_albumart(self, album):
data = None
if album in self._albums:
album = self._albums[album]
if not album.get_tracks():
return None
self._logger.debug("get albumart for album \"%s\"", album.get_title())
size = 1
offset = 0
index = 0
# Read data until size is reached
try:
while offset < size:
self._write('albumart', args=[album.get_tracks()[0].get_file(), offset])
# Read first line which tells us whether there is an albumart
line = self._read_line()
if line.startswith(Client.PROTOCOL_ERROR):
error = line[len(Client.PROTOCOL_ERROR):].strip()
self._logger.debug("command failed: %r", error)
raise CommandException(error)
# First line is the file size
size = int(self._parse_dict([line])['size'])
self._logger.debug("size: %d", size)
# Second line is the count of bytes read
binary = int(self._parse_dict([self._read_line()])['binary'])
self._logger.debug("binary: %d", binary)
# Create new data array on the first iteration
if not data:
data = bytearray(size)
# Create a view for the current chunk of data
data_view = memoryview(data)[offset:offset+binary]
# Read actual bytes
self._read_bytes(data_view, binary)
offset += binary
# Read line break to complete previous repsonse
self._read_line()
# Read command completion
end = self._read_line()
if not end.startswith(Client.PROTOCOL_COMPLETION):
self._logger.debug("albumart not completed")
data = None
break
except CommandException as e:
# If no albumart can be found, do not throw an exception
if e.get_error() == Client.PROTOCOL_ERROR_NOEXISTS:
data = None
else:
raise e
return (album, data)
def _get_custom(self, name):
self._callback(Client.SIGNAL_CUSTOM, name)
return (name, )
def _start_worker(self):
@ -643,22 +725,46 @@ class Client(Base):
def _add_action(self, method, *args):
"""Add an action to the action list."""
self._logger.debug("add action %r (%r)", method.__name__, args)
action = (method, args)
future = concurrent.futures.Future()
action = (future, method, args)
self._actions.put(action)
self._noidle()
return future
def _add_action_signal(self, signal, method, *args):
"""Add an action to the action list that triggers a callback."""
self._logger.debug("add action signal %r: %r (%r)", signal, method.__name__, args)
future = Future(signal)
future.add_done_callback(self._callback_future)
self._add_action_future(future, method, *args)
return future
def _add_action_future(self, future, method, *args):
"""Add an action to the action list based on a futre."""
self._logger.debug("add action future %r (%r)", method.__name__, args)
action = (future, method, args)
self._actions.put(action)
self._noidle()
def _work(self, action):
(method, args) = action
(future, method, args) = action
self._logger.debug("work: %r", method.__name__)
try:
method(*args)
result = method(*args)
future.set_result(result)
except ConnectionException as e:
self._logger.exception(e)
future.set_exception(e)
self._callback(Client.SIGNAL_ERROR, e)
self._disconnect_socket()
except Exception as e:
self._logger.exception(e)
future.set_exception(e)
self._callback(Client.SIGNAL_ERROR, e)
@ -808,7 +914,7 @@ class Client(Base):
if lookup and id in self._albums.keys():
album = self._albums[id]
else:
album = MCGAlbum(song['album'], self._host, self._image_dir)
album = MCGAlbum(song['album'], self._host)
if lookup:
self._albums[id] = album
return album
@ -876,7 +982,7 @@ class MCGAlbum:
_FILTER_DELIMITER = ' '
def __init__(self, title, host, image_dir):
def __init__(self, title, host):
self._artists = []
self._albumartists = []
self._pathes = []
@ -885,11 +991,8 @@ class MCGAlbum:
self._title = title
self._dates = []
self._host = host
self._image_dir = image_dir
self._tracks = []
self._length = 0
self._cover = None
self._cover_searched = False
self._id = Utils.generate_id(title)
@ -959,12 +1062,6 @@ class MCGAlbum:
return self._length
def get_cover(self):
if self._cover is None and not self._cover_searched:
self._find_cover()
return self._cover
def filter(self, filter_string):
if len(filter_string) == 0:
return True
@ -1017,55 +1114,6 @@ class MCGAlbum:
return 1
def _find_cover(self):
names = list(MCGAlbum._FILE_NAMES)
names.append(self._title)
if self._host == "localhost" or self._host == "127.0.0.1" or self._host == "::1":
self._cover = self._find_cover_local(names)
else:
self._cover = self._find_cover_web(names)
self._cover_searched = True
def _find_cover_web(self, names):
for path in self._pathes:
for name in names:
for ext in self._FILE_EXTS:
url = '/'.join([
'http:/',
self._host,
urllib.request.quote(self._image_dir.strip("/")),
urllib.request.quote(path),
urllib.request.quote('.'.join([name, ext]))
])
request = urllib.request.Request(url)
try:
response = urllib.request.urlopen(request)
return url
except urllib.error.URLError as e:
pass
def _find_cover_local(self, names):
for path in self._pathes:
for name in names:
for ext in self._FILE_EXTS:
filename = os.path.join(self._image_dir, path, '.'.join([name, ext]))
if os.path.isfile(filename):
return filename
return self._find_cover_local_fallback()
def _find_cover_local_fallback(self):
for path in self._pathes:
for ext in self._FILE_EXTS:
filename = os.path.join(self._image_dir, path, "*."+ext)
files = glob.glob(filename)
if len(files) > 0:
return files[0]
class MCGTrack: