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:
parent
c53681ea82
commit
bb8b816e8f
5 changed files with 266 additions and 230 deletions
214
mcg/client.py
214
mcg/client.py
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue