1# Copyright 2014, 2017 Nick Boultbee 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2 of the License, or 6# (at your option) any later version. 7 8import socket 9from telnetlib import Telnet 10import time 11from urllib.parse import quote, unquote 12 13from quodlibet import _ 14from quodlibet import app 15from quodlibet.util.dprint import print_w, print_d, print_ 16 17 18class SqueezeboxException(Exception): 19 """Errors communicating with the Squeezebox""" 20 21 22class SqueezeboxServerSettings(dict): 23 """Encapsulates Server settings""" 24 def __str__(self): 25 try: 26 return _("Squeezebox server at {hostname}:{port}").format(**self) 27 except KeyError: 28 return _("unidentified Squeezebox server") 29 30 31class SqueezeboxPlayerSettings(dict): 32 """Encapsulates player settings""" 33 def __str__(self): 34 try: 35 return "{name} [{playerid}]".format(**self) 36 except KeyError: 37 return _("unidentified Squeezebox player: %r" % self) 38 39 40class SqueezeboxServer(object): 41 """Encapsulates access to a Squeezebox player via a squeezecenter server""" 42 43 _TIMEOUT = 10 44 _MAX_FAILURES = 3 45 telnet = None 46 is_connected = False 47 current_player = 0 48 players = [] 49 config = SqueezeboxServerSettings() 50 _debug = False 51 52 def __init__(self, hostname="localhost", port=9090, user="", password="", 53 library_dir='', current_player=0, debug=False): 54 self._debug = debug 55 self.failures = 0 56 self.delta = 600 # Default in ms 57 self.config = SqueezeboxServerSettings(locals()) 58 if hostname: 59 del self.config["self"] 60 del self.config["current_player"] 61 self.current_player = int(current_player) or 0 62 try: 63 if self._debug: 64 print_d("Trying %s..." % self.config) 65 self.telnet = Telnet(hostname, port, self._TIMEOUT) 66 except socket.error as e: 67 print_d("Couldn't talk to %s (%s)" % (self.config, e)) 68 else: 69 result = self.__request("login %s %s" % (user, password)) 70 if result != (6 * '*'): 71 raise SqueezeboxException( 72 "Couldn't log in to squeezebox: response was '%s'" 73 % result) 74 self.is_connected = True 75 self.failures = 0 76 print_d("Connected to Squeezebox Server! %s" % self) 77 # Reset players (forces reload) 78 self.players = [] 79 self.get_players() 80 81 def get_library_dir(self): 82 return self.config['library_dir'] 83 84 def __request(self, line, raw=False, want_reply=True): 85 """ 86 Send a request to the server, if connected, and return its response 87 """ 88 line = line.strip() 89 90 if not (self.is_connected or line.split()[0] == 'login'): 91 print_d("Can't do '%s' - not connected" % line.split()[0], self) 92 return None 93 94 if self._debug: 95 print_(">>>> \"%s\"" % line) 96 try: 97 self.telnet.write((line + "\n").encode('utf-8')) 98 if not want_reply: 99 return None 100 raw_response = self.telnet.read_until(b"\n").decode('utf-8') 101 except socket.error as e: 102 print_w("Couldn't communicate with squeezebox (%s)" % e) 103 self.failures += 1 104 if self.failures >= self._MAX_FAILURES: 105 print_w("Too many Squeezebox failures. Disconnecting") 106 self.is_connected = False 107 return None 108 response = (raw_response if raw else unquote(raw_response)).strip() 109 if self._debug: 110 print_("<<<< \"%s\"" % (response,)) 111 return (response[len(line) - 1:] if line.endswith("?") 112 else response[len(line) + 1:]) 113 114 def get_players(self): 115 """ Returns (and caches) a list of the Squeezebox players available""" 116 if self.players: 117 return self.players 118 pairs = self.__request("players 0 99", True).split(" ") 119 120 def demunge(string): 121 s = unquote(string) 122 cpos = s.index(":") 123 return s[0:cpos], s[cpos + 1:] 124 125 # Do a meaningful URL-unescaping and tuplification for all values 126 pairs = [demunge(p) for p in pairs] 127 128 # First element is always count 129 count = int(pairs.pop(0)[1]) 130 self.players = [] 131 for pair in pairs: 132 if pair[0] == "playerindex": 133 playerindex = int(pair[1]) 134 self.players.append(SqueezeboxPlayerSettings()) 135 else: 136 # Don't worry playerindex is always the first entry... 137 self.players[playerindex][pair[0]] = pair[1] 138 if self._debug: 139 print_d("Found %d player(s): %s" % 140 (len(self.players), self.players)) 141 assert (count == len(self.players)) 142 return self.players 143 144 def player_request(self, line, want_reply=True): 145 if not self.is_connected: 146 return 147 try: 148 return self.__request( 149 "%s %s" % 150 (self.players[self.current_player]["playerid"], line), 151 want_reply=want_reply) 152 except IndexError: 153 return None 154 155 def get_version(self): 156 if self.is_connected: 157 return self.__request("version ?") 158 else: 159 return "(not connected)" 160 161 def play(self): 162 """Plays the current song""" 163 self.player_request("play") 164 165 def is_stopped(self): 166 """Returns whether the player is in any sort of non-playing mode""" 167 response = self.player_request("mode ?") 168 return "play" != response 169 170 def playlist_play(self, path): 171 """Play song immediately""" 172 self.player_request("playlist play %s" % (quote(path))) 173 174 def playlist_add(self, path): 175 self.player_request("playlist add %s" % (quote(path)), False) 176 177 def playlist_save(self, name): 178 self.player_request("playlist save %s" % (quote(name)), False) 179 180 def playlist_clear(self): 181 self.player_request("playlist clear", False) 182 183 def playlist_resume(self, name, resume, wipe=False): 184 cmd = ("playlist resume %s noplay:%d wipePlaylist:%d" 185 % (quote(name), int(not resume), int(wipe))) 186 self.player_request(cmd, want_reply=False) 187 188 def change_song(self, path): 189 """Queue up a song""" 190 self.player_request("playlist clear") 191 self.player_request("playlist insert %s" % (quote(path))) 192 193 def seek_to(self, ms): 194 """Seeks the current song to `ms` milliseconds from start""" 195 if not self.is_connected: 196 return 197 if self._debug: 198 print_d("Requested %0.2f s, adding drift of %d ms..." 199 % (ms / 1000.0, self.delta)) 200 ms += self.delta 201 start = time.time() 202 self.player_request("time %d" % round(int(ms) / 1000)) 203 end = time.time() 204 took = (end - start) * 1000 205 reported_time = self.get_milliseconds() 206 ql_pos = app.player.get_position() 207 # Assume 50% of the time taken to complete is response. 208 # TODO: Better predictive modelling 209 new_delta = ql_pos - reported_time 210 self.delta = (self.delta + new_delta) / 2 211 if self._debug: 212 print_d("Player at %0.0f but QL at %0.2f." 213 "(Took %0.0f ms). Drift was %+0.0f ms" % 214 (reported_time / 1000.0, ql_pos / 1000.0, took, new_delta)) 215 216 def get_milliseconds(self): 217 secs = self.player_request("time ?") or 0 218 return float(secs) * 1000.0 219 220 def pause(self): 221 self.player_request("pause 1") 222 223 def unpause(self): 224 if self.is_stopped(): 225 self.play() 226 ms = app.player.get_position() 227 self.seek_to(ms) 228 #self.player_request("pause 0") 229 230 def stop(self): 231 self.player_request("stop") 232 233 def __str__(self): 234 return str(self.config) 235