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
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
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
74 youtube URL Resolve the YouTube URL to a download URL
75 rewrite OLDURL NEWURL Change the feed URL of [OLDURL] to [NEWURL]
107 # A poor man's argparse/getopt - but it works for our use case :)
109 for flag in ('-v', '--verbose'):
111 sys.argv.remove(flag)
115 for flag in ('-q', '--quiet'):
117 sys.argv.remove(flag)
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
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):
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)
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)
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)
184 def get_terminal_size():
185 if None in (termios, fcntl, struct):
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)
195 class gPodderCli(object):
197 EXIT_COMMANDS = ('quit', 'exit', 'bye')
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(
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)
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))
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):
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']
245 return (n[:x] for x in range(len(n), 0, -1))
247 # Return True if the given prefix is unique in "names"
249 return len([n for n in names if n.startswith(p)]) == 1
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
260 if unique_expansion is not None:
261 expansions[prefix].append(unique_expansion)
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] + '...'
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):
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):
291 result = '[' + inyellow('SKIP') + ']'
293 result = '[' + ingreen('DONE') + ']'
295 result = '[' + inred('FAIL') + ']'
298 print('\r' + self._current_action + result)
301 self._current_action = ''
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.
322 url = util.normalize_feed_url(original_url)
324 self._error(_('Invalid url: %s') % original_url)
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
335 return self._model.load_podcast(
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')))
344 print(inyellow(_('Podcast requires authentication')))
345 print(inyellow(_('Please login to %s:') % (url,)))
346 username = input(_('User name:') + ' ')
348 password = input(_('Password:') + ' ')
350 auth_tokens[e.url] = (username, password)
357 # Load existing podcast
358 for podcast in self._model.get_podcasts():
359 if podcast.url == url:
363 self._error(_('You are not subscribed to %s.') % url)
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)
373 podcast = self.get_podcast(url, create=True)
375 self._error(_('Cannot subscribe to %s.') % url)
378 if title is not None:
379 podcast.rename(title)
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)
391 self._info(_('Successfully added %s.' % url))
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):
402 self._print_config(key)
406 current_value = self._config._lookup(key)
407 current_type = type(current_value)
409 self._error(_('This configuration option does not exist.'))
412 if current_type == dict:
413 self._error(_('Can only set leaf configuration nodes.'))
416 self._config.update_field(key, value)
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)
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),
434 @FirstArgumentIsPodcastURL
435 def unsubscribe(self, url):
436 podcast = self.get_podcast(url)
439 self._error(_('You are not subscribed to %s.') % url)
443 self._error(_('Unsubscribed from %s.') % url)
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):
453 if self.is_episode_new(episode):
456 if (episode.state == gpodder.STATE_DOWNLOADED):
459 if (episode.state == gpodder.STATE_DELETED):
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()))
472 @FirstArgumentIsPodcastURL
474 podcast = self.get_podcast(url)
477 self._error(_('You are not subscribed to %s.') % url)
479 def feed_update_status_msg(podcast):
480 if podcast.pause_subscription:
484 title, url, status = podcast.title, podcast.url, \
485 feed_update_status_msg(podcast)
486 episodes = self._episodesList(podcast)
487 episodes = '\n '.join(episodes)
491 Feed update is %(status)s
499 @FirstArgumentIsPodcastURL
500 def episodes(self, *args):
503 # TODO: Start using argparse for things like that
505 args.remove('--guid')
509 self._error(_('Invalid command.'))
513 if url.startswith('-'):
514 self._error(_('Invalid option: %s.') % (url,))
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)
528 """ % (podcast.url, episodes))
530 self._pager('\n'.join(output))
534 for podcast in self._model.get_podcasts():
535 if not podcast.pause_subscription:
536 print('#', ingreen(podcast.title))
538 print('#', inred(podcast.title),
539 '-', _('Updates disabled'))
545 def _update_podcast(self, podcast):
546 with self._action(' %s', podcast.title):
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):
556 print(_('Checking for new episodes'))
557 for podcast in self._model.get_podcasts():
558 if url is not None and podcast.url != url:
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))
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)))
573 @FirstArgumentIsPodcastURL
574 def pending(self, url=None):
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)
587 util.delete_empty_folders(gpodder.downloads)
588 print(inblue(self._pending_message(count)))
591 @FirstArgumentIsPodcastURL
592 def partial(self, *args):
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)
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(),
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
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))
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.')
645 @FirstArgumentIsPodcastURL
646 def download(self, url=None, guid=None):
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):
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(),
671 @FirstArgumentIsPodcastURL
672 def delete(self, url, guid):
673 podcast = self.get_podcast(url)
674 episode_to_delete = None
677 self._error(_('You are not subscribed to %s.') % url)
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.'))
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)
690 self._error(_('Episode has already been deleted.'))
694 @FirstArgumentIsPodcastURL
695 def disable(self, url):
696 podcast = self.get_podcast(url)
699 self._error(_('You are not subscribed to %s.') % url)
701 if not podcast.pause_subscription:
702 podcast.pause_subscription = True
705 self._error(_('Disabling feed update from %s.') % url)
709 @FirstArgumentIsPodcastURL
710 def enable(self, url):
711 podcast = self.get_podcast(url)
714 self._error(_('You are not subscribed to %s.') % url)
716 if podcast.pause_subscription:
717 podcast.pause_subscription = False
720 self._error(_('Enabling feed update from %s.') % url)
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)
733 def search(self, *terms):
734 query = ' '.join(terms)
738 directory = my.Directory()
739 results = directory.search(query)
740 self._show_directory_results(results)
743 directory = my.Directory()
744 results = directory.toplist()
745 self._show_directory_results(results, True)
747 def _show_directory_results(self, results, multiple=False):
749 self._error(_('No podcasts found.'))
752 if not interactive_console or is_single_command:
753 print('\n'.join(url for title, url in results))
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)))
763 msg = _('Enter index to subscribe, ? for list')
765 index = input(msg + ': ')
777 self._error(_('Invalid value.'))
780 if not (1 <= index <= len(results)):
781 self._error(_('Invalid value.'))
784 title, url = results[index - 1]
785 self._info(_('Adding %s...') % title)
790 @FirstArgumentIsPodcastURL
791 def rewrite(self, old_url, new_url):
792 podcast = self.get_podcast(old_url)
794 self._error(_('You are not subscribed to %s.') % old_url)
796 result = podcast.rewrite_url(new_url)
798 self._error(_('Invalid URL: %s') % new_url)
801 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
803 'new_url': new_url, })
807 print(stylize(__doc__), file=sys.stderr, end='')
811 def ep_repr(episode):
812 return '{} / {}'.format(episode.channel.title, episode.title)
814 def msg_title(title, message):
816 msg = '{}: {}'.format(title, message)
818 msg = '{}'.format(message)
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:
830 return not line or (line.lower() == _('yes'))
832 def _delete_episode_list(episodes, confirm=True, callback=None):
836 episodes = [e for e in episodes if not e.archive]
839 title = _('Episodes are locked')
841 'The selected episodes are locked. Please unlock the '
842 'episodes that you want to delete before trying '
844 _notification(message, title)
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):
855 print(_('Please wait while episodes are deleted'))
857 def finish_deletion(episode_urls, channel_urls):
858 # Episodes have been deleted - persist the database
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()
881 util.idle_add(finish_deletion, episode_urls, channel_urls)
883 util.idle_add(callback, episode_urls, channel_urls, None)
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]])
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))
900 msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
902 index = input(msg + ': ')
905 return callback([e for i, e in enumerate(episodes) if selected[i]])
911 selected = [True, ] * len(episodes)
915 selected = [False, ] * len(episodes)
922 self._error(_('Invalid value.'))
925 if not (1 <= index <= len(episodes)):
926 self._error(_('Invalid value.'))
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)))
934 self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
936 def _not_applicable(*args, **kwargs):
939 def _mount_volume_for_file(file):
940 result, message = util.mount_volume_for_file(file, None)
942 self._error(_('mounting volume for file %(file)s failed with: %(error)s'
943 % dict(file=file.get_uri(), error=message)))
946 class DownloadStatusModel(object):
947 def register_task(self, ask):
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)
960 done_lock = threading.Lock()
961 self.mygpo_client = my.MygPoClient(self._config)
962 sync_ui = gPodderSyncUI(self._config,
967 self._model.get_podcasts(),
968 DownloadStatusModel(),
969 DownloadQueueManager(),
972 _delete_episode_list,
974 _mount_volume_for_file)
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):
988 for ext in sorted(extensions, key=by_enabled_name):
990 print(' ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
992 print(' ', inblue(ext.name), ext.metadata.title)
995 def _extensions_info(self, ext):
997 print(inyellow(ext.name))
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'))
1011 def _extension_enable(self, container, new_enabled):
1012 if container.enabled == new_enabled:
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:
1027 if getattr(container, 'on_ui_initialized', None) is not None:
1028 container.on_ui_initialized(
1030 self._extensions_podcast_update_cb,
1031 self._extensions_episode_download_cb)
1032 enabled_str = _('enabled') if now_enabled else _('disabled')
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
1041 error_msg = str(container.error)
1042 self._error(_('Extension cannot be activated'))
1043 self._error(error_msg)
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))
1053 for ext in gpodder.user_extensions.get_extensions():
1054 if ext.name == extension_name:
1058 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
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)
1068 # -------------------------------------------------------------------
1070 def _pager(self, output):
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:
1083 print(os.linesep.join(x.strip() for x in ("""
1084 gPodder %(__version__)s (%(__date__)s) - %(__url__)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()))
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(' ')
1101 line = input('gpo> ')
1105 except KeyboardInterrupt:
1109 if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1113 args = shlex.split(line)
1114 except ValueError as value_error:
1115 self._error(_('Syntax error: %(error)s') %
1116 {'error': value_error})
1121 except KeyboardInterrupt:
1122 self._error('Keyboard interrupt.')
1128 def _error(self, *args):
1129 print(inred(' '.join(args)), file=sys.stderr)
1131 # Warnings look like error messages for now
1134 def _info(self, *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__)
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):
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]
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):
1176 def _tab_completion(self, text, count):
1177 """Tab completion function for readline"""
1178 if readline is 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):
1190 args = current_line.split()
1191 command = args.pop(0)
1192 command_function = getattr(self, command, None)
1193 if not command_function:
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)
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)
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)
1209 def _parse_single(self, command_line):
1211 result = self._parse(command_line)
1212 except KeyboardInterrupt:
1213 self._error('Keyboard interrupt.')
1218 def _parse(self, command_line):
1219 if not command_line:
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))
1237 self._error(_('The requested function is not available.'))
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)
1250 logger = logging.getLogger(__name__)
1252 msg = model.check_root_folder_path()
1254 print(msg, file=sys.stderr)
1257 is_single_command = True
1259 cli._parse_single(args)
1260 elif interactive_console:
1263 print(__doc__, end='')
1266 if __name__ == '__main__':