Feedcore: Check feedparser version (bug 1648)
[gpodder.git] / bin / gpo
blob29e229a9c7ecf4ee97b9644d6de7340c181d22a6
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
5 # gPodder - A media aggregator and podcast client
6 # Copyright (c) 2005-2012 Thomas Perl and the gPodder Team
8 # gPodder is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
13 # gPodder is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 # gpo - A better command-line interface to gPodder using the gPodder API
24 # by Thomas Perl <thp@gpodder.org>; 2009-05-07
27 """
28 Usage: gpo [--verbose|-v] [COMMAND] [params...]
30 - Subscription management -
32 subscribe URL [TITLE] Subscribe to a new feed at URL (as TITLE)
33 search QUERY Search the gpodder.net directory for QUERY
34 toplist Show the gpodder.net top-subscribe podcasts
36 import FILENAME|URL Subscribe to all podcasts in an OPML file
37 export FILENAME Export all subscriptions to an OPML file
39 rename URL TITLE Rename feed at URL to TITLE
40 unsubscribe URL Unsubscribe from feed at URL
41 enable URL Enable feed updates for the feed at URL
42 disable URL Disable feed updates for the feed at URL
44 info URL Show information about feed at URL
45 list List all subscribed podcasts
46 update [URL] Check for new episodes (all or only at URL)
48 - Episode management -
50 download [URL] Download new episodes (all or only from URL)
51 pending [URL] List new episodes (all or only from URL)
52 episodes [URL] List episodes (all or only from URL)
54 - Configuration -
56 set [key] [value] List one (all) keys or set to a new value
58 - Other commands -
60 youtube URL Resolve the YouTube URL to a download URL
61 rewrite OLDURL NEWURL Change the feed URL of [OLDURL] to [NEWURL]
62 webui [public] Start gPodder's Web UI server
63 (public = listen on all network interfaces)
65 """
67 import sys
68 import collections
69 import os
70 import re
71 import inspect
72 import functools
73 try:
74 import readline
75 except ImportError:
76 readline = None
77 import shlex
78 import pydoc
79 import logging
81 try:
82 import termios
83 import fcntl
84 import struct
85 except ImportError:
86 termios = None
87 fcntl = None
88 struct = None
90 # A poor man's argparse/getopt - but it works for our use case :)
91 verbose = False
92 for flag in ('-v', '--verbose'):
93 if flag in sys.argv:
94 sys.argv.remove(flag)
95 verbose = True
96 break
98 gpodder_script = sys.argv[0]
99 if os.path.islink(gpodder_script):
100 gpodder_script = os.readlink(gpodder_script)
101 gpodder_dir = os.path.join(os.path.dirname(gpodder_script), '..')
102 prefix = os.path.abspath(os.path.normpath(gpodder_dir))
104 src_dir = os.path.join(prefix, 'src')
106 if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
107 # Run gPodder from local source folder (not installed)
108 sys.path.insert(0, src_dir)
110 import gpodder
111 _ = gpodder.gettext
112 N_ = gpodder.ngettext
114 gpodder.prefix = prefix
116 # This is the command-line UI variant
117 gpodder.ui.cli = True
119 # Platform detection (i.e. Maemo 5, etc..)
120 gpodder.detect_platform()
122 have_ansi = sys.stdout.isatty() and not gpodder.win32
123 interactive_console = sys.stdin.isatty() and sys.stdout.isatty()
124 is_single_command = False
126 from gpodder import log
127 log.setup(verbose)
129 from gpodder import api
130 from gpodder import my
131 from gpodder import opml
132 from gpodder import util
133 from gpodder.config import config_value_to_string
135 def incolor(color_id, s):
136 if have_ansi and cli.config.ui.cli.colors:
137 return '\033[9%dm%s\033[0m' % (color_id, s)
138 return s
140 def safe_print(*args, **kwargs):
141 def convert(arg):
142 return unicode(util.convert_bytes(arg))
144 ofile = kwargs.get('file', sys.stdout)
145 output = u' '.join(map(convert, args))
146 if ofile.encoding is None:
147 output = util.sanitize_encoding(output)
148 else:
149 output = output.encode(ofile.encoding, 'replace')
151 try:
152 ofile.write(output)
153 except Exception, e:
154 print """
155 *** ENCODING FAIL ***
157 Please report this to http://bugs.gpodder.org/:
159 args = %s
160 map(convert, args) = %s
162 Exception = %s
163 """ % (repr(args), repr(map(convert, args)), e)
165 if kwargs.get('newline', True):
166 ofile.write(os.linesep)
167 ofile.flush()
169 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
170 inred, ingreen, inyellow, inblue = (functools.partial(incolor, x)
171 for x in range(1, 5))
173 def FirstArgumentIsPodcastURL(function):
174 """Decorator for functions that take a podcast URL as first arg"""
175 setattr(function, '_first_arg_is_podcast', True)
176 return function
178 def get_terminal_size():
179 if None in (termios, fcntl, struct):
180 return (80, 24)
182 s = struct.pack('HHHH', 0, 0, 0, 0)
183 stdout = sys.stdout.fileno()
184 x = fcntl.ioctl(stdout, termios.TIOCGWINSZ, s)
185 rows, cols, xp, yp = struct.unpack('HHHH', x)
186 return rows, cols
188 class gPodderCli(object):
189 COLUMNS = 80
190 EXIT_COMMANDS = ('quit', 'exit', 'bye')
192 def __init__(self):
193 self.client = api.PodcastClient()
194 self.config = self.client._config
196 self._current_action = ''
197 self._commands = dict((name.rstrip('_'), func)
198 for name, func in inspect.getmembers(self)
199 if inspect.ismethod(func) and not name.startswith('_'))
200 self._prefixes, self._expansions = self._build_prefixes_expansions()
201 self._prefixes.update({'?': 'help'})
202 self._valid_commands = sorted(self._prefixes.values())
203 gpodder.user_extensions.on_ui_initialized(self.client.core.model,
204 self._extensions_podcast_update_cb,
205 self._extensions_episode_download_cb)
207 def _build_prefixes_expansions(self):
208 prefixes = {}
209 expansions = collections.defaultdict(list)
210 names = sorted(self._commands.keys())
211 names.extend(self.EXIT_COMMANDS)
213 # Generator for all prefixes of a given string (longest first)
214 # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
215 mkprefixes = lambda n: (n[:x] for x in xrange(len(n), 0, -1))
217 # Return True if the given prefix is unique in "names"
218 is_unique = lambda p: len([n for n in names if n.startswith(p)]) == 1
220 for name in names:
221 is_still_unique = True
222 unique_expansion = None
223 for prefix in mkprefixes(name):
224 if is_unique(prefix):
225 unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
226 prefixes[prefix] = name
227 continue
229 if unique_expansion is not None:
230 expansions[prefix].append(unique_expansion)
231 continue
233 return prefixes, expansions
235 def _extensions_podcast_update_cb(self, podcast):
236 self._info(_('Podcast update requested by extensions.'))
237 self._update_podcast(podcast)
239 def _extensions_episode_download_cb(self, episode):
240 self._info(_('Episode download requested by extensions.'))
241 self._download_episode(episode)
243 def _start_action(self, msg, *args):
244 line = util.convert_bytes(msg % args)
245 if len(line) > self.COLUMNS-7:
246 line = line[:self.COLUMNS-7-3] + '...'
247 else:
248 line = line + (' '*(self.COLUMNS-7-len(line)))
249 self._current_action = line
250 safe_print(self._current_action, newline=False)
252 def _update_action(self, progress):
253 if have_ansi:
254 progress = '%3.0f%%' % (progress*100.,)
255 result = '['+inblue(progress)+']'
256 safe_print('\r' + self._current_action + result, newline=False)
258 def _finish_action(self, success=True, skip=False):
259 if skip:
260 result = '['+inyellow('SKIP')+']'
261 elif success:
262 result = '['+ingreen('DONE')+']'
263 else:
264 result = '['+inred('FAIL')+']'
266 if have_ansi:
267 safe_print('\r' + self._current_action + result)
268 else:
269 safe_print(result)
270 self._current_action = ''
272 def _atexit(self):
273 self.client.finish()
275 # -------------------------------------------------------------------
277 def import_(self, url):
278 for channel in opml.Importer(url).items:
279 self.subscribe(channel['url'], channel.get('title'))
281 def export(self, filename):
282 podcasts = self.client._model.get_podcasts()
283 opml.Exporter(filename).write(podcasts)
285 def subscribe(self, url, title=None):
286 url = util.normalize_feed_url(url)
287 if url is None:
288 self._error(_('Invalid URL.'))
289 return True
291 if self.client.get_podcast(url) is not None:
292 self._info(_('You are already subscribed to %s.') % url)
293 return True
295 try:
296 if self.client.create_podcast(url, title) is None:
297 self._error(_('Cannot subscribe to %s.') % url)
298 return True
299 except Exception, e:
300 if hasattr(e, 'strerror'):
301 self._error(e.strerror)
302 else:
303 self._error(str(e))
304 return True
306 self.client.commit()
308 self._info(_('Successfully added %s.' % url))
309 return True
311 def _print_config(self, search_for):
312 for key in self.config.all_keys():
313 if search_for is None or search_for.lower() in key.lower():
314 value = config_value_to_string(self.config._lookup(key))
315 safe_print(key, '=', value)
317 def set(self, key=None, value=None):
318 if value is None:
319 self._print_config(key)
320 return
322 try:
323 current_value = self.config._lookup(key)
324 current_type = type(current_value)
325 except KeyError:
326 self._error(_('This configuration option does not exist.'))
327 return
329 if current_type == dict:
330 self._error(_('Can only set leaf configuration nodes.'))
331 return
333 self.config.update_field(key, value)
334 self.set(key)
336 @FirstArgumentIsPodcastURL
337 def rename(self, url, title):
338 podcast = self.client.get_podcast(url)
340 if podcast is None:
341 self._error(_('You are not subscribed to %s.') % url)
342 else:
343 old_title = podcast.title
344 podcast.rename(title)
345 self.client.commit()
346 self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
347 'old_title': util.convert_bytes(old_title),
348 'new_title': util.convert_bytes(title),
351 return True
353 @FirstArgumentIsPodcastURL
354 def unsubscribe(self, url):
355 podcast = self.client.get_podcast(url)
357 if podcast is None:
358 self._error(_('You are not subscribed to %s.') % url)
359 else:
360 podcast.delete()
361 self.client.commit()
362 self._error(_('Unsubscribed from %s.') % url)
364 return True
366 def _episodesList(self, podcast):
367 def status_str(episode):
368 if episode.is_new:
369 return u' * '
370 if episode.is_downloaded:
371 return u' ▉ '
372 if episode.is_deleted:
373 return u' ░ '
375 return u' '
377 episodes = (u'%3d. %s %s' % (i+1, status_str(e), e.title)
378 for i, e in enumerate(podcast.get_episodes()))
379 return episodes
381 @FirstArgumentIsPodcastURL
382 def info(self, url):
383 podcast = self.client.get_podcast(url)
385 if podcast is None:
386 self._error(_('You are not subscribed to %s.') % url)
387 else:
388 title, url, status = podcast.title, podcast.url, podcast.feed_update_status_msg()
389 episodes = self._episodesList(podcast)
390 episodes = u'\n '.join(episodes)
391 self._pager(u"""
392 Title: %(title)s
393 URL: %(url)s
394 Feed update is %(status)s
396 Episodes:
397 %(episodes)s
398 """ % locals())
400 return True
402 @FirstArgumentIsPodcastURL
403 def episodes(self, url=None):
404 output = []
405 for podcast in self.client.get_podcasts():
406 podcast_printed = False
407 if url is None or podcast.url == url:
408 episodes = self._episodesList(podcast)
409 episodes = u'\n '.join(episodes)
410 output.append(u"""
411 Episodes from %s:
413 """ % (podcast.url, episodes))
415 self._pager(u'\n'.join(output))
416 return True
418 def list(self):
419 for podcast in self.client.get_podcasts():
420 if podcast.update_enabled():
421 safe_print('#', ingreen(podcast.title))
422 else:
423 safe_print('#', inred(podcast.title),
424 '-', _('Updates disabled'))
426 safe_print(podcast.url)
428 return True
430 def _update_podcast(self, podcast):
431 self._start_action(' %s', podcast.title)
432 try:
433 podcast.update()
434 self._finish_action()
435 except Exception, e:
436 self._finish_action(False)
438 def _pending_message(self, count):
439 return N_('%(count)d new episode', '%(count)d new episodes',
440 count) % {'count': count}
442 @FirstArgumentIsPodcastURL
443 def update(self, url=None):
444 count = 0
445 safe_print(_('Checking for new episodes'))
446 for podcast in self.client.get_podcasts():
447 if url is not None and podcast.url != url:
448 continue
450 if podcast.update_enabled():
451 self._update_podcast(podcast)
452 count += sum(1 for e in podcast.get_episodes() if e.is_new)
453 else:
454 self._start_action(_('Skipping %(podcast)s') % {
455 'podcast': podcast.title})
456 self._finish_action(skip=True)
458 safe_print(inblue(self._pending_message(count)))
459 return True
461 @FirstArgumentIsPodcastURL
462 def pending(self, url=None):
463 count = 0
464 for podcast in self.client.get_podcasts():
465 podcast_printed = False
466 if url is None or podcast.url == url:
467 for episode in podcast.get_episodes():
468 if episode.is_new:
469 if not podcast_printed:
470 safe_print('#', ingreen(podcast.title))
471 podcast_printed = True
472 safe_print(' ', episode.title)
473 count += 1
475 safe_print(inblue(self._pending_message(count)))
476 return True
478 def _download_episode(self, episode):
479 self._start_action('Downloading %s', episode.title)
480 episode.download(self._update_action)
481 self._finish_action()
483 @FirstArgumentIsPodcastURL
484 def download(self, url=None):
485 count = 0
486 for podcast in self.client.get_podcasts():
487 podcast_printed = False
488 if url is None or podcast.url == url:
489 for episode in podcast.get_episodes():
490 if episode.is_new:
491 if not podcast_printed:
492 safe_print(inblue(podcast.title))
493 podcast_printed = True
494 self._download_episode(episode)
495 count += 1
497 safe_print(count, 'episodes downloaded.')
498 return True
500 @FirstArgumentIsPodcastURL
501 def disable(self, url):
502 podcast = self.client.get_podcast(url)
504 if podcast is None:
505 self._error(_('You are not subscribed to %s.') % url)
506 else:
507 podcast.disable()
508 self.client.commit()
509 self._error(_('Disabling feed update from %s.') % url)
511 return True
513 @FirstArgumentIsPodcastURL
514 def enable(self, url):
515 podcast = self.client.get_podcast(url)
517 if podcast is None:
518 self._error(_('You are not subscribed to %s.') % url)
519 else:
520 podcast.enable()
521 self.client.commit()
522 self._error(_('Enabling feed update from %s.') % url)
524 return True
526 def youtube(self, url):
527 yurl = self.client.youtube_url_resolver(url)
528 safe_print(yurl)
529 return True
531 def webui(self, public=None):
532 from gpodder import webui
533 if public == 'public':
534 # Warn the user that the web UI is listening on all network
535 # interfaces, which could lead to problems.
536 # Only use this on a trusted, private network!
537 self._warn(_('Listening on ALL network interfaces.'))
538 webui.main(only_localhost=False, core=self.client.core)
539 else:
540 webui.main(core=self.client.core)
542 def search(self, *terms):
543 query = ' '.join(terms)
544 if not query:
545 return
547 directory = my.Directory()
548 results = directory.search(query)
549 self._show_directory_results(results)
551 def toplist(self):
552 directory = my.Directory()
553 results = directory.toplist()
554 self._show_directory_results(results, True)
556 def _show_directory_results(self, results, multiple=False):
557 if not results:
558 self._error(_('No podcasts found.'))
559 return
561 if not interactive_console or is_single_command:
562 safe_print('\n'.join(url for title, url in results))
563 return
565 def show_list():
566 self._pager('\n'.join(u'%3d: %s\n %s' %
567 (index+1, title, url if title != url else '')
568 for index, (title, url) in enumerate(results)))
570 show_list()
572 msg = _('Enter index to subscribe, ? for list')
573 while True:
574 index = raw_input(msg + ': ')
576 if not index:
577 return
579 if index == '?':
580 show_list()
581 continue
583 try:
584 index = int(index)
585 except ValueError:
586 self._error(_('Invalid value.'))
587 continue
589 if not (1 <= index <= len(results)):
590 self._error(_('Invalid value.'))
591 continue
593 title, url = results[index-1]
594 self._info(_('Adding %s...') % title)
595 self.subscribe(url)
596 if not multiple:
597 break
599 @FirstArgumentIsPodcastURL
600 def rewrite(self, old_url, new_url):
601 podcast = self.client.get_podcast(old_url)
602 if podcast is None:
603 self._error(_('You are not subscribed to %s.') % old_url)
604 else:
605 result = podcast.rewrite_url(new_url)
606 if result is None:
607 self._error(_('Invalid URL: %s') % new_url)
608 else:
609 new_url = result
610 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
612 'old_url': old_url,
613 'new_url': new_url,
615 return True
617 def help(self):
618 safe_print(stylize(__doc__), file=sys.stderr, newline=False)
619 return True
621 # -------------------------------------------------------------------
623 def _pager(self, output):
624 if have_ansi:
625 # Need two additional rows for command prompt
626 rows_needed = len(output.splitlines()) + 2
627 rows, cols = get_terminal_size()
628 if rows_needed < rows:
629 safe_print(output)
630 else:
631 pydoc.pager(util.sanitize_encoding(output))
632 else:
633 safe_print(output)
635 def _shell(self):
636 safe_print(os.linesep.join(x.strip() for x in ("""
637 gPodder %(__version__)s "%(__relname__)s" (%(__date__)s) - %(__url__)s
638 %(__copyright__)s
639 License: %(__license__)s
641 Entering interactive shell. Type 'help' for help.
642 Press Ctrl+D (EOF) or type 'quit' to quit.
643 """ % gpodder.__dict__).splitlines()))
645 if readline is not None:
646 readline.parse_and_bind('tab: complete')
647 readline.set_completer(self._tab_completion)
648 readline.set_completer_delims(' ')
650 while True:
651 try:
652 line = raw_input('gpo> ')
653 except EOFError:
654 safe_print('')
655 break
656 except KeyboardInterrupt:
657 safe_print('')
658 continue
660 if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
661 break
663 try:
664 args = shlex.split(line)
665 except ValueError, value_error:
666 self._error(_('Syntax error: %(error)s') %
667 {'error': value_error})
668 continue
670 try:
671 self._parse(args)
672 except KeyboardInterrupt:
673 self._error('Keyboard interrupt.')
674 except EOFError:
675 self._error('EOF.')
677 self._atexit()
679 def _error(self, *args):
680 safe_print(inred(' '.join(args)), file=sys.stderr)
682 # Warnings look like error messages for now
683 _warn = _error
685 def _info(self, *args):
686 safe_print(*args)
688 def _checkargs(self, func, command_line):
689 args, varargs, keywords, defaults = inspect.getargspec(func)
690 args.pop(0) # Remove "self" from args
691 defaults = defaults or ()
692 minarg, maxarg = len(args)-len(defaults), len(args)
694 if len(command_line) < minarg or (len(command_line) > maxarg and \
695 varargs is None):
696 self._error('Wrong argument count for %s.' % func.__name__)
697 return False
699 return func(*command_line)
701 def _tab_completion_podcast(self, text, count):
702 """Tab completion for podcast URLs"""
703 urls = [p.url for p in self.client.get_podcasts() if text in p.url]
704 if count < len(urls):
705 return urls[count]
707 return None
710 def _tab_completion(self, text, count):
711 """Tab completion function for readline"""
712 if readline is None:
713 return None
715 current_line = readline.get_line_buffer()
716 if text == current_line:
717 for name in self._valid_commands:
718 if name.startswith(text):
719 if count == 0:
720 return name
721 else:
722 count -= 1
723 else:
724 args = current_line.split()
725 command = args.pop(0)
726 command_function = getattr(self, command, None)
727 if not command_function:
728 return None
729 if getattr(command_function, '_first_arg_is_podcast', False):
730 if not args or (len(args) == 1 and not current_line.endswith(' ')):
731 return self._tab_completion_podcast(text, count)
733 return None
736 def _parse_single(self, command_line):
737 try:
738 result = self._parse(command_line)
739 except KeyboardInterrupt:
740 self._error('Keyboard interrupt.')
741 result = -1
742 self._atexit()
743 return result
745 def _parse(self, command_line):
746 if not command_line:
747 return False
749 command = command_line.pop(0)
751 # Resolve command aliases
752 command = self._prefixes.get(command, command)
754 if command in self._commands:
755 func = self._commands[command]
756 if inspect.ismethod(func):
757 return self._checkargs(func, command_line)
759 if command in self._expansions:
760 safe_print(_('Ambigous command. Did you mean..'))
761 for cmd in self._expansions[command]:
762 safe_print(' ', inblue(cmd))
763 else:
764 self._error(_('The requested function is not available.'))
766 return False
769 def stylize(s):
770 s = re.sub(r' .{27}', lambda m: inblue(m.group(0)), s)
771 s = re.sub(r' - .*', lambda m: ingreen(m.group(0)), s)
772 return s
774 if __name__ == '__main__':
775 cli = gPodderCli()
776 args = sys.argv[1:]
777 if args:
778 is_single_command = True
779 cli._parse_single(args)
780 elif interactive_console:
781 cli._shell()
782 else:
783 safe_print(__doc__, newline=False)