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 observer = gpodder.config.get_network_proxy_observer(self._config)
220 self._config.add_observer(observer)
221 # Trigger the global gpodder.config._proxies observer contraption to initialize it.
222 observer("network.", None, None)
224 @contextlib.contextmanager
225 def _action(self, msg):
226 self._start_action(msg)
229 self._finish_action()
230 except Exception as ex:
231 logger.warning('Action could not be completed', exc_info=True)
232 self._finish_action(False)
234 def _run_cleanups(self):
235 # Find expired (old) episodes and delete them
236 old_episodes = list(common.get_expired_episodes(self._model.get_podcasts(), self._config))
238 with self._action('Cleaning up old downloads'):
239 for old_episode in old_episodes:
240 old_episode.delete_from_disk()
242 def _build_prefixes_expansions(self):
244 expansions = collections.defaultdict(list)
245 names = sorted(self._commands.keys())
246 names.extend(self.EXIT_COMMANDS)
248 # Generator for all prefixes of a given string (longest first)
249 # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
251 return (n[:x] for x in range(len(n), 0, -1))
253 # Return True if the given prefix is unique in "names"
255 return len([n for n in names if n.startswith(p)]) == 1
258 is_still_unique = True
259 unique_expansion = None
260 for prefix in mkprefixes(name):
261 if is_unique(prefix):
262 unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
263 prefixes[prefix] = name
266 if unique_expansion is not None:
267 expansions[prefix].append(unique_expansion)
270 return prefixes, expansions
272 def _extensions_podcast_update_cb(self, podcast):
273 self._info(_('Podcast update requested by extensions.'))
274 self._update_podcast(podcast)
276 def _extensions_episode_download_cb(self, episode):
277 self._info(_('Episode download requested by extensions.'))
278 self._download_episode(episode)
280 def _start_action(self, msg):
281 line = util.convert_bytes(msg)
282 if len(line) > self.COLUMNS - 7:
283 line = line[:self.COLUMNS - 7 - 3] + '...'
285 line = line + (' ' * (self.COLUMNS - 7 - len(line)))
286 self._current_action = line
287 print(self._current_action, end='')
289 def _update_action(self, progress):
291 progress = '%3.0f%%' % (progress * 100.,)
292 result = '[' + inblue(progress) + ']'
293 print('\r' + self._current_action + result, end='')
295 def _finish_action(self, success=True, skip=False):
297 result = '[' + inyellow('SKIP') + ']'
299 result = '[' + ingreen('DONE') + ']'
301 result = '[' + inred('FAIL') + ']'
304 print('\r' + self._current_action + result)
307 self._current_action = ''
312 # -------------------------------------------------------------------
314 def import_(self, url):
315 for channel in opml.Importer(url).items:
316 self.subscribe(channel['url'], channel.get('title'))
318 def export(self, filename):
319 podcasts = self._model.get_podcasts()
320 opml.Exporter(filename).write(podcasts)
322 def get_podcast(self, original_url, create=False, check_only=False):
323 """Get a specific podcast by URL
325 Returns a podcast object for the URL or None if
326 the podcast has not been subscribed to.
328 url = util.normalize_feed_url(original_url)
330 self._error(_('Invalid url: %s') % original_url)
333 # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
334 url = youtube.parse_youtube_url(url)
336 # Subscribe to new podcast
341 return self._model.load_podcast(
343 authentication_tokens=auth_tokens.get(url, None),
344 max_episodes=self._config.limit.episodes)
345 except feedcore.AuthenticationRequired as e:
346 if e.url in auth_tokens:
347 print(inred(_('Wrong username/password')))
350 print(inyellow(_('Podcast requires authentication')))
351 print(inyellow(_('Please login to %s:') % (url,)))
352 username = input(_('User name:') + ' ')
354 password = input(_('Password:') + ' ')
356 auth_tokens[e.url] = (username, password)
363 # Load existing podcast
364 for podcast in self._model.get_podcasts():
365 if podcast.url == url:
369 self._error(_('You are not subscribed to %s.') % url)
372 def subscribe(self, url, title=None):
373 existing = self.get_podcast(url, check_only=True)
374 if existing is not None:
375 self._error(_('Already subscribed to %s.') % existing.url)
379 podcast = self.get_podcast(url, create=True)
381 self._error(_('Cannot subscribe to %s.') % url)
384 if title is not None:
385 podcast.rename(title)
387 except Exception as e:
388 logger.warning('Cannot subscribe: %s', e, exc_info=True)
389 if hasattr(e, 'strerror'):
390 self._error(e.strerror)
397 self._info(_('Successfully added %s.' % url))
400 def _print_config(self, search_for):
401 for key in self._config.all_keys():
402 if search_for is None or search_for.lower() in key.lower():
403 value = config_value_to_string(self._config._lookup(key))
404 print(key, '=', value)
406 def set(self, key=None, value=None):
408 self._print_config(key)
412 current_value = self._config._lookup(key)
413 current_type = type(current_value)
415 self._error(_('This configuration option does not exist.'))
418 if current_type == dict:
419 self._error(_('Can only set leaf configuration nodes.'))
422 self._config.update_field(key, value)
425 @FirstArgumentIsPodcastURL
426 def rename(self, url, title):
427 podcast = self.get_podcast(url)
429 if podcast is not None:
430 old_title = podcast.title
431 podcast.rename(title)
433 self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
434 'old_title': util.convert_bytes(old_title),
435 'new_title': util.convert_bytes(title),
440 @FirstArgumentIsPodcastURL
441 def unsubscribe(self, url):
442 podcast = self.get_podcast(url)
445 self._error(_('You are not subscribed to %s.') % url)
447 # Clean up downloads and download directories
448 common.clean_up_downloads()
452 self._error(_('Unsubscribed from %s.') % url)
454 # Delete downloaded episodes
455 podcast.remove_downloaded()
457 # TODO: subscribe and unsubscribe need to sync with mygpo
458 # Upload subscription list changes to the web service
459 # self.mygpo_client.on_unsubscribe([podcast.url])
463 def is_episode_new(self, episode):
464 return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
466 def _episodesList(self, podcast, show_guid=False):
467 def status_str(episode):
469 if self.is_episode_new(episode):
472 if (episode.state == gpodder.STATE_DOWNLOADED):
475 if (episode.state == gpodder.STATE_DELETED):
480 def guid_str(episode):
481 return ((' %s' % episode.guid) if show_guid else '')
483 episodes = ('%3d.%s %s %s' % (i + 1, guid_str(e),
484 status_str(e), e.title)
485 for i, e in enumerate(podcast.get_all_episodes()))
488 @FirstArgumentIsPodcastURL
490 podcast = self.get_podcast(url)
493 self._error(_('You are not subscribed to %s.') % url)
495 def feed_update_status_msg(podcast):
496 if podcast.pause_subscription:
500 title, url, description, link, status = (
501 podcast.title, podcast.url, podcast.description, podcast.link,
502 feed_update_status_msg(podcast))
503 description = '\n'.join(textwrap.wrap(description, subsequent_indent=' ' * 8))
504 episodes = self._episodesList(podcast)
505 episodes = '\n '.join(episodes)
512 Feed update is %(status)s
520 @FirstArgumentIsPodcastURL
521 def episodes(self, *args):
524 # TODO: Start using argparse for things like that
526 args.remove('--guid')
530 self._error(_('Invalid command.'))
534 if url.startswith('-'):
535 self._error(_('Invalid option: %s.') % (url,))
541 for podcast in self._model.get_podcasts():
542 podcast_printed = False
543 if url is None or podcast.url == url:
544 episodes = self._episodesList(podcast, show_guid=show_guid)
545 episodes = '\n '.join(episodes)
549 """ % (podcast.url, episodes))
551 self._pager('\n'.join(output))
555 for podcast in self._model.get_podcasts():
556 if not podcast.pause_subscription:
557 print('#', ingreen(podcast.title))
559 print('#', inred(podcast.title),
560 '-', _('Updates disabled'))
566 def _update_podcast(self, podcast):
567 with self._action(' %s' % podcast.title):
570 def _pending_message(self, count):
571 return N_('%(count)d new episode', '%(count)d new episodes',
572 count) % {'count': count}
574 @FirstArgumentIsPodcastURL
575 def update(self, url=None):
577 print(_('Checking for new episodes'))
578 for podcast in self._model.get_podcasts():
579 if url is not None and podcast.url != url:
582 if not podcast.pause_subscription:
583 self._update_podcast(podcast)
584 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
586 self._start_action(_('Skipping %(podcast)s') % {
587 'podcast': podcast.title})
588 self._finish_action(skip=True)
590 util.delete_empty_folders(gpodder.downloads)
591 print(inblue(self._pending_message(count)))
594 @FirstArgumentIsPodcastURL
595 def pending(self, url=None):
597 for podcast in self._model.get_podcasts():
598 podcast_printed = False
599 if url is None or podcast.url == url:
600 for episode in podcast.get_all_episodes():
601 if self.is_episode_new(episode):
602 if not podcast_printed:
603 print('#', ingreen(podcast.title))
604 podcast_printed = True
605 print(' ', episode.title)
608 util.delete_empty_folders(gpodder.downloads)
609 print(inblue(self._pending_message(count)))
612 @FirstArgumentIsPodcastURL
613 def partial(self, *args):
615 return e.channel.title
617 def guid_str(episode):
618 return (('%s ' % episode.guid) if show_guid else '')
620 def on_finish(resumable_episodes):
621 count = len(resumable_episodes)
622 resumable_episodes = sorted(resumable_episodes, key=by_channel)
624 for e in resumable_episodes:
625 if e.channel != last_channel:
626 print('#', ingreen(e.channel.title))
627 last_channel = e.channel
628 print(' %s%s' % (guid_str(e), e.title))
629 print(inblue(N_('%(count)d partial file',
630 '%(count)d partial files',
631 count) % {'count': count}))
633 show_guid = '--guid' in args
635 common.find_partial_downloads(self._model.get_podcasts(),
642 def _download_episode(self, episode):
643 with self._action('Downloading %s' % episode.title):
644 if episode.download_task is None:
645 task = download.DownloadTask(episode, self._config)
647 task = episode.download_task
648 task.add_progress_callback(self._update_action)
649 task.status = download.DownloadTask.DOWNLOADING
653 def _download_episodes(self, episodes):
654 if self._config.downloads.chronological_order:
655 # download older episodes first
656 episodes = list(model.Model.sort_episodes_by_pubdate(episodes))
659 # Queue episodes to create partial files
661 if e.download_task is None:
662 download.DownloadTask(e, self._config)
665 for episode in episodes:
666 if episode.channel != last_podcast:
667 print(inblue(episode.channel.title))
668 last_podcast = episode.channel
669 self._download_episode(episode)
671 util.delete_empty_folders(gpodder.downloads)
672 print(len(episodes), 'episodes downloaded.')
675 @FirstArgumentIsPodcastURL
676 def download(self, url=None, guid=None):
678 for podcast in self._model.get_podcasts():
679 if url is None or podcast.url == url:
680 for episode in podcast.get_all_episodes():
681 if (not guid and self.is_episode_new(episode)) or (guid and episode.guid == guid):
682 episodes.append(episode)
683 return self._download_episodes(episodes)
685 @FirstArgumentIsPodcastURL
686 def resume(self, guid=None):
687 def guid_str(episode):
688 return (('%s ' % episode.guid) if show_guid else '')
690 def on_finish(episodes):
692 episodes = [e for e in episodes if e.guid == guid]
693 self._download_episodes(episodes)
695 common.find_partial_downloads(self._model.get_podcasts(),
702 @FirstArgumentIsPodcastURL
703 def delete(self, url, guid):
704 podcast = self.get_podcast(url)
705 episode_to_delete = None
708 self._error(_('You are not subscribed to %s.') % url)
710 for episode in podcast.get_all_episodes():
711 if (episode.guid == guid):
712 episode_to_delete = episode
714 if not episode_to_delete:
715 self._error(_('No episode with the specified GUID found.'))
717 if episode_to_delete.state != gpodder.STATE_DELETED:
718 episode_to_delete.delete_from_disk()
719 self._info(_('Deleted episode "%s".') % episode_to_delete.title)
721 self._error(_('Episode has already been deleted.'))
725 @FirstArgumentIsPodcastURL
726 def disable(self, url):
727 podcast = self.get_podcast(url)
730 self._error(_('You are not subscribed to %s.') % url)
732 if not podcast.pause_subscription:
733 podcast.pause_subscription = True
736 self._error(_('Disabling feed update from %s.') % url)
740 @FirstArgumentIsPodcastURL
741 def enable(self, url):
742 podcast = self.get_podcast(url)
745 self._error(_('You are not subscribed to %s.') % url)
747 if podcast.pause_subscription:
748 podcast.pause_subscription = False
751 self._error(_('Enabling feed update from %s.') % url)
755 def youtube(self, url):
756 fmt_ids = youtube.get_fmt_ids(self._config.youtube, False)
757 yurl, duration = youtube.get_real_download_url(url, False, fmt_ids)
758 if duration is not None:
759 episode.total_time = int(int(duration) / 1000)
764 def search(self, *terms):
765 query = ' '.join(terms)
769 directory = my.Directory()
770 results = directory.search(query)
771 self._show_directory_results(results)
774 directory = my.Directory()
775 results = directory.toplist()
776 self._show_directory_results(results, True)
778 def _show_directory_results(self, results, multiple=False):
780 self._error(_('No podcasts found.'))
783 if not interactive_console or is_single_command:
784 print('\n'.join(url for title, url in results))
788 self._pager('\n'.join(
789 '%3d: %s\n %s' % (index + 1, title, url if title != url else '')
790 for index, (title, url) in enumerate(results)))
794 msg = _('Enter index to subscribe, ? for list')
796 index = input(msg + ': ')
808 self._error(_('Invalid value.'))
811 if not (1 <= index <= len(results)):
812 self._error(_('Invalid value.'))
815 title, url = results[index - 1]
816 self._info(_('Adding %s...') % title)
821 @FirstArgumentIsPodcastURL
822 def rewrite(self, old_url, new_url):
823 podcast = self.get_podcast(old_url)
825 self._error(_('You are not subscribed to %s.') % old_url)
827 result = podcast.rewrite_url(new_url)
829 self._error(_('Invalid URL: %s') % new_url)
832 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
834 'new_url': new_url, })
838 print(stylize(__doc__), file=sys.stderr, end='')
842 def ep_repr(episode):
843 return '{} / {}'.format(episode.channel.title, episode.title)
845 def msg_title(title, message):
847 msg = '{}: {}'.format(title, message)
849 msg = '{}'.format(message)
852 def _notification(message, title=None, important=False, widget=None):
853 print(msg_title(message, title))
855 def _show_confirmation(message, title=None):
856 msg = msg_title(message, title)
857 msg = _("%(title)s: %(msg)s ([yes]/no): ") % dict(title=title, msg=message)
858 if not interactive_console:
861 return not line or (line.lower() == _('yes'))
863 def _delete_episode_list(episodes, confirm=True, callback=None):
867 episodes = [e for e in episodes if not e.archive]
870 title = _('Episodes are locked')
872 'The selected episodes are locked. Please unlock the '
873 'episodes that you want to delete before trying '
875 _notification(message, title)
878 count = len(episodes)
879 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
880 count) % {'count': count}
881 message = _('Deleting episodes removes downloaded files.')
883 if confirm and not _show_confirmation(message, title):
886 print(_('Please wait while episodes are deleted'))
888 def finish_deletion(episode_urls, channel_urls):
889 # Episodes have been deleted - persist the database
895 episodes_status_update = []
896 for idx, episode in enumerate(episodes):
897 if not episode.archive:
898 self._start_action(_('Deleting episode: %(episode)s') % {
899 'episode': episode.title})
900 episode.delete_from_disk()
901 self._finish_action(success=True)
902 episode_urls.add(episode.url)
903 channel_urls.add(episode.channel.url)
904 episodes_status_update.append(episode)
906 # Notify the web service about the status update + upload
907 if self.mygpo_client.can_access_webservice():
908 self.mygpo_client.on_delete(episodes_status_update)
909 self.mygpo_client.flush()
912 util.idle_add(finish_deletion, episode_urls, channel_urls)
914 util.idle_add(callback, episode_urls, channel_urls, None)
918 def _episode_selector(parent_window, title=None, instructions=None, episodes=None,
919 selected=None, columns=None, callback=None, _config=None):
920 if not interactive_console:
921 return callback([e for i, e in enumerate(episodes) if selected[i]])
924 self._pager('\n'.join(
925 '[%s] %3d: %s' % (('X' if selected[index] else ' '), index + 1, ep_repr(e))
926 for index, e in enumerate(episodes)))
928 print("{}. {}".format(title, instructions))
931 msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
933 index = input(msg + ': ')
936 return callback([e for i, e in enumerate(episodes) if selected[i]])
942 selected = [True, ] * len(episodes)
946 selected = [False, ] * len(episodes)
953 self._error(_('Invalid value.'))
956 if not (1 <= index <= len(episodes)):
957 self._error(_('Invalid value.'))
960 e = episodes[index - 1]
961 selected[index - 1] = not selected[index - 1]
962 if selected[index - 1]:
963 self._info(_('Will delete %(episode)s') % dict(episode=ep_repr(e)))
965 self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
967 def _not_applicable(*args, **kwargs):
970 def _mount_volume_for_file(file):
971 result, message = util.mount_volume_for_file(file, None)
973 self._error(_('mounting volume for file %(file)s failed with: %(error)s'
974 % dict(file=file.get_uri(), error=message)))
977 class DownloadStatusModel(object):
978 def register_task(self, ask):
981 class DownloadQueueManager(object):
982 def queue_task(x, task):
983 def progress_updated(progress):
984 self._update_action(progress)
985 with self._action(_('Syncing %s') % ep_repr(task.episode)):
986 task.status = sync.SyncTask.DOWNLOADING
987 task.add_progress_callback(progress_updated)
990 if task.notify_as_finished():
991 if self._config.device_sync.after_sync.mark_episodes_played:
992 logger.info('Marking as played on transfer: %s', task.episode.url)
993 task.episode.mark(is_played=True)
995 if self._config.device_sync.after_sync.delete_episodes:
996 logger.info('Removing episode after transfer: %s', task.episode.url)
997 task.episode.delete_from_disk()
1001 done_lock = threading.Lock()
1002 self.mygpo_client = my.MygPoClient(self._config)
1003 sync_ui = gPodderSyncUI(self._config,
1008 self._model.get_podcasts(),
1009 DownloadStatusModel(),
1010 DownloadQueueManager(),
1013 _delete_episode_list,
1015 _mount_volume_for_file)
1017 sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
1018 done_lock.acquire() # block until done
1020 def _extensions_list(self):
1021 def by_category(ext):
1022 return ext.metadata.category
1024 def by_enabled_name(ext):
1025 return ('0' if ext.enabled else '1') + ext.name
1027 for cat, extensions in itertools.groupby(sorted(gpodder.user_extensions.get_extensions(), key=by_category), by_category):
1029 for ext in sorted(extensions, key=by_enabled_name):
1031 print(' ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
1033 print(' ', inblue(ext.name), ext.metadata.title)
1036 def _extensions_info(self, ext):
1038 print(inyellow(ext.name))
1040 print(inblue(ext.name))
1042 print(_('Title:'), ext.metadata.title)
1043 print(_('Category:'), _(ext.metadata.category))
1044 print(_('Description:'), ext.metadata.description)
1045 print(_('Authors:'), ext.metadata.authors)
1046 if ext.metadata.doc:
1047 print(_('Documentation:'), ext.metadata.doc)
1048 print(_('Enabled:'), _('yes') if ext.enabled else _('no'))
1052 def _extension_enable(self, container, new_enabled):
1053 if container.enabled == new_enabled:
1056 enabled_extensions = list(self._config.extensions.enabled)
1058 if new_enabled and container.name not in enabled_extensions:
1059 enabled_extensions.append(container.name)
1060 elif not new_enabled and container.name in enabled_extensions:
1061 enabled_extensions.remove(container.name)
1063 self._config.extensions.enabled = enabled_extensions
1065 now_enabled = (container.name in self._config.extensions.enabled)
1066 if new_enabled == now_enabled:
1068 if getattr(container, 'on_ui_initialized', None) is not None:
1069 container.on_ui_initialized(
1071 self._extensions_podcast_update_cb,
1072 self._extensions_episode_download_cb)
1073 enabled_str = _('enabled') if now_enabled else _('disabled')
1076 _('Extension %(name)s (%(title)s) %(enabled)s')
1077 % dict(name=container.name, title=container.metadata.title, enabled=enabled_str)))
1078 elif container.error is not None:
1079 if hasattr(container.error, 'message'):
1080 error_msg = container.error.message
1082 error_msg = str(container.error)
1083 self._error(_('Extension cannot be activated'))
1084 self._error(error_msg)
1088 def extensions(self, action='list', extension_name=None):
1089 if action in ('enable', 'disable', 'info'):
1090 if not extension_name:
1091 print(inred('E: extensions {} missing the extension name').format(action))
1094 for ext in gpodder.user_extensions.get_extensions():
1095 if ext.name == extension_name:
1099 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
1102 if action == 'list':
1103 return self._extensions_list()
1104 elif action in ('enable', 'disable'):
1105 self._extension_enable(extension, action == 'enable')
1106 elif action == 'info':
1107 self._extensions_info(extension)
1109 # -------------------------------------------------------------------
1111 def _pager(self, output):
1113 # Need two additional rows for command prompt
1114 rows_needed = len(output.splitlines()) + 2
1115 rows, cols = get_terminal_size()
1116 if rows_needed < rows:
1124 print(os.linesep.join(x.strip() for x in ("""
1125 gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1127 License: %(__license__)s
1129 Entering interactive shell. Type 'help' for help.
1130 Press Ctrl+D (EOF) or type 'quit' to quit.
1131 """ % gpodder.__dict__).splitlines()))
1135 if readline is not None:
1136 readline.parse_and_bind('tab: complete')
1137 readline.set_completer(self._tab_completion)
1138 readline.set_completer_delims(' ')
1142 line = input('gpo> ')
1146 except KeyboardInterrupt:
1150 if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1154 args = shlex.split(line)
1155 except ValueError as value_error:
1156 self._error(_('Syntax error: %(error)s') %
1157 {'error': value_error})
1162 except KeyboardInterrupt:
1163 self._error('Keyboard interrupt.')
1169 def _error(self, *args):
1170 print(inred(' '.join(args)), file=sys.stderr)
1172 # Warnings look like error messages for now
1175 def _info(self, *args):
1178 def _checkargs(self, func, command_line):
1179 argspec = inspect.getfullargspec(func)
1180 assert not argspec.kwonlyargs # keyword-only arguments are unsupported
1181 args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1182 args.pop(0) # Remove "self" from args
1183 defaults = defaults or ()
1184 minarg, maxarg = len(args) - len(defaults), len(args)
1186 if (len(command_line) < minarg
1187 or (len(command_line) > maxarg and varargs is None)):
1188 self._error('Wrong argument count for %s.' % func.__name__)
1191 return func(*command_line)
1193 def _tab_completion_podcast(self, text, count):
1194 """Tab completion for podcast URLs"""
1195 urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1196 if count < len(urls):
1201 def _tab_completion_in(self, text, count, choices):
1202 """Tab completion for a list of choices"""
1203 compat_choices = [c for c in choices if text in c]
1204 if count < len(compat_choices):
1205 return compat_choices[count]
1209 def _tab_completion_extensions(self, text, count):
1210 """Tab completion for extension names"""
1211 exts = [e.name for e in gpodder.user_extensions.get_extensions() if text in e.name]
1212 if count < len(exts):
1217 def _tab_completion(self, text, count):
1218 """Tab completion function for readline"""
1219 if readline is None:
1222 current_line = readline.get_line_buffer()
1223 if text == current_line:
1224 for name in self._valid_commands:
1225 if name.startswith(text):
1231 args = current_line.split()
1232 command = args.pop(0)
1233 command_function = getattr(self, command, None)
1234 if not command_function:
1236 if getattr(command_function, '_first_arg_is_podcast', False):
1237 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1238 return self._tab_completion_podcast(text, count)
1239 first_in = getattr(command_function, '_first_arg_in', False)
1241 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1242 return self._tab_completion_in(text, count, first_in)
1243 snd_ext = getattr(command_function, '_second_arg_is_extension', False)
1245 if (len(args) > 0 and len(args) < 2 and args[0] != 'list') or (len(args) == 2 and not current_line.endswith(' ')):
1246 return self._tab_completion_extensions(text, count)
1250 def _parse_single(self, command_line):
1252 result = self._parse(command_line)
1253 except KeyboardInterrupt:
1254 self._error('Keyboard interrupt.')
1259 def _parse(self, command_line):
1260 if not command_line:
1263 command = command_line.pop(0)
1265 # Resolve command aliases
1266 command = self._prefixes.get(command, command)
1268 if command in self._commands:
1269 func = self._commands[command]
1270 if inspect.ismethod(func):
1271 return self._checkargs(func, command_line)
1273 if command in self._expansions:
1274 print(_('Ambiguous command. Did you mean..'))
1275 for cmd in self._expansions[command]:
1276 print(' ', inblue(cmd))
1278 self._error(_('The requested function is not available.'))
1284 s = re.sub(r' .{27}', lambda m: inblue(m.group(0)), s)
1285 s = re.sub(r' - .*', lambda m: ingreen(m.group(0)), s)
1291 logger = logging.getLogger(__name__)
1293 msg = model.check_root_folder_path()
1295 print(msg, file=sys.stderr)
1298 is_single_command = True
1300 cli._parse_single(args)
1301 elif interactive_console:
1304 print(__doc__, end='')
1307 if __name__ == '__main__':