diff options
author | Mike Crute <mcrute@gmail.com> | 2017-06-12 22:09:47 -0700 |
---|---|---|
committer | Mike Crute <mcrute@gmail.com> | 2017-06-12 22:22:38 -0700 |
commit | bd7fb11786c6b84af3f702ce915e3f07a7280b2b (patch) | |
tree | 3afb6149292d01073cb60aea79d22e31fd4d57f8 | |
parent | 9cc2ff1434c0d34452c8e02da63fd4c919261509 (diff) | |
download | pydora-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.py | 47 | ||||
-rw-r--r-- | pydora/mpg123.py | 224 | ||||
-rwxr-xr-x | pydora/player.py | 25 |
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 | |||
31 | try: | ||
32 | from shutil import which | ||
33 | except 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 @@ | |||
1 | import os | ||
2 | import time | ||
3 | import fcntl | ||
1 | import select | 4 | import select |
2 | 5 | ||
6 | from pandora.py2compat import which | ||
3 | from .utils import iterate_forever, SilentPopen | 7 | from .utils import iterate_forever, SilentPopen |
4 | 8 | ||
5 | 9 | ||
6 | class Player(object): | 10 | class PlayerException(Exception): |
7 | """Remote control for an mpg123 process | 11 | """Base class for all player exceptions |
12 | """ | ||
13 | pass | ||
14 | |||
15 | |||
16 | class 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 | 22 | class PlayerUnusable(PlayerException): |
10 | play pandora audio | 23 | """Player can not be used on this system |
24 | """ | ||
25 | pass | ||
26 | |||
27 | |||
28 | class 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 | |||
193 | class 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 | |||
222 | class 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 | |||
12 | import sys | 12 | import sys |
13 | from pandora import clientbuilder | 13 | from pandora import clientbuilder |
14 | 14 | ||
15 | from .mpg123 import Player | ||
16 | from .utils import Colors, Screen | 15 | from .utils import Colors, Screen |
16 | from .mpg123 import MPG123Player, VLCPlayer | ||
17 | from .mpg123 import UnsupportedEncoding, PlayerUnusable | ||
17 | 18 | ||
18 | 19 | ||
19 | class PlayerCallbacks(object): | 20 | class 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 | ||