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):
220 self._start_action(msg)
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):
275 line = util.convert_bytes(msg)
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.warning('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)
959 if task.notify_as_finished():
960 if self._config.device_sync.after_sync.mark_episodes_played:
961 logger.info('Marking as played on transfer: %s', task.episode.url)
962 task.episode.mark(is_played=True)
964 if self._config.device_sync.after_sync.delete_episodes:
965 logger.info('Removing episode after transfer: %s', task.episode.url)
966 task.episode.delete_from_disk()
970 done_lock = threading.Lock()
971 self.mygpo_client = my.MygPoClient(self._config)
972 sync_ui = gPodderSyncUI(self._config,
977 self._model.get_podcasts(),
978 DownloadStatusModel(),
979 DownloadQueueManager(),
982 _delete_episode_list,
984 _mount_volume_for_file)
986 sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
987 done_lock.acquire() # block until done
989 def _extensions_list(self):
990 def by_category(ext):
991 return ext.metadata.category
993 def by_enabled_name(ext):
994 return ('0' if ext.enabled else '1') + ext.name
996 for cat, extensions in itertools.groupby(sorted(gpodder.user_extensions.get_extensions(), key=by_category), by_category):
998 for ext in sorted(extensions, key=by_enabled_name):
1000 print(' ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
1002 print(' ', inblue(ext.name), ext.metadata.title)
1005 def _extensions_info(self, ext):
1007 print(inyellow(ext.name))
1009 print(inblue(ext.name))
1011 print(_('Title:'), ext.metadata.title)
1012 print(_('Category:'), _(ext.metadata.category))
1013 print(_('Description:'), ext.metadata.description)
1014 print(_('Authors:'), ext.metadata.authors)
1015 if ext.metadata.doc:
1016 print(_('Documentation:'), ext.metadata.doc)
1017 print(_('Enabled:'), _('yes') if ext.enabled else _('no'))
1021 def _extension_enable(self, container, new_enabled):
1022 if container.enabled == new_enabled:
1025 enabled_extensions = list(self._config.extensions.enabled)
1027 if new_enabled and container.name not in enabled_extensions:
1028 enabled_extensions.append(container.name)
1029 elif not new_enabled and container.name in enabled_extensions:
1030 enabled_extensions.remove(container.name)
1032 self._config.extensions.enabled = enabled_extensions
1034 now_enabled = (container.name in self._config.extensions.enabled)
1035 if new_enabled == now_enabled:
1037 if getattr(container, 'on_ui_initialized', None) is not None:
1038 container.on_ui_initialized(
1040 self._extensions_podcast_update_cb,
1041 self._extensions_episode_download_cb)
1042 enabled_str = _('enabled') if now_enabled else _('disabled')
1045 _('Extension %(name)s (%(title)s) %(enabled)s')
1046 % dict(name=container.name, title=container.metadata.title, enabled=enabled_str)))
1047 elif container.error is not None:
1048 if hasattr(container.error, 'message'):
1049 error_msg = container.error.message
1051 error_msg = str(container.error)
1052 self._error(_('Extension cannot be activated'))
1053 self._error(error_msg)
1057 def extensions(self, action='list', extension_name=None):
1058 if action in ('enable', 'disable', 'info'):
1059 if not extension_name:
1060 print(inred('E: extensions {} missing the extension name').format(action))
1063 for ext in gpodder.user_extensions.get_extensions():
1064 if ext.name == extension_name:
1068 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
1071 if action == 'list':
1072 return self._extensions_list()
1073 elif action in ('enable', 'disable'):
1074 self._extension_enable(extension, action == 'enable')
1075 elif action == 'info':
1076 self._extensions_info(extension)
1078 # -------------------------------------------------------------------
1080 def _pager(self, output):
1082 # Need two additional rows for command prompt
1083 rows_needed = len(output.splitlines()) + 2
1084 rows, cols = get_terminal_size()
1085 if rows_needed < rows:
1093 print(os.linesep.join(x.strip() for x in ("""
1094 gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1096 License: %(__license__)s
1098 Entering interactive shell. Type 'help' for help.
1099 Press Ctrl+D (EOF) or type 'quit' to quit.
1100 """ % gpodder.__dict__).splitlines()))
1104 if readline is not None:
1105 readline.parse_and_bind('tab: complete')
1106 readline.set_completer(self._tab_completion)
1107 readline.set_completer_delims(' ')
1111 line = input('gpo> ')
1115 except KeyboardInterrupt:
1119 if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1123 args = shlex.split(line)
1124 except ValueError as value_error:
1125 self._error(_('Syntax error: %(error)s') %
1126 {'error': value_error})
1131 except KeyboardInterrupt:
1132 self._error('Keyboard interrupt.')
1138 def _error(self, *args):
1139 print(inred(' '.join(args)), file=sys.stderr)
1141 # Warnings look like error messages for now
1144 def _info(self, *args):
1147 def _checkargs(self, func, command_line):
1148 argspec = inspect.getfullargspec(func)
1149 assert not argspec.kwonlyargs # keyword-only arguments are unsupported
1150 args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1151 args.pop(0) # Remove "self" from args
1152 defaults = defaults or ()
1153 minarg, maxarg = len(args) - len(defaults), len(args)
1155 if (len(command_line) < minarg or
1156 (len(command_line) > maxarg and varargs is None)):
1157 self._error('Wrong argument count for %s.' % func.__name__)
1160 return func(*command_line)
1162 def _tab_completion_podcast(self, text, count):
1163 """Tab completion for podcast URLs"""
1164 urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1165 if count < len(urls):
1170 def _tab_completion_in(self, text, count, choices):
1171 """Tab completion for a list of choices"""
1172 compat_choices = [c for c in choices if text in c]
1173 if count < len(compat_choices):
1174 return compat_choices[count]
1178 def _tab_completion_extensions(self, text, count):
1179 """Tab completion for extension names"""
1180 exts = [e.name for e in gpodder.user_extensions.get_extensions() if text in e.name]
1181 if count < len(exts):
1186 def _tab_completion(self, text, count):
1187 """Tab completion function for readline"""
1188 if readline is None:
1191 current_line = readline.get_line_buffer()
1192 if text == current_line:
1193 for name in self._valid_commands:
1194 if name.startswith(text):
1200 args = current_line.split()
1201 command = args.pop(0)
1202 command_function = getattr(self, command, None)
1203 if not command_function:
1205 if getattr(command_function, '_first_arg_is_podcast', False):
1206 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1207 return self._tab_completion_podcast(text, count)
1208 first_in = getattr(command_function, '_first_arg_in', False)
1210 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1211 return self._tab_completion_in(text, count, first_in)
1212 snd_ext = getattr(command_function, '_second_arg_is_extension', False)
1214 if (len(args) > 0 and len(args) < 2 and args[0] != 'list') or (len(args) == 2 and not current_line.endswith(' ')):
1215 return self._tab_completion_extensions(text, count)
1219 def _parse_single(self, command_line):
1221 result = self._parse(command_line)
1222 except KeyboardInterrupt:
1223 self._error('Keyboard interrupt.')
1228 def _parse(self, command_line):
1229 if not command_line:
1232 command = command_line.pop(0)
1234 # Resolve command aliases
1235 command = self._prefixes.get(command, command)
1237 if command in self._commands:
1238 func = self._commands[command]
1239 if inspect.ismethod(func):
1240 return self._checkargs(func, command_line)
1242 if command in self._expansions:
1243 print(_('Ambiguous command. Did you mean..'))
1244 for cmd in self._expansions[command]:
1245 print(' ', inblue(cmd))
1247 self._error(_('The requested function is not available.'))
1253 s = re.sub(r' .{27}', lambda m: inblue(m.group(0)), s)
1254 s = re.sub(r' - .*', lambda m: ingreen(m.group(0)), s)
1260 logger = logging.getLogger(__name__)
1262 msg = model.check_root_folder_path()
1264 print(msg, file=sys.stderr)
1267 is_single_command = True
1269 cli._parse_single(args)
1270 elif interactive_console:
1273 print(__doc__, end='')
1276 if __name__ == '__main__':