diff options
Diffstat (limited to 'pydora/audio_backend.py')
-rw-r--r-- | pydora/audio_backend.py | 274 |
1 files changed, 274 insertions, 0 deletions
diff --git a/pydora/audio_backend.py b/pydora/audio_backend.py new file mode 100644 index 0000000..9599771 --- /dev/null +++ b/pydora/audio_backend.py | |||
@@ -0,0 +1,274 @@ | |||
1 | import os | ||
2 | import time | ||
3 | import fcntl | ||
4 | import select | ||
5 | |||
6 | from pandora.py2compat import which | ||
7 | from .utils import iterate_forever, SilentPopen | ||
8 | |||
9 | |||
10 | class PlayerException(Exception): | ||
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 | |||
21 | |||
22 | class PlayerUnusable(PlayerException): | ||
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. | ||
38 | """ | ||
39 | |||
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 | """ | ||
47 | self._control_channel = control_channel | ||
48 | self._control_fd = control_channel.fileno() | ||
49 | self._callbacks = callbacks | ||
50 | self._process = None | ||
51 | self._cmd = [self._find_path()] | ||
52 | |||
53 | def _find_path(self): | ||
54 | """Find the path to the backend binary | ||
55 | |||
56 | This method may fail with a PlayerUnusable exception in which case the | ||
57 | consumer should opt for another backend. | ||
58 | """ | ||
59 | raise NotImplementedError | ||
60 | |||
61 | def _load_track(self, song): | ||
62 | """Load a track into the audio backend by song model | ||
63 | """ | ||
64 | raise NotImplementedError | ||
65 | |||
66 | def _player_stopped(self, value): | ||
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() | ||
104 | |||
105 | def _send_cmd(self, cmd): | ||
106 | """Write command to remote process | ||
107 | """ | ||
108 | self._process.stdin.write("{}\n".format(cmd).encode("utf-8")) | ||
109 | self._process.stdin.flush() | ||
110 | |||
111 | def stop(self): | ||
112 | """Stop the currently playing song | ||
113 | """ | ||
114 | self._send_cmd("stop") | ||
115 | |||
116 | def pause(self): | ||
117 | """Pause the player | ||
118 | """ | ||
119 | self._send_cmd("pause") | ||
120 | |||
121 | def __del__(self): | ||
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 | ||
129 | """ | ||
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() | ||
143 | |||
144 | def play(self, song): | ||
145 | """Play a new song from a Pandora model | ||
146 | |||
147 | Returns once the stream starts but does not shut down the remote audio | ||
148 | output backend process. Calls the input callback when the user has | ||
149 | input. | ||
150 | """ | ||
151 | self._callbacks.play(song) | ||
152 | self._load_track(song) | ||
153 | time.sleep(2) # Give the backend time to load the track | ||
154 | |||
155 | while True: | ||
156 | try: | ||
157 | self._callbacks.pre_poll() | ||
158 | self._ensure_started() | ||
159 | self._loop_hook() | ||
160 | |||
161 | readers, _, _ = select.select( | ||
162 | [self._control_channel, self._process.stdout], [], [], 1) | ||
163 | |||
164 | for handle in readers: | ||
165 | if handle.fileno() == self._control_fd: | ||
166 | self._callbacks.input(handle.readline().strip(), song) | ||
167 | else: | ||
168 | value = self._read_from_process(handle) | ||
169 | if self._player_stopped(value): | ||
170 | return | ||
171 | finally: | ||
172 | self._callbacks.post_poll() | ||
173 | |||
174 | def end_station(self): | ||
175 | """Stop playing the station | ||
176 | """ | ||
177 | raise StopIteration | ||
178 | |||
179 | def play_station(self, station): | ||
180 | """Play the station until something ends it | ||
181 | |||
182 | This function will run forever until termintated by calling | ||
183 | end_station. | ||
184 | """ | ||
185 | for song in iterate_forever(station.get_playlist): | ||
186 | try: | ||
187 | self.play(song) | ||
188 | except StopIteration: | ||
189 | self.stop() | ||
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() | ||