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] [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
67 youtube URL Resolve the YouTube URL to a download URL
68 youtubefix Migrate old YouTube subscriptions to new feeds
69 rewrite OLDURL NEWURL Change the feed URL of [OLDURL] to [NEWURL]
100 # A poor man's argparse/getopt - but it works for our use case :)
102 for flag in ('-v', '--verbose'):
104 sys.argv.remove(flag)
108 gpodder_script = sys.argv[0]
109 gpodder_script = os.path.realpath(gpodder_script)
110 gpodder_dir = os.path.join(os.path.dirname(gpodder_script), '..')
111 # TODO: Read parent directory links as well (/bin -> /usr/bin, like on Fedora, see Bug #1618)
112 # This would allow /usr/share/gpodder/ (not /share/gpodder/) to be found from /bin/gpodder
113 prefix = os.path.abspath(os.path.normpath(gpodder_dir))
115 src_dir = os.path.join(prefix, 'src')
117 if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
118 # Run gPodder from local source folder (not installed)
119 sys.path.insert(0, src_dir)
121 import gpodder # isort:skip
122 from gpodder import common, core, download, feedcore, log, model, my, opml, sync, util, youtube # isort:skip
123 from gpodder.config import config_value_to_string # isort:skip
124 from gpodder.syncui import gPodderSyncUI # isort:skip
127 N_ = gpodder.ngettext
129 gpodder.images_folder = os.path.join(prefix, 'share', 'gpodder', 'images')
130 gpodder.prefix = prefix
132 # This is the command-line UI variant
133 gpodder.ui.cli = True
135 have_ansi = sys.stdout.isatty() and not gpodder.ui.win32
136 interactive_console = sys.stdin.isatty() and sys.stdout.isatty()
137 is_single_command = False
142 def noop(*args, **kwargs):
146 def incolor(color_id, s):
147 if have_ansi and cli._config.ui.cli.colors:
148 return '\033[9%dm%s\033[0m' % (color_id, s)
152 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
153 inred, ingreen, inyellow, inblue = (functools.partial(incolor, x) for x in range(1, 5))
156 def FirstArgumentIsPodcastURL(function):
157 """Decorator for functions that take a podcast URL as first arg"""
158 setattr(function, '_first_arg_is_podcast', True)
162 def get_terminal_size():
163 if None in (termios, fcntl, struct):
166 s = struct.pack('HHHH', 0, 0, 0, 0)
167 stdout = sys.stdout.fileno()
168 x = fcntl.ioctl(stdout, termios.TIOCGWINSZ, s)
169 rows, cols, xp, yp = struct.unpack('HHHH', x)
173 class gPodderCli(object):
175 EXIT_COMMANDS = ('quit', 'exit', 'bye')
178 self.core = core.Core()
179 self._db = self.core.db
180 self._config = self.core.config
181 self._model = self.core.model
183 self._current_action = ''
184 self._commands = dict(
185 (name.rstrip('_'), func)
186 for name, func in inspect.getmembers(self)
187 if inspect.ismethod(func) and not name.startswith('_'))
188 self._prefixes, self._expansions = self._build_prefixes_expansions()
189 self._prefixes.update({'?': 'help'})
190 self._valid_commands = sorted(self._prefixes.values())
191 gpodder.user_extensions.on_ui_initialized(
193 self._extensions_podcast_update_cb,
194 self._extensions_episode_download_cb)
196 @contextlib.contextmanager
197 def _action(self, msg, *args):
198 self._start_action(msg, *args)
201 self._finish_action()
202 except Exception as ex:
203 logger.warning('Action could not be completed', exc_info=True)
204 self._finish_action(False)
206 def _run_cleanups(self):
207 # Find expired (old) episodes and delete them
208 old_episodes = list(common.get_expired_episodes(self._model.get_podcasts(), self._config))
210 with self._action('Cleaning up old downloads'):
211 for old_episode in old_episodes:
212 old_episode.delete_from_disk()
214 def _build_prefixes_expansions(self):
216 expansions = collections.defaultdict(list)
217 names = sorted(self._commands.keys())
218 names.extend(self.EXIT_COMMANDS)
220 # Generator for all prefixes of a given string (longest first)
221 # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
223 return (n[:x] for x in range(len(n), 0, -1))
225 # Return True if the given prefix is unique in "names"
227 return len([n for n in names if n.startswith(p)]) == 1
230 is_still_unique = True
231 unique_expansion = None
232 for prefix in mkprefixes(name):
233 if is_unique(prefix):
234 unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
235 prefixes[prefix] = name
238 if unique_expansion is not None:
239 expansions[prefix].append(unique_expansion)
242 return prefixes, expansions
244 def _extensions_podcast_update_cb(self, podcast):
245 self._info(_('Podcast update requested by extensions.'))
246 self._update_podcast(podcast)
248 def _extensions_episode_download_cb(self, episode):
249 self._info(_('Episode download requested by extensions.'))
250 self._download_episode(episode)
252 def _start_action(self, msg, *args):
253 line = util.convert_bytes(msg % args)
254 if len(line) > self.COLUMNS - 7:
255 line = line[:self.COLUMNS - 7 - 3] + '...'
257 line = line + (' ' * (self.COLUMNS - 7 - len(line)))
258 self._current_action = line
259 print(self._current_action, end='')
261 def _update_action(self, progress):
263 progress = '%3.0f%%' % (progress * 100.,)
264 result = '[' + inblue(progress) + ']'
265 print('\r' + self._current_action + result, end='')
267 def _finish_action(self, success=True, skip=False):
269 result = '[' + inyellow('SKIP') + ']'
271 result = '[' + ingreen('DONE') + ']'
273 result = '[' + inred('FAIL') + ']'
276 print('\r' + self._current_action + result)
279 self._current_action = ''
284 # -------------------------------------------------------------------
286 def import_(self, url):
287 for channel in opml.Importer(url).items:
288 self.subscribe(channel['url'], channel.get('title'))
290 def export(self, filename):
291 podcasts = self._model.get_podcasts()
292 opml.Exporter(filename).write(podcasts)
294 def get_podcast(self, original_url, create=False, check_only=False):
295 """Get a specific podcast by URL
297 Returns a podcast object for the URL or None if
298 the podcast has not been subscribed to.
300 url = util.normalize_feed_url(original_url)
302 self._error(_('Invalid url: %s') % original_url)
305 # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
306 url = youtube.parse_youtube_url(url)
308 # Subscribe to new podcast
313 return self._model.load_podcast(
315 authentication_tokens=auth_tokens.get(url, None),
316 max_episodes=self._config.max_episodes_per_feed)
317 except feedcore.AuthenticationRequired as e:
318 if e.url in auth_tokens:
319 print(inred(_('Wrong username/password')))
322 print(inyellow(_('Podcast requires authentication')))
323 print(inyellow(_('Please login to %s:') % (url,)))
324 username = input(_('User name:') + ' ')
326 password = input(_('Password:') + ' ')
328 auth_tokens[e.url] = (username, password)
335 # Load existing podcast
336 for podcast in self._model.get_podcasts():
337 if podcast.url == url:
341 self._error(_('You are not subscribed to %s.') % url)
344 def subscribe(self, url, title=None):
345 existing = self.get_podcast(url, check_only=True)
346 if existing is not None:
347 self._error(_('Already subscribed to %s.') % existing.url)
351 podcast = self.get_podcast(url, create=True)
353 self._error(_('Cannot subscribe to %s.') % url)
356 if title is not None:
357 podcast.rename(title)
359 except Exception as e:
360 logger.warn('Cannot subscribe: %s', e, exc_info=True)
361 if hasattr(e, 'strerror'):
362 self._error(e.strerror)
369 self._info(_('Successfully added %s.' % url))
372 def _print_config(self, search_for):
373 for key in self._config.all_keys():
374 if search_for is None or search_for.lower() in key.lower():
375 value = config_value_to_string(self._config._lookup(key))
376 print(key, '=', value)
378 def set(self, key=None, value=None):
380 self._print_config(key)
384 current_value = self._config._lookup(key)
385 current_type = type(current_value)
387 self._error(_('This configuration option does not exist.'))
390 if current_type == dict:
391 self._error(_('Can only set leaf configuration nodes.'))
394 self._config.update_field(key, value)
397 @FirstArgumentIsPodcastURL
398 def rename(self, url, title):
399 podcast = self.get_podcast(url)
401 if podcast is not None:
402 old_title = podcast.title
403 podcast.rename(title)
405 self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
406 'old_title': util.convert_bytes(old_title),
407 'new_title': util.convert_bytes(title),
412 @FirstArgumentIsPodcastURL
413 def unsubscribe(self, url):
414 podcast = self.get_podcast(url)
417 self._error(_('You are not subscribed to %s.') % url)
421 self._error(_('Unsubscribed from %s.') % url)
425 def is_episode_new(self, episode):
426 return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
428 def _episodesList(self, podcast, show_guid=False):
429 def status_str(episode):
431 if self.is_episode_new(episode):
434 if (episode.state == gpodder.STATE_DOWNLOADED):
437 if (episode.state == gpodder.STATE_DELETED):
442 def guid_str(episode):
443 return ((' %s' % episode.guid) if show_guid else '')
445 episodes = ('%3d.%s %s %s' % (i + 1, guid_str(e),
446 status_str(e), e.title)
447 for i, e in enumerate(podcast.get_all_episodes()))
450 @FirstArgumentIsPodcastURL
452 podcast = self.get_podcast(url)
455 self._error(_('You are not subscribed to %s.') % url)
457 def feed_update_status_msg(podcast):
458 if podcast.pause_subscription:
462 title, url, status = podcast.title, podcast.url, \
463 feed_update_status_msg(podcast)
464 episodes = self._episodesList(podcast)
465 episodes = '\n '.join(episodes)
469 Feed update is %(status)s
477 @FirstArgumentIsPodcastURL
478 def episodes(self, *args):
481 # TODO: Start using argparse for things like that
483 args.remove('--guid')
487 self._error(_('Invalid command.'))
491 if url.startswith('-'):
492 self._error(_('Invalid option: %s.') % (url,))
498 for podcast in self._model.get_podcasts():
499 podcast_printed = False
500 if url is None or podcast.url == url:
501 episodes = self._episodesList(podcast, show_guid=show_guid)
502 episodes = '\n '.join(episodes)
506 """ % (podcast.url, episodes))
508 self._pager('\n'.join(output))
512 for podcast in self._model.get_podcasts():
513 if not podcast.pause_subscription:
514 print('#', ingreen(podcast.title))
516 print('#', inred(podcast.title),
517 '-', _('Updates disabled'))
523 def _update_podcast(self, podcast):
524 with self._action(' %s', podcast.title):
527 def _pending_message(self, count):
528 return N_('%(count)d new episode', '%(count)d new episodes',
529 count) % {'count': count}
531 @FirstArgumentIsPodcastURL
532 def update(self, url=None):
534 print(_('Checking for new episodes'))
535 for podcast in self._model.get_podcasts():
536 if url is not None and podcast.url != url:
539 if not podcast.pause_subscription:
540 self._update_podcast(podcast)
541 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
543 self._start_action(_('Skipping %(podcast)s') % {
544 'podcast': podcast.title})
545 self._finish_action(skip=True)
547 util.delete_empty_folders(gpodder.downloads)
548 print(inblue(self._pending_message(count)))
551 @FirstArgumentIsPodcastURL
552 def pending(self, url=None):
554 for podcast in self._model.get_podcasts():
555 podcast_printed = False
556 if url is None or podcast.url == url:
557 for episode in podcast.get_all_episodes():
558 if self.is_episode_new(episode):
559 if not podcast_printed:
560 print('#', ingreen(podcast.title))
561 podcast_printed = True
562 print(' ', episode.title)
565 util.delete_empty_folders(gpodder.downloads)
566 print(inblue(self._pending_message(count)))
569 @FirstArgumentIsPodcastURL
570 def partial(self, *args):
572 return e.channel.title
574 def guid_str(episode):
575 return (('%s ' % episode.guid) if show_guid else '')
577 def on_finish(resumable_episodes):
578 count = len(resumable_episodes)
579 resumable_episodes = sorted(resumable_episodes, key=by_channel)
581 for e in resumable_episodes:
582 if e.channel != last_channel:
583 print('#', ingreen(e.channel.title))
584 last_channel = e.channel
585 print(' %s%s' % (guid_str(e), e.title))
586 print(inblue(N_('%(count)d partial file',
587 '%(count)d partial files',
588 count) % {'count': count}))
590 show_guid = '--guid' in args
592 common.find_partial_downloads(self._model.get_podcasts(),
598 def _download_episode(self, episode):
599 with self._action('Downloading %s', episode.title):
600 task = download.DownloadTask(episode, self._config)
601 task.add_progress_callback(self._update_action)
602 task.status = download.DownloadTask.DOWNLOADING
605 def _download_episodes(self, episodes):
606 if self._config.downloads.chronological_order:
607 # download older episodes first
608 episodes = list(model.Model.sort_episodes_by_pubdate(episodes))
612 for episode in episodes:
613 if episode.channel != last_podcast:
614 print(inblue(episode.channel.title))
615 last_podcast = episode.channel
616 self._download_episode(episode)
618 util.delete_empty_folders(gpodder.downloads)
619 print(len(episodes), 'episodes downloaded.')
622 @FirstArgumentIsPodcastURL
623 def download(self, url=None, guid=None):
625 for podcast in self._model.get_podcasts():
626 if url is None or podcast.url == url:
627 for episode in podcast.get_all_episodes():
628 if (not guid and self.is_episode_new(episode)) or (guid and episode.guid == guid):
629 episodes.append(episode)
630 return self._download_episodes(episodes)
632 @FirstArgumentIsPodcastURL
633 def resume(self, guid=None):
634 def guid_str(episode):
635 return (('%s ' % episode.guid) if show_guid else '')
637 def on_finish(episodes):
639 episodes = [e for e in episodes if e.guid == guid]
640 self._download_episodes(episodes)
642 common.find_partial_downloads(self._model.get_podcasts(),
648 @FirstArgumentIsPodcastURL
649 def delete(self, url, guid):
650 podcast = self.get_podcast(url)
651 episode_to_delete = None
654 self._error(_('You are not subscribed to %s.') % url)
656 for episode in podcast.get_all_episodes():
657 if (episode.guid == guid):
658 episode_to_delete = episode
660 if not episode_to_delete:
661 self._error(_('No episode with the specified GUID found.'))
663 if episode_to_delete.state != gpodder.STATE_DELETED:
664 episode_to_delete.delete_from_disk()
665 self._info(_('Deleted episode "%s".') % episode_to_delete.title)
667 self._error(_('Episode has already been deleted.'))
671 @FirstArgumentIsPodcastURL
672 def disable(self, url):
673 podcast = self.get_podcast(url)
676 self._error(_('You are not subscribed to %s.') % url)
678 if not podcast.pause_subscription:
679 podcast.pause_subscription = True
682 self._error(_('Disabling feed update from %s.') % url)
686 @FirstArgumentIsPodcastURL
687 def enable(self, url):
688 podcast = self.get_podcast(url)
691 self._error(_('You are not subscribed to %s.') % url)
693 if podcast.pause_subscription:
694 podcast.pause_subscription = False
697 self._error(_('Enabling feed update from %s.') % url)
701 def youtube(self, url):
702 fmt_ids = youtube.get_fmt_ids(self._config.youtube)
703 yurl = youtube.get_real_download_url(url, fmt_ids)
708 def youtubefix(self):
709 if not self._config.youtube.api_key_v3:
710 self._error(_('Please register a YouTube API key and set it using %(command)s.') % {
711 'command': 'set youtube.api_key_v3 KEY',
715 reported_anything = False
716 for podcast in self._model.get_podcasts():
717 url, user = youtube.for_each_feed_pattern(lambda url, channel: (url, channel), podcast.url, (None, None))
718 if url is not None and user is not None:
720 logger.info('Getting channels for YouTube user %s (%s)', user, url)
721 new_urls = youtube.get_channels_for_user(user, self._config.youtube.api_key_v3)
722 logger.debug('YouTube channels retrieved: %r', new_urls)
724 if len(new_urls) != 1:
725 self._info('%s: %s' % (url, _('No unique URL found')))
726 reported_anything = True
729 new_url = new_urls[0]
730 if new_url in set(x.url for x in self._model.get_podcasts()):
731 self._info('%s: %s' % (url, _('Already subscribed')))
732 reported_anything = True
735 logger.info('New feed location: %s => %s', url, new_url)
737 self._info(_('Changing: %(old_url)s => %(new_url)s') % {'old_url': url, 'new_url': new_url})
738 reported_anything = True
739 podcast.url = new_url
741 except Exception as e:
742 logger.error('Exception happened while updating download list.', exc_info=True)
743 self._error(_('Make sure the API key is correct. Error: %(message)s') % {'message': str(e)})
746 if not reported_anything:
747 self._info(_('Nothing to fix'))
750 def search(self, *terms):
751 query = ' '.join(terms)
755 directory = my.Directory()
756 results = directory.search(query)
757 self._show_directory_results(results)
760 directory = my.Directory()
761 results = directory.toplist()
762 self._show_directory_results(results, True)
764 def _show_directory_results(self, results, multiple=False):
766 self._error(_('No podcasts found.'))
769 if not interactive_console or is_single_command:
770 print('\n'.join(url for title, url in results))
774 self._pager('\n'.join(
775 '%3d: %s\n %s' % (index + 1, title, url if title != url else '')
776 for index, (title, url) in enumerate(results)))
780 msg = _('Enter index to subscribe, ? for list')
782 index = input(msg + ': ')
794 self._error(_('Invalid value.'))
797 if not (1 <= index <= len(results)):
798 self._error(_('Invalid value.'))
801 title, url = results[index - 1]
802 self._info(_('Adding %s...') % title)
807 @FirstArgumentIsPodcastURL
808 def rewrite(self, old_url, new_url):
809 podcast = self.get_podcast(old_url)
811 self._error(_('You are not subscribed to %s.') % old_url)
813 result = podcast.rewrite_url(new_url)
815 self._error(_('Invalid URL: %s') % new_url)
818 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
820 'new_url': new_url, })
824 print(stylize(__doc__), file=sys.stderr, end='')
828 def ep_repr(episode):
829 return '{} / {}'.format(episode.channel.title, episode.title)
831 def msg_title(title, message):
833 msg = '{}: {}'.format(title, message)
835 msg = '{}'.format(message)
838 def _notification(message, title=None, important=False, widget=None):
839 print(msg_title(message, title))
841 def _show_confirmation(message, title=None):
842 msg = msg_title(message, title)
843 msg = _("%(title)s: %(msg)s ([yes]/no): ") % dict(title=title, msg=message)
844 if not interactive_console:
847 return not line or (line.lower() == _('yes'))
849 def _delete_episode_list(episodes, confirm=True, skip_locked=True, callback=None):
854 episodes = [e for e in episodes if not e.archive]
857 title = _('Episodes are locked')
859 'The selected episodes are locked. Please unlock the '
860 'episodes that you want to delete before trying '
862 _notification(message, title)
865 count = len(episodes)
866 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
867 count) % {'count': count}
868 message = _('Deleting episodes removes downloaded files.')
870 if confirm and not _show_confirmation(message, title):
873 print(_('Please wait while episodes are deleted'))
875 def finish_deletion(episode_urls, channel_urls):
876 # Episodes have been deleted - persist the database
882 episodes_status_update = []
883 for idx, episode in enumerate(episodes):
884 if not episode.archive or not skip_locked:
885 self._start_action(_('Deleting episode: %(episode)s') % {
886 'episode': episode.title})
887 episode.delete_from_disk()
888 self._finish_action(success=True)
889 episode_urls.add(episode.url)
890 channel_urls.add(episode.channel.url)
891 episodes_status_update.append(episode)
893 # Notify the web service about the status update + upload
894 if self.mygpo_client.can_access_webservice():
895 self.mygpo_client.on_delete(episodes_status_update)
896 self.mygpo_client.flush()
899 util.idle_add(finish_deletion, episode_urls, channel_urls)
901 util.idle_add(callback, episode_urls, channel_urls, None)
905 def _episode_selector(parent_window, title=None, instructions=None, episodes=None,
906 selected=None, columns=None, callback=None, _config=None):
907 if not interactive_console:
908 return callback([e for i, e in enumerate(episodes) if selected[i]])
911 self._pager('\n'.join(
912 '[%s] %3d: %s' % (('X' if selected[index] else ' '), index + 1, ep_repr(e))
913 for index, e in enumerate(episodes)))
915 print("{}. {}".format(title, instructions))
918 msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
920 index = input(msg + ': ')
923 return callback([e for i, e in enumerate(episodes) if selected[i]])
929 selected = [True, ] * len(episodes)
933 selected = [False, ] * len(episodes)
940 self._error(_('Invalid value.'))
943 if not (1 <= index <= len(episodes)):
944 self._error(_('Invalid value.'))
947 e = episodes[index - 1]
948 selected[index - 1] = not selected[index - 1]
949 if selected[index - 1]:
950 self._info(_('Will delete %(episode)s') % dict(episode=ep_repr(e)))
952 self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
954 def _not_applicable(*args, **kwargs):
957 class DownloadStatusModel(object):
958 def register_task(self, ask):
961 class DownloadQueueManager(object):
962 def queue_task(x, task):
963 def progress_updated(progress):
964 self._update_action(progress)
965 with self._action(_('Syncing %s'), ep_repr(task.episode)):
966 task.status = sync.SyncTask.DOWNLOADING
967 task.add_progress_callback(progress_updated)
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,
985 sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
986 done_lock.acquire() # block until done
988 # -------------------------------------------------------------------
990 def _pager(self, output):
992 # Need two additional rows for command prompt
993 rows_needed = len(output.splitlines()) + 2
994 rows, cols = get_terminal_size()
995 if rows_needed < rows:
1003 print(os.linesep.join(x.strip() for x in ("""
1004 gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1006 License: %(__license__)s
1008 Entering interactive shell. Type 'help' for help.
1009 Press Ctrl+D (EOF) or type 'quit' to quit.
1010 """ % gpodder.__dict__).splitlines()))
1014 if readline is not None:
1015 readline.parse_and_bind('tab: complete')
1016 readline.set_completer(self._tab_completion)
1017 readline.set_completer_delims(' ')
1021 line = input('gpo> ')
1025 except KeyboardInterrupt:
1029 if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1033 args = shlex.split(line)
1034 except ValueError as value_error:
1035 self._error(_('Syntax error: %(error)s') %
1036 {'error': value_error})
1041 except KeyboardInterrupt:
1042 self._error('Keyboard interrupt.')
1048 def _error(self, *args):
1049 print(inred(' '.join(args)), file=sys.stderr)
1051 # Warnings look like error messages for now
1054 def _info(self, *args):
1057 def _checkargs(self, func, command_line):
1058 argspec = inspect.getfullargspec(func)
1059 assert not argspec.kwonlyargs # keyword-only arguments are unsupported
1060 args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1061 args.pop(0) # Remove "self" from args
1062 defaults = defaults or ()
1063 minarg, maxarg = len(args) - len(defaults), len(args)
1065 if (len(command_line) < minarg or
1066 (len(command_line) > maxarg and varargs is None)):
1067 self._error('Wrong argument count for %s.' % func.__name__)
1070 return func(*command_line)
1072 def _tab_completion_podcast(self, text, count):
1073 """Tab completion for podcast URLs"""
1074 urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1075 if count < len(urls):
1080 def _tab_completion(self, text, count):
1081 """Tab completion function for readline"""
1082 if readline is None:
1085 current_line = readline.get_line_buffer()
1086 if text == current_line:
1087 for name in self._valid_commands:
1088 if name.startswith(text):
1094 args = current_line.split()
1095 command = args.pop(0)
1096 command_function = getattr(self, command, None)
1097 if not command_function:
1099 if getattr(command_function, '_first_arg_is_podcast', False):
1100 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1101 return self._tab_completion_podcast(text, count)
1105 def _parse_single(self, command_line):
1107 result = self._parse(command_line)
1108 except KeyboardInterrupt:
1109 self._error('Keyboard interrupt.')
1114 def _parse(self, command_line):
1115 if not command_line:
1118 command = command_line.pop(0)
1120 # Resolve command aliases
1121 command = self._prefixes.get(command, command)
1123 if command in self._commands:
1124 func = self._commands[command]
1125 if inspect.ismethod(func):
1126 return self._checkargs(func, command_line)
1128 if command in self._expansions:
1129 print(_('Ambiguous command. Did you mean..'))
1130 for cmd in self._expansions[command]:
1131 print(' ', inblue(cmd))
1133 self._error(_('The requested function is not available.'))
1139 s = re.sub(r' .{27}', lambda m: inblue(m.group(0)), s)
1140 s = re.sub(r' - .*', lambda m: ingreen(m.group(0)), s)
1146 logger = logging.getLogger(__name__)
1148 msg = model.check_root_folder_path()
1150 print(msg, file=sys.stderr)
1153 is_single_command = True
1155 cli._parse_single(args)
1156 elif interactive_console:
1159 print(__doc__, end='')
1162 if __name__ == '__main__':