SendTo automatic rename filename (#1620)
[gpodder.git] / bin / gpo
blob2f68b6b4dc034b30d8b6a94f5a0b9778f79c5e28
1 #!/usr/bin/env python3
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
27 """
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
61   - Configuration -
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
72   - Other commands -
74     youtube URL                Resolve the YouTube URL to a download URL
75     rewrite OLDURL NEWURL      Change the feed URL of [OLDURL] to [NEWURL]
77 """
80 import collections
81 import contextlib
82 import functools
83 import inspect
84 import itertools
85 import logging
86 import os
87 import pydoc
88 import re
89 import shlex
90 import sys
91 import textwrap
92 import threading
94 try:
95     import readline
96 except ImportError:
97     readline = None
99 try:
100     import fcntl
101     import struct
102     import termios
103 except ImportError:
104     fcntl = None
105     struct = None
106     termios = None
108 # A poor man's argparse/getopt - but it works for our use case :)
109 verbose = False
110 for flag in ('-v', '--verbose'):
111     if flag in sys.argv:
112         sys.argv.remove(flag)
113         verbose = True
114         break
115 quiet = False
116 for flag in ('-q', '--quiet'):
117     if flag in sys.argv:
118         sys.argv.remove(flag)
119         quiet = True
120         break
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
144 _ = gpodder.gettext
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):
159     pass
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)
165     return 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)
175     return function
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)
182     return function
185 def get_terminal_size():
186     if None in (termios, fcntl, struct):
187         return (80, 24)
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)
193     return rows, cols
196 class gPodderCli(object):
197     COLUMNS = 80
198     EXIT_COMMANDS = ('quit', 'exit', 'bye')
200     def __init__(self):
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(
215             self.core.model,
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)
227         try:
228             yield
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))
237         if old_episodes:
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):
243         prefixes = {}
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']
250         def mkprefixes(n):
251             return (n[:x] for x in range(len(n), 0, -1))
253         # Return True if the given prefix is unique in "names"
254         def is_unique(p):
255             return len([n for n in names if n.startswith(p)]) == 1
257         for name in names:
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
264                     continue
266                 if unique_expansion is not None:
267                     expansions[prefix].append(unique_expansion)
268                     continue
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] + '...'
284         else:
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):
290         if have_ansi:
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):
296         if skip:
297             result = '[' + inyellow('SKIP') + ']'
298         elif success:
299             result = '[' + ingreen('DONE') + ']'
300         else:
301             result = '[' + inred('FAIL') + ']'
303         if have_ansi:
304             print('\r' + self._current_action + result)
305         else:
306             print(result)
307         self._current_action = ''
309     def _atexit(self):
310         self.core.shutdown()
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.
327         """
328         url = util.normalize_feed_url(original_url)
329         if url is None:
330             self._error(_('Invalid url: %s') % original_url)
331             return None
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
337         if create:
338             auth_tokens = {}
339             while True:
340                 try:
341                     return self._model.load_podcast(
342                         url, create=True,
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')))
348                         return None
349                     else:
350                         print(inyellow(_('Podcast requires authentication')))
351                         print(inyellow(_('Please login to %s:') % (url,)))
352                         username = input(_('User name:') + ' ')
353                         if username:
354                             password = input(_('Password:') + ' ')
355                             if password:
356                                 auth_tokens[e.url] = (username, password)
357                                 url = e.url
358                             else:
359                                 return None
360                         else:
361                             return None
363         # Load existing podcast
364         for podcast in self._model.get_podcasts():
365             if podcast.url == url:
366                 return podcast
368         if not check_only:
369             self._error(_('You are not subscribed to %s.') % url)
370         return None
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)
376             return True
378         try:
379             podcast = self.get_podcast(url, create=True)
380             if podcast is None:
381                 self._error(_('Cannot subscribe to %s.') % url)
382                 return True
384             if title is not None:
385                 podcast.rename(title)
386             podcast.save()
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)
391             else:
392                 self._error(str(e))
393             return True
395         self._db.commit()
397         self._info(_('Successfully added %s.' % url))
398         return True
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):
407         if value is None:
408             self._print_config(key)
409             return
411         try:
412             current_value = self._config._lookup(key)
413             current_type = type(current_value)
414         except KeyError:
415             self._error(_('This configuration option does not exist.'))
416             return
418         if current_type == dict:
419             self._error(_('Can only set leaf configuration nodes.'))
420             return
422         self._config.update_field(key, value)
423         self.set(key)
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)
432             self._db.commit()
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),
436             })
438         return True
440     @FirstArgumentIsPodcastURL
441     def unsubscribe(self, url):
442         podcast = self.get_podcast(url)
444         if podcast is None:
445             self._error(_('You are not subscribed to %s.') % url)
446         else:
447             # Clean up downloads and download directories
448             common.clean_up_downloads()
450             podcast.delete()
451             self._db.commit()
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])
461         return True
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):
468             # is new
469             if self.is_episode_new(episode):
470                 return ' * '
471             # is downloaded
472             if (episode.state == gpodder.STATE_DOWNLOADED):
473                 return ' ▉ '
474             # is deleted
475             if (episode.state == gpodder.STATE_DELETED):
476                 return ' ░ '
478             return '   '
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()))
486         return episodes
488     @FirstArgumentIsPodcastURL
489     def info(self, url):
490         podcast = self.get_podcast(url)
492         if podcast is None:
493             self._error(_('You are not subscribed to %s.') % url)
494         else:
495             def feed_update_status_msg(podcast):
496                 if podcast.pause_subscription:
497                     return "disabled"
498                 return "enabled"
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)
506             self._pager("""
507     Title: %(title)s
508     URL: %(url)s
509     Description:
510         %(description)s
511     Link: %(link)s
512     Feed update is %(status)s
514     Episodes:
515       %(episodes)s
516             """ % locals())
518         return True
520     @FirstArgumentIsPodcastURL
521     def episodes(self, *args):
522         show_guid = False
523         args = list(args)
524         # TODO: Start using argparse for things like that
525         if '--guid' in args:
526             args.remove('--guid')
527             show_guid = True
529         if len(args) > 1:
530             self._error(_('Invalid command.'))
531             return
532         elif len(args) == 1:
533             url = args[0]
534             if url.startswith('-'):
535                 self._error(_('Invalid option: %s.') % (url,))
536                 return
537         else:
538             url = None
540         output = []
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)
546                 output.append("""
547     Episodes from %s:
548       %s
549 """ % (podcast.url, episodes))
551         self._pager('\n'.join(output))
552         return True
554     def list(self):
555         for podcast in self._model.get_podcasts():
556             if not podcast.pause_subscription:
557                 print('#', ingreen(podcast.title))
558             else:
559                 print('#', inred(podcast.title),
560                       '-', _('Updates disabled'))
562             print(podcast.url)
564         return True
566     def _update_podcast(self, podcast):
567         with self._action(' %s' % podcast.title):
568             podcast.update()
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):
576         count = 0
577         print(_('Checking for new episodes'))
578         for podcast in self._model.get_podcasts():
579             if url is not None and podcast.url != url:
580                 continue
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))
585             else:
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)))
592         return True
594     @FirstArgumentIsPodcastURL
595     def pending(self, url=None):
596         count = 0
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)
606                         count += 1
608         util.delete_empty_folders(gpodder.downloads)
609         print(inblue(self._pending_message(count)))
610         return True
612     @FirstArgumentIsPodcastURL
613     def partial(self, *args):
614         def by_channel(e):
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)
623             last_channel = None
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(),
636                                       noop,
637                                       noop,
638                                       noop,
639                                       on_finish)
640         return True
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)
646             else:
647                 task = episode.download_task
648             task.add_progress_callback(self._update_action)
649             task.status = download.DownloadTask.DOWNLOADING
650             task.run()
651             task.recycle()
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))
658         if episodes:
659             # Queue episodes to create partial files
660             for e in episodes:
661                 if e.download_task is None:
662                     download.DownloadTask(e, self._config)
664             last_podcast = None
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.')
673         return True
675     @FirstArgumentIsPodcastURL
676     def download(self, url=None, guid=None):
677         episodes = []
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):
691             if guid:
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(),
696                                       noop,
697                                       noop,
698                                       noop,
699                                       on_finish)
700         return True
702     @FirstArgumentIsPodcastURL
703     def delete(self, url, guid):
704         podcast = self.get_podcast(url)
705         episode_to_delete = None
707         if podcast is None:
708             self._error(_('You are not subscribed to %s.') % url)
709         else:
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.'))
716             else:
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)
720                 else:
721                     self._error(_('Episode has already been deleted.'))
723         return True
725     @FirstArgumentIsPodcastURL
726     def disable(self, url):
727         podcast = self.get_podcast(url)
729         if podcast is None:
730             self._error(_('You are not subscribed to %s.') % url)
731         else:
732             if not podcast.pause_subscription:
733                 podcast.pause_subscription = True
734                 podcast.save()
735             self._db.commit()
736             self._error(_('Disabling feed update from %s.') % url)
738         return True
740     @FirstArgumentIsPodcastURL
741     def enable(self, url):
742         podcast = self.get_podcast(url)
744         if podcast is None:
745             self._error(_('You are not subscribed to %s.') % url)
746         else:
747             if podcast.pause_subscription:
748                 podcast.pause_subscription = False
749                 podcast.save()
750             self._db.commit()
751             self._error(_('Enabling feed update from %s.') % url)
753         return True
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)
760         print(yurl)
762         return True
764     def search(self, *terms):
765         query = ' '.join(terms)
766         if not query:
767             return
769         directory = my.Directory()
770         results = directory.search(query)
771         self._show_directory_results(results)
773     def toplist(self):
774         directory = my.Directory()
775         results = directory.toplist()
776         self._show_directory_results(results, True)
778     def _show_directory_results(self, results, multiple=False):
779         if not results:
780             self._error(_('No podcasts found.'))
781             return
783         if not interactive_console or is_single_command:
784             print('\n'.join(url for title, url in results))
785             return
787         def show_list():
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)))
792         show_list()
794         msg = _('Enter index to subscribe, ? for list')
795         while True:
796             index = input(msg + ': ')
798             if not index:
799                 return
801             if index == '?':
802                 show_list()
803                 continue
805             try:
806                 index = int(index)
807             except ValueError:
808                 self._error(_('Invalid value.'))
809                 continue
811             if not (1 <= index <= len(results)):
812                 self._error(_('Invalid value.'))
813                 continue
815             title, url = results[index - 1]
816             self._info(_('Adding %s...') % title)
817             self.subscribe(url)
818             if not multiple:
819                 break
821     @FirstArgumentIsPodcastURL
822     def rewrite(self, old_url, new_url):
823         podcast = self.get_podcast(old_url)
824         if podcast is None:
825             self._error(_('You are not subscribed to %s.') % old_url)
826         else:
827             result = podcast.rewrite_url(new_url)
828             if result is None:
829                 self._error(_('Invalid URL: %s') % new_url)
830             else:
831                 new_url = result
832                 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
833                             {'old_url': old_url,
834                              'new_url': new_url, })
835         return True
837     def help(self):
838         print(stylize(__doc__), file=sys.stderr, end='')
839         return True
841     def sync(self):
842         def ep_repr(episode):
843             return '{} / {}'.format(episode.channel.title, episode.title)
845         def msg_title(title, message):
846             if title:
847                 msg = '{}: {}'.format(title, message)
848             else:
849                 msg = '{}'.format(message)
850             return msg
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:
859                 return True
860             line = input(msg)
861             return not line or (line.lower() == _('yes'))
863         def _delete_episode_list(episodes, confirm=True, callback=None):
864             if not episodes:
865                 return False
867             episodes = [e for e in episodes if not e.archive]
869             if not episodes:
870                 title = _('Episodes are locked')
871                 message = _(
872                     'The selected episodes are locked. Please unlock the '
873                     'episodes that you want to delete before trying '
874                     'to delete them.')
875                 _notification(message, title)
876                 return False
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):
884                 return False
886             print(_('Please wait while episodes are deleted'))
888             def finish_deletion(episode_urls, channel_urls):
889                 # Episodes have been deleted - persist the database
890                 self.db.commit()
892             episode_urls = set()
893             channel_urls = set()
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()
911             if callback is None:
912                 util.idle_add(finish_deletion, episode_urls, channel_urls)
913             else:
914                 util.idle_add(callback, episode_urls, channel_urls, None)
916             return True
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]])
923             def show_list():
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))
929             show_list()
931             msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
932             while True:
933                 index = input(msg + ': ')
935                 if not index:
936                     return callback([e for i, e in enumerate(episodes) if selected[i]])
938                 if index == '?':
939                     show_list()
940                     continue
941                 elif index == 'X':
942                     selected = [True, ] * len(episodes)
943                     show_list()
944                     continue
945                 elif index == ' ':
946                     selected = [False, ] * len(episodes)
947                     show_list()
948                     continue
949                 else:
950                     try:
951                         index = int(index)
952                     except ValueError:
953                         self._error(_('Invalid value.'))
954                         continue
956                     if not (1 <= index <= len(episodes)):
957                         self._error(_('Invalid value.'))
958                         continue
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)))
964                     else:
965                         self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
967         def _not_applicable(*args, **kwargs):
968             pass
970         def _mount_volume_for_file(file):
971             result, message = util.mount_volume_for_file(file, None)
972             if not result:
973                 self._error(_('mounting volume for file %(file)s failed with: %(error)s'
974                     % dict(file=file.get_uri(), error=message)))
975             return result
977         class DownloadStatusModel(object):
978             def register_task(self, ask):
979                 pass
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)
988                     task.run()
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()
999                     task.recycle()
1001         done_lock = threading.Lock()
1002         self.mygpo_client = my.MygPoClient(self._config)
1003         sync_ui = gPodderSyncUI(self._config,
1004                                 _notification,
1005                                 None,
1006                                 _show_confirmation,
1007                                 _not_applicable,
1008                                 self._model.get_podcasts(),
1009                                 DownloadStatusModel(),
1010                                 DownloadQueueManager(),
1011                                 _not_applicable,
1012                                 self._db.commit,
1013                                 _delete_episode_list,
1014                                 _episode_selector,
1015                                 _mount_volume_for_file)
1016         done_lock.acquire()
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):
1028             print(_(cat))
1029             for ext in sorted(extensions, key=by_enabled_name):
1030                 if ext.enabled:
1031                     print('  ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
1032                 else:
1033                     print('  ', inblue(ext.name), ext.metadata.title)
1034         return True
1036     def _extensions_info(self, ext):
1037         if ext.enabled:
1038             print(inyellow(ext.name))
1039         else:
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'))
1050         return True
1052     def _extension_enable(self, container, new_enabled):
1053         if container.enabled == new_enabled:
1054             return True
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:
1067             if now_enabled:
1068                 if getattr(container, 'on_ui_initialized', None) is not None:
1069                     container.on_ui_initialized(
1070                         self.core.model,
1071                         self._extensions_podcast_update_cb,
1072                         self._extensions_episode_download_cb)
1073             enabled_str = _('enabled') if now_enabled else _('disabled')
1074             self._info(
1075                 inblue(
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
1081             else:
1082                 error_msg = str(container.error)
1083             self._error(_('Extension cannot be activated'))
1084             self._error(error_msg)
1085         return True
1087     @ExtensionsFunction
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))
1092                 return False
1093             extension = None
1094             for ext in gpodder.user_extensions.get_extensions():
1095                 if ext.name == extension_name:
1096                     extension = ext
1097                     break
1098             if not extension:
1099                 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
1100                 return False
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)
1108         return True
1109     # -------------------------------------------------------------------
1111     def _pager(self, output):
1112         if have_ansi:
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:
1117                 print(output)
1118             else:
1119                 pydoc.pager(output)
1120         else:
1121             print(output)
1123     def _shell(self):
1124         print(os.linesep.join(x.strip() for x in ("""
1125         gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1126         %(__copyright__)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()))
1133         cli._run_cleanups()
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(' ')
1140         while True:
1141             try:
1142                 line = input('gpo> ')
1143             except EOFError:
1144                 print('')
1145                 break
1146             except KeyboardInterrupt:
1147                 print('')
1148                 continue
1150             if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1151                 break
1153             try:
1154                 args = shlex.split(line)
1155             except ValueError as value_error:
1156                 self._error(_('Syntax error: %(error)s') %
1157                             {'error': value_error})
1158                 continue
1160             try:
1161                 self._parse(args)
1162             except KeyboardInterrupt:
1163                 self._error('Keyboard interrupt.')
1164             except EOFError:
1165                 self._error('EOF.')
1167         self._atexit()
1169     def _error(self, *args):
1170         print(inred(' '.join(args)), file=sys.stderr)
1172     # Warnings look like error messages for now
1173     _warn = _error
1175     def _info(self, *args):
1176         print(*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__)
1189             return False
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):
1197             return urls[count]
1199         return None
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]
1207         return None
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):
1213             return exts[count]
1215         return None
1217     def _tab_completion(self, text, count):
1218         """Tab completion function for readline"""
1219         if readline is None:
1220             return 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):
1226                     if count == 0:
1227                         return name
1228                     else:
1229                         count -= 1
1230         else:
1231             args = current_line.split()
1232             command = args.pop(0)
1233             command_function = getattr(self, command, None)
1234             if not command_function:
1235                 return None
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)
1240             if first_in:
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)
1244             if snd_ext:
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)
1248         return None
1250     def _parse_single(self, command_line):
1251         try:
1252             result = self._parse(command_line)
1253         except KeyboardInterrupt:
1254             self._error('Keyboard interrupt.')
1255             result = -1
1256         self._atexit()
1257         return result
1259     def _parse(self, command_line):
1260         if not command_line:
1261             return False
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))
1277         else:
1278             self._error(_('The requested function is not available.'))
1280         return False
1283 def stylize(s):
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)
1286     return s
1289 def main():
1290     global logger, cli
1291     logger = logging.getLogger(__name__)
1292     cli = gPodderCli()
1293     msg = model.check_root_folder_path()
1294     if msg:
1295         print(msg, file=sys.stderr)
1296     args = sys.argv[1:]
1297     if args:
1298         is_single_command = True
1299         cli._run_cleanups()
1300         cli._parse_single(args)
1301     elif interactive_console:
1302         cli._shell()
1303     else:
1304         print(__doc__, end='')
1307 if __name__ == '__main__':
1308     main()