aboutsummaryrefslogtreecommitdiff
path: root/pydora/audio_backend.py
diff options
context:
space:
mode:
Diffstat (limited to 'pydora/audio_backend.py')
-rw-r--r--pydora/audio_backend.py274
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 @@
1import os
2import time
3import fcntl
4import select
5
6from pandora.py2compat import which
7from .utils import iterate_forever, SilentPopen
8
9
10class PlayerException(Exception):
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
21
22class PlayerUnusable(PlayerException):
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.
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
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()