Update copyright years for 2013
[gpodder.git] / bin / gpo
bloba6bf14e610fe46b5173e11678780c62fa6d16522
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
5 # gPodder - A media aggregator and podcast client
6 # Copyright (c) 2005-2013 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)
64 pipe Start gPodder in pipe-based IPC server mode
66 """
68 from __future__ import print_function
70 import sys
71 import collections
72 import os
73 import re
74 import inspect
75 import functools
76 try:
77 import readline
78 except ImportError:
79 readline = None
80 import shlex
81 import pydoc
82 import logging
84 try:
85 import termios
86 import fcntl
87 import struct
88 except ImportError:
89 termios = None
90 fcntl = None
91 struct = None
93 # A poor man's argparse/getopt - but it works for our use case :)
94 verbose = False
95 for flag in ('-v', '--verbose'):
96 if flag in sys.argv:
97 sys.argv.remove(flag)
98 verbose = True
99 break
101 gpodder_script = sys.argv[0]
102 if os.path.islink(gpodder_script):
103 gpodder_script = os.readlink(gpodder_script)
104 gpodder_dir = os.path.join(os.path.dirname(gpodder_script), '..')
105 prefix = os.path.abspath(os.path.normpath(gpodder_dir))
107 src_dir = os.path.join(prefix, 'src')
109 if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
110 # Run gPodder from local source folder (not installed)
111 sys.path.insert(0, src_dir)
113 import gpodder
114 _ = gpodder.gettext
115 N_ = gpodder.ngettext
117 gpodder.images_folder = os.path.join(prefix, 'share', 'gpodder', 'images')
118 gpodder.prefix = prefix
120 # This is the command-line UI variant
121 gpodder.ui.cli = True
123 # Platform detection (i.e. MeeGo 1.2 Harmattan, etc..)
124 gpodder.detect_platform()
126 have_ansi = sys.stdout.isatty() and not gpodder.ui.win32
127 interactive_console = sys.stdin.isatty() and sys.stdout.isatty()
128 is_single_command = False
130 from gpodder import log
131 log.setup(verbose)
133 from gpodder import core
134 from gpodder import download
135 from gpodder import my
136 from gpodder import opml
137 from gpodder import util
138 from gpodder import youtube
139 from gpodder.config import config_value_to_string
141 def incolor(color_id, s):
142 if have_ansi and cli._config.ui.cli.colors:
143 return '\033[9%dm%s\033[0m' % (color_id, s)
144 return s
146 def safe_print(*args, **kwargs):
147 def convert(arg):
148 return unicode(util.convert_bytes(arg))
150 ofile = kwargs.get('file', sys.stdout)
151 output = u' '.join(map(convert, args))
152 if ofile.encoding is None:
153 output = util.sanitize_encoding(output)
154 else:
155 output = output.encode(ofile.encoding, 'replace')
157 try:
158 ofile.write(output)
159 except Exception, e:
160 print("""
161 *** ENCODING FAIL ***
163 Please report this to http://bugs.gpodder.org/:
165 args = %s
166 map(convert, args) = %s
168 Exception = %s
169 """ % (repr(args), repr(map(convert, args)), e))
171 ofile.write(kwargs.get('end', os.linesep))
172 ofile.flush()
174 # On Python 3 the encoding insanity is gone, so our safe_print()
175 # function simply becomes the normal print() function. Good stuff!
176 if sys.version_info >= (3,):
177 safe_print = print
179 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
180 inred, ingreen, inyellow, inblue = (functools.partial(incolor, x)
181 for x in range(1, 5))
183 def FirstArgumentIsPodcastURL(function):
184 """Decorator for functions that take a podcast URL as first arg"""
185 setattr(function, '_first_arg_is_podcast', True)
186 return function
188 def get_terminal_size():
189 if None in (termios, fcntl, struct):
190 return (80, 24)
192 s = struct.pack('HHHH', 0, 0, 0, 0)
193 stdout = sys.stdout.fileno()
194 x = fcntl.ioctl(stdout, termios.TIOCGWINSZ, s)
195 rows, cols, xp, yp = struct.unpack('HHHH', x)
196 return rows, cols
198 class gPodderCli(object):
199 COLUMNS = 80
200 EXIT_COMMANDS = ('quit', 'exit', 'bye')
202 def __init__(self):
203 self.core = core.Core()
204 self._db = self.core.db
205 self._config = self.core.config
206 self._model = self.core.model
208 self._current_action = ''
209 self._commands = dict((name.rstrip('_'), func)
210 for name, func in inspect.getmembers(self)
211 if inspect.ismethod(func) and not name.startswith('_'))
212 self._prefixes, self._expansions = self._build_prefixes_expansions()
213 self._prefixes.update({'?': 'help'})
214 self._valid_commands = sorted(self._prefixes.values())
215 gpodder.user_extensions.on_ui_initialized(self.core.model,
216 self._extensions_podcast_update_cb,
217 self._extensions_episode_download_cb)
219 def _build_prefixes_expansions(self):
220 prefixes = {}
221 expansions = collections.defaultdict(list)
222 names = sorted(self._commands.keys())
223 names.extend(self.EXIT_COMMANDS)
225 # Generator for all prefixes of a given string (longest first)
226 # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
227 mkprefixes = lambda n: (n[:x] for x in xrange(len(n), 0, -1))
229 # Return True if the given prefix is unique in "names"
230 is_unique = lambda p: len([n for n in names if n.startswith(p)]) == 1
232 for name in names:
233 is_still_unique = True
234 unique_expansion = None
235 for prefix in mkprefixes(name):
236 if is_unique(prefix):
237 unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
238 prefixes[prefix] = name
239 continue
241 if unique_expansion is not None:
242 expansions[prefix].append(unique_expansion)
243 continue
245 return prefixes, expansions
247 def _extensions_podcast_update_cb(self, podcast):
248 self._info(_('Podcast update requested by extensions.'))
249 self._update_podcast(podcast)
251 def _extensions_episode_download_cb(self, episode):
252 self._info(_('Episode download requested by extensions.'))
253 self._download_episode(episode)
255 def _start_action(self, msg, *args):
256 line = util.convert_bytes(msg % args)
257 if len(line) > self.COLUMNS-7:
258 line = line[:self.COLUMNS-7-3] + '...'
259 else:
260 line = line + (' '*(self.COLUMNS-7-len(line)))
261 self._current_action = line
262 safe_print(self._current_action, end='')
264 def _update_action(self, progress):
265 if have_ansi:
266 progress = '%3.0f%%' % (progress*100.,)
267 result = '['+inblue(progress)+']'
268 safe_print('\r' + self._current_action + result, end='')
270 def _finish_action(self, success=True, skip=False):
271 if skip:
272 result = '['+inyellow('SKIP')+']'
273 elif success:
274 result = '['+ingreen('DONE')+']'
275 else:
276 result = '['+inred('FAIL')+']'
278 if have_ansi:
279 safe_print('\r' + self._current_action + result)
280 else:
281 safe_print(result)
282 self._current_action = ''
284 def _atexit(self):
285 self.core.shutdown()
287 # -------------------------------------------------------------------
289 def import_(self, url):
290 for channel in opml.Importer(url).items:
291 self.subscribe(channel['url'], channel.get('title'))
293 def export(self, filename):
294 podcasts = self._model.get_podcasts()
295 opml.Exporter(filename).write(podcasts)
297 def get_podcast(self, url, create=False):
298 """Get a specific podcast by URL
300 Returns a podcast object for the URL or None if
301 the podcast has not been subscribed to.
303 url = util.normalize_feed_url(url)
304 if url is None:
305 self._error(_('Invalid url: %s') % url)
306 return None
308 podcast = self._model.load_podcast(url, create=create, \
309 max_episodes=self._config.max_episodes_per_feed)
310 if podcast is None:
311 self._error(_('You are not subscribed to %s.') % url)
312 return None
314 return podcast
316 def subscribe(self, url, title=None):
317 try:
318 podcast = self.get_podcast(url, create=True)
319 if podcast is None:
320 self._error(_('Cannot subscribe to %s.') % url)
321 return True
323 if title is not None:
324 podcast.rename(title)
325 podcast.save()
326 except Exception, e:
327 if hasattr(e, 'strerror'):
328 self._error(e.strerror)
329 else:
330 self._error(str(e))
331 return True
333 self._db.commit()
335 self._info(_('Successfully added %s.' % url))
336 return True
338 def _print_config(self, search_for):
339 for key in self._config.all_keys():
340 if search_for is None or search_for.lower() in key.lower():
341 value = config_value_to_string(self._config._lookup(key))
342 safe_print(key, '=', value)
344 def set(self, key=None, value=None):
345 if value is None:
346 self._print_config(key)
347 return
349 try:
350 current_value = self._config._lookup(key)
351 current_type = type(current_value)
352 except KeyError:
353 self._error(_('This configuration option does not exist.'))
354 return
356 if current_type == dict:
357 self._error(_('Can only set leaf configuration nodes.'))
358 return
360 self._config.update_field(key, value)
361 self.set(key)
363 @FirstArgumentIsPodcastURL
364 def rename(self, url, title):
365 podcast = self.get_podcast(url)
367 if podcast is not None:
368 old_title = podcast.title
369 podcast.rename(title)
370 self._db.commit()
371 self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
372 'old_title': util.convert_bytes(old_title),
373 'new_title': util.convert_bytes(title),
376 return True
378 @FirstArgumentIsPodcastURL
379 def unsubscribe(self, url):
380 podcast = self.get_podcast(url)
382 if podcast is None:
383 self._error(_('You are not subscribed to %s.') % url)
384 else:
385 podcast.delete()
386 self._db.commit()
387 self._error(_('Unsubscribed from %s.') % url)
389 return True
391 def is_episode_new(self, episode):
392 return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
394 def _episodesList(self, podcast):
395 def status_str(episode):
396 # is new
397 if self.is_episode_new(episode):
398 return u' * '
399 # is downloaded
400 if (episode.state == gpodder.STATE_DOWNLOADED):
401 return u' ▉ '
402 # is deleted
403 if (episode.state == gpodder.STATE_DELETED):
404 return u' ░ '
406 return u' '
408 episodes = (u'%3d. %s %s' % (i+1, status_str(e), e.title)
409 for i, e in enumerate(podcast.get_all_episodes()))
410 return episodes
412 @FirstArgumentIsPodcastURL
413 def info(self, url):
414 podcast = self.get_podcast(url)
416 if podcast is None:
417 self._error(_('You are not subscribed to %s.') % url)
418 else:
419 def feed_update_status_msg(podcast):
420 if podcast.pause_subscription:
421 return "disabled"
422 return "enabled"
424 title, url, status = podcast.title, podcast.url, \
425 feed_update_status_msg(podcast)
426 episodes = self._episodesList(podcast)
427 episodes = u'\n '.join(episodes)
428 self._pager(u"""
429 Title: %(title)s
430 URL: %(url)s
431 Feed update is %(status)s
433 Episodes:
434 %(episodes)s
435 """ % locals())
437 return True
439 @FirstArgumentIsPodcastURL
440 def episodes(self, url=None):
441 output = []
442 for podcast in self._model.get_podcasts():
443 podcast_printed = False
444 if url is None or podcast.url == url:
445 episodes = self._episodesList(podcast)
446 episodes = u'\n '.join(episodes)
447 output.append(u"""
448 Episodes from %s:
450 """ % (podcast.url, episodes))
452 self._pager(u'\n'.join(output))
453 return True
455 def list(self):
456 for podcast in self._model.get_podcasts():
457 if not podcast.pause_subscription:
458 safe_print('#', ingreen(podcast.title))
459 else:
460 safe_print('#', inred(podcast.title),
461 '-', _('Updates disabled'))
463 safe_print(podcast.url)
465 return True
467 def _update_podcast(self, podcast):
468 self._start_action(' %s', podcast.title)
469 try:
470 podcast.update()
471 self._finish_action()
472 except Exception, e:
473 self._finish_action(False)
475 def _pending_message(self, count):
476 return N_('%(count)d new episode', '%(count)d new episodes',
477 count) % {'count': count}
479 @FirstArgumentIsPodcastURL
480 def update(self, url=None):
481 count = 0
482 safe_print(_('Checking for new episodes'))
483 for podcast in self._model.get_podcasts():
484 if url is not None and podcast.url != url:
485 continue
487 if not podcast.pause_subscription:
488 self._update_podcast(podcast)
489 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
490 else:
491 self._start_action(_('Skipping %(podcast)s') % {
492 'podcast': podcast.title})
493 self._finish_action(skip=True)
495 safe_print(inblue(self._pending_message(count)))
496 return True
498 @FirstArgumentIsPodcastURL
499 def pending(self, url=None):
500 count = 0
501 for podcast in self._model.get_podcasts():
502 podcast_printed = False
503 if url is None or podcast.url == url:
504 for episode in podcast.get_all_episodes():
505 if self.is_episode_new(episode):
506 if not podcast_printed:
507 safe_print('#', ingreen(podcast.title))
508 podcast_printed = True
509 safe_print(' ', episode.title)
510 count += 1
512 safe_print(inblue(self._pending_message(count)))
513 return True
515 def _download_episode(self, episode):
516 self._start_action('Downloading %s', episode.title)
518 task = download.DownloadTask(episode, self._config)
519 task.add_progress_callback(self._update_action)
520 task.status = download.DownloadTask.QUEUED
521 task.run()
523 self._finish_action()
525 @FirstArgumentIsPodcastURL
526 def download(self, url=None):
527 count = 0
528 for podcast in self._model.get_podcasts():
529 podcast_printed = False
530 if url is None or podcast.url == url:
531 for episode in podcast.get_all_episodes():
532 if self.is_episode_new(episode):
533 if not podcast_printed:
534 safe_print(inblue(podcast.title))
535 podcast_printed = True
536 self._download_episode(episode)
537 count += 1
539 safe_print(count, 'episodes downloaded.')
540 return True
542 @FirstArgumentIsPodcastURL
543 def disable(self, url):
544 podcast = self.get_podcast(url)
546 if podcast is None:
547 self._error(_('You are not subscribed to %s.') % url)
548 else:
549 if not podcast.pause_subscription:
550 podcast.pause_subscription = True
551 podcast.save()
552 self._db.commit()
553 self._error(_('Disabling feed update from %s.') % url)
555 return True
557 @FirstArgumentIsPodcastURL
558 def enable(self, url):
559 podcast = self.get_podcast(url)
561 if podcast is None:
562 self._error(_('You are not subscribed to %s.') % url)
563 else:
564 if podcast.pause_subscription:
565 podcast.pause_subscription = False
566 podcast.save()
567 self._db.commit()
568 self._error(_('Enabling feed update from %s.') % url)
570 return True
572 def youtube(self, url):
573 fmt_ids = youtube.get_fmt_ids(self._config.youtube)
574 yurl = youtube.get_real_download_url(url, fmt_ids)
575 safe_print(yurl)
577 return True
579 def webui(self, public=None):
580 from gpodder import webui
581 if public == 'public':
582 # Warn the user that the web UI is listening on all network
583 # interfaces, which could lead to problems.
584 # Only use this on a trusted, private network!
585 self._warn(_('Listening on ALL network interfaces.'))
586 webui.main(only_localhost=False, core=self.core)
587 else:
588 webui.main(core=self.core)
590 def pipe(self):
591 from gpodder import pipe
592 pipe.main(core=self.core)
594 def search(self, *terms):
595 query = ' '.join(terms)
596 if not query:
597 return
599 directory = my.Directory()
600 results = directory.search(query)
601 self._show_directory_results(results)
603 def toplist(self):
604 directory = my.Directory()
605 results = directory.toplist()
606 self._show_directory_results(results, True)
608 def _show_directory_results(self, results, multiple=False):
609 if not results:
610 self._error(_('No podcasts found.'))
611 return
613 if not interactive_console or is_single_command:
614 safe_print('\n'.join(url for title, url in results))
615 return
617 def show_list():
618 self._pager('\n'.join(u'%3d: %s\n %s' %
619 (index+1, title, url if title != url else '')
620 for index, (title, url) in enumerate(results)))
622 show_list()
624 msg = _('Enter index to subscribe, ? for list')
625 while True:
626 index = raw_input(msg + ': ')
628 if not index:
629 return
631 if index == '?':
632 show_list()
633 continue
635 try:
636 index = int(index)
637 except ValueError:
638 self._error(_('Invalid value.'))
639 continue
641 if not (1 <= index <= len(results)):
642 self._error(_('Invalid value.'))
643 continue
645 title, url = results[index-1]
646 self._info(_('Adding %s...') % title)
647 self.subscribe(url)
648 if not multiple:
649 break
651 @FirstArgumentIsPodcastURL
652 def rewrite(self, old_url, new_url):
653 podcast = self.get_podcast(old_url)
654 if podcast is None:
655 self._error(_('You are not subscribed to %s.') % old_url)
656 else:
657 result = podcast.rewrite_url(new_url)
658 if result is None:
659 self._error(_('Invalid URL: %s') % new_url)
660 else:
661 new_url = result
662 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
664 'old_url': old_url,
665 'new_url': new_url,
667 return True
669 def help(self):
670 safe_print(stylize(__doc__), file=sys.stderr, end='')
671 return True
673 # -------------------------------------------------------------------
675 def _pager(self, output):
676 if have_ansi:
677 # Need two additional rows for command prompt
678 rows_needed = len(output.splitlines()) + 2
679 rows, cols = get_terminal_size()
680 if rows_needed < rows:
681 safe_print(output)
682 else:
683 pydoc.pager(util.sanitize_encoding(output))
684 else:
685 safe_print(output)
687 def _shell(self):
688 safe_print(os.linesep.join(x.strip() for x in ("""
689 gPodder %(__version__)s "%(__relname__)s" (%(__date__)s) - %(__url__)s
690 %(__copyright__)s
691 License: %(__license__)s
693 Entering interactive shell. Type 'help' for help.
694 Press Ctrl+D (EOF) or type 'quit' to quit.
695 """ % gpodder.__dict__).splitlines()))
697 if readline is not None:
698 readline.parse_and_bind('tab: complete')
699 readline.set_completer(self._tab_completion)
700 readline.set_completer_delims(' ')
702 while True:
703 try:
704 line = raw_input('gpo> ')
705 except EOFError:
706 safe_print('')
707 break
708 except KeyboardInterrupt:
709 safe_print('')
710 continue
712 if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
713 break
715 try:
716 args = shlex.split(line)
717 except ValueError, value_error:
718 self._error(_('Syntax error: %(error)s') %
719 {'error': value_error})
720 continue
722 try:
723 self._parse(args)
724 except KeyboardInterrupt:
725 self._error('Keyboard interrupt.')
726 except EOFError:
727 self._error('EOF.')
729 self._atexit()
731 def _error(self, *args):
732 safe_print(inred(' '.join(args)), file=sys.stderr)
734 # Warnings look like error messages for now
735 _warn = _error
737 def _info(self, *args):
738 safe_print(*args)
740 def _checkargs(self, func, command_line):
741 args, varargs, keywords, defaults = inspect.getargspec(func)
742 args.pop(0) # Remove "self" from args
743 defaults = defaults or ()
744 minarg, maxarg = len(args)-len(defaults), len(args)
746 if len(command_line) < minarg or (len(command_line) > maxarg and \
747 varargs is None):
748 self._error('Wrong argument count for %s.' % func.__name__)
749 return False
751 return func(*command_line)
753 def _tab_completion_podcast(self, text, count):
754 """Tab completion for podcast URLs"""
755 urls = [p.url for p in self._model.get_podcasts() if text in p.url]
756 if count < len(urls):
757 return urls[count]
759 return None
762 def _tab_completion(self, text, count):
763 """Tab completion function for readline"""
764 if readline is None:
765 return None
767 current_line = readline.get_line_buffer()
768 if text == current_line:
769 for name in self._valid_commands:
770 if name.startswith(text):
771 if count == 0:
772 return name
773 else:
774 count -= 1
775 else:
776 args = current_line.split()
777 command = args.pop(0)
778 command_function = getattr(self, command, None)
779 if not command_function:
780 return None
781 if getattr(command_function, '_first_arg_is_podcast', False):
782 if not args or (len(args) == 1 and not current_line.endswith(' ')):
783 return self._tab_completion_podcast(text, count)
785 return None
788 def _parse_single(self, command_line):
789 try:
790 result = self._parse(command_line)
791 except KeyboardInterrupt:
792 self._error('Keyboard interrupt.')
793 result = -1
794 self._atexit()
795 return result
797 def _parse(self, command_line):
798 if not command_line:
799 return False
801 command = command_line.pop(0)
803 # Resolve command aliases
804 command = self._prefixes.get(command, command)
806 if command in self._commands:
807 func = self._commands[command]
808 if inspect.ismethod(func):
809 return self._checkargs(func, command_line)
811 if command in self._expansions:
812 safe_print(_('Ambiguous command. Did you mean..'))
813 for cmd in self._expansions[command]:
814 safe_print(' ', inblue(cmd))
815 else:
816 self._error(_('The requested function is not available.'))
818 return False
821 def stylize(s):
822 s = re.sub(r' .{27}', lambda m: inblue(m.group(0)), s)
823 s = re.sub(r' - .*', lambda m: ingreen(m.group(0)), s)
824 return s
826 if __name__ == '__main__':
827 cli = gPodderCli()
828 args = sys.argv[1:]
829 if args:
830 is_single_command = True
831 cli._parse_single(args)
832 elif interactive_console:
833 cli._shell()
834 else:
835 safe_print(__doc__, end='')