From 1cc879f39941fc302f9a841a532c9f749797cca4 Mon Sep 17 00:00:00 2001 From: kaliko Date: Sat, 21 Sep 2013 18:49:56 +0200 Subject: [PATCH] Initial import The global architecture with the plugin interface is there :) --- launch | 21 ++++ sima/__init__.py | 6 ++ sima/client.py | 205 +++++++++++++++++++++++++++++++++++++++ sima/core.py | 51 ++++++++++ sima/lib/__init__.py | 0 sima/lib/player.py | 66 +++++++++++++ sima/lib/plugin.py | 42 ++++++++ sima/lib/track.py | 147 ++++++++++++++++++++++++++++ sima/plugins/__init__.py | 0 sima/plugins/crop.py | 26 +++++ 10 files changed, 564 insertions(+) create mode 100755 launch create mode 100644 sima/__init__.py create mode 100644 sima/client.py create mode 100644 sima/core.py create mode 100644 sima/lib/__init__.py create mode 100644 sima/lib/player.py create mode 100644 sima/lib/plugin.py create mode 100644 sima/lib/track.py create mode 100644 sima/plugins/__init__.py create mode 100644 sima/plugins/crop.py diff --git a/launch b/launch new file mode 100755 index 0000000..83ba3ba --- /dev/null +++ b/launch @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +def main(): + from sima import core + from sima.plugins.crop import Crop + m = core.Sima() + m.register_plugin(Crop) + try: + m.run() + except KeyboardInterrupt: + m.shutdown() + + +# Script starts here +if __name__ == '__main__': + main() + + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/__init__.py b/sima/__init__.py new file mode 100644 index 0000000..bd387be --- /dev/null +++ b/sima/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab + diff --git a/sima/client.py b/sima/client.py new file mode 100644 index 0000000..e19a5aa --- /dev/null +++ b/sima/client.py @@ -0,0 +1,205 @@ +# -* coding: utf-8 -*- +"""MPD client for Sima + +This client is built above python-musicpd a fork of python-mpd +""" + +# standart library import +from select import select + +# third parties componants +try: + from musicpd import (MPDClient, MPDError, CommandError) +except ImportError as err: + from sys import exit as sexit + print('ERROR: missing python-musicpd?\n{0}'.format(err)) + sexit(1) + +# local import +from .lib.player import Player +from .lib.track import Track + + +class PlayerError(Exception): + """Fatal error in poller.""" + +class PlayerCommandError(PlayerError): + """Command error""" + + +class PlayerClient(Player): + """MPC Client + From python-musicpd: + _fetch_nothing … + _fetch_item single str + _fetch_object single dict + _fetch_list list of str + _fetch_playlist list of str + _fetch_changes list of dict + _fetch_database list of dict + _fetch_songs list of dict, especially tracks + _fetch_plugins, + TODO: handle exception in command not going through _client_wrapper() (ie. + find_aa, remove…) + """ + def __init__(self, host="localhost", port="6600", password=None): + self._host = host + self._port = port + self._password = password + self._client = MPDClient() + self._client.iterate = True + + def __getattr__(self, attr): + command = attr + wrapper = self._execute + return lambda *args: wrapper(command, args) + + def _execute(self, command, args): + self._write_command(command, args) + return self._client_wrapper() + + def _write_command(self, command, args=[]): + self._comm = command + self._args = list() + for arg in args: + self._args.append(arg) + + def _client_wrapper(self): + func = self._client.__getattr__(self._comm) + try: + ans = func(*self._args) + # WARNING: MPDError is an ancestor class of # CommandError + except CommandError as err: + raise PlayerCommandError('MPD command error: %s' % err) + except (MPDError, IOError) as err: + raise PlayerError(err) + return self._track_format(ans) + + def _track_format(self, ans): + # TODO: ain't working for "sticker find" and "sticker list" + tracks_listing = ["playlistfind", "playlistid", "playlistinfo", + "playlistsearch", "plchanges", "listplaylistinfo", "find", + "search", "sticker find",] + track_obj = ['currentsong'] + unicode_obj = ["idle", "listplaylist", "list", "sticker list", + "commands", "notcommands", "tagtypes", "urlhandlers",] + if self._comm in tracks_listing + track_obj: + # pylint: disable=w0142 + if isinstance(ans, list): + return [Track(**track) for track in ans] + elif isinstance(ans, dict): + return Track(**ans) + return ans + + def find_track(self, artist, title=None): + #return getattr(self, 'find')('artist', artist, 'title', title) + if title: + return self.find('artist', artist, 'title', title) + return self.find('artist', artist) + + def find_album(self, artist, album): + """ + Special wrapper around album search: + Album lookup is made through AlbumArtist/Album instead of Artist/Album + """ + alb_art_search = self.find('albumartist', artist, 'album', album) + if alb_art_search: + return alb_art_search + return self.find('artist', artist, 'album', album) + + def monitor(self): + try: + self._client.send_idle('database', 'playlist', 'player', 'options') + select([self._client], [], [], 60) + return self._client.fetch_idle() + except (MPDError, IOError) as err: + raise PlayerError("Couldn't init idle: %s" % err) + + def idle(self): + try: + self._client.send_idle('database', 'playlist', 'player', 'options') + select([self._client], [], [], 60) + return self._client.fetch_idle() + except (MPDError, IOError) as err: + raise PlayerError("Couldn't init idle: %s" % err) + + def remove(self, position=0): + self._client.delete(position) + + @property + def state(self): + return str(self._client.status().get('state')) + + @property + def current(self): + return self.currentsong() + + def playlist(self): + """ + Override deprecated MPD playlist command + """ + return self.playlistinfo() + + def connect(self): + self.disconnect() + try: + self._client.connect(self._host, self._port) + + # Catch socket errors + except IOError as err: + raise PlayerError('Could not connect to "%s:%s": %s' % + (self._host, self._port, err.strerror)) + + # Catch all other possible errors + # ConnectionError and ProtocolError are always fatal. Others may not + # be, but we don't know how to handle them here, so treat them as if + # they are instead of ignoring them. + except MPDError as err: + raise PlayerError('Could not connect to "%s:%s": %s' % + (self._host, self._port, err)) + + if self._password: + try: + self._client.password(self._password) + + # Catch errors with the password command (e.g., wrong password) + except CommandError as err: + raise PlayerError("Could not connect to '%s': " + "password command failed: %s" % + (self._host, err)) + + # Catch all other possible errors + except (MPDError, IOError) as err: + raise PlayerError("Could not connect to '%s': " + "error with password command: %s" % + (self._host, err)) + # Controls we have sufficient rights for MPD_sima + needed_cmds = ['status', 'stats', 'add', 'find', \ + 'search', 'currentsong', 'ping'] + + available_cmd = self._client.commands() + for nddcmd in needed_cmds: + if nddcmd not in available_cmd: + self.disconnect() + raise PlayerError('Could connect to "%s", ' + 'but command "%s" not available' % + (self._host, nddcmd)) + + def disconnect(self): + # Try to tell MPD we're closing the connection first + try: + self._client.close() + # If that fails, don't worry, just ignore it and disconnect + except (MPDError, IOError): + pass + + try: + self._client.disconnect() + # Disconnecting failed, so use a new client object instead + # This should never happen. If it does, something is seriously broken, + # and the client object shouldn't be trusted to be re-used. + except (MPDError, IOError): + self._client = MPDClient() + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/core.py b/sima/core.py new file mode 100644 index 0000000..9c96cb5 --- /dev/null +++ b/sima/core.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from .client import PlayerClient + +class Sima(object): + """Main class, plugin and player management + """ + + def __init__(self): + self.plugins = list() + self.player = None + self.connect_player() + + def register_plugin(self, plugin_class): + self.plugins.append(plugin_class(self)) + + def foreach_plugin(self, method, *args, **kwds): + for plugin in self.plugins: + getattr(plugin, method)(*args, **kwds) + + def connect_player(self): + """Instanciate player client and connect it + """ + self.player = PlayerClient() # Player client + self.player.connect() + + def shutdown(self): + """General shutdown method + """ + self.player.disconnect() + self.foreach_plugin('shutdown') + + def run(self): + """Dispatching callbacks to plugins + """ + print(self.player.status()) + while 42: + # hanging here untill a monitored event is raised in the player + changed = self.player.monitor() + print(changed) + print(self.player.current) + if 'playlist' in changed: + self.foreach_plugin('callback_playlist') + if 'player' in changed: + pass + + + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/lib/__init__.py b/sima/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sima/lib/player.py b/sima/lib/player.py new file mode 100644 index 0000000..c5c69f8 --- /dev/null +++ b/sima/lib/player.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +# TODO: +# Add decorator to filter through history? + +from sima.lib.track import Track + + +class Player(object): + + """Player interface to inherit from. + + When querying palyer music library for tracks, Player instance *must* return + Track objects (usually a list of them) + """ + + def __init__(self): + self.state = {} + self.current = {} + + def monitor(self): + """Monitor player for change + Returns : + * database player media library has changed + * playlist playlist modified + * options player options changed: repeat mode, etc… + * player player state changed: paused, stopped, skip track… + """ + raise NotImplementedError + + def remove(self, position=0): + """Removes the oldest element of the playlist (index 0) + """ + raise NotImplementedError + + def find_track(self, artist, title=None): + """ + Find tracks for a specific artist or filtering with a track title + >>> player.find_track('The Beatles') + >>> player.find_track('Nirvana', title='Smells Like Teen Spirit') + + Returns a list of Track objects + """ + raise NotImplementedError + + def find_album(self, artist, album): + """ + Find tracks by track's album name + >>> player.find_track('Nirvana', 'Nevermind') + + Returns a list of Track objects + """ + + def disconnect(self): + """Closing client connection with the Player + """ + raise NotImplementedError + + def connect(self): + """Connect client to the Player + """ + raise NotImplementedError + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab + diff --git a/sima/lib/plugin.py b/sima/lib/plugin.py new file mode 100644 index 0000000..ac21b2f --- /dev/null +++ b/sima/lib/plugin.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +class Plugin(): + def __init__(self, daemon): + self.__daemon = daemon + #self.history = daemon.player.history + + @property + def name(self): + return self.__class__.__name__.lower() + + def callback_playlist(self): + """ + Called on playlist changes + + Not returning data + """ + pass + + def callback_next_song(self): + """Not returning data, + Could be use to scrobble + """ + pass + + def callback_need_song(self): + """Returns a list of Track objects to add + """ + pass + + def callback_player_stop(self): + """Not returning data, + Could be use to ensure player never stops + """ + pass + + def shutdown(self): + pass + + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/lib/track.py b/sima/lib/track.py new file mode 100644 index 0000000..93aa350 --- /dev/null +++ b/sima/lib/track.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2009, 2010, 2011, 2013 Jack Kaliko +# Copyright (c) 2009 J. Alexander Treuman (Tag collapse method) +# Copyright (c) 2008 Rick van Hattem +# +# This file is part of MPD_sima +# +# MPD_sima is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# MPD_sima is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with MPD_sima. If not, see . +# +# + +import time + + +class Track(object): + """ + Track object. + Instanciate with mpd replies. + """ + + def __init__(self, file=None, time=0, pos=0, **kwargs): + self.title = self.artist = self.album = self.albumartist = '' + self._pos = pos + self.empty = False + self._file = file + if not kwargs: + self.empty = True + self.time = time + self.__dict__.update(**kwargs) + self.tags_to_collapse = list(['artist', 'album', 'title', 'date', + 'genre', 'albumartist']) + # have tags been collapsed? + self.collapse_tags_bool = False + self.collapsed_tags = list() + # Needed for multiple tags which returns a list instead of a string + self.collapse_tags() + + def collapse_tags(self): + """ + Necessary to deal with tags defined multiple times. + These entries are set as lists instead of strings. + """ + for tag, value in self.__dict__.items(): + if tag not in self.tags_to_collapse: + continue + if isinstance(value, list): + self.collapse_tags_bool = True + self.collapsed_tags.append(tag) + self.__dict__.update({tag: ', '.join(set(value))}) + + def get_filename(self): + """return filename""" + if not self.file: + return None + return self.file + + def __repr__(self): + return '%s(artist="%s", album="%s", title="%s", filename="%s")' % ( + self.__class__.__name__, + self.artist, + self.album, + self.title, + self.file, + ) + + def __str__(self): + return '{artist} - {album} - {title} ({duration})'.format( + duration=self.duration, + **self.__dict__ + ) + + def __int__(self): + return self.time + + def __add__(self, other): + return Track(time=self.time + other.time) + + def __sub__(self, other): + return Track(time=self.time - other.time) + + def __hash__(self): + if self.file: + return hash(self.file) + else: + return id(self) + + def __eq__(self, other): + return hash(self) == hash(other) + + def __ne__(self, other): + return hash(self) != hash(other) + + def __bool__(self): + return not self.empty + + @property + def pos(self): + """return position of track in the playlist""" + return int(self._pos) + + @property + def file(self): + """file is an immutable attribute that's used for the hash method""" + return self._file + + def get_time(self): + """get time property""" + return self._time + + def set_time(self, value): + """set time property""" + self._time = int(value) + + time = property(get_time, set_time, doc='song duration in seconds') + + @property + def duration(self): + """Compute fancy duration""" + temps = time.gmtime(int(self.time)) + if temps.tm_hour: + fmt = '%H:%M:%S' + else: + fmt = '%M:%S' + return time.strftime(fmt, temps) + + +def main(): + pass + +# Script starts here +if __name__ == '__main__': + main() + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/plugins/__init__.py b/sima/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sima/plugins/crop.py b/sima/plugins/crop.py new file mode 100644 index 0000000..b75f0d9 --- /dev/null +++ b/sima/plugins/crop.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Crops playlist +""" + +# standart library import +#from select import select + +# third parties componants + +# local import +from ..lib.plugin import Plugin + +class Crop(Plugin): + """ + Crop playlist on next track + """ + + def callback_playlist(self): + player = self._Plugin__daemon.player + target_lengh = 10 + while player.currentsong().pos > target_lengh: + player.remove() + + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab -- 2.39.2