aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Crute <mcrute@gmail.com>2017-06-12 22:09:47 -0700
committerMike Crute <mcrute@gmail.com>2017-06-12 22:22:38 -0700
commitbd7fb11786c6b84af3f702ce915e3f07a7280b2b (patch)
tree3afb6149292d01073cb60aea79d22e31fd4d57f8
parent9cc2ff1434c0d34452c8e02da63fd4c919261509 (diff)
downloadpydora-bd7fb11786c6b84af3f702ce915e3f07a7280b2b.tar.bz2
pydora-bd7fb11786c6b84af3f702ce915e3f07a7280b2b.tar.xz
pydora-bd7fb11786c6b84af3f702ce915e3f07a7280b2b.zip
Split player backends, add VLC strategy
Extract core player logic and mpg123-bound logic into parent-child classes so that other player backend strategies can be added. Create a headless VLC strategy that uses VLC if it's available. Update the pydora player to prefer VLC if it's available on the system because it supports a much more broad set of codecs and Pandora is now preferring AAC formatted files.
-rw-r--r--pandora/py2compat.py47
-rw-r--r--pydora/mpg123.py224
-rwxr-xr-xpydora/player.py25
3 files changed, 269 insertions, 27 deletions
diff --git a/pandora/py2compat.py b/pandora/py2compat.py
index d83f8c5..fe45462 100644
--- a/pandora/py2compat.py
+++ b/pandora/py2compat.py
@@ -26,3 +26,50 @@ except ImportError:
26 from mock import Mock, MagicMock, call, patch # noqa: F401 26 from mock import Mock, MagicMock, call, patch # noqa: F401
27 except ImportError: 27 except ImportError:
28 pass 28 pass
29
30
31try:
32 from shutil import which
33except ImportError:
34 import os
35 import sys
36
37 # Copypasta from Python 3.6, exists in 3.3+
38 def which(cmd, mode=os.F_OK | os.X_OK, path=None):
39 def _access_check(fn, mode):
40 return (os.path.exists(fn) and os.access(fn, mode)
41 and not os.path.isdir(fn))
42
43 if os.path.dirname(cmd):
44 if _access_check(cmd, mode):
45 return cmd
46 return None
47
48 if path is None:
49 path = os.environ.get("PATH", os.defpath)
50 if not path:
51 return None
52 path = path.split(os.pathsep)
53
54 if sys.platform == "win32":
55 if os.curdir not in path:
56 path.insert(0, os.curdir)
57
58 pathext = os.environ.get("PATHEXT", "").split(os.pathsep)
59 if any(cmd.lower().endswith(ext.lower()) for ext in pathext):
60 files = [cmd]
61 else:
62 files = [cmd + ext for ext in pathext]
63 else:
64 files = [cmd]
65
66 seen = set()
67 for dir in path:
68 normdir = os.path.normcase(dir)
69 if normdir not in seen:
70 seen.add(normdir)
71 for thefile in files:
72 name = os.path.join(dir, thefile)
73 if _access_check(name, mode):
74 return name
75 return None
diff --git a/pydora/mpg123.py b/pydora/mpg123.py
index 7077f2b..9599771 100644
--- a/pydora/mpg123.py
+++ b/pydora/mpg123.py
@@ -1,39 +1,109 @@
1import os
2import time
3import fcntl
1import select 4import select
2 5
6from pandora.py2compat import which
3from .utils import iterate_forever, SilentPopen 7from .utils import iterate_forever, SilentPopen
4 8
5 9
6class Player(object): 10class PlayerException(Exception):
7 """Remote control for an mpg123 process 11 """Base class for all player exceptions
12 """
13 pass
14
15
16class UnsupportedEncoding(PlayerException):
17 """Song encoding is not supported by player backend
18 """
19 pass
20
8 21
9 Starts and owns a handle to an mpg123 process then feeds commands to it to 22class PlayerUnusable(PlayerException):
10 play pandora audio 23 """Player can not be used on this system
24 """
25 pass
26
27
28class BasePlayer(object):
29 """Audio Backend Process Manager
30
31 Starts and owns a handle to an audio backend process then feeds commands to
32 it to play pandora audio. This class provides all the base functionality
33 for managing the process and feeding it input but should be subclassed to
34 fill in the rest of the interface that is specific to a backend.
35
36 Consumers should call start before using the class to pre-start the command
37 and decrease response latency when the first play command is sent.
11 """ 38 """
12 39
13 def __init__(self, callbacks, control_channel): 40 def __init__(self, callbacks, control_channel):
41 """Constructor
42
43 Will attempt to find the player binary on construction and fail if it
44 is not found. Subclasses should append any additional arguments to
45 _cmd.
46 """
14 self._control_channel = control_channel 47 self._control_channel = control_channel
15 self._control_fd = control_channel.fileno() 48 self._control_fd = control_channel.fileno()
16 self._callbacks = callbacks 49 self._callbacks = callbacks
17 self._process = None 50 self._process = None
18 self._ensure_started() 51 self._cmd = [self._find_path()]
19 52
20 def __del__(self): 53 def _find_path(self):
21 self._process.kill() 54 """Find the path to the backend binary
22 55
23 def _ensure_started(self): 56 This method may fail with a PlayerUnusable exception in which case the
24 """Ensure mpg123 is started 57 consumer should opt for another backend.
25 """ 58 """
26 if self._process and self._process.poll() is None: 59 raise NotImplementedError
27 return
28 60
29 self._process = SilentPopen( 61 def _load_track(self, song):
30 ["mpg123", "-q", "-R", "--ignore-mime", "."]) 62 """Load a track into the audio backend by song model
63 """
64 raise NotImplementedError
31 65
32 # Only output play status in the player stdout 66 def _player_stopped(self, value):
33 self._send_cmd("silence") 67 """Determine if player has stopped
68 """
69 raise NotImplementedError
70
71 def raise_volume(self):
72 """Raise the volume of the audio output
73
74 The player backend may not support this functionality in which case it
75 should not override this method.
76 """
77 raise NotImplementedError
78
79 def lower_volume(self):
80 """Lower the volume of the audio output
81
82 The player backend may not support this functionality in which case it
83 should not override this method.
84 """
85 raise NotImplementedError
86
87 def _post_start(self):
88 """Optionally, do something after the audio backend is started
89 """
90 return
91
92 def _loop_hook(self):
93 """Optionally, do something each main loop iteration
94 """
95 return
96
97 def _read_from_process(self, handle):
98 """Read a line from the process and clean it
99
100 Different audio backends return text in different formats so provides a
101 hook for each subclass to customize reader behaviour.
102 """
103 return handle.readline().strip()
34 104
35 def _send_cmd(self, cmd): 105 def _send_cmd(self, cmd):
36 """Write command to remote mpg123 process 106 """Write command to remote process
37 """ 107 """
38 self._process.stdin.write("{}\n".format(cmd).encode("utf-8")) 108 self._process.stdin.write("{}\n".format(cmd).encode("utf-8"))
39 self._process.stdin.flush() 109 self._process.stdin.flush()
@@ -48,34 +118,54 @@ class Player(object):
48 """ 118 """
49 self._send_cmd("pause") 119 self._send_cmd("pause")
50 120
51 def _player_stopped(self, value): 121 def __del__(self):
52 """Determine if player has stopped 122 if self._process:
123 self._process.kill()
124
125 def start(self):
126 """Start the audio backend process for the player
127
128 This is just a friendlier API for consumers
53 """ 129 """
54 return value.startswith(b"@P") and value.decode("utf-8")[3] == "0" 130 self._ensure_started()
131
132 def _ensure_started(self):
133 """Ensure player backing process is started
134 """
135 if self._process and self._process.poll() is None:
136 return
137
138 if not getattr(self, "_cmd"):
139 raise RuntimeError("Player command is not configured")
140
141 self._process = SilentPopen(self._cmd)
142 self._post_start()
55 143
56 def play(self, song): 144 def play(self, song):
57 """Play a new song from a Pandora model 145 """Play a new song from a Pandora model
58 146
59 Returns once the stream starts but does not shut down the remote mpg123 147 Returns once the stream starts but does not shut down the remote audio
60 process. Calls the input callback when the user has input. 148 output backend process. Calls the input callback when the user has
149 input.
61 """ 150 """
62 self._callbacks.play(song) 151 self._callbacks.play(song)
63 self._send_cmd("load {}".format(song.audio_url)) 152 self._load_track(song)
153 time.sleep(2) # Give the backend time to load the track
64 154
65 while True: 155 while True:
66 try: 156 try:
67 self._callbacks.pre_poll() 157 self._callbacks.pre_poll()
68 self._ensure_started() 158 self._ensure_started()
159 self._loop_hook()
69 160
70 readers, _, _ = select.select( 161 readers, _, _ = select.select(
71 [self._control_channel, self._process.stdout], [], [], 1) 162 [self._control_channel, self._process.stdout], [], [], 1)
72 163
73 for handle in readers: 164 for handle in readers:
74 value = handle.readline().strip()
75
76 if handle.fileno() == self._control_fd: 165 if handle.fileno() == self._control_fd:
77 self._callbacks.input(value, song) 166 self._callbacks.input(handle.readline().strip(), song)
78 else: 167 else:
168 value = self._read_from_process(handle)
79 if self._player_stopped(value): 169 if self._player_stopped(value):
80 return 170 return
81 finally: 171 finally:
@@ -98,3 +188,87 @@ class Player(object):
98 except StopIteration: 188 except StopIteration:
99 self.stop() 189 self.stop()
100 return 190 return
191
192
193class MPG123Player(BasePlayer):
194 """Player Backend Using mpg123
195 """
196
197 def __init__(self, callbacks, control_channel):
198 super(MPG123Player, self).__init__(callbacks, control_channel)
199 self._cmd.extend(["-q", "-R", "--ignore-mime", "."])
200
201 def _find_path(self):
202 loc = which("mpg123")
203 if not loc:
204 raise PlayerUnusable("Unable to find mpg123")
205
206 return loc
207
208 def _load_track(self, song):
209 if song.encoding != "mp3":
210 raise UnsupportedEncoding("mpg123 only supports mp3 files")
211
212 self._send_cmd("load {}".format(song.audio_url))
213
214 def _post_start(self):
215 # Only output play status in the player stdout
216 self._send_cmd("silence")
217
218 def _player_stopped(self, value):
219 return value.startswith(b"@P") and value.decode("utf-8")[3] == "0"
220
221
222class VLCPlayer(BasePlayer):
223
224 POLL_INTERVAL = 3
225 CHUNK_SIZE = 1024
226 VOL_STEPS = 5
227
228 def __init__(self, callbacks, control_channel):
229 super(VLCPlayer, self).__init__(callbacks, control_channel)
230 self._cmd.extend(["-I", "rc", "--advanced", "--rc-fake-tty", "-q"])
231 self._last_poll = 0
232
233 def _find_path(self):
234 loc = which("vlc")
235 if not loc: # Mac OS X
236 loc = which("VLC", path="/Applications/VLC.app/Contents/MacOS")
237
238 if not loc:
239 raise PlayerUnusable("Unable to find VLC")
240
241 return loc
242
243 def raise_volume(self):
244 self._send_cmd("volup {}".format(self.VOL_STEPS))
245
246 def lower_volume(self):
247 self._send_cmd("voldown {}".format(self.VOL_STEPS))
248
249 def _post_start(self):
250 """Set stdout to non-blocking
251
252 VLC does not always return a newline when reading status so in order to
253 be lazy and still use the read API without caring about how much output
254 there is we switch stdout to nonblocking mode and just read a large
255 chunk of datin order to be lazy and still use the read API without
256 caring about how much output there is we switch stdout to nonblocking
257 mode and just read a large chunk of data.
258 """
259 flags = fcntl.fcntl(self._process.stdout, fcntl.F_GETFL)
260 fcntl.fcntl(self._process.stdout, fcntl.F_SETFL, flags | os.O_NONBLOCK)
261
262 def _read_from_process(self, handle):
263 return handle.read(self.CHUNK_SIZE).strip()
264
265 def _load_track(self, song):
266 self._send_cmd("add {}".format(song.audio_url))
267
268 def _player_stopped(self, value):
269 return "state stopped" in value.decode("utf-8")
270
271 def _loop_hook(self):
272 if (time.time() - self._last_poll) >= self.POLL_INTERVAL:
273 self._send_cmd("status")
274 self._last_poll = time.time()
diff --git a/pydora/player.py b/pydora/player.py
index a6b926d..c56fe21 100755
--- a/pydora/player.py
+++ b/pydora/player.py
@@ -12,8 +12,9 @@ import os
12import sys 12import sys
13from pandora import clientbuilder 13from pandora import clientbuilder
14 14
15from .mpg123 import Player
16from .utils import Colors, Screen 15from .utils import Colors, Screen
16from .mpg123 import MPG123Player, VLCPlayer
17from .mpg123 import UnsupportedEncoding, PlayerUnusable
17 18
18 19
19class PlayerCallbacks(object): 20class PlayerCallbacks(object):
@@ -61,7 +62,24 @@ class PlayerApp(object):
61 62
62 def __init__(self): 63 def __init__(self):
63 self.client = None 64 self.client = None
64 self.player = Player(self, sys.stdin) 65
66 def get_player(self):
67 try:
68 player = VLCPlayer(self, sys.stdin)
69 Screen.print_success("Using VLC")
70 return player
71 except PlayerUnusable:
72 pass
73
74 try:
75 player = MPG123Player(self, sys.stdin)
76 Screen.print_success("Using mpg123")
77 return player
78 except PlayerUnusable:
79 pass
80
81 Screen.print_error("Unable to find a player")
82 sys.exit(1)
65 83
66 def get_client(self): 84 def get_client(self):
67 cfg_file = os.environ.get("PYDORA_CFG", "") 85 cfg_file = os.environ.get("PYDORA_CFG", "")
@@ -184,6 +202,9 @@ class PlayerApp(object):
184 Screen.set_echo(True) 202 Screen.set_echo(True)
185 203
186 def run(self): 204 def run(self):
205 self.player = self.get_player()
206 self.player.start()
207
187 self.client = self.get_client() 208 self.client = self.get_client()
188 self.stations = self.client.get_station_list() 209 self.stations = self.client.get_station_list()
189 210