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]
108 # A poor man's argparse/getopt - but it works for our use case :)
110 for flag in ('-v', '--verbose'):
112 sys.argv.remove(flag)
116 for flag in ('-q', '--quiet'):
118 sys.argv.remove(flag)
122 gpodder_script = sys.argv[0]
123 gpodder_script = os.path.realpath(gpodder_script)
124 gpodder_dir = os.path.join(os.path.dirname(gpodder_script), '..')
125 # TODO: Read parent directory links as well (/bin -> /usr/bin, like on Fedora, see Bug #1618)
126 # This would allow /usr/share/gpodder/ (not /share/gpodder/) to be found from /bin/gpodder
127 prefix = os.path.abspath(os.path.normpath(gpodder_dir))
129 src_dir = os.path.join(prefix, 'src')
131 if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
132 # Run gPodder from local source folder (not installed)
133 sys.path.insert(0, src_dir)
135 import gpodder # isort:skip
137 from gpodder import log # isort:skip
138 log.setup(verbose, quiet)
140 from gpodder import common, core, download, feedcore, model, my, opml, sync, util, youtube # isort:skip
141 from gpodder.config import config_value_to_string # isort:skip
142 from gpodder.syncui import gPodderSyncUI # isort:skip
145 N_ = gpodder.ngettext
147 gpodder.images_folder = os.path.join(prefix, 'share', 'gpodder', 'images')
148 gpodder.prefix = prefix
150 # This is the command-line UI variant
151 gpodder.ui.cli = True
153 have_ansi = sys.stdout.isatty() and not gpodder.ui.win32
154 interactive_console = sys.stdin.isatty() and sys.stdout.isatty()
155 is_single_command = False
158 def noop(*args, **kwargs):
162 def incolor(color_id, s):
163 if have_ansi and cli._config.ui.cli.colors:
164 return '\033[9%dm%s\033[0m' % (color_id, s)
168 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
169 inred, ingreen, inyellow, inblue = (functools.partial(incolor, x) for x in range(1, 5))
172 def FirstArgumentIsPodcastURL(function):
173 """Decorator for functions that take a podcast URL as first arg"""
174 setattr(function, '_first_arg_is_podcast', True)
178 def ExtensionsFunction(function):
179 """Decorator for functions that take an extension as second arg"""
180 setattr(function, '_first_arg_in', ('list', 'info', 'enable', 'disable'))
181 setattr(function, '_second_arg_is_extension', True)
185 def get_terminal_size():
186 if None in (termios, fcntl, struct):
189 s = struct.pack('HHHH', 0, 0, 0, 0)
190 stdout = sys.stdout.fileno()
191 x = fcntl.ioctl(stdout, termios.TIOCGWINSZ, s)
192 rows, cols, xp, yp = struct.unpack('HHHH', x)
196 class gPodderCli(object):
198 EXIT_COMMANDS = ('quit', 'exit', 'bye')
201 self.core = core.Core()
202 self._db = self.core.db
203 self._config = self.core.config
204 self._model = self.core.model
206 self._current_action = ''
207 self._commands = dict(
208 (name.rstrip('_'), func)
209 for name, func in inspect.getmembers(self)
210 if inspect.ismethod(func) and not name.startswith('_'))
211 self._prefixes, self._expansions = self._build_prefixes_expansions()
212 self._prefixes.update({'?': 'help'})
213 self._valid_commands = sorted(self._prefixes.values())
214 gpodder.user_extensions.on_ui_initialized(
216 self._extensions_podcast_update_cb,
217 self._extensions_episode_download_cb)
219 @contextlib.contextmanager
220 def _action(self, msg):
221 self._start_action(msg)
224 self._finish_action()
225 except Exception as ex:
226 logger.warning('Action could not be completed', exc_info=True)
227 self._finish_action(False)
229 def _run_cleanups(self):
230 # Find expired (old) episodes and delete them
231 old_episodes = list(common.get_expired_episodes(self._model.get_podcasts(), self._config))
233 with self._action('Cleaning up old downloads'):
234 for old_episode in old_episodes:
235 old_episode.delete_from_disk()
237 def _build_prefixes_expansions(self):
239 expansions = collections.defaultdict(list)
240 names = sorted(self._commands.keys())
241 names.extend(self.EXIT_COMMANDS)
243 # Generator for all prefixes of a given string (longest first)
244 # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
246 return (n[:x] for x in range(len(n), 0, -1))
248 # Return True if the given prefix is unique in "names"
250 return len([n for n in names if n.startswith(p)]) == 1
253 is_still_unique = True
254 unique_expansion = None
255 for prefix in mkprefixes(name):
256 if is_unique(prefix):
257 unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
258 prefixes[prefix] = name
261 if unique_expansion is not None:
262 expansions[prefix].append(unique_expansion)
265 return prefixes, expansions
267 def _extensions_podcast_update_cb(self, podcast):
268 self._info(_('Podcast update requested by extensions.'))
269 self._update_podcast(podcast)
271 def _extensions_episode_download_cb(self, episode):
272 self._info(_('Episode download requested by extensions.'))
273 self._download_episode(episode)
275 def _start_action(self, msg):
276 line = util.convert_bytes(msg)
277 if len(line) > self.COLUMNS - 7:
278 line = line[:self.COLUMNS - 7 - 3] + '...'
280 line = line + (' ' * (self.COLUMNS - 7 - len(line)))
281 self._current_action = line
282 print(self._current_action, end='')
284 def _update_action(self, progress):
286 progress = '%3.0f%%' % (progress * 100.,)
287 result = '[' + inblue(progress) + ']'
288 print('\r' + self._current_action + result, end='')
290 def _finish_action(self, success=True, skip=False):
292 result = '[' + inyellow('SKIP') + ']'
294 result = '[' + ingreen('DONE') + ']'
296 result = '[' + inred('FAIL') + ']'
299 print('\r' + self._current_action + result)
302 self._current_action = ''
307 # -------------------------------------------------------------------
309 def import_(self, url):
310 for channel in opml.Importer(url).items:
311 self.subscribe(channel['url'], channel.get('title'))
313 def export(self, filename):
314 podcasts = self._model.get_podcasts()
315 opml.Exporter(filename).write(podcasts)
317 def get_podcast(self, original_url, create=False, check_only=False):
318 """Get a specific podcast by URL
320 Returns a podcast object for the URL or None if
321 the podcast has not been subscribed to.
323 url = util.normalize_feed_url(original_url)
325 self._error(_('Invalid url: %s') % original_url)
328 # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
329 url = youtube.parse_youtube_url(url)
331 # Subscribe to new podcast
336 return self._model.load_podcast(
338 authentication_tokens=auth_tokens.get(url, None),
339 max_episodes=self._config.limit.episodes)
340 except feedcore.AuthenticationRequired as e:
341 if e.url in auth_tokens:
342 print(inred(_('Wrong username/password')))
345 print(inyellow(_('Podcast requires authentication')))
346 print(inyellow(_('Please login to %s:') % (url,)))
347 username = input(_('User name:') + ' ')
349 password = input(_('Password:') + ' ')
351 auth_tokens[e.url] = (username, password)
358 # Load existing podcast
359 for podcast in self._model.get_podcasts():
360 if podcast.url == url:
364 self._error(_('You are not subscribed to %s.') % url)
367 def subscribe(self, url, title=None):
368 existing = self.get_podcast(url, check_only=True)
369 if existing is not None:
370 self._error(_('Already subscribed to %s.') % existing.url)
374 podcast = self.get_podcast(url, create=True)
376 self._error(_('Cannot subscribe to %s.') % url)
379 if title is not None:
380 podcast.rename(title)
382 except Exception as e:
383 logger.warning('Cannot subscribe: %s', e, exc_info=True)
384 if hasattr(e, 'strerror'):
385 self._error(e.strerror)
392 self._info(_('Successfully added %s.' % url))
395 def _print_config(self, search_for):
396 for key in self._config.all_keys():
397 if search_for is None or search_for.lower() in key.lower():
398 value = config_value_to_string(self._config._lookup(key))
399 print(key, '=', value)
401 def set(self, key=None, value=None):
403 self._print_config(key)
407 current_value = self._config._lookup(key)
408 current_type = type(current_value)
410 self._error(_('This configuration option does not exist.'))
413 if current_type == dict:
414 self._error(_('Can only set leaf configuration nodes.'))
417 self._config.update_field(key, value)
420 @FirstArgumentIsPodcastURL
421 def rename(self, url, title):
422 podcast = self.get_podcast(url)
424 if podcast is not None:
425 old_title = podcast.title
426 podcast.rename(title)
428 self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
429 'old_title': util.convert_bytes(old_title),
430 'new_title': util.convert_bytes(title),
435 @FirstArgumentIsPodcastURL
436 def unsubscribe(self, url):
437 podcast = self.get_podcast(url)
440 self._error(_('You are not subscribed to %s.') % url)
442 # Clean up downloads and download directories
443 common.clean_up_downloads()
447 self._error(_('Unsubscribed from %s.') % url)
449 # Delete downloaded episodes
450 podcast.remove_downloaded()
452 # TODO: subscribe and unsubscribe need to sync with mygpo
453 # Upload subscription list changes to the web service
454 # self.mygpo_client.on_unsubscribe([podcast.url])
458 def is_episode_new(self, episode):
459 return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
461 def _episodesList(self, podcast, show_guid=False):
462 def status_str(episode):
464 if self.is_episode_new(episode):
467 if (episode.state == gpodder.STATE_DOWNLOADED):
470 if (episode.state == gpodder.STATE_DELETED):
475 def guid_str(episode):
476 return ((' %s' % episode.guid) if show_guid else '')
478 episodes = ('%3d.%s %s %s' % (i + 1, guid_str(e),
479 status_str(e), e.title)
480 for i, e in enumerate(podcast.get_all_episodes()))
483 @FirstArgumentIsPodcastURL
485 podcast = self.get_podcast(url)
488 self._error(_('You are not subscribed to %s.') % url)
490 def feed_update_status_msg(podcast):
491 if podcast.pause_subscription:
495 title, url, description, link, status = (
496 podcast.title, podcast.url, podcast.description, podcast.link,
497 feed_update_status_msg(podcast))
498 description = '\n'.join(textwrap.wrap(description, subsequent_indent=' ' * 8))
499 episodes = self._episodesList(podcast)
500 episodes = '\n '.join(episodes)
507 Feed update is %(status)s
515 @FirstArgumentIsPodcastURL
516 def episodes(self, *args):
519 # TODO: Start using argparse for things like that
521 args.remove('--guid')
525 self._error(_('Invalid command.'))
529 if url.startswith('-'):
530 self._error(_('Invalid option: %s.') % (url,))
536 for podcast in self._model.get_podcasts():
537 podcast_printed = False
538 if url is None or podcast.url == url:
539 episodes = self._episodesList(podcast, show_guid=show_guid)
540 episodes = '\n '.join(episodes)
544 """ % (podcast.url, episodes))
546 self._pager('\n'.join(output))
550 for podcast in self._model.get_podcasts():
551 if not podcast.pause_subscription:
552 print('#', ingreen(podcast.title))
554 print('#', inred(podcast.title),
555 '-', _('Updates disabled'))
561 def _update_podcast(self, podcast):
562 with self._action(' %s' % podcast.title):
565 def _pending_message(self, count):
566 return N_('%(count)d new episode', '%(count)d new episodes',
567 count) % {'count': count}
569 @FirstArgumentIsPodcastURL
570 def update(self, url=None):
572 print(_('Checking for new episodes'))
573 for podcast in self._model.get_podcasts():
574 if url is not None and podcast.url != url:
577 if not podcast.pause_subscription:
578 self._update_podcast(podcast)
579 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
581 self._start_action(_('Skipping %(podcast)s') % {
582 'podcast': podcast.title})
583 self._finish_action(skip=True)
585 util.delete_empty_folders(gpodder.downloads)
586 print(inblue(self._pending_message(count)))
589 @FirstArgumentIsPodcastURL
590 def pending(self, url=None):
592 for podcast in self._model.get_podcasts():
593 podcast_printed = False
594 if url is None or podcast.url == url:
595 for episode in podcast.get_all_episodes():
596 if self.is_episode_new(episode):
597 if not podcast_printed:
598 print('#', ingreen(podcast.title))
599 podcast_printed = True
600 print(' ', episode.title)
603 util.delete_empty_folders(gpodder.downloads)
604 print(inblue(self._pending_message(count)))
607 @FirstArgumentIsPodcastURL
608 def partial(self, *args):
610 return e.channel.title
612 def guid_str(episode):
613 return (('%s ' % episode.guid) if show_guid else '')
615 def on_finish(resumable_episodes):
616 count = len(resumable_episodes)
617 resumable_episodes = sorted(resumable_episodes, key=by_channel)
619 for e in resumable_episodes:
620 if e.channel != last_channel:
621 print('#', ingreen(e.channel.title))
622 last_channel = e.channel
623 print(' %s%s' % (guid_str(e), e.title))
624 print(inblue(N_('%(count)d partial file',
625 '%(count)d partial files',
626 count) % {'count': count}))
628 show_guid = '--guid' in args
630 common.find_partial_downloads(self._model.get_podcasts(),
637 def _download_episode(self, episode):
638 with self._action('Downloading %s' % episode.title):
639 if episode.download_task is None:
640 task = download.DownloadTask(episode, self._config)
642 task = episode.download_task
643 task.add_progress_callback(self._update_action)
644 task.status = download.DownloadTask.DOWNLOADING
648 def _download_episodes(self, episodes):
649 if self._config.downloads.chronological_order:
650 # download older episodes first
651 episodes = list(model.Model.sort_episodes_by_pubdate(episodes))
654 # Queue episodes to create partial files
656 if e.download_task is None:
657 download.DownloadTask(e, self._config)
660 for episode in episodes:
661 if episode.channel != last_podcast:
662 print(inblue(episode.channel.title))
663 last_podcast = episode.channel
664 self._download_episode(episode)
666 util.delete_empty_folders(gpodder.downloads)
667 print(len(episodes), 'episodes downloaded.')
670 @FirstArgumentIsPodcastURL
671 def download(self, url=None, guid=None):
673 for podcast in self._model.get_podcasts():
674 if url is None or podcast.url == url:
675 for episode in podcast.get_all_episodes():
676 if (not guid and self.is_episode_new(episode)) or (guid and episode.guid == guid):
677 episodes.append(episode)
678 return self._download_episodes(episodes)
680 @FirstArgumentIsPodcastURL
681 def resume(self, guid=None):
682 def guid_str(episode):
683 return (('%s ' % episode.guid) if show_guid else '')
685 def on_finish(episodes):
687 episodes = [e for e in episodes if e.guid == guid]
688 self._download_episodes(episodes)
690 common.find_partial_downloads(self._model.get_podcasts(),
697 @FirstArgumentIsPodcastURL
698 def delete(self, url, guid):
699 podcast = self.get_podcast(url)
700 episode_to_delete = None
703 self._error(_('You are not subscribed to %s.') % url)
705 for episode in podcast.get_all_episodes():
706 if (episode.guid == guid):
707 episode_to_delete = episode
709 if not episode_to_delete:
710 self._error(_('No episode with the specified GUID found.'))
712 if episode_to_delete.state != gpodder.STATE_DELETED:
713 episode_to_delete.delete_from_disk()
714 self._info(_('Deleted episode "%s".') % episode_to_delete.title)
716 self._error(_('Episode has already been deleted.'))
720 @FirstArgumentIsPodcastURL
721 def disable(self, url):
722 podcast = self.get_podcast(url)
725 self._error(_('You are not subscribed to %s.') % url)
727 if not podcast.pause_subscription:
728 podcast.pause_subscription = True
731 self._error(_('Disabling feed update from %s.') % url)
735 @FirstArgumentIsPodcastURL
736 def enable(self, url):
737 podcast = self.get_podcast(url)
740 self._error(_('You are not subscribed to %s.') % url)
742 if podcast.pause_subscription:
743 podcast.pause_subscription = False
746 self._error(_('Enabling feed update from %s.') % url)
750 def youtube(self, url):
751 fmt_ids = youtube.get_fmt_ids(self._config.youtube, False)
752 yurl, duration = youtube.get_real_download_url(url, False, fmt_ids)
753 if duration is not None:
754 episode.total_time = int(int(duration) / 1000)
759 def search(self, *terms):
760 query = ' '.join(terms)
764 directory = my.Directory()
765 results = directory.search(query)
766 self._show_directory_results(results)
769 directory = my.Directory()
770 results = directory.toplist()
771 self._show_directory_results(results, True)
773 def _show_directory_results(self, results, multiple=False):
775 self._error(_('No podcasts found.'))
778 if not interactive_console or is_single_command:
779 print('\n'.join(url for title, url in results))
783 self._pager('\n'.join(
784 '%3d: %s\n %s' % (index + 1, title, url if title != url else '')
785 for index, (title, url) in enumerate(results)))
789 msg = _('Enter index to subscribe, ? for list')
791 index = input(msg + ': ')
803 self._error(_('Invalid value.'))
806 if not (1 <= index <= len(results)):
807 self._error(_('Invalid value.'))
810 title, url = results[index - 1]
811 self._info(_('Adding %s...') % title)
816 @FirstArgumentIsPodcastURL
817 def rewrite(self, old_url, new_url):
818 podcast = self.get_podcast(old_url)
820 self._error(_('You are not subscribed to %s.') % old_url)
822 result = podcast.rewrite_url(new_url)
824 self._error(_('Invalid URL: %s') % new_url)
827 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
829 'new_url': new_url, })
833 print(stylize(__doc__), file=sys.stderr, end='')
837 def ep_repr(episode):
838 return '{} / {}'.format(episode.channel.title, episode.title)
840 def msg_title(title, message):
842 msg = '{}: {}'.format(title, message)
844 msg = '{}'.format(message)
847 def _notification(message, title=None, important=False, widget=None):
848 print(msg_title(message, title))
850 def _show_confirmation(message, title=None):
851 msg = msg_title(message, title)
852 msg = _("%(title)s: %(msg)s ([yes]/no): ") % dict(title=title, msg=message)
853 if not interactive_console:
856 return not line or (line.lower() == _('yes'))
858 def _delete_episode_list(episodes, confirm=True, callback=None):
862 episodes = [e for e in episodes if not e.archive]
865 title = _('Episodes are locked')
867 'The selected episodes are locked. Please unlock the '
868 'episodes that you want to delete before trying '
870 _notification(message, title)
873 count = len(episodes)
874 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
875 count) % {'count': count}
876 message = _('Deleting episodes removes downloaded files.')
878 if confirm and not _show_confirmation(message, title):
881 print(_('Please wait while episodes are deleted'))
883 def finish_deletion(episode_urls, channel_urls):
884 # Episodes have been deleted - persist the database
890 episodes_status_update = []
891 for idx, episode in enumerate(episodes):
892 if not episode.archive:
893 self._start_action(_('Deleting episode: %(episode)s') % {
894 'episode': episode.title})
895 episode.delete_from_disk()
896 self._finish_action(success=True)
897 episode_urls.add(episode.url)
898 channel_urls.add(episode.channel.url)
899 episodes_status_update.append(episode)
901 # Notify the web service about the status update + upload
902 if self.mygpo_client.can_access_webservice():
903 self.mygpo_client.on_delete(episodes_status_update)
904 self.mygpo_client.flush()
907 util.idle_add(finish_deletion, episode_urls, channel_urls)
909 util.idle_add(callback, episode_urls, channel_urls, None)
913 def _episode_selector(parent_window, title=None, instructions=None, episodes=None,
914 selected=None, columns=None, callback=None, _config=None):
915 if not interactive_console:
916 return callback([e for i, e in enumerate(episodes) if selected[i]])
919 self._pager('\n'.join(
920 '[%s] %3d: %s' % (('X' if selected[index] else ' '), index + 1, ep_repr(e))
921 for index, e in enumerate(episodes)))
923 print("{}. {}".format(title, instructions))
926 msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
928 index = input(msg + ': ')
931 return callback([e for i, e in enumerate(episodes) if selected[i]])
937 selected = [True, ] * len(episodes)
941 selected = [False, ] * len(episodes)
948 self._error(_('Invalid value.'))
951 if not (1 <= index <= len(episodes)):
952 self._error(_('Invalid value.'))
955 e = episodes[index - 1]
956 selected[index - 1] = not selected[index - 1]
957 if selected[index - 1]:
958 self._info(_('Will delete %(episode)s') % dict(episode=ep_repr(e)))
960 self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
962 def _not_applicable(*args, **kwargs):
965 def _mount_volume_for_file(file):
966 result, message = util.mount_volume_for_file(file, None)
968 self._error(_('mounting volume for file %(file)s failed with: %(error)s'
969 % dict(file=file.get_uri(), error=message)))
972 class DownloadStatusModel(object):
973 def register_task(self, ask):
976 class DownloadQueueManager(object):
977 def queue_task(x, task):
978 def progress_updated(progress):
979 self._update_action(progress)
980 with self._action(_('Syncing %s') % ep_repr(task.episode)):
981 task.status = sync.SyncTask.DOWNLOADING
982 task.add_progress_callback(progress_updated)
985 if task.notify_as_finished():
986 if self._config.device_sync.after_sync.mark_episodes_played:
987 logger.info('Marking as played on transfer: %s', task.episode.url)
988 task.episode.mark(is_played=True)
990 if self._config.device_sync.after_sync.delete_episodes:
991 logger.info('Removing episode after transfer: %s', task.episode.url)
992 task.episode.delete_from_disk()
996 done_lock = threading.Lock()
997 self.mygpo_client = my.MygPoClient(self._config)
998 sync_ui = gPodderSyncUI(self._config,
1003 self._model.get_podcasts(),
1004 DownloadStatusModel(),
1005 DownloadQueueManager(),
1008 _delete_episode_list,
1010 _mount_volume_for_file)
1012 sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
1013 done_lock.acquire() # block until done
1015 def _extensions_list(self):
1016 def by_category(ext):
1017 return ext.metadata.category
1019 def by_enabled_name(ext):
1020 return ('0' if ext.enabled else '1') + ext.name
1022 for cat, extensions in itertools.groupby(sorted(gpodder.user_extensions.get_extensions(), key=by_category), by_category):
1024 for ext in sorted(extensions, key=by_enabled_name):
1026 print(' ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
1028 print(' ', inblue(ext.name), ext.metadata.title)
1031 def _extensions_info(self, ext):
1033 print(inyellow(ext.name))
1035 print(inblue(ext.name))
1037 print(_('Title:'), ext.metadata.title)
1038 print(_('Category:'), _(ext.metadata.category))
1039 print(_('Description:'), ext.metadata.description)
1040 print(_('Authors:'), ext.metadata.authors)
1041 if ext.metadata.doc:
1042 print(_('Documentation:'), ext.metadata.doc)
1043 print(_('Enabled:'), _('yes') if ext.enabled else _('no'))
1047 def _extension_enable(self, container, new_enabled):
1048 if container.enabled == new_enabled:
1051 enabled_extensions = list(self._config.extensions.enabled)
1053 if new_enabled and container.name not in enabled_extensions:
1054 enabled_extensions.append(container.name)
1055 elif not new_enabled and container.name in enabled_extensions:
1056 enabled_extensions.remove(container.name)
1058 self._config.extensions.enabled = enabled_extensions
1060 now_enabled = (container.name in self._config.extensions.enabled)
1061 if new_enabled == now_enabled:
1063 if getattr(container, 'on_ui_initialized', None) is not None:
1064 container.on_ui_initialized(
1066 self._extensions_podcast_update_cb,
1067 self._extensions_episode_download_cb)
1068 enabled_str = _('enabled') if now_enabled else _('disabled')
1071 _('Extension %(name)s (%(title)s) %(enabled)s')
1072 % dict(name=container.name, title=container.metadata.title, enabled=enabled_str)))
1073 elif container.error is not None:
1074 if hasattr(container.error, 'message'):
1075 error_msg = container.error.message
1077 error_msg = str(container.error)
1078 self._error(_('Extension cannot be activated'))
1079 self._error(error_msg)
1083 def extensions(self, action='list', extension_name=None):
1084 if action in ('enable', 'disable', 'info'):
1085 if not extension_name:
1086 print(inred('E: extensions {} missing the extension name').format(action))
1089 for ext in gpodder.user_extensions.get_extensions():
1090 if ext.name == extension_name:
1094 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
1097 if action == 'list':
1098 return self._extensions_list()
1099 elif action in ('enable', 'disable'):
1100 self._extension_enable(extension, action == 'enable')
1101 elif action == 'info':
1102 self._extensions_info(extension)
1104 # -------------------------------------------------------------------
1106 def _pager(self, output):
1108 # Need two additional rows for command prompt
1109 rows_needed = len(output.splitlines()) + 2
1110 rows, cols = get_terminal_size()
1111 if rows_needed < rows:
1119 print(os.linesep.join(x.strip() for x in ("""
1120 gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1122 License: %(__license__)s
1124 Entering interactive shell. Type 'help' for help.
1125 Press Ctrl+D (EOF) or type 'quit' to quit.
1126 """ % gpodder.__dict__).splitlines()))
1130 if readline is not None:
1131 readline.parse_and_bind('tab: complete')
1132 readline.set_completer(self._tab_completion)
1133 readline.set_completer_delims(' ')
1137 line = input('gpo> ')
1141 except KeyboardInterrupt:
1145 if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1149 args = shlex.split(line)
1150 except ValueError as value_error:
1151 self._error(_('Syntax error: %(error)s') %
1152 {'error': value_error})
1157 except KeyboardInterrupt:
1158 self._error('Keyboard interrupt.')
1164 def _error(self, *args):
1165 print(inred(' '.join(args)), file=sys.stderr)
1167 # Warnings look like error messages for now
1170 def _info(self, *args):
1173 def _checkargs(self, func, command_line):
1174 argspec = inspect.getfullargspec(func)
1175 assert not argspec.kwonlyargs # keyword-only arguments are unsupported
1176 args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1177 args.pop(0) # Remove "self" from args
1178 defaults = defaults or ()
1179 minarg, maxarg = len(args) - len(defaults), len(args)
1181 if (len(command_line) < minarg
1182 or (len(command_line) > maxarg and varargs is None)):
1183 self._error('Wrong argument count for %s.' % func.__name__)
1186 return func(*command_line)
1188 def _tab_completion_podcast(self, text, count):
1189 """Tab completion for podcast URLs"""
1190 urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1191 if count < len(urls):
1196 def _tab_completion_in(self, text, count, choices):
1197 """Tab completion for a list of choices"""
1198 compat_choices = [c for c in choices if text in c]
1199 if count < len(compat_choices):
1200 return compat_choices[count]
1204 def _tab_completion_extensions(self, text, count):
1205 """Tab completion for extension names"""
1206 exts = [e.name for e in gpodder.user_extensions.get_extensions() if text in e.name]
1207 if count < len(exts):
1212 def _tab_completion(self, text, count):
1213 """Tab completion function for readline"""
1214 if readline is None:
1217 current_line = readline.get_line_buffer()
1218 if text == current_line:
1219 for name in self._valid_commands:
1220 if name.startswith(text):
1226 args = current_line.split()
1227 command = args.pop(0)
1228 command_function = getattr(self, command, None)
1229 if not command_function:
1231 if getattr(command_function, '_first_arg_is_podcast', False):
1232 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1233 return self._tab_completion_podcast(text, count)
1234 first_in = getattr(command_function, '_first_arg_in', False)
1236 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1237 return self._tab_completion_in(text, count, first_in)
1238 snd_ext = getattr(command_function, '_second_arg_is_extension', False)
1240 if (len(args) > 0 and len(args) < 2 and args[0] != 'list') or (len(args) == 2 and not current_line.endswith(' ')):
1241 return self._tab_completion_extensions(text, count)
1245 def _parse_single(self, command_line):
1247 result = self._parse(command_line)
1248 except KeyboardInterrupt:
1249 self._error('Keyboard interrupt.')
1254 def _parse(self, command_line):
1255 if not command_line:
1258 command = command_line.pop(0)
1260 # Resolve command aliases
1261 command = self._prefixes.get(command, command)
1263 if command in self._commands:
1264 func = self._commands[command]
1265 if inspect.ismethod(func):
1266 return self._checkargs(func, command_line)
1268 if command in self._expansions:
1269 print(_('Ambiguous command. Did you mean..'))
1270 for cmd in self._expansions[command]:
1271 print(' ', inblue(cmd))
1273 self._error(_('The requested function is not available.'))
1279 s = re.sub(r' .{27}', lambda m: inblue(m.group(0)), s)
1280 s = re.sub(r' - .*', lambda m: ingreen(m.group(0)), s)
1286 logger = logging.getLogger(__name__)
1288 msg = model.check_root_folder_path()
1290 print(msg, file=sys.stderr)
1293 is_single_command = True
1295 cli._parse_single(args)
1296 elif interactive_console:
1299 print(__doc__, end='')
1302 if __name__ == '__main__':