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