make messages
[gpodder.git] / bin / gpo
blobd24e1767029b735388f2c2686ff060d5af718426
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
5 # gPodder - A media aggregator and podcast client
6 # Copyright (c) 2005-2018 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|--quiet|-q] [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] [GUID]      Download new episodes (all or only from URL) or single GUID
51     delete [URL] [GUID]        Delete from feed at URL an episode with given GUID
52     pending [URL]              List new episodes (all or only from URL)
53     episodes [--guid] [URL]    List episodes with or without GUIDs (all or only from URL)
54     partial [--guid]           List partially downloaded episodes with or without GUIDs
55     resume [--guid]            Resume partially downloaded episodes or single GUID
57   - Episode management -
59     sync                       Sync podcasts to device
61   - Configuration -
63     set [key] [value]          List one (all) keys or set to a new value
65   - Extension management -
67     extensions [LIST]          List all available extensions
68     extension info <NAME>      Information about an extension
69     extension enable <NAME>    Enable an extension
70     extension disable <NAME>   Disable an extension
72   - Other commands -
74     youtube URL                Resolve the YouTube URL to a download URL
75     rewrite OLDURL NEWURL      Change the feed URL of [OLDURL] to [NEWURL]
77 """
80 import collections
81 import contextlib
82 import functools
83 import inspect
84 import itertools
85 import logging
86 import os
87 import pydoc
88 import re
89 import shlex
90 import sys
91 import threading
93 try:
94     import readline
95 except ImportError:
96     readline = None
98 try:
99     import fcntl
100     import struct
101     import termios
102 except ImportError:
103     fcntl = None
104     struct = None
105     termios = None
107 # A poor man's argparse/getopt - but it works for our use case :)
108 verbose = False
109 for flag in ('-v', '--verbose'):
110     if flag in sys.argv:
111         sys.argv.remove(flag)
112         verbose = True
113         break
114 quiet = False
115 for flag in ('-q', '--quiet'):
116     if flag in sys.argv:
117         sys.argv.remove(flag)
118         quiet = True
119         break
121 gpodder_script = sys.argv[0]
122 gpodder_script = os.path.realpath(gpodder_script)
123 gpodder_dir = os.path.join(os.path.dirname(gpodder_script), '..')
124 # TODO: Read parent directory links as well (/bin -> /usr/bin, like on Fedora, see Bug #1618)
125 # This would allow /usr/share/gpodder/ (not /share/gpodder/) to be found from /bin/gpodder
126 prefix = os.path.abspath(os.path.normpath(gpodder_dir))
128 src_dir = os.path.join(prefix, 'src')
130 if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
131     # Run gPodder from local source folder (not installed)
132     sys.path.insert(0, src_dir)
134 import gpodder  # isort:skip
136 from gpodder import log  # isort:skip
137 log.setup(verbose, quiet)
139 from gpodder import common, core, download, feedcore, model, my, opml, sync, util, youtube  # isort:skip
140 from gpodder.config import config_value_to_string  # isort:skip
141 from gpodder.syncui import gPodderSyncUI  # isort:skip
143 _ = gpodder.gettext
144 N_ = gpodder.ngettext
146 gpodder.images_folder = os.path.join(prefix, 'share', 'gpodder', 'images')
147 gpodder.prefix = prefix
149 # This is the command-line UI variant
150 gpodder.ui.cli = True
152 have_ansi = sys.stdout.isatty() and not gpodder.ui.win32
153 interactive_console = sys.stdin.isatty() and sys.stdout.isatty()
154 is_single_command = False
157 def noop(*args, **kwargs):
158     pass
161 def incolor(color_id, s):
162     if have_ansi and cli._config.ui.cli.colors:
163         return '\033[9%dm%s\033[0m' % (color_id, s)
164     return s
167 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
168 inred, ingreen, inyellow, inblue = (functools.partial(incolor, x) for x in range(1, 5))
171 def FirstArgumentIsPodcastURL(function):
172     """Decorator for functions that take a podcast URL as first arg"""
173     setattr(function, '_first_arg_is_podcast', True)
174     return function
177 def ExtensionsFunction(function):
178     """Decorator for functions that take an extension as second arg"""
179     setattr(function, '_first_arg_in', ('list', 'info', 'enable', 'disable'))
180     setattr(function, '_second_arg_is_extension', True)
181     return function
184 def get_terminal_size():
185     if None in (termios, fcntl, struct):
186         return (80, 24)
188     s = struct.pack('HHHH', 0, 0, 0, 0)
189     stdout = sys.stdout.fileno()
190     x = fcntl.ioctl(stdout, termios.TIOCGWINSZ, s)
191     rows, cols, xp, yp = struct.unpack('HHHH', x)
192     return rows, cols
195 class gPodderCli(object):
196     COLUMNS = 80
197     EXIT_COMMANDS = ('quit', 'exit', 'bye')
199     def __init__(self):
200         self.core = core.Core()
201         self._db = self.core.db
202         self._config = self.core.config
203         self._model = self.core.model
205         self._current_action = ''
206         self._commands = dict(
207             (name.rstrip('_'), func)
208             for name, func in inspect.getmembers(self)
209             if inspect.ismethod(func) and not name.startswith('_'))
210         self._prefixes, self._expansions = self._build_prefixes_expansions()
211         self._prefixes.update({'?': 'help'})
212         self._valid_commands = sorted(self._prefixes.values())
213         gpodder.user_extensions.on_ui_initialized(
214             self.core.model,
215             self._extensions_podcast_update_cb,
216             self._extensions_episode_download_cb)
218     @contextlib.contextmanager
219     def _action(self, msg, *args):
220         self._start_action(msg, *args)
221         try:
222             yield
223             self._finish_action()
224         except Exception as ex:
225             logger.warning('Action could not be completed', exc_info=True)
226             self._finish_action(False)
228     def _run_cleanups(self):
229         # Find expired (old) episodes and delete them
230         old_episodes = list(common.get_expired_episodes(self._model.get_podcasts(), self._config))
231         if old_episodes:
232             with self._action('Cleaning up old downloads'):
233                 for old_episode in old_episodes:
234                     old_episode.delete_from_disk()
236     def _build_prefixes_expansions(self):
237         prefixes = {}
238         expansions = collections.defaultdict(list)
239         names = sorted(self._commands.keys())
240         names.extend(self.EXIT_COMMANDS)
242         # Generator for all prefixes of a given string (longest first)
243         # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
244         def mkprefixes(n):
245             return (n[:x] for x in range(len(n), 0, -1))
247         # Return True if the given prefix is unique in "names"
248         def is_unique(p):
249             return len([n for n in names if n.startswith(p)]) == 1
251         for name in names:
252             is_still_unique = True
253             unique_expansion = None
254             for prefix in mkprefixes(name):
255                 if is_unique(prefix):
256                     unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
257                     prefixes[prefix] = name
258                     continue
260                 if unique_expansion is not None:
261                     expansions[prefix].append(unique_expansion)
262                     continue
264         return prefixes, expansions
266     def _extensions_podcast_update_cb(self, podcast):
267         self._info(_('Podcast update requested by extensions.'))
268         self._update_podcast(podcast)
270     def _extensions_episode_download_cb(self, episode):
271         self._info(_('Episode download requested by extensions.'))
272         self._download_episode(episode)
274     def _start_action(self, msg, *args):
275         line = util.convert_bytes(msg % args)
276         if len(line) > self.COLUMNS - 7:
277             line = line[:self.COLUMNS - 7 - 3] + '...'
278         else:
279             line = line + (' ' * (self.COLUMNS - 7 - len(line)))
280         self._current_action = line
281         print(self._current_action, end='')
283     def _update_action(self, progress):
284         if have_ansi:
285             progress = '%3.0f%%' % (progress * 100.,)
286             result = '[' + inblue(progress) + ']'
287             print('\r' + self._current_action + result, end='')
289     def _finish_action(self, success=True, skip=False):
290         if skip:
291             result = '[' + inyellow('SKIP') + ']'
292         elif success:
293             result = '[' + ingreen('DONE') + ']'
294         else:
295             result = '[' + inred('FAIL') + ']'
297         if have_ansi:
298             print('\r' + self._current_action + result)
299         else:
300             print(result)
301         self._current_action = ''
303     def _atexit(self):
304         self.core.shutdown()
306     # -------------------------------------------------------------------
308     def import_(self, url):
309         for channel in opml.Importer(url).items:
310             self.subscribe(channel['url'], channel.get('title'))
312     def export(self, filename):
313         podcasts = self._model.get_podcasts()
314         opml.Exporter(filename).write(podcasts)
316     def get_podcast(self, original_url, create=False, check_only=False):
317         """Get a specific podcast by URL
319         Returns a podcast object for the URL or None if
320         the podcast has not been subscribed to.
321         """
322         url = util.normalize_feed_url(original_url)
323         if url is None:
324             self._error(_('Invalid url: %s') % original_url)
325             return None
327         # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
328         url = youtube.parse_youtube_url(url)
330         # Subscribe to new podcast
331         if create:
332             auth_tokens = {}
333             while True:
334                 try:
335                     return self._model.load_podcast(
336                         url, create=True,
337                         authentication_tokens=auth_tokens.get(url, None),
338                         max_episodes=self._config.max_episodes_per_feed)
339                 except feedcore.AuthenticationRequired as e:
340                     if e.url in auth_tokens:
341                         print(inred(_('Wrong username/password')))
342                         return None
343                     else:
344                         print(inyellow(_('Podcast requires authentication')))
345                         print(inyellow(_('Please login to %s:') % (url,)))
346                         username = input(_('User name:') + ' ')
347                         if username:
348                             password = input(_('Password:') + ' ')
349                             if password:
350                                 auth_tokens[e.url] = (username, password)
351                                 url = e.url
352                             else:
353                                 return None
354                         else:
355                             return None
357         # Load existing podcast
358         for podcast in self._model.get_podcasts():
359             if podcast.url == url:
360                 return podcast
362         if not check_only:
363             self._error(_('You are not subscribed to %s.') % url)
364         return None
366     def subscribe(self, url, title=None):
367         existing = self.get_podcast(url, check_only=True)
368         if existing is not None:
369             self._error(_('Already subscribed to %s.') % existing.url)
370             return True
372         try:
373             podcast = self.get_podcast(url, create=True)
374             if podcast is None:
375                 self._error(_('Cannot subscribe to %s.') % url)
376                 return True
378             if title is not None:
379                 podcast.rename(title)
380             podcast.save()
381         except Exception as e:
382             logger.warn('Cannot subscribe: %s', e, exc_info=True)
383             if hasattr(e, 'strerror'):
384                 self._error(e.strerror)
385             else:
386                 self._error(str(e))
387             return True
389         self._db.commit()
391         self._info(_('Successfully added %s.' % url))
392         return True
394     def _print_config(self, search_for):
395         for key in self._config.all_keys():
396             if search_for is None or search_for.lower() in key.lower():
397                 value = config_value_to_string(self._config._lookup(key))
398                 print(key, '=', value)
400     def set(self, key=None, value=None):
401         if value is None:
402             self._print_config(key)
403             return
405         try:
406             current_value = self._config._lookup(key)
407             current_type = type(current_value)
408         except KeyError:
409             self._error(_('This configuration option does not exist.'))
410             return
412         if current_type == dict:
413             self._error(_('Can only set leaf configuration nodes.'))
414             return
416         self._config.update_field(key, value)
417         self.set(key)
419     @FirstArgumentIsPodcastURL
420     def rename(self, url, title):
421         podcast = self.get_podcast(url)
423         if podcast is not None:
424             old_title = podcast.title
425             podcast.rename(title)
426             self._db.commit()
427             self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
428                 'old_title': util.convert_bytes(old_title),
429                 'new_title': util.convert_bytes(title),
430             })
432         return True
434     @FirstArgumentIsPodcastURL
435     def unsubscribe(self, url):
436         podcast = self.get_podcast(url)
438         if podcast is None:
439             self._error(_('You are not subscribed to %s.') % url)
440         else:
441             podcast.delete()
442             self._db.commit()
443             self._error(_('Unsubscribed from %s.') % url)
445         return True
447     def is_episode_new(self, episode):
448         return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
450     def _episodesList(self, podcast, show_guid=False):
451         def status_str(episode):
452             # is new
453             if self.is_episode_new(episode):
454                 return ' * '
455             # is downloaded
456             if (episode.state == gpodder.STATE_DOWNLOADED):
457                 return ' ▉ '
458             # is deleted
459             if (episode.state == gpodder.STATE_DELETED):
460                 return ' ░ '
462             return '   '
464         def guid_str(episode):
465             return ((' %s' % episode.guid) if show_guid else '')
467         episodes = ('%3d.%s %s %s' % (i + 1, guid_str(e),
468                                       status_str(e), e.title)
469                     for i, e in enumerate(podcast.get_all_episodes()))
470         return episodes
472     @FirstArgumentIsPodcastURL
473     def info(self, url):
474         podcast = self.get_podcast(url)
476         if podcast is None:
477             self._error(_('You are not subscribed to %s.') % url)
478         else:
479             def feed_update_status_msg(podcast):
480                 if podcast.pause_subscription:
481                     return "disabled"
482                 return "enabled"
484             title, url, status = podcast.title, podcast.url, \
485                 feed_update_status_msg(podcast)
486             episodes = self._episodesList(podcast)
487             episodes = '\n      '.join(episodes)
488             self._pager("""
489     Title: %(title)s
490     URL: %(url)s
491     Feed update is %(status)s
493     Episodes:
494       %(episodes)s
495             """ % locals())
497         return True
499     @FirstArgumentIsPodcastURL
500     def episodes(self, *args):
501         show_guid = False
502         args = list(args)
503         # TODO: Start using argparse for things like that
504         if '--guid' in args:
505             args.remove('--guid')
506             show_guid = True
508         if len(args) > 1:
509             self._error(_('Invalid command.'))
510             return
511         elif len(args) == 1:
512             url = args[0]
513             if url.startswith('-'):
514                 self._error(_('Invalid option: %s.') % (url,))
515                 return
516         else:
517             url = None
519         output = []
520         for podcast in self._model.get_podcasts():
521             podcast_printed = False
522             if url is None or podcast.url == url:
523                 episodes = self._episodesList(podcast, show_guid=show_guid)
524                 episodes = '\n      '.join(episodes)
525                 output.append("""
526     Episodes from %s:
527       %s
528 """ % (podcast.url, episodes))
530         self._pager('\n'.join(output))
531         return True
533     def list(self):
534         for podcast in self._model.get_podcasts():
535             if not podcast.pause_subscription:
536                 print('#', ingreen(podcast.title))
537             else:
538                 print('#', inred(podcast.title),
539                       '-', _('Updates disabled'))
541             print(podcast.url)
543         return True
545     def _update_podcast(self, podcast):
546         with self._action(' %s', podcast.title):
547             podcast.update()
549     def _pending_message(self, count):
550         return N_('%(count)d new episode', '%(count)d new episodes',
551                   count) % {'count': count}
553     @FirstArgumentIsPodcastURL
554     def update(self, url=None):
555         count = 0
556         print(_('Checking for new episodes'))
557         for podcast in self._model.get_podcasts():
558             if url is not None and podcast.url != url:
559                 continue
561             if not podcast.pause_subscription:
562                 self._update_podcast(podcast)
563                 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
564             else:
565                 self._start_action(_('Skipping %(podcast)s') % {
566                     'podcast': podcast.title})
567                 self._finish_action(skip=True)
569         util.delete_empty_folders(gpodder.downloads)
570         print(inblue(self._pending_message(count)))
571         return True
573     @FirstArgumentIsPodcastURL
574     def pending(self, url=None):
575         count = 0
576         for podcast in self._model.get_podcasts():
577             podcast_printed = False
578             if url is None or podcast.url == url:
579                 for episode in podcast.get_all_episodes():
580                     if self.is_episode_new(episode):
581                         if not podcast_printed:
582                             print('#', ingreen(podcast.title))
583                             podcast_printed = True
584                         print(' ', episode.title)
585                         count += 1
587         util.delete_empty_folders(gpodder.downloads)
588         print(inblue(self._pending_message(count)))
589         return True
591     @FirstArgumentIsPodcastURL
592     def partial(self, *args):
593         def by_channel(e):
594             return e.channel.title
596         def guid_str(episode):
597             return (('%s ' % episode.guid) if show_guid else '')
599         def on_finish(resumable_episodes):
600             count = len(resumable_episodes)
601             resumable_episodes = sorted(resumable_episodes, key=by_channel)
602             last_channel = None
603             for e in resumable_episodes:
604                 if e.channel != last_channel:
605                     print('#', ingreen(e.channel.title))
606                     last_channel = e.channel
607                 print('  %s%s' % (guid_str(e), e.title))
608             print(inblue(N_('%(count)d partial file',
609                    '%(count)d partial files',
610                    count) % {'count': count}))
612         show_guid = '--guid' in args
614         common.find_partial_downloads(self._model.get_podcasts(),
615                                       noop,
616                                       noop,
617                                       on_finish)
618         return True
620     def _download_episode(self, episode):
621         with self._action('Downloading %s', episode.title):
622             task = download.DownloadTask(episode, self._config)
623             task.add_progress_callback(self._update_action)
624             task.status = download.DownloadTask.DOWNLOADING
625             task.run()
626             task.recycle()
628     def _download_episodes(self, episodes):
629         if self._config.downloads.chronological_order:
630             # download older episodes first
631             episodes = list(model.Model.sort_episodes_by_pubdate(episodes))
633         if episodes:
634             last_podcast = None
635             for episode in episodes:
636                 if episode.channel != last_podcast:
637                     print(inblue(episode.channel.title))
638                     last_podcast = episode.channel
639                 self._download_episode(episode)
641             util.delete_empty_folders(gpodder.downloads)
642         print(len(episodes), 'episodes downloaded.')
643         return True
645     @FirstArgumentIsPodcastURL
646     def download(self, url=None, guid=None):
647         episodes = []
648         for podcast in self._model.get_podcasts():
649             if url is None or podcast.url == url:
650                 for episode in podcast.get_all_episodes():
651                     if (not guid and self.is_episode_new(episode)) or (guid and episode.guid == guid):
652                         episodes.append(episode)
653         return self._download_episodes(episodes)
655     @FirstArgumentIsPodcastURL
656     def resume(self, guid=None):
657         def guid_str(episode):
658             return (('%s ' % episode.guid) if show_guid else '')
660         def on_finish(episodes):
661             if guid:
662                 episodes = [e for e in episodes if e.guid == guid]
663             self._download_episodes(episodes)
665         common.find_partial_downloads(self._model.get_podcasts(),
666                                       noop,
667                                       noop,
668                                       on_finish)
669         return True
671     @FirstArgumentIsPodcastURL
672     def delete(self, url, guid):
673         podcast = self.get_podcast(url)
674         episode_to_delete = None
676         if podcast is None:
677             self._error(_('You are not subscribed to %s.') % url)
678         else:
679             for episode in podcast.get_all_episodes():
680                 if (episode.guid == guid):
681                     episode_to_delete = episode
683             if not episode_to_delete:
684                 self._error(_('No episode with the specified GUID found.'))
685             else:
686                 if episode_to_delete.state != gpodder.STATE_DELETED:
687                     episode_to_delete.delete_from_disk()
688                     self._info(_('Deleted episode "%s".') % episode_to_delete.title)
689                 else:
690                     self._error(_('Episode has already been deleted.'))
692         return True
694     @FirstArgumentIsPodcastURL
695     def disable(self, url):
696         podcast = self.get_podcast(url)
698         if podcast is None:
699             self._error(_('You are not subscribed to %s.') % url)
700         else:
701             if not podcast.pause_subscription:
702                 podcast.pause_subscription = True
703                 podcast.save()
704             self._db.commit()
705             self._error(_('Disabling feed update from %s.') % url)
707         return True
709     @FirstArgumentIsPodcastURL
710     def enable(self, url):
711         podcast = self.get_podcast(url)
713         if podcast is None:
714             self._error(_('You are not subscribed to %s.') % url)
715         else:
716             if podcast.pause_subscription:
717                 podcast.pause_subscription = False
718                 podcast.save()
719             self._db.commit()
720             self._error(_('Enabling feed update from %s.') % url)
722         return True
724     def youtube(self, url):
725         fmt_ids = youtube.get_fmt_ids(self._config.youtube, False)
726         yurl, duration = youtube.get_real_download_url(url, False, fmt_ids)
727         if duration is not None:
728             episode.total_time = int(int(duration) / 1000)
729         print(yurl)
731         return True
733     def search(self, *terms):
734         query = ' '.join(terms)
735         if not query:
736             return
738         directory = my.Directory()
739         results = directory.search(query)
740         self._show_directory_results(results)
742     def toplist(self):
743         directory = my.Directory()
744         results = directory.toplist()
745         self._show_directory_results(results, True)
747     def _show_directory_results(self, results, multiple=False):
748         if not results:
749             self._error(_('No podcasts found.'))
750             return
752         if not interactive_console or is_single_command:
753             print('\n'.join(url for title, url in results))
754             return
756         def show_list():
757             self._pager('\n'.join(
758                 '%3d: %s\n     %s' % (index + 1, title, url if title != url else '')
759                 for index, (title, url) in enumerate(results)))
761         show_list()
763         msg = _('Enter index to subscribe, ? for list')
764         while True:
765             index = input(msg + ': ')
767             if not index:
768                 return
770             if index == '?':
771                 show_list()
772                 continue
774             try:
775                 index = int(index)
776             except ValueError:
777                 self._error(_('Invalid value.'))
778                 continue
780             if not (1 <= index <= len(results)):
781                 self._error(_('Invalid value.'))
782                 continue
784             title, url = results[index - 1]
785             self._info(_('Adding %s...') % title)
786             self.subscribe(url)
787             if not multiple:
788                 break
790     @FirstArgumentIsPodcastURL
791     def rewrite(self, old_url, new_url):
792         podcast = self.get_podcast(old_url)
793         if podcast is None:
794             self._error(_('You are not subscribed to %s.') % old_url)
795         else:
796             result = podcast.rewrite_url(new_url)
797             if result is None:
798                 self._error(_('Invalid URL: %s') % new_url)
799             else:
800                 new_url = result
801                 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
802                             {'old_url': old_url,
803                              'new_url': new_url, })
804         return True
806     def help(self):
807         print(stylize(__doc__), file=sys.stderr, end='')
808         return True
810     def sync(self):
811         def ep_repr(episode):
812             return '{} / {}'.format(episode.channel.title, episode.title)
814         def msg_title(title, message):
815             if title:
816                 msg = '{}: {}'.format(title, message)
817             else:
818                 msg = '{}'.format(message)
819             return msg
821         def _notification(message, title=None, important=False, widget=None):
822             print(msg_title(message, title))
824         def _show_confirmation(message, title=None):
825             msg = msg_title(message, title)
826             msg = _("%(title)s: %(msg)s ([yes]/no): ") % dict(title=title, msg=message)
827             if not interactive_console:
828                 return True
829             line = input(msg)
830             return not line or (line.lower() == _('yes'))
832         def _delete_episode_list(episodes, confirm=True, callback=None):
833             if not episodes:
834                 return False
836             episodes = [e for e in episodes if not e.archive]
838             if not episodes:
839                 title = _('Episodes are locked')
840                 message = _(
841                     'The selected episodes are locked. Please unlock the '
842                     'episodes that you want to delete before trying '
843                     'to delete them.')
844                 _notification(message, title)
845                 return False
847             count = len(episodes)
848             title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
849                        count) % {'count': count}
850             message = _('Deleting episodes removes downloaded files.')
852             if confirm and not _show_confirmation(message, title):
853                 return False
855             print(_('Please wait while episodes are deleted'))
857             def finish_deletion(episode_urls, channel_urls):
858                 # Episodes have been deleted - persist the database
859                 self.db.commit()
861             episode_urls = set()
862             channel_urls = set()
864             episodes_status_update = []
865             for idx, episode in enumerate(episodes):
866                 if not episode.archive:
867                     self._start_action(_('Deleting episode: %(episode)s') % {
868                             'episode': episode.title})
869                     episode.delete_from_disk()
870                     self._finish_action(success=True)
871                     episode_urls.add(episode.url)
872                     channel_urls.add(episode.channel.url)
873                     episodes_status_update.append(episode)
875             # Notify the web service about the status update + upload
876             if self.mygpo_client.can_access_webservice():
877                 self.mygpo_client.on_delete(episodes_status_update)
878                 self.mygpo_client.flush()
880             if callback is None:
881                 util.idle_add(finish_deletion, episode_urls, channel_urls)
882             else:
883                 util.idle_add(callback, episode_urls, channel_urls, None)
885             return True
887         def _episode_selector(parent_window, title=None, instructions=None, episodes=None,
888                               selected=None, columns=None, callback=None, _config=None):
889             if not interactive_console:
890                 return callback([e for i, e in enumerate(episodes) if selected[i]])
892             def show_list():
893                 self._pager('\n'.join(
894                     '[%s] %3d: %s' % (('X' if selected[index] else ' '), index + 1, ep_repr(e))
895                     for index, e in enumerate(episodes)))
897             print("{}. {}".format(title, instructions))
898             show_list()
900             msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
901             while True:
902                 index = input(msg + ': ')
904                 if not index:
905                     return callback([e for i, e in enumerate(episodes) if selected[i]])
907                 if index == '?':
908                     show_list()
909                     continue
910                 elif index == 'X':
911                     selected = [True, ] * len(episodes)
912                     show_list()
913                     continue
914                 elif index == ' ':
915                     selected = [False, ] * len(episodes)
916                     show_list()
917                     continue
918                 else:
919                     try:
920                         index = int(index)
921                     except ValueError:
922                         self._error(_('Invalid value.'))
923                         continue
925                     if not (1 <= index <= len(episodes)):
926                         self._error(_('Invalid value.'))
927                         continue
929                     e = episodes[index - 1]
930                     selected[index - 1] = not selected[index - 1]
931                     if selected[index - 1]:
932                         self._info(_('Will delete %(episode)s') % dict(episode=ep_repr(e)))
933                     else:
934                         self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
936         def _not_applicable(*args, **kwargs):
937             pass
939         def _mount_volume_for_file(file):
940             result, message = util.mount_volume_for_file(file, None)
941             if not result:
942                 self._error(_('mounting volume for file %(file)s failed with: %(error)s'
943                     % dict(file=file.get_uri(), error=message)))
944             return result
946         class DownloadStatusModel(object):
947             def register_task(self, ask):
948                 pass
950         class DownloadQueueManager(object):
951             def queue_task(x, task):
952                 def progress_updated(progress):
953                     self._update_action(progress)
954                 with self._action(_('Syncing %s'), ep_repr(task.episode)):
955                     task.status = sync.SyncTask.DOWNLOADING
956                     task.add_progress_callback(progress_updated)
957                     task.run()
958                     task.recycle()
960         done_lock = threading.Lock()
961         self.mygpo_client = my.MygPoClient(self._config)
962         sync_ui = gPodderSyncUI(self._config,
963                                 _notification,
964                                 None,
965                                 _show_confirmation,
966                                 _not_applicable,
967                                 self._model.get_podcasts(),
968                                 DownloadStatusModel(),
969                                 DownloadQueueManager(),
970                                 _not_applicable,
971                                 self._db.commit,
972                                 _delete_episode_list,
973                                 _episode_selector,
974                                 _mount_volume_for_file)
975         done_lock.acquire()
976         sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
977         done_lock.acquire()  # block until done
979     def _extensions_list(self):
980         def by_category(ext):
981             return ext.metadata.category
983         def by_enabled_name(ext):
984             return ('0' if ext.enabled else '1') + ext.name
986         for cat, extensions in itertools.groupby(sorted(gpodder.user_extensions.get_extensions(), key=by_category), by_category):
987             print(_(cat))
988             for ext in sorted(extensions, key=by_enabled_name):
989                 if ext.enabled:
990                     print('  ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
991                 else:
992                     print('  ', inblue(ext.name), ext.metadata.title)
993         return True
995     def _extensions_info(self, ext):
996         if ext.enabled:
997             print(inyellow(ext.name))
998         else:
999             print(inblue(ext.name))
1001         print(_('Title:'), ext.metadata.title)
1002         print(_('Category:'), _(ext.metadata.category))
1003         print(_('Description:'), ext.metadata.description)
1004         print(_('Authors:'), ext.metadata.authors)
1005         if ext.metadata.doc:
1006             print(_('Documentation:'), ext.metadata.doc)
1007         print(_('Enabled:'), _('yes') if ext.enabled else _('no'))
1009         return True
1011     def _extension_enable(self, container, new_enabled):
1012         if container.enabled == new_enabled:
1013             return True
1015         enabled_extensions = list(self._config.extensions.enabled)
1017         if new_enabled and container.name not in enabled_extensions:
1018             enabled_extensions.append(container.name)
1019         elif not new_enabled and container.name in enabled_extensions:
1020             enabled_extensions.remove(container.name)
1022         self._config.extensions.enabled = enabled_extensions
1024         now_enabled = (container.name in self._config.extensions.enabled)
1025         if new_enabled == now_enabled:
1026             if now_enabled:
1027                 if getattr(container, 'on_ui_initialized', None) is not None:
1028                     container.on_ui_initialized(
1029                         self.core.model,
1030                         self._extensions_podcast_update_cb,
1031                         self._extensions_episode_download_cb)
1032             enabled_str = _('enabled') if now_enabled else _('disabled')
1033             self._info(
1034                 inblue(
1035                     _('Extension %(name)s (%(title)s) %(enabled)s')
1036                     % dict(name=container.name, title=container.metadata.title, enabled=enabled_str)))
1037         elif container.error is not None:
1038             if hasattr(container.error, 'message'):
1039                 error_msg = container.error.message
1040             else:
1041                 error_msg = str(container.error)
1042             self._error(_('Extension cannot be activated'))
1043             self._error(error_msg)
1044         return True
1046     @ExtensionsFunction
1047     def extensions(self, action='list', extension_name=None):
1048         if action in ('enable', 'disable', 'info'):
1049             if not extension_name:
1050                 print(inred('E: extensions {} missing the extension name').format(action))
1051                 return False
1052             extension = None
1053             for ext in gpodder.user_extensions.get_extensions():
1054                 if ext.name == extension_name:
1055                     extension = ext
1056                     break
1057             if not extension:
1058                 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
1059                 return False
1061         if action == 'list':
1062             return self._extensions_list()
1063         elif action in ('enable', 'disable'):
1064             self._extension_enable(extension, action == 'enable')
1065         elif action == 'info':
1066             self._extensions_info(extension)
1067         return True
1068     # -------------------------------------------------------------------
1070     def _pager(self, output):
1071         if have_ansi:
1072             # Need two additional rows for command prompt
1073             rows_needed = len(output.splitlines()) + 2
1074             rows, cols = get_terminal_size()
1075             if rows_needed < rows:
1076                 print(output)
1077             else:
1078                 pydoc.pager(output)
1079         else:
1080             print(output)
1082     def _shell(self):
1083         print(os.linesep.join(x.strip() for x in ("""
1084         gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1085         %(__copyright__)s
1086         License: %(__license__)s
1088         Entering interactive shell. Type 'help' for help.
1089         Press Ctrl+D (EOF) or type 'quit' to quit.
1090         """ % gpodder.__dict__).splitlines()))
1092         cli._run_cleanups()
1094         if readline is not None:
1095             readline.parse_and_bind('tab: complete')
1096             readline.set_completer(self._tab_completion)
1097             readline.set_completer_delims(' ')
1099         while True:
1100             try:
1101                 line = input('gpo> ')
1102             except EOFError:
1103                 print('')
1104                 break
1105             except KeyboardInterrupt:
1106                 print('')
1107                 continue
1109             if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1110                 break
1112             try:
1113                 args = shlex.split(line)
1114             except ValueError as value_error:
1115                 self._error(_('Syntax error: %(error)s') %
1116                             {'error': value_error})
1117                 continue
1119             try:
1120                 self._parse(args)
1121             except KeyboardInterrupt:
1122                 self._error('Keyboard interrupt.')
1123             except EOFError:
1124                 self._error('EOF.')
1126         self._atexit()
1128     def _error(self, *args):
1129         print(inred(' '.join(args)), file=sys.stderr)
1131     # Warnings look like error messages for now
1132     _warn = _error
1134     def _info(self, *args):
1135         print(*args)
1137     def _checkargs(self, func, command_line):
1138         argspec = inspect.getfullargspec(func)
1139         assert not argspec.kwonlyargs  # keyword-only arguments are unsupported
1140         args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1141         args.pop(0)  # Remove "self" from args
1142         defaults = defaults or ()
1143         minarg, maxarg = len(args) - len(defaults), len(args)
1145         if (len(command_line) < minarg or
1146                 (len(command_line) > maxarg and varargs is None)):
1147             self._error('Wrong argument count for %s.' % func.__name__)
1148             return False
1150         return func(*command_line)
1152     def _tab_completion_podcast(self, text, count):
1153         """Tab completion for podcast URLs"""
1154         urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1155         if count < len(urls):
1156             return urls[count]
1158         return None
1160     def _tab_completion_in(self, text, count, choices):
1161         """Tab completion for a list of choices"""
1162         compat_choices = [c for c in choices if text in c]
1163         if count < len(compat_choices):
1164             return compat_choices[count]
1166         return None
1168     def _tab_completion_extensions(self, text, count):
1169         """Tab completion for extension names"""
1170         exts = [e.name for e in gpodder.user_extensions.get_extensions() if text in e.name]
1171         if count < len(exts):
1172             return exts[count]
1174         return None
1176     def _tab_completion(self, text, count):
1177         """Tab completion function for readline"""
1178         if readline is None:
1179             return None
1181         current_line = readline.get_line_buffer()
1182         if text == current_line:
1183             for name in self._valid_commands:
1184                 if name.startswith(text):
1185                     if count == 0:
1186                         return name
1187                     else:
1188                         count -= 1
1189         else:
1190             args = current_line.split()
1191             command = args.pop(0)
1192             command_function = getattr(self, command, None)
1193             if not command_function:
1194                 return None
1195             if getattr(command_function, '_first_arg_is_podcast', False):
1196                 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1197                     return self._tab_completion_podcast(text, count)
1198             first_in = getattr(command_function, '_first_arg_in', False)
1199             if first_in:
1200                 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1201                     return self._tab_completion_in(text, count, first_in)
1202             snd_ext = getattr(command_function, '_second_arg_is_extension', False)
1203             if snd_ext:
1204                 if (len(args) > 0 and len(args) < 2 and args[0] != 'list') or (len(args) == 2 and not current_line.endswith(' ')):
1205                     return self._tab_completion_extensions(text, count)
1207         return None
1209     def _parse_single(self, command_line):
1210         try:
1211             result = self._parse(command_line)
1212         except KeyboardInterrupt:
1213             self._error('Keyboard interrupt.')
1214             result = -1
1215         self._atexit()
1216         return result
1218     def _parse(self, command_line):
1219         if not command_line:
1220             return False
1222         command = command_line.pop(0)
1224         # Resolve command aliases
1225         command = self._prefixes.get(command, command)
1227         if command in self._commands:
1228             func = self._commands[command]
1229             if inspect.ismethod(func):
1230                 return self._checkargs(func, command_line)
1232         if command in self._expansions:
1233             print(_('Ambiguous command. Did you mean..'))
1234             for cmd in self._expansions[command]:
1235                 print('   ', inblue(cmd))
1236         else:
1237             self._error(_('The requested function is not available.'))
1239         return False
1242 def stylize(s):
1243     s = re.sub(r'    .{27}', lambda m: inblue(m.group(0)), s)
1244     s = re.sub(r'  - .*', lambda m: ingreen(m.group(0)), s)
1245     return s
1248 def main():
1249     global logger, cli
1250     logger = logging.getLogger(__name__)
1251     cli = gPodderCli()
1252     msg = model.check_root_folder_path()
1253     if msg:
1254         print(msg, file=sys.stderr)
1255     args = sys.argv[1:]
1256     if args:
1257         is_single_command = True
1258         cli._run_cleanups()
1259         cli._parse_single(args)
1260     elif interactive_console:
1261         cli._shell()
1262     else:
1263         print(__doc__, end='')
1266 if __name__ == '__main__':
1267     main()