]>
Commit | Line | Data |
---|---|---|
1 | #!/usr/bin/python | |
2 | # -*- coding: utf-8 -*- | |
3 | ||
4 | """Tool to control VLC via remote network interface. | |
5 | ||
6 | All excess args are sent as a one shot command. | |
7 | """ | |
8 | ||
9 | from __future__ import print_function | |
10 | ||
11 | import argparse | |
12 | import code | |
13 | import contextlib | |
14 | import logging | |
15 | import os | |
16 | import re | |
17 | import readline | |
18 | import socket | |
19 | import sys | |
20 | import time | |
21 | ||
22 | ||
23 | def setup_logging(logfile=None, base=None, debug=False, stdout=False): | |
24 | """Set up the logging module.""" | |
25 | fmt = '%(asctime)s: %(levelname)-7s: ' | |
26 | if debug: | |
27 | fmt += '%(filename)s:%(funcName)s: ' | |
28 | fmt += '%(message)s' | |
29 | # 'Sat, 05 Oct 2013 18:58:50 -0400 (EST)' | |
30 | tzname = time.strftime('%Z', time.localtime()) | |
31 | datefmt = '%a, %d %b %Y %H:%M:%S ' + tzname | |
32 | ||
33 | level = logging.DEBUG if debug else logging.INFO | |
34 | ||
35 | if stdout is True: | |
36 | handler = logging.StreamHandler(stream=sys.stdout) | |
37 | else: | |
38 | if logfile is None: | |
39 | assert base | |
40 | logfile = os.path.join(os.path.dirname(os.path.dirname( | |
41 | os.path.abspath(__file__))), 'logs', base) | |
42 | handler = logging.handlers.RotatingFileHandler( | |
43 | logfile, maxBytes=(10 * 1024 * 1024), backupCount=1) | |
44 | ||
45 | formatter = logging.Formatter(fmt, datefmt) | |
46 | handler.setFormatter(formatter) | |
47 | ||
48 | logger = logging.getLogger() | |
49 | logger.addHandler(handler) | |
50 | logger.setLevel(level) | |
51 | ||
52 | ||
53 | @contextlib.contextmanager | |
54 | def connection(host, port, quiet=False): | |
55 | """Connect to |host| on |port| and return the socket.""" | |
56 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
57 | try: | |
58 | if not quiet: | |
59 | print('Connecting to %s:%s ... ' %(host, port), end='') | |
60 | sys.stdout.flush() | |
61 | try: | |
62 | s.connect((host, port)) | |
63 | except socket.error as e: | |
64 | logging.fatal('%s', e) | |
65 | sys.exit(1) | |
66 | s.settimeout(0.1) | |
67 | if not quiet: | |
68 | print('connected!') | |
69 | yield s | |
70 | finally: | |
71 | #s.shutdown() | |
72 | s.close() | |
73 | ||
74 | ||
75 | class VlcConsole(code.InteractiveConsole): | |
76 | """VLC command line console.""" | |
77 | ||
78 | def __init__(self, *args, **kwargs): | |
79 | self.server = kwargs.pop('server') | |
80 | self._commands = [] | |
81 | code.InteractiveConsole.__init__(self, *args, **kwargs) | |
82 | ||
83 | def _drain_socket(self): | |
84 | """Process any remaining status from the server.""" | |
85 | try: | |
86 | while True: | |
87 | self.write(self.server.recv(1024 * 1024)) | |
88 | except socket.timeout: | |
89 | return | |
90 | ||
91 | def _send_command(self, cmd): | |
92 | """Send |cmd| to the server.""" | |
93 | logging.debug('Sending command: %s', cmd) | |
94 | self.server.send(cmd + '\n') | |
95 | ||
96 | def _run_command(self, cmd, passthru=False): | |
97 | """Run |cmd| and return its output.""" | |
98 | self._drain_socket() | |
99 | self._send_command(cmd) | |
100 | result = '' | |
101 | while True: | |
102 | try: | |
103 | result += self.server.recv(1024 * 1024) | |
104 | except socket.timeout: | |
105 | if '\n' in result: | |
106 | break | |
107 | if passthru: | |
108 | self.write(result) | |
109 | return result | |
110 | ||
111 | def _load_custom_parser(self, cmd): | |
112 | """Find custom parser for |cmd| as needed.""" | |
113 | parser_key = '_custom_parser_%s' % (cmd,) | |
114 | parser = getattr(self, parser_key, None) | |
115 | if parser is None: | |
116 | new_parser = argparse.ArgumentParser(prog=cmd, add_help=False) | |
117 | parser = getattr(self, '_custom_getparser_%s' % (cmd,))(new_parser) | |
118 | setattr(self, parser_key, parser) | |
119 | return parser | |
120 | ||
121 | def _get_current_position(self): | |
122 | """Get current position in the playback (in seconds). | |
123 | ||
124 | Example output: | |
125 | 1234 | |
126 | """ | |
127 | return int(self._run_command('get_time').splitlines()[-1]) | |
128 | ||
129 | def _get_stracks(self): | |
130 | """Get all available tracks. | |
131 | ||
132 | Example output: | |
133 | +----[ Subtitle Track ] | |
134 | | -1 - Disable | |
135 | | 3 - Track 1 - [English] | |
136 | | 4 - Track 2 - [Arabic] * | |
137 | | 5 - Track 3 - [Dutch] | |
138 | +----[ end of Subtitle Track ] | |
139 | """ | |
140 | current = '-1' | |
141 | tracks = [] | |
142 | for line in self._run_command('strack').splitlines(): | |
143 | if line.startswith('| '): | |
144 | split = line.split() | |
145 | if len(split) > 1: | |
146 | track = split[1] | |
147 | tracks.append(track) | |
148 | if split[-1] == '*': | |
149 | current = track | |
150 | logging.debug('Current track: %s', current) | |
151 | logging.debug('Found tracks: %r', tracks) | |
152 | return (current, tracks) | |
153 | ||
154 | def _get_current_volume(self): | |
155 | """Return current volume. | |
156 | ||
157 | Example output: | |
158 | status change: ( audio volume: 115 ) | |
159 | """ | |
160 | vol_re = re.compile(r'^status change: \( audio volume: ([0-9]+) \)') | |
161 | for line in self._run_command('volume').splitlines(): | |
162 | m = vol_re.match(line) | |
163 | if m: | |
164 | return int(m.group(1)) | |
165 | return None | |
166 | ||
167 | def _custom_getparser_help(self, parser): | |
168 | parser.add_argument('command', nargs='?') | |
169 | return parser | |
170 | ||
171 | # def _custom_help(self, opts): | |
172 | # if opts.command: | |
173 | # | |
174 | # else: | |
175 | # | |
176 | ||
177 | def _custom_getparser_seek(self, parser): | |
178 | parser.add_argument('position', | |
179 | help='Either a time in seconds, ' | |
180 | 'or "b" for back or "f" for forward') | |
181 | return parser | |
182 | ||
183 | def _custom_seek(self, opts): | |
184 | """Implement relative seek. | |
185 | ||
186 | VLC only supports absolute seeking. | |
187 | """ | |
188 | OFFSETS = (0, 5, 15, 30, 60) | |
189 | ||
190 | if (opts.position.replace('b', '') == '' or | |
191 | opts.position.replace('f', '') == ''): | |
192 | current_position = self._get_current_position() | |
193 | offset = OFFSETS[len(opts.position)] | |
194 | if opts.position[0] == 'b': | |
195 | offset *= -1 | |
196 | position = current_position + offset | |
197 | else: | |
198 | try: | |
199 | position = int(opts.position) | |
200 | except ValueError: | |
201 | logging.error('seek takes an int, not "%s"', opts.position) | |
202 | return | |
203 | ||
204 | self._run_command('seek %s' % (position,), passthru=True) | |
205 | ||
206 | def _custom_getparser_strack(self, parser): | |
207 | parser.add_argument('track', nargs='?', | |
208 | help='Use "n" for next track, ' | |
209 | 'or a number to select') | |
210 | return parser | |
211 | ||
212 | def _custom_strack(self, opts): | |
213 | if opts.track == 'n': | |
214 | # Select next track. | |
215 | current, tracks = self._get_stracks() | |
216 | pos = tracks.index(current) + 1 | |
217 | if pos == len(tracks): | |
218 | pos = 0 | |
219 | track = tracks[pos] | |
220 | else: | |
221 | track = '' if opts.track is None else opts.track | |
222 | self._run_command('strack %s' % (track,), passthru=True) | |
223 | ||
224 | def _custom_getparser_vol(self, parser): | |
225 | parser.add_argument('direction', choices=('down', 'up', 'set')) | |
226 | parser.add_argument('level', nargs='?', default='10', type=int, | |
227 | help='{down,up}: Volume level to adjust ' | |
228 | '(256 is 100%); ' | |
229 | 'set: Volume level to set (percentage)') | |
230 | return parser | |
231 | ||
232 | def _custom_vol(self, opts): | |
233 | """Implement relative volume. | |
234 | ||
235 | VLC only supports absolute volume. | |
236 | """ | |
237 | vol = self._get_current_volume() | |
238 | if vol is None: | |
239 | self.write('Unknown current volume :(\n') | |
240 | return | |
241 | if opts.direction == 'up': | |
242 | vol += opts.level | |
243 | elif opts.direction == 'down': | |
244 | vol -= opts.level | |
245 | else: | |
246 | vol = int(256 * (opts.level / 100.0)) | |
247 | self._send_command('volume %s' % (vol,)) | |
248 | ||
249 | CUSTOM_COMMANDS = { | |
250 | 'fullscreen': 'f', | |
251 | # 'help': _custom_help, | |
252 | 'seek': _custom_seek, | |
253 | 'strack': _custom_strack, | |
254 | 'vol': _custom_vol, | |
255 | } | |
256 | ||
257 | @property | |
258 | def commands(self): | |
259 | """List of commands the server knows about. | |
260 | ||
261 | Example format: | |
262 | +----[ Remote control commands ] | |
263 | | | |
264 | | add XYZ . . . . . . . . . . . . add XYZ to playlist | |
265 | .... | |
266 | | quit . . . . . . . . . . . . . . . . . . . quit vlc | |
267 | | | |
268 | +----[ end of help ] | |
269 | """ | |
270 | if self._commands: | |
271 | return self._commands | |
272 | ||
273 | #logging.debug('Help output: {{{%s}}}', text) | |
274 | text = self._run_command('help') | |
275 | commands = [] | |
276 | for line in text.splitlines(): | |
277 | if line.startswith('| '): | |
278 | split = line.split() | |
279 | if len(split) > 1: | |
280 | commands.append(split[1].rstrip('.')) | |
281 | logging.debug('parsed commands: %s', commands) | |
282 | self._commands = sorted(set(commands + self.CUSTOM_COMMANDS.keys())) | |
283 | return self._commands | |
284 | ||
285 | def _completer(self, text, state): | |
286 | """Complete |text|.""" | |
287 | start = readline.get_begidx() | |
288 | if start != 0: | |
289 | # Only complete commands atm, not their args. | |
290 | return None | |
291 | ||
292 | if state == 0 and text == '': | |
293 | self.write('\n') | |
294 | out = '' | |
295 | for i, command in enumerate(self.commands): | |
296 | out += '%-20s' % command | |
297 | if ((i + 1) % 4) == 0: | |
298 | self.write(out + '\n') | |
299 | out = '' | |
300 | if out: | |
301 | self.write(out + '\n') | |
302 | return None | |
303 | elif state or text == '': | |
304 | return None | |
305 | ||
306 | ret = [] | |
307 | for command in self.commands: | |
308 | if command.startswith(text): | |
309 | ret.append(command) | |
310 | logging.debug('matches: %r', ret) | |
311 | if not ret: | |
312 | return None | |
313 | elif len(ret) == 1: | |
314 | return ret[0] | |
315 | else: | |
316 | return None | |
317 | ||
318 | def completer(self, text, state): | |
319 | """Complete |text|.""" | |
320 | try: | |
321 | logging.debug('completer: state:%s text:{%s}', state, text) | |
322 | return self._completer(text, state) | |
323 | except Exception: | |
324 | logging.exception('completer failed:') | |
325 | ||
326 | def init_console(self): | |
327 | """Set up the command line interface.""" | |
328 | sys.ps1 = 'vlc-rc$ ' | |
329 | sys.ps2 = '> ' | |
330 | readline.parse_and_bind('tab: complete') | |
331 | readline.set_completer(self.completer) | |
332 | ||
333 | def runsource(self, source, filename=None, symbol=None): | |
334 | """Run the |source| (vlc command) the user wants.""" | |
335 | logging.debug('source=%r, filename=%r, symbol=%r', | |
336 | source, filename, symbol) | |
337 | if source: | |
338 | cmd = source.split() | |
339 | custom_command = self.CUSTOM_COMMANDS.get(cmd[0]) | |
340 | if not custom_command is None: | |
341 | if isinstance(custom_command, str): | |
342 | logging.debug('command alias found: %s', custom_command) | |
343 | source = ' '.join([custom_command] + cmd[1:]) | |
344 | else: | |
345 | logging.debug('custom command found: %s', custom_command) | |
346 | parser = self._load_custom_parser(cmd[0]) | |
347 | try: | |
348 | opts = parser.parse_args(cmd[1:]) | |
349 | custom_command(self, opts) | |
350 | except SystemExit: | |
351 | pass | |
352 | return | |
353 | ||
354 | self._send_command(source) | |
355 | if source in ('logout', 'quit'): | |
356 | sys.exit(0) | |
357 | self._drain_socket() | |
358 | ||
359 | ||
360 | def get_parser(): | |
361 | """Get a command line parser.""" | |
362 | parser = argparse.ArgumentParser(description=__doc__) | |
363 | parser.add_argument('-H', '--host', default='vapier', | |
364 | help='Server to talk to') | |
365 | parser.add_argument('-p', '--port', default=8001, | |
366 | help='Port to connect to') | |
367 | parser.add_argument('--debug', default=False, action='store_true', | |
368 | help='Enable debug mode') | |
369 | return parser | |
370 | ||
371 | ||
372 | def main(argv): | |
373 | """The main script entry point""" | |
374 | parser = get_parser() | |
375 | (opts, args) = parser.parse_known_args(argv) | |
376 | setup_logging(debug=opts.debug, stdout=True) | |
377 | ||
378 | with connection(opts.host, opts.port, quiet=bool(args)) as server: | |
379 | console = VlcConsole(server=server) | |
380 | if args: | |
381 | console.push(' '.join(args)) | |
382 | else: | |
383 | console.init_console() | |
384 | console.interact(banner='VLC interactive console; run "help".') | |
385 | ||
386 | ||
387 | if __name__ == '__main__': | |
388 | exit(main(sys.argv[1:])) |