commit a53302eb62e902ccb7a47cc9cddc8b126dca652b Author: yutent Date: Thu Aug 17 00:06:21 2023 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52cf7cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__ +*.txt + +._* + +.Spotlight-V100 +.Trashes +.DS_Store +.AppleDouble +.LSOverride \ No newline at end of file diff --git a/main.py b/main.py new file mode 100755 index 0000000..2156770 --- /dev/null +++ b/main.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +import gi, sys, os +# import dbus +# import dbus.service, dbus.mainloop.glib +from pprint import pprint as print + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, Gdk, GLib, GdkPixbuf + +from window import SonistWindow +# from mpd.asyncio import MPDClient +from mpd.base import MPDClient + +app_id = 'fun.wkit.sonist' + + +class Application(Gtk.Application): + def __init__(self): + Gtk.Application.__init__(self, application_id = app_id) + + self.window = SonistWindow() + + self.mpc = MPDClient() + self.mpc.timeout = 10 + self.mpc.connect("localhost", 6600) + + + def do_activate(self): + print('hello mpc') + # self.window.show_all() + + + +""" class ApplicationService(dbus.service.Object): + def __init__(self, app): + self.app = app + bus_name = dbus.service.BusName(app_id, bus = dbus.SessionBus()) + dbus.service.Object.__init__(self, bus_name, '/') + + + @dbus.service.method(app_id) + def call_app(self): + self.app.present() + """ + +if __name__ == "__main__": + # dbus.mainloop.glib.DBusGMainLoop(set_as_default = True) + # bus = dbus.SessionBus() + + # try: + # obj = bus.get_object(app_id, '/') + # obj.call_app() + # sys.exit(0) + # except dbus.DBusException: + # pass + + + app = Application() + app.run(sys.argv) + + # ApplicationService(app) + + Gtk.main() \ No newline at end of file diff --git a/mpd/__init__.py b/mpd/__init__.py new file mode 100644 index 0000000..0897f46 --- /dev/null +++ b/mpd/__init__.py @@ -0,0 +1,38 @@ +# python-mpd2: Python MPD client library +# +# Copyright (C) 2008-2010 J. Alexander Treuman +# Copyright (C) 2012 J. Thalheim +# Copyright (C) 2016 Robert Niederreiter +# +# python-mpd2 is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# python-mpd2 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with python-mpd2. If not, see . + +from mpd.base import CommandError +from mpd.base import CommandListError +from mpd.base import ConnectionError +from mpd.base import FailureResponseCode +from mpd.base import IteratingError +from mpd.base import MPDClient +from mpd.base import MPDError +from mpd.base import PendingCommandError +from mpd.base import ProtocolError +from mpd.base import VERSION + + +try: + from mpd.twisted import MPDProtocol +except ImportError: + + class MPDProtocol: + def __init__(): + raise "No twisted module found" diff --git a/mpd/asyncio.py b/mpd/asyncio.py new file mode 100644 index 0000000..0bd1343 --- /dev/null +++ b/mpd/asyncio.py @@ -0,0 +1,593 @@ +"""Asynchronous access to MPD using the asyncio methods of Python 3. + +Interaction happens over the mpd.asyncio.MPDClient class, whose connect and +command methods are coroutines. + +Some commands (eg. listall) additionally support the asynchronous iteration +(aiter, `async for`) interface; using it allows the library user to obtain +items of result as soon as they arrive. + +The .idle() method works differently here: It is an asynchronous iterator that +produces a list of changed subsystems whenever a new one is available. The +MPDClient object automatically switches in and out of idle mode depending on +which subsystems there is currently interest in. + +Command lists are currently not supported. + + +This module requires Python 3.5.2 or later to run. +""" + +import warnings +import asyncio +from functools import partial +from typing import Optional, List, Tuple, Iterable, Callable, Union + +from mpd.base import HELLO_PREFIX, ERROR_PREFIX, SUCCESS +from mpd.base import MPDClientBase +from mpd.base import MPDClient as SyncMPDClient +from mpd.base import ProtocolError, ConnectionError, CommandError, CommandListError +from mpd.base import mpd_command_provider + + +class BaseCommandResult(asyncio.Future): + """A future that carries its command/args/callback with it for the + convenience of passing it around to the command queue.""" + + def __init__(self, command, args, callback): + super().__init__() + self._command = command + self._args = args + self._callback = callback + + async def _feed_from(self, mpdclient): + while True: + line = await mpdclient._read_line() + self._feed_line(line) + if line is None: + return + + +class CommandResult(BaseCommandResult): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__spooled_lines = [] + + def _feed_line(self, line): # FIXME just inline? + """Put the given line into the callback machinery, and set the result on a None line.""" + if line is None: + if self.cancelled(): + # Data was still pulled out of the connection, but the original + # requester has cancelled the request -- no need to filter the + # data through the preprocessing callback + pass + else: + self.set_result(self._callback(self.__spooled_lines)) + else: + self.__spooled_lines.append(line) + + def _feed_error(self, error): + if not self.done(): + self.set_exception(error) + else: + # These do occur (especially during the test suite run) when a + # disconnect was already initialized, but the run task being + # cancelled has not ever yielded at all and thus still needs to run + # through to its first await point (which is then in a situation + # where properties it'd like to access are already cleaned up, + # resulting in an AttributeError) + # + # Rather than quenching them here, they are made visible (so that + # other kinds of double errors raise visibly, even though none are + # known right now); instead, the run loop yields initially with a + # sleep(0) that ensures it can be cancelled properly at any time. + raise error + +class BinaryCommandResult(asyncio.Future): + # Unlike the regular commands that defer to any callback that may be + # defined for them, this uses the predefined _read_binary mechanism of the + # mpdclient + async def _feed_from(self, mpdclient): + # Data must be pulled out no matter whether will later be ignored or not + binary = await mpdclient._read_binary() + if self.cancelled(): + pass + else: + self.set_result(binary) + + _feed_error = CommandResult._feed_error + +class CommandResultIterable(BaseCommandResult): + """Variant of CommandResult where the underlying callback is an + asynchronous` generator, and can thus interpret lines as they come along. + + The result can be used with the aiter interface (`async for`). If it is + still used as a future instead, it eventually results in a list. + + Commands used with this CommandResult must use their passed lines not like + an iterable (as in the synchronous implementation), but as a asyncio.Queue. + Furthermore, they must check whether the queue elements are exceptions, and + raise them. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__spooled_lines = asyncio.Queue() + + def _feed_line(self, line): + self.__spooled_lines.put_nowait(line) + + _feed_error = _feed_line + + def __await__(self): + asyncio.Task(self.__feed_future()) + return super().__await__() + + __iter__ = __await__ # for 'yield from' style invocation + + async def __feed_future(self): + result = [] + try: + async for r in self: + result.append(r) + except Exception as e: + self.set_exception(e) + else: + if not self.cancelled(): + self.set_result(result) + + def __aiter__(self): + if self.done(): + raise RuntimeError("Command result is already being consumed") + return self._callback(self.__spooled_lines).__aiter__() + + +@mpd_command_provider +class MPDClient(MPDClientBase): + __run_task = None # doubles as indicator for being connected + + #: Indicator of whether there is a pending idle command that was not terminated yet. + # When in doubt; this is True, thus erring at the side of caution (because + # a "noidle" being sent while racing against an incoming idle notification + # does no harm) + __in_idle = False + + #: Indicator that the last attempted idle failed. + # + # When set, IMMEDIATE_COMMAND_TIMEOUT is ignored in favor of waiting until + # *something* else happens, and only then retried. + # + # Note that the only known condition in which this happens is when between + # start of the connection and the presentation of credentials, more than + # IMMEDIATE_COMMAND_TIMEOUT passes. + __idle_failed = False + + #: Seconds after a command's completion to send idle. Setting this too high + # causes "blind spots" in the client's view of the server, setting it too + # low sends needless idle/noidle after commands in quick succession. + IMMEDIATE_COMMAND_TIMEOUT = 0.1 + + #: FIFO list of processors that may consume the read stream one after the + # other + # + # As we don't have any other form of backpressure in the sending side + # (which is not expected to be limited), its limit of COMMAND_QUEUE_LENGTH + # serves as a limit against commands queuing up indefinitely. (It's not + # *directly* throttling output, but as the convention is to put the + # processor on the queue and then send the command, and commands are of + # limited size, this is practically creating backpressure.) + __command_queue = None + + #: Construction size of __command_queue. The default limit is high enough + # that a client can easily send off all existing commands simultaneously + # without needlessly blocking the TCP flow, but small enough that + # freespinning tasks create warnings. + COMMAND_QUEUE_LENGTH = 128 + + #: Callbacks registered by any current callers of `idle()`. + # + # The first argument lists the changes that the caller is interested in + # (and all current listeners' union is used to populate the `idle` + # command's arguments), the latter is an actual callback that will be + # passed either a set of changes or an exception. + __idle_consumers: Optional[List[Tuple[ + Iterable[str], + Callable[[Union[List[str], Exception]], None] + ]]] = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__rfile = self.__wfile = None + + async def connect(self, host, port=6600, loop=None): + if loop is not None: + warnings.warn("loop passed into MPDClient.connect is ignored, this will become an error", DeprecationWarning) + if host.startswith("@"): + host = "\0" + host[1:] + if host.startswith("\0") or "/" in host: + r, w = await asyncio.open_unix_connection(host) + else: + r, w = await asyncio.open_connection(host, port) + self.__rfile, self.__wfile = r, w + + self.__command_queue = asyncio.Queue(maxsize=self.COMMAND_QUEUE_LENGTH) + self.__idle_consumers = [] #: list of (subsystem-list, callbacks) tuples + + try: + helloline = await asyncio.wait_for(self.__readline(), timeout=5) + except asyncio.TimeoutError: + self.disconnect() + raise ConnectionError("No response from server while reading MPD hello") + # FIXME should be reusable w/o reaching in + SyncMPDClient._hello(self, helloline) + + self.__run_task = asyncio.Task(self.__run()) + + @property + def connected(self): + return self.__run_task is not None + + def disconnect(self): + if ( + self.__run_task is not None + ): # is None eg. when connection fails in .connect() + self.__run_task.cancel() + if self.__wfile is not None: + self.__wfile.close() + self.__rfile = self.__wfile = None + self.__run_task = None + self.__command_queue = None + if self.__idle_consumers is not None: + # copying the list as each raising callback will remove itself from __idle_consumers + for subsystems, callback in list(self.__idle_consumers): + callback(ConnectionError()) + self.__idle_consumers = None + + def _get_idle_interests(self): + """Accumulate a set of interests from the current __idle_consumers. + Returns the union of their subscribed subjects, [] if at least one of + them is the empty catch-all set, or None if there are no interests at + all.""" + + if not self.__idle_consumers: + return None + if any(len(s) == 0 for (s, c) in self.__idle_consumers): + return [] + return set.union(*(set(s) for (s, c) in self.__idle_consumers)) + + def _end_idle(self): + """If the main task is currently idling, make it leave idle and process + the next command (if one is present) or just restart idle""" + + if self.__in_idle: + self.__write("noidle\n") + self.__in_idle = False + + async def __run(self): + # See CommandResult._feed_error documentation + await asyncio.sleep(0) + result = None + + try: + while True: + try: + result = await asyncio.wait_for( + self.__command_queue.get(), + timeout=self.IMMEDIATE_COMMAND_TIMEOUT, + ) + except asyncio.TimeoutError: + # The cancellation of the __command_queue.get() that happens + # in this case is intended, and is just what asyncio.Queue + # suggests for "get with timeout". + + if not self.__command_queue.empty(): + # A __command_queue.put() has happened after the + # asyncio.wait_for() timeout but before execution of + # this coroutine resumed. Looping around again will + # fetch the new entry from the queue. + continue + + if self.__idle_failed: + # We could try for a more elaborate path where we now + # await the command queue indefinitely, but as we're + # already in an error case, this whole situation only + # persists until the error is processed somewhere else, + # so ticking once per idle timeout is OK to keep things + # simple. + continue + + subsystems = self._get_idle_interests() + if subsystems is None: + # The presumably most quiet subsystem -- in this case, + # idle is only used to keep the connection alive. + subsystems = ["database"] + + # Careful: There can't be any await points between the + # except and here, or the sequence between the idle and the + # command processor might be wrong. + result = CommandResult("idle", subsystems, self._parse_list) + result.add_done_callback(self.__idle_result) + self.__in_idle = True + self._write_command(result._command, result._args) + + # A new command was issued, so there's a chance that whatever + # made idle fail is now fixed. + self.__idle_failed = False + + try: + await result._feed_from(self) + except CommandError as e: + result._feed_error(e) + # This kind of error we can tolerate without breaking up + # the connection; any other would fly out, be reported + # through the result and terminate the connection + + except Exception as e: + # Prevent the destruction of the pending task in the shutdown + # function -- it's just shutting down by itself. + self.__run_task = None + self.disconnect() + + if result is not None: + # The last command has failed: Forward that result. + # + # (In idle, that's fine too -- everyone watching see a + # nonspecific event). + result._feed_error(e) + return + else: + raise + # Typically this is a bug in mpd.asyncio. + + def __idle_result(self, result): + try: + idle_changes = result.result() + except CommandError as e: + # Don't retry until something changed + self.__idle_failed = True + + # Not raising this any further: The callbacks are notified that + # "something is up" (which is all their API gives), and whichever + # command is issued to act on it will hopefully run into the same + # condition. + # + # This does swallow the exact error cause. + + idle_changes = set() + for subsystems, _ in self.__idle_consumers: + idle_changes = idle_changes.union(subsystems) + + # make generator accessible multiple times + idle_changes = list(idle_changes) + + for subsystems, callback in self.__idle_consumers: + if not subsystems or any(s in subsystems for s in idle_changes): + callback(idle_changes) + + # helper methods + + async def __readline(self): + """Wrapper around .__rfile.readline that handles encoding""" + data = await self.__rfile.readline() + try: + return data.decode("utf8") + except UnicodeDecodeError: + self.disconnect() + raise ProtocolError("Invalid UTF8 received") + + async def _read_chunk(self, length): + try: + return await self.__rfile.readexactly(length) + except asyncio.IncompleteReadError: + raise ConnectionError("Connection lost while reading binary") + + def __write(self, text): + """Wrapper around .__wfile.write that handles encoding.""" + self.__wfile.write(text.encode("utf8")) + + # copied and subtly modifiedstuff from base + + # This is just a wrapper for the below. + def _write_line(self, text): + self.__write(text + "\n") + + # FIXME This code should be shareable. + _write_command = SyncMPDClient._write_command + + async def _read_line(self): + line = await self.__readline() + if not line.endswith("\n"): + raise ConnectionError("Connection lost while reading line") + line = line.rstrip("\n") + if line.startswith(ERROR_PREFIX): + error = line[len(ERROR_PREFIX) :].strip() + raise CommandError(error) + if line == SUCCESS: + return None + return line + + async def _parse_objects_direct(self, lines, delimiters=[], lookup_delimiter=False): + obj = {} + while True: + line = await lines.get() + if isinstance(line, BaseException): + raise line + if line is None: + break + key, value = self._parse_pair(line, separator=": ") + key = key.lower() + if lookup_delimiter and not delimiters: + delimiters = [key] + if obj: + if key in delimiters: + yield obj + obj = {} + elif key in obj: + if not isinstance(obj[key], list): + obj[key] = [obj[key], value] + else: + obj[key].append(value) + continue + obj[key] = value + if obj: + yield obj + + async def _execute_binary(self, command, args): + # Fun fact: By fetching data in lockstep, this is a bit less efficient + # than it could be (which would be "after having received the first + # chunk, guess that the other chunks are of equal size and request at + # several multiples concurrently, ensuring the TCP connection can stay + # full), but at the other hand it leaves the command queue empty so + # that more time critical commands can be executed right away + + data = None + args = list(args) + assert len(args) == 1 + args.append(0) + final_metadata = None + while True: + partial_result = BinaryCommandResult() + await self.__command_queue.put(partial_result) + self._end_idle() + self._write_command(command, args) + metadata = await partial_result + chunk = metadata.pop('binary', None) + + if final_metadata is None: + data = chunk + final_metadata = metadata + if not data: + break + try: + size = int(final_metadata['size']) + except KeyError: + size = len(chunk) + except ValueError: + raise CommandError("Size data unsuitable for binary transfer") + else: + if metadata != final_metadata: + raise CommandError("Metadata of binary data changed during transfer") + if chunk is None: + raise CommandError("Binary field vanished changed during transfer") + data += chunk + args[-1] = len(data) + if len(data) > size: + raise CommandListError("Binary data announced size exceeded") + elif len(data) == size: + break + + if data is not None: + final_metadata['binary'] = data + + final_metadata.pop('size', None) + + return final_metadata + + # omits _read_chunk checking because the async version already + # raises; otherwise it's just awaits sprinkled in + async def _read_binary(self): + obj = {} + + while True: + line = await self._read_line() + if line is None: + break + + key, value = self._parse_pair(line, ": ") + + if key == "binary": + chunk_size = int(value) + value = await self._read_chunk(chunk_size) + + if await self.__rfile.readexactly(1) != b"\n": + # newline after binary content + self.disconnect() + raise ConnectionError("Connection lost while reading line") + + obj[key] = value + return obj + + # command provider interface + @classmethod + def add_command(cls, name, callback): + if callback.mpd_commands_binary: + async def f(self, *args): + result = await self._execute_binary(name, args) + + # With binary, the callback is applied to the final result + # rather than to the iterator over the lines (cf. + # MPDClient._execute_binary) + return callback(self, result) + else: + command_class = ( + CommandResultIterable if callback.mpd_commands_direct else CommandResult + ) + if hasattr(cls, name): + # Idle and noidle are explicitly implemented, skipping them. + return + + def f(self, *args): + result = command_class(name, args, partial(callback, self)) + if self.__run_task is None: + raise ConnectionError("Can not send command to disconnected client") + + try: + self.__command_queue.put_nowait(result) + except asyncio.QueueFull as e: + e.args = ("Command queue overflowing; this indicates the" + " application sending commands in an uncontrolled" + " fashion without awaiting them, and typically" + " indicates a memory leak.",) + # While we *could* indicate to the queued result that it has + # yet to send its request, that'd practically create a queue of + # awaited items in the user application that's growing + # unlimitedly, eliminating any chance of timely responses. + # Furthermore, the author sees no practical use case that's not + # violating MPD's guidance of "Do not manage a client-side copy + # of MPD's database". If a use case *does* come up, any change + # would need to maintain the property of providing backpressure + # information. That would require an API change. + raise + + self._end_idle() + # Careful: There can't be any await points between the queue + # appending and the write + try: + self._write_command(result._command, result._args) + except BaseException as e: + self.disconnect() + result.set_exception(e) + return result + + escaped_name = name.replace(" ", "_") + f.__name__ = escaped_name + setattr(cls, escaped_name, f) + + # commands that just work differently + async def idle(self, subsystems=()): + if self.__idle_consumers is None: + raise ConnectionError("Can not start idle on a disconnected client") + + interests_before = self._get_idle_interests() + # A queue accepting either a list of things that changed in a single + # idle cycle, or an exception to be raised + changes = asyncio.Queue() + try: + entry = (subsystems, changes.put_nowait) + self.__idle_consumers.append(entry) + if self._get_idle_interests != interests_before: + # Technically this does not enter idle *immediately* but rather + # only after any commands after IMMEDIATE_COMMAND_TIMEOUT; + # practically that should be a good thing. + self._end_idle() + while True: + item = await changes.get() + if isinstance(item, Exception): + raise item + yield item + finally: + if self.__idle_consumers is not None: + self.__idle_consumers.remove(entry) + + def noidle(self): + raise AttributeError("noidle is not supported / required in mpd.asyncio") diff --git a/mpd/base.py b/mpd/base.py new file mode 100644 index 0000000..56dfc8f --- /dev/null +++ b/mpd/base.py @@ -0,0 +1,866 @@ +# python-mpd2: Python MPD client library +# +# Copyright (C) 2008-2010 J. Alexander Treuman +# Copyright (C) 2012 J. Thalheim +# Copyright (C) 2016 Robert Niederreiter +# +# python-mpd2 is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# python-mpd2 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with python-mpd2. If not, see . + +import logging +import re +import socket +import sys +import warnings + +from enum import Enum + + +VERSION = (3, 1, 0) +HELLO_PREFIX = "OK MPD " +ERROR_PREFIX = "ACK " +ERROR_PATTERN = re.compile(r"\[(?P\d+)@(?P\d+)\]\s+{(?P\w+)}\s+(?P.*)") +SUCCESS = "OK" +NEXT = "list_OK" + + +def escape(text): + return text.replace("\\", "\\\\").replace('"', '\\"') + + +try: + from logging import NullHandler +except ImportError: # NullHandler was introduced in python2.7 + + class NullHandler(logging.Handler): + def emit(self, record): + pass + + +logger = logging.getLogger(__name__) +logger.addHandler(NullHandler()) + + +# MPD Protocol errors as found in CommandError exceptions +# https://github.com/MusicPlayerDaemon/MPD/blob/master/src/protocol/Ack.hxx +class FailureResponseCode(Enum): + NOT_LIST = 1 + ARG = 2 + PASSWORD = 3 + PERMISSION = 4 + UNKNOWN = 5 + + NO_EXIST = 50 + PLAYLIST_MAX = 51 + SYSTEM = 52 + PLAYLIST_LOAD = 53 + UPDATE_ALREADY = 54 + PLAYER_SYNC = 55 + EXIST = 56 + + +class MPDError(Exception): + pass + + +class ConnectionError(MPDError): + pass + + +class ProtocolError(MPDError): + pass + + +class CommandError(MPDError): + def __init__(self, error): + self.errno = None + self.offset = None + self.command = None + self.msg = None + + match = ERROR_PATTERN.match(error) + if match: + self.errno = FailureResponseCode(int(match.group("errno"))) + self.offset = int(match.group("offset")) + self.command = match.group("command") + self.msg = match.group("msg") + + +class CommandListError(MPDError): + pass + + +class PendingCommandError(MPDError): + pass + + +class IteratingError(MPDError): + pass + + +class mpd_commands(object): + """Decorator for registering MPD commands with it's corresponding result + callback. + """ + + def __init__(self, *commands, **kwargs): + self.commands = commands + self.is_direct = kwargs.pop("is_direct", False) + self.is_binary = kwargs.pop("is_binary", False) + if kwargs: + raise AttributeError( + "mpd_commands() got unexpected keyword" + " arguments %s" % ",".join(kwargs) + ) + + def __call__(self, ob): + ob.mpd_commands = self.commands + ob.mpd_commands_direct = self.is_direct + ob.mpd_commands_binary = self.is_binary + return ob + + +def mpd_command_provider(cls): + """Decorator hooking up registered MPD commands to concrete client + implementation. + + A class using this decorator must inherit from ``MPDClientBase`` and + implement it's ``add_command`` function. + """ + + def collect(cls, callbacks=dict()): + """Collect MPD command callbacks from given class. + + Searches class __dict__ on given class and all it's bases for functions + which have been decorated with @mpd_commands and returns a dict + containing callback name as keys and + (callback, callback implementing class) tuples as values. + """ + for name, ob in cls.__dict__.items(): + if hasattr(ob, "mpd_commands") and name not in callbacks: + callbacks[name] = (ob, cls) + for base in cls.__bases__: + callbacks = collect(base, callbacks) + return callbacks + + for name, value in collect(cls).items(): + callback, from_ = value + for command in callback.mpd_commands: + cls.add_command(command, callback) + return cls + + +class Noop(object): + """An instance of this class represents a MPD command callback which + does nothing. + """ + + mpd_commands = None + + +class MPDClientBase(object): + """Abstract MPD client. + + This class defines a general client contract, provides MPD protocol parsers + and defines all available MPD commands and it's corresponding result + parsing callbacks. There might be the need of overriding some callbacks on + subclasses. + """ + + def __init__(self, use_unicode=None): + self.iterate = False + if use_unicode is not None: + warnings.warn( + "use_unicode parameter to ``MPDClientBase`` constructor is " + "deprecated", + DeprecationWarning, + stacklevel=2, + ) + self._reset() + + @property + def use_unicode(self): + warnings.warn( + "``use_unicode`` is deprecated: python-mpd 2.x always uses " + "Unicode", + DeprecationWarning, + stacklevel=2, + ) + return True + + @classmethod + def add_command(cls, name, callback): + raise NotImplementedError( + "Abstract ``MPDClientBase`` does not implement ``add_command``" + ) + + def noidle(self): + raise NotImplementedError( + "Abstract ``MPDClientBase`` does not implement ``noidle``" + ) + + def command_list_ok_begin(self): + raise NotImplementedError( + "Abstract ``MPDClientBase`` does not implement " "``command_list_ok_begin``" + ) + + def command_list_end(self): + raise NotImplementedError( + "Abstract ``MPDClientBase`` does not implement " "``command_list_end``" + ) + + def _reset(self): + self.mpd_version = None + self._command_list = None + + def _parse_pair(self, line, separator): + if line is None: + return + pair = line.split(separator, 1) + if len(pair) < 2: + raise ProtocolError("Could not parse pair: '{}'".format(line)) + return pair + + def _parse_pairs(self, lines, separator=": "): + for line in lines: + yield self._parse_pair(line, separator) + + def _parse_objects(self, lines, delimiters=[], lookup_delimiter=False): + obj = {} + for key, value in self._parse_pairs(lines): + key = key.lower() + if lookup_delimiter and key not in delimiters: + delimiters = delimiters + [key] + if obj: + if key in delimiters: + if lookup_delimiter: + if key in obj: + yield obj + obj = obj.copy() + while delimiters[-1] != key: + obj.pop(delimiters[-1], None) + delimiters.pop() + else: + yield obj + obj = {} + elif key in obj: + if not isinstance(obj[key], list): + obj[key] = [obj[key], value] + else: + obj[key].append(value) + continue + obj[key] = value + if obj: + yield obj + + # Use this instead of _parse_objects whenever the result is returned + # immediately in a command implementation + _parse_objects_direct = _parse_objects + + def _parse_raw_stickers(self, lines): + for key, sticker in self._parse_pairs(lines): + value = sticker.split("=", 1) + if len(value) < 2: + raise ProtocolError("Could not parse sticker: {}".format(repr(sticker))) + yield tuple(value) + + NOOP = mpd_commands("close", "kill")(Noop()) + + @mpd_commands("plchangesposid", is_direct=True) + def _parse_changes(self, lines): + return self._parse_objects_direct(lines, ["cpos"]) + + @mpd_commands("listall", "listallinfo", "listfiles", "lsinfo", is_direct=True) + def _parse_database(self, lines): + return self._parse_objects_direct(lines, ["file", "directory", "playlist"]) + + @mpd_commands("idle") + def _parse_idle(self, lines): + return self._parse_list(lines) + + @mpd_commands("addid", "config", "replay_gain_status", "rescan", "update") + def _parse_item(self, lines): + pairs = list(self._parse_pairs(lines)) + if len(pairs) != 1: + return + return pairs[0][1] + + @mpd_commands( + "channels", "commands", "listplaylist", "notcommands", "tagtypes", "urlhandlers" + ) + def _parse_list(self, lines): + seen = None + for key, value in self._parse_pairs(lines): + if key != seen: + if seen is not None: + raise ProtocolError("Expected key '{}', got '{}'".format(seen, key)) + seen = key + yield value + + @mpd_commands("list", is_direct=True) + def _parse_list_groups(self, lines): + return self._parse_objects_direct(lines, lookup_delimiter=True) + + @mpd_commands("readmessages", is_direct=True) + def _parse_messages(self, lines): + return self._parse_objects_direct(lines, ["channel"]) + + @mpd_commands("listmounts", is_direct=True) + def _parse_mounts(self, lines): + return self._parse_objects_direct(lines, ["mount"]) + + @mpd_commands("listneighbors", is_direct=True) + def _parse_neighbors(self, lines): + return self._parse_objects_direct(lines, ["neighbor"]) + + @mpd_commands("listpartitions", is_direct=True) + def _parse_partitions(self, lines): + return self._parse_objects_direct(lines, ["partition"]) + + @mpd_commands( + "add", + "addtagid", + "binarylimit", + "clear", + "clearerror", + "cleartagid", + "consume", + "crossfade", + "delete", + "deleteid", + "delpartition", + "disableoutput", + "enableoutput", + "findadd", + "load", + "mixrampdb", + "mixrampdelay", + "mount", + "move", + "moveid", + "moveoutput", + "newpartition", + "next", + "outputvolume", + "partition", + "password", + "pause", + "ping", + "play", + "playid", + "playlistadd", + "playlistclear", + "playlistdelete", + "playlistmove", + "previous", + "prio", + "prioid", + "random", + "rangeid", + "rename", + "repeat", + "replay_gain_mode", + "rm", + "save", + "searchadd", + "searchaddpl", + "seek", + "seekcur", + "seekid", + "sendmessage", + "setvol", + "shuffle", + "single", + "sticker delete", + "sticker set", + "stop", + "subscribe", + "swap", + "swapid", + "toggleoutput", + "unmount", + "unsubscribe", + "volume", + ) + def _parse_nothing(self, lines): + for line in lines: + raise ProtocolError( + "Got unexpected return value: '{}'".format(", ".join(lines)) + ) + + @mpd_commands("count", "currentsong", "readcomments", "stats", "status") + def _parse_object(self, lines): + objs = list(self._parse_objects(lines)) + if not objs: + return {} + return objs[0] + + @mpd_commands("outputs", is_direct=True) + def _parse_outputs(self, lines): + return self._parse_objects_direct(lines, ["outputid"]) + + @mpd_commands("playlist") + def _parse_playlist(self, lines): + for key, value in self._parse_pairs(lines, ":"): + yield value + + @mpd_commands("listplaylists", is_direct=True) + def _parse_playlists(self, lines): + return self._parse_objects_direct(lines, ["playlist"]) + + @mpd_commands("decoders", is_direct=True) + def _parse_plugins(self, lines): + return self._parse_objects_direct(lines, ["plugin"]) + + @mpd_commands( + "find", + "listplaylistinfo", + "playlistfind", + "playlistid", + "playlistinfo", + "playlistsearch", + "plchanges", + "search", + "sticker find", + is_direct=True, + ) + def _parse_songs(self, lines): + return self._parse_objects_direct(lines, ["file"]) + + @mpd_commands("sticker get") + def _parse_sticker(self, lines): + key, value = list(self._parse_raw_stickers(lines))[0] + return value + + @mpd_commands("sticker list") + def _parse_stickers(self, lines): + return dict(self._parse_raw_stickers(lines)) + + @mpd_commands("albumart", "readpicture", is_binary=True) + def _parse_plain_binary(self, structure): + return structure + + +def _create_callback(self, function, wrap_result): + """Create MPD command related response callback. + """ + if not callable(function): + return None + + def command_callback(): + # command result callback expects response from MPD as iterable lines, + # thus read available lines from socket + res = function(self, self._read_lines()) + # wrap result in iterator helper if desired + if wrap_result: + res = self._wrap_iterator(res) + return res + + return command_callback + + +def _create_command(wrapper, name, return_value, wrap_result): + """Create MPD command related function. + """ + + def mpd_command(self, *args): + callback = _create_callback(self, return_value, wrap_result) + return wrapper(self, name, args, callback) + + return mpd_command + + +class _NotConnected(object): + def __getattr__(self, attr): + return self._dummy + + def _dummy(*args): + raise ConnectionError("Not connected") + + +@mpd_command_provider +class MPDClient(MPDClientBase): + idletimeout = None + _timeout = None + _wrap_iterator_parsers = [ + MPDClientBase._parse_list, + MPDClientBase._parse_list_groups, + MPDClientBase._parse_playlist, + MPDClientBase._parse_changes, + MPDClientBase._parse_songs, + MPDClientBase._parse_mounts, + MPDClientBase._parse_neighbors, + MPDClientBase._parse_partitions, + MPDClientBase._parse_playlists, + MPDClientBase._parse_database, + MPDClientBase._parse_messages, + MPDClientBase._parse_outputs, + MPDClientBase._parse_plugins, + ] + + def __init__(self, use_unicode=None): + if use_unicode is not None: + warnings.warn( + "use_unicode parameter to ``MPDClient`` constructor is " + "deprecated", + DeprecationWarning, + stacklevel=2, + ) + super(MPDClient, self).__init__() + + def _reset(self): + super(MPDClient, self)._reset() + self._iterating = False + self._sock = None + self._rbfile = _NotConnected() + self._wfile = _NotConnected() + + def _execute(self, command, args, retval): + if self._iterating: + raise IteratingError("Cannot execute '{}' while iterating".format(command)) + if self._command_list is not None: + if not callable(retval): + raise CommandListError( + "'{}' not allowed in command list".format(command) + ) + self._write_command(command, args) + self._command_list.append(retval) + else: + self._write_command(command, args) + if callable(retval): + return retval() + return retval + + def _write_line(self, line): + try: + self._wfile.write("{}\n".format(line)) + self._wfile.flush() + except socket.error as e: + error_message = "Connection to server was reset" + logger.info(error_message) + self._reset() + e = ConnectionError(error_message) + raise e.with_traceback(sys.exc_info()[2]) + + def _write_command(self, command, args=[]): + parts = [command] + for arg in args: + if type(arg) is tuple: + if len(arg) == 0: + parts.append('":"') + elif len(arg) == 1: + parts.append('"{}:"'.format(int(arg[0]))) + else: + parts.append('"{}:{}"'.format(int(arg[0]), int(arg[1]))) + else: + parts.append('"{}"'.format(escape(str(arg)))) + # Minimize logging cost if the logging is not activated. + if logger.isEnabledFor(logging.DEBUG): + if command == "password": + logger.debug("Calling MPD password(******)") + else: + logger.debug("Calling MPD %s%r", command, args) + cmd = " ".join(parts) + self._write_line(cmd) + + def _read_line(self): + line = self._rbfile.readline().decode("utf-8") + if not line.endswith("\n"): + self.disconnect() + raise ConnectionError("Connection lost while reading line") + line = line.rstrip("\n") + if line.startswith(ERROR_PREFIX): + error = line[len(ERROR_PREFIX) :].strip() + raise CommandError(error) + if self._command_list is not None: + if line == NEXT: + return + if line == SUCCESS: + raise ProtocolError("Got unexpected '{}'".format(SUCCESS)) + elif line == SUCCESS: + return + return line + + def _read_lines(self): + line = self._read_line() + while line is not None: + yield line + line = self._read_line() + + def _read_chunk(self, amount): + chunk = bytearray() + while amount > 0: + result = self._rbfile.read(amount) + if len(result) == 0: + break + chunk.extend(result) + amount -= len(result) + return bytes(chunk) + + def _read_binary(self): + """From the data stream, read Unicode lines until one says "binary: + \\n"; at that point, read binary data of the given length. + + This behaves like _parse_objects (with empty set of delimiters; even + returning only a single result), but rather than feeding from a lines + iterable (which would be preprocessed too far), it reads directly off + the stream.""" + + obj = {} + + while True: + line = self._read_line() + if line is None: + break + + key, value = self._parse_pair(line, ": ") + + if key == "binary": + chunk_size = int(value) + value = self._read_chunk(chunk_size) + + if len(value) != chunk_size: + self.disconnect() + raise ConnectionError( + "Connection lost while reading binary data: " + "expected %d bytes, got %d" % (chunk_size, len(value)) + ) + + if self._rbfile.read(1) != b"\n": + # newline after binary content + self.disconnect() + raise ConnectionError("Connection lost while reading line") + + obj[key] = value + return obj + + def _execute_binary(self, command, args): + """Execute a command repeatedly with an additional offset argument, + keeping all the identical returned dictionary items and concatenating + the binary chunks following the binary item into one of exactly size. + + This differs from _execute in that rather than passing the lines to the + callback which'd then call on something like _parse_objects, it builds + a parsed object on its own (as a prerequisite to the chunk driving + process) and then joins together the chunks into a single big response.""" + if self._iterating or self._command_list is not None: + raise IteratingError("Cannot execute '{}' with command lists".format(command)) + data = None + args = list(args) + assert len(args) == 1 + args.append(0) + final_metadata = None + while True: + self._write_command(command, args) + metadata = self._read_binary() + chunk = metadata.pop('binary', None) + + if final_metadata is None: + data = chunk + final_metadata = metadata + if not data: + break + try: + size = int(final_metadata['size']) + except KeyError: + size = len(chunk) + except ValueError: + raise CommandError("Size data unsuitable for binary transfer") + else: + if metadata != final_metadata: + raise CommandError("Metadata of binary data changed during transfer") + if chunk is None: + raise CommandError("Binary field vanished changed during transfer") + data += chunk + args[-1] = len(data) + if len(data) > size: + raise CommandListError("Binary data announced size exceeded") + elif len(data) == size: + break + + if data is not None: + final_metadata['binary'] = data + + final_metadata.pop('size', None) + + return final_metadata + + def _read_command_list(self): + try: + for retval in self._command_list: + yield retval() + finally: + self._command_list = None + self._parse_nothing(self._read_lines()) + + def _iterator_wrapper(self, iterator): + try: + for item in iterator: + yield item + finally: + self._iterating = False + + def _wrap_iterator(self, iterator): + if not self.iterate: + return list(iterator) + self._iterating = True + return self._iterator_wrapper(iterator) + + def _hello(self, line): + if not line.endswith("\n"): + self.disconnect() + raise ConnectionError("Connection lost while reading MPD hello") + line = line.rstrip("\n") + if not line.startswith(HELLO_PREFIX): + raise ProtocolError("Got invalid MPD hello: '{}'".format(line)) + self.mpd_version = line[len(HELLO_PREFIX) :].strip() + + def _connect_unix(self, path): + if not hasattr(socket, "AF_UNIX"): + raise ConnectionError("Unix domain sockets not supported on this platform") + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect(path) + return sock + + def _connect_tcp(self, host, port): + try: + flags = socket.AI_ADDRCONFIG + except AttributeError: + flags = 0 + err = None + for res in socket.getaddrinfo( + host, port, socket.AF_UNSPEC, socket.SOCK_STREAM, socket.IPPROTO_TCP, flags + ): + af, socktype, proto, canonname, sa = res + sock = None + try: + sock = socket.socket(af, socktype, proto) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.settimeout(self.timeout) + sock.connect(sa) + return sock + except socket.error as e: + err = e + if sock is not None: + sock.close() + if err is not None: + raise err + else: + raise ConnectionError("getaddrinfo returns an empty list") + + @mpd_commands("idle") + def _parse_idle(self, lines): + self._sock.settimeout(self.idletimeout) + ret = self._wrap_iterator(self._parse_list(lines)) + self._sock.settimeout(self._timeout) + return ret + + @property + def timeout(self): + return self._timeout + + @timeout.setter + def timeout(self, timeout): + self._timeout = timeout + if self._sock is not None: + self._sock.settimeout(timeout) + + def connect(self, host, port=None, timeout=None): + logger.info("Calling MPD connect(%r, %r, timeout=%r)", host, port, timeout) + if self._sock is not None: + raise ConnectionError("Already connected") + if timeout is not None: + warnings.warn( + "The timeout parameter in connect() is deprecated! " + "Use MPDClient.timeout = yourtimeout instead.", + DeprecationWarning, + ) + self.timeout = timeout + if host.startswith("@"): + host = "\0" + host[1:] + if host.startswith(("/", "\0")): + self._sock = self._connect_unix(host) + else: + if port is None: + raise ValueError( + "port argument must be specified when connecting via tcp" + ) + self._sock = self._connect_tcp(host, port) + + # - Force UTF-8 encoding, since this is dependant from the LC_CTYPE + # locale. + # - by setting newline explicit, we force to send '\n' also on + # windows + self._rbfile = self._sock.makefile("rb", newline="\n") + self._wfile = self._sock.makefile("w", encoding="utf-8", newline="\n") + + try: + helloline = self._rbfile.readline().decode("utf-8") + self._hello(helloline) + except Exception: + self.disconnect() + raise + + def disconnect(self): + logger.info("Calling MPD disconnect()") + if self._rbfile is not None and not isinstance(self._rbfile, _NotConnected): + self._rbfile.close() + if self._wfile is not None and not isinstance(self._wfile, _NotConnected): + self._wfile.close() + if self._sock is not None: + self._sock.close() + self._reset() + + def fileno(self): + if self._sock is None: + raise ConnectionError("Not connected") + return self._sock.fileno() + + def command_list_ok_begin(self): + if self._command_list is not None: + raise CommandListError("Already in command list") + if self._iterating: + raise IteratingError("Cannot begin command list while iterating") + self._write_command("command_list_ok_begin") + self._command_list = [] + + def command_list_end(self): + if self._command_list is None: + raise CommandListError("Not in command list") + if self._iterating: + raise IteratingError("Already iterating over a command list") + self._write_command("command_list_end") + return self._wrap_iterator(self._read_command_list()) + + @classmethod + def add_command(cls, name, callback): + wrap_result = callback in cls._wrap_iterator_parsers + if callback.mpd_commands_binary: + method = lambda self, *args: callback(self, cls._execute_binary(self, name, args)) + else: + method = _create_command(cls._execute, name, callback, wrap_result) + # create new mpd commands as function: + escaped_name = name.replace(" ", "_") + setattr(cls, escaped_name, method) + + @classmethod + def remove_command(cls, name): + if not hasattr(cls, name): + raise ValueError("Can't remove not existent '{}' command".format(name)) + name = name.replace(" ", "_") + delattr(cls, str(name)) + + +# vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: diff --git a/window.py b/window.py new file mode 100644 index 0000000..3dad446 --- /dev/null +++ b/window.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +import gi, sys, os +from pprint import pprint as print + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, Gdk, GLib, GdkPixbuf + +class SonistWindow(Gtk.Window): + def __init__(self): + Gtk.Window.__init__(self) + + self.set_default_size(480, 320) + + button = Gtk.Button() + + self.add(button) + + self.show_all() \ No newline at end of file