Prevent crash when gpo sync removes episodes from gpodder.
[gpodder.git] / bin / gpo
blob0315f7b2a0b858b60e9336db6afe3bd4fe289c3e
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 threading
93 try:
94     import readline
95 except ImportError:
96     readline = None
98 try:
99     import fcntl
100     import struct
101     import termios
102 except ImportError:
103     fcntl = None
104     struct = None
105     termios = None
107 # A poor man's argparse/getopt - but it works for our use case :)
108 verbose = False
109 for flag in ('-v', '--verbose'):
110     if flag in sys.argv:
111         sys.argv.remove(flag)
112         verbose = True
113         break
114 quiet = False
115 for flag in ('-q', '--quiet'):
116     if flag in sys.argv:
117         sys.argv.remove(flag)
118         quiet = True
119         break
121 gpodder_script = sys.argv[0]
122 gpodder_script = os.path.realpath(gpodder_script)
123 gpodder_dir = os.path.join(os.path.dirname(gpodder_script), '..')
124 # TODO: Read parent directory links as well (/bin -> /usr/bin, like on Fedora, see Bug #1618)
125 # This would allow /usr/share/gpodder/ (not /share/gpodder/) to be found from /bin/gpodder
126 prefix = os.path.abspath(os.path.normpath(gpodder_dir))
128 src_dir = os.path.join(prefix, 'src')
130 if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
131     # Run gPodder from local source folder (not installed)
132     sys.path.insert(0, src_dir)
134 import gpodder  # isort:skip
136 from gpodder import log  # isort:skip
137 log.setup(verbose, quiet)
139 from gpodder import common, core, download, feedcore, model, my, opml, sync, util, youtube  # isort:skip
140 from gpodder.config import config_value_to_string  # isort:skip
141 from gpodder.syncui import gPodderSyncUI  # isort:skip
143 _ = gpodder.gettext
144 N_ = gpodder.ngettext
146 gpodder.images_folder = os.path.join(prefix, 'share', 'gpodder', 'images')
147 gpodder.prefix = prefix
149 # This is the command-line UI variant
150 gpodder.ui.cli = True
152 have_ansi = sys.stdout.isatty() and not gpodder.ui.win32
153 interactive_console = sys.stdin.isatty() and sys.stdout.isatty()
154 is_single_command = False
157 def noop(*args, **kwargs):
158     pass
161 def incolor(color_id, s):
162     if have_ansi and cli._config.ui.cli.colors:
163         return '\033[9%dm%s\033[0m' % (color_id, s)
164     return s
167 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
168 inred, ingreen, inyellow, inblue = (functools.partial(incolor, x) for x in range(1, 5))
171 def FirstArgumentIsPodcastURL(function):
172     """Decorator for functions that take a podcast URL as first arg"""
173     setattr(function, '_first_arg_is_podcast', True)
174     return function
177 def ExtensionsFunction(function):
178     """Decorator for functions that take an extension as second arg"""
179     setattr(function, '_first_arg_in', ('list', 'info', 'enable', 'disable'))
180     setattr(function, '_second_arg_is_extension', True)
181     return function
184 def get_terminal_size():
185     if None in (termios, fcntl, struct):
186         return (80, 24)
188     s = struct.pack('HHHH', 0, 0, 0, 0)
189     stdout = sys.stdout.fileno()
190     x = fcntl.ioctl(stdout, termios.TIOCGWINSZ, s)
191     rows, cols, xp, yp = struct.unpack('HHHH', x)
192     return rows, cols
195 class gPodderCli(object):
196     COLUMNS = 80
197     EXIT_COMMANDS = ('quit', 'exit', 'bye')
199     def __init__(self):
200         self.core = core.Core()
201         self._db = self.core.db
202         self._config = self.core.config
203         self._model = self.core.model
205         self._current_action = ''
206         self._commands = dict(
207             (name.rstrip('_'), func)
208             for name, func in inspect.getmembers(self)
209             if inspect.ismethod(func) and not name.startswith('_'))
210         self._prefixes, self._expansions = self._build_prefixes_expansions()
211         self._prefixes.update({'?': 'help'})
212         self._valid_commands = sorted(self._prefixes.values())
213         gpodder.user_extensions.on_ui_initialized(
214             self.core.model,
215             self._extensions_podcast_update_cb,
216             self._extensions_episode_download_cb)
218     @contextlib.contextmanager
219     def _action(self, msg):
220         self._start_action(msg)
221         try:
222             yield
223             self._finish_action()
224         except Exception as ex:
225             logger.warning('Action could not be completed', exc_info=True)
226             self._finish_action(False)
228     def _run_cleanups(self):
229         # Find expired (old) episodes and delete them
230         old_episodes = list(common.get_expired_episodes(self._model.get_podcasts(), self._config))
231         if old_episodes:
232             with self._action('Cleaning up old downloads'):
233                 for old_episode in old_episodes:
234                     old_episode.delete_from_disk()
236     def _build_prefixes_expansions(self):
237         prefixes = {}
238         expansions = collections.defaultdict(list)
239         names = sorted(self._commands.keys())
240         names.extend(self.EXIT_COMMANDS)
242         # Generator for all prefixes of a given string (longest first)
243         # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
244         def mkprefixes(n):
245             return (n[:x] for x in range(len(n), 0, -1))
247         # Return True if the given prefix is unique in "names"
248         def is_unique(p):
249             return len([n for n in names if n.startswith(p)]) == 1
251         for name in names:
252             is_still_unique = True
253             unique_expansion = None
254             for prefix in mkprefixes(name):
255                 if is_unique(prefix):
256                     unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
257                     prefixes[prefix] = name
258                     continue
260                 if unique_expansion is not None:
261                     expansions[prefix].append(unique_expansion)
262                     continue
264         return prefixes, expansions
266     def _extensions_podcast_update_cb(self, podcast):
267         self._info(_('Podcast update requested by extensions.'))
268         self._update_podcast(podcast)
270     def _extensions_episode_download_cb(self, episode):
271         self._info(_('Episode download requested by extensions.'))
272         self._download_episode(episode)
274     def _start_action(self, msg):
275         line = util.convert_bytes(msg)
276         if len(line) > self.COLUMNS - 7:
277             line = line[:self.COLUMNS - 7 - 3] + '...'
278         else:
279             line = line + (' ' * (self.COLUMNS - 7 - len(line)))
280         self._current_action = line
281         print(self._current_action, end='')
283     def _update_action(self, progress):
284         if have_ansi:
285             progress = '%3.0f%%' % (progress * 100.,)
286             result = '[' + inblue(progress) + ']'
287             print('\r' + self._current_action + result, end='')
289     def _finish_action(self, success=True, skip=False):
290         if skip:
291             result = '[' + inyellow('SKIP') + ']'
292         elif success:
293             result = '[' + ingreen('DONE') + ']'
294         else:
295             result = '[' + inred('FAIL') + ']'
297         if have_ansi:
298             print('\r' + self._current_action + result)
299         else:
300             print(result)
301         self._current_action = ''
303     def _atexit(self):
304         self.core.shutdown()
306     # -------------------------------------------------------------------
308     def import_(self, url):
309         for channel in opml.Importer(url).items:
310             self.subscribe(channel['url'], channel.get('title'))
312     def export(self, filename):
313         podcasts = self._model.get_podcasts()
314         opml.Exporter(filename).write(podcasts)
316     def get_podcast(self, original_url, create=False, check_only=False):
317         """Get a specific podcast by URL
319         Returns a podcast object for the URL or None if
320         the podcast has not been subscribed to.
321         """
322         url = util.normalize_feed_url(original_url)
323         if url is None:
324             self._error(_('Invalid url: %s') % original_url)
325             return None
327         # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
328         url = youtube.parse_youtube_url(url)
330         # Subscribe to new podcast
331         if create:
332             auth_tokens = {}
333             while True:
334                 try:
335                     return self._model.load_podcast(
336                         url, create=True,
337                         authentication_tokens=auth_tokens.get(url, None),
338                         max_episodes=self._config.max_episodes_per_feed)
339                 except feedcore.AuthenticationRequired as e:
340                     if e.url in auth_tokens:
341                         print(inred(_('Wrong username/password')))
342                         return None
343                     else:
344                         print(inyellow(_('Podcast requires authentication')))
345                         print(inyellow(_('Please login to %s:') % (url,)))
346                         username = input(_('User name:') + ' ')
347                         if username:
348                             password = input(_('Password:') + ' ')
349                             if password:
350                                 auth_tokens[e.url] = (username, password)
351                                 url = e.url
352                             else:
353                                 return None
354                         else:
355                             return None
357         # Load existing podcast
358         for podcast in self._model.get_podcasts():
359             if podcast.url == url:
360                 return podcast
362         if not check_only:
363             self._error(_('You are not subscribed to %s.') % url)
364         return None
366     def subscribe(self, url, title=None):
367         existing = self.get_podcast(url, check_only=True)
368         if existing is not None:
369             self._error(_('Already subscribed to %s.') % existing.url)
370             return True
372         try:
373             podcast = self.get_podcast(url, create=True)
374             if podcast is None:
375                 self._error(_('Cannot subscribe to %s.') % url)
376                 return True
378             if title is not None:
379                 podcast.rename(title)
380             podcast.save()
381         except Exception as e:
382             logger.warn('Cannot subscribe: %s', e, exc_info=True)
383             if hasattr(e, 'strerror'):
384                 self._error(e.strerror)
385             else:
386                 self._error(str(e))
387             return True
389         self._db.commit()
391         self._info(_('Successfully added %s.' % url))
392         return True
394     def _print_config(self, search_for):
395         for key in self._config.all_keys():
396             if search_for is None or search_for.lower() in key.lower():
397                 value = config_value_to_string(self._config._lookup(key))
398                 print(key, '=', value)
400     def set(self, key=None, value=None):
401         if value is None:
402             self._print_config(key)
403             return
405         try:
406             current_value = self._config._lookup(key)
407             current_type = type(current_value)
408         except KeyError:
409             self._error(_('This configuration option does not exist.'))
410             return
412         if current_type == dict:
413             self._error(_('Can only set leaf configuration nodes.'))
414             return
416         self._config.update_field(key, value)
417         self.set(key)
419     @FirstArgumentIsPodcastURL
420     def rename(self, url, title):
421         podcast = self.get_podcast(url)
423         if podcast is not None:
424             old_title = podcast.title
425             podcast.rename(title)
426             self._db.commit()
427             self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
428                 'old_title': util.convert_bytes(old_title),
429                 'new_title': util.convert_bytes(title),
430             })
432         return True
434     @FirstArgumentIsPodcastURL
435     def unsubscribe(self, url):
436         podcast = self.get_podcast(url)
438         if podcast is None:
439             self._error(_('You are not subscribed to %s.') % url)
440         else:
441             podcast.delete()
442             self._db.commit()
443             self._error(_('Unsubscribed from %s.') % url)
445         return True
447     def is_episode_new(self, episode):
448         return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
450     def _episodesList(self, podcast, show_guid=False):
451         def status_str(episode):
452             # is new
453             if self.is_episode_new(episode):
454                 return ' * '
455             # is downloaded
456             if (episode.state == gpodder.STATE_DOWNLOADED):
457                 return ' ▉ '
458             # is deleted
459             if (episode.state == gpodder.STATE_DELETED):
460                 return ' ░ '
462             return '   '
464         def guid_str(episode):
465             return ((' %s' % episode.guid) if show_guid else '')
467         episodes = ('%3d.%s %s %s' % (i + 1, guid_str(e),
468                                       status_str(e), e.title)
469                     for i, e in enumerate(podcast.get_all_episodes()))
470         return episodes
472     @FirstArgumentIsPodcastURL
473     def info(self, url):
474         podcast = self.get_podcast(url)
476         if podcast is None:
477             self._error(_('You are not subscribed to %s.') % url)
478         else:
479             def feed_update_status_msg(podcast):
480                 if podcast.pause_subscription:
481                     return "disabled"
482                 return "enabled"
484             title, url, status = podcast.title, podcast.url, \
485                 feed_update_status_msg(podcast)
486             episodes = self._episodesList(podcast)
487             episodes = '\n      '.join(episodes)
488             self._pager("""
489     Title: %(title)s
490     URL: %(url)s
491     Feed update is %(status)s
493     Episodes:
494       %(episodes)s
495             """ % locals())
497         return True
499     @FirstArgumentIsPodcastURL
500     def episodes(self, *args):
501         show_guid = False
502         args = list(args)
503         # TODO: Start using argparse for things like that
504         if '--guid' in args:
505             args.remove('--guid')
506             show_guid = True
508         if len(args) > 1:
509             self._error(_('Invalid command.'))
510             return
511         elif len(args) == 1:
512             url = args[0]
513             if url.startswith('-'):
514                 self._error(_('Invalid option: %s.') % (url,))
515                 return
516         else:
517             url = None
519         output = []
520         for podcast in self._model.get_podcasts():
521             podcast_printed = False
522             if url is None or podcast.url == url:
523                 episodes = self._episodesList(podcast, show_guid=show_guid)
524                 episodes = '\n      '.join(episodes)
525                 output.append("""
526     Episodes from %s:
527       %s
528 """ % (podcast.url, episodes))
530         self._pager('\n'.join(output))
531         return True
533     def list(self):
534         for podcast in self._model.get_podcasts():
535             if not podcast.pause_subscription:
536                 print('#', ingreen(podcast.title))
537             else:
538                 print('#', inred(podcast.title),
539                       '-', _('Updates disabled'))
541             print(podcast.url)
543         return True
545     def _update_podcast(self, podcast):
546         with self._action(' %s' % podcast.title):
547             podcast.update()
549     def _pending_message(self, count):
550         return N_('%(count)d new episode', '%(count)d new episodes',
551                   count) % {'count': count}
553     @FirstArgumentIsPodcastURL
554     def update(self, url=None):
555         count = 0
556         print(_('Checking for new episodes'))
557         for podcast in self._model.get_podcasts():
558             if url is not None and podcast.url != url:
559                 continue
561             if not podcast.pause_subscription:
562                 self._update_podcast(podcast)
563                 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
564             else:
565                 self._start_action(_('Skipping %(podcast)s') % {
566                     'podcast': podcast.title})
567                 self._finish_action(skip=True)
569         util.delete_empty_folders(gpodder.downloads)
570         print(inblue(self._pending_message(count)))
571         return True
573     @FirstArgumentIsPodcastURL
574     def pending(self, url=None):
575         count = 0
576         for podcast in self._model.get_podcasts():
577             podcast_printed = False
578             if url is None or podcast.url == url:
579                 for episode in podcast.get_all_episodes():
580                     if self.is_episode_new(episode):
581                         if not podcast_printed:
582                             print('#', ingreen(podcast.title))
583                             podcast_printed = True
584                         print(' ', episode.title)
585                         count += 1
587         util.delete_empty_folders(gpodder.downloads)
588         print(inblue(self._pending_message(count)))
589         return True
591     @FirstArgumentIsPodcastURL
592     def partial(self, *args):
593         def by_channel(e):
594             return e.channel.title
596         def guid_str(episode):
597             return (('%s ' % episode.guid) if show_guid else '')
599         def on_finish(resumable_episodes):
600             count = len(resumable_episodes)
601             resumable_episodes = sorted(resumable_episodes, key=by_channel)
602             last_channel = None
603             for e in resumable_episodes:
604                 if e.channel != last_channel:
605                     print('#', ingreen(e.channel.title))
606                     last_channel = e.channel
607                 print('  %s%s' % (guid_str(e), e.title))
608             print(inblue(N_('%(count)d partial file',
609                    '%(count)d partial files',
610                    count) % {'count': count}))
612         show_guid = '--guid' in args
614         common.find_partial_downloads(self._model.get_podcasts(),
615                                       noop,
616                                       noop,
617                                       on_finish)
618         return True
620     def _download_episode(self, episode):
621         with self._action('Downloading %s' % episode.title):
622             task = download.DownloadTask(episode, self._config)
623             task.add_progress_callback(self._update_action)
624             task.status = download.DownloadTask.DOWNLOADING
625             task.run()
626             task.recycle()
628     def _download_episodes(self, episodes):
629         if self._config.downloads.chronological_order:
630             # download older episodes first
631             episodes = list(model.Model.sort_episodes_by_pubdate(episodes))
633         if episodes:
634             last_podcast = None
635             for episode in episodes:
636                 if episode.channel != last_podcast:
637                     print(inblue(episode.channel.title))
638                     last_podcast = episode.channel
639                 self._download_episode(episode)
641             util.delete_empty_folders(gpodder.downloads)
642         print(len(episodes), 'episodes downloaded.')
643         return True
645     @FirstArgumentIsPodcastURL
646     def download(self, url=None, guid=None):
647         episodes = []
648         for podcast in self._model.get_podcasts():
649             if url is None or podcast.url == url:
650                 for episode in podcast.get_all_episodes():
651                     if (not guid and self.is_episode_new(episode)) or (guid and episode.guid == guid):
652                         episodes.append(episode)
653         return self._download_episodes(episodes)
655     @FirstArgumentIsPodcastURL
656     def resume(self, guid=None):
657         def guid_str(episode):
658             return (('%s ' % episode.guid) if show_guid else '')
660         def on_finish(episodes):
661             if guid:
662                 episodes = [e for e in episodes if e.guid == guid]
663             self._download_episodes(episodes)
665         common.find_partial_downloads(self._model.get_podcasts(),
666                                       noop,
667                                       noop,
668                                       on_finish)
669         return True
671     @FirstArgumentIsPodcastURL
672     def delete(self, url, guid):
673         podcast = self.get_podcast(url)
674         episode_to_delete = None
676         if podcast is None:
677             self._error(_('You are not subscribed to %s.') % url)
678         else:
679             for episode in podcast.get_all_episodes():
680                 if (episode.guid == guid):
681                     episode_to_delete = episode
683             if not episode_to_delete:
684                 self._error(_('No episode with the specified GUID found.'))
685             else:
686                 if episode_to_delete.state != gpodder.STATE_DELETED:
687                     episode_to_delete.delete_from_disk()
688                     self._info(_('Deleted episode "%s".') % episode_to_delete.title)
689                 else:
690                     self._error(_('Episode has already been deleted.'))
692         return True
694     @FirstArgumentIsPodcastURL
695     def disable(self, url):
696         podcast = self.get_podcast(url)
698         if podcast is None:
699             self._error(_('You are not subscribed to %s.') % url)
700         else:
701             if not podcast.pause_subscription:
702                 podcast.pause_subscription = True
703                 podcast.save()
704             self._db.commit()
705             self._error(_('Disabling feed update from %s.') % url)
707         return True
709     @FirstArgumentIsPodcastURL
710     def enable(self, url):
711         podcast = self.get_podcast(url)
713         if podcast is None:
714             self._error(_('You are not subscribed to %s.') % url)
715         else:
716             if podcast.pause_subscription:
717                 podcast.pause_subscription = False
718                 podcast.save()
719             self._db.commit()
720             self._error(_('Enabling feed update from %s.') % url)
722         return True
724     def youtube(self, url):
725         fmt_ids = youtube.get_fmt_ids(self._config.youtube, False)
726         yurl, duration = youtube.get_real_download_url(url, False, fmt_ids)
727         if duration is not None:
728             episode.total_time = int(int(duration) / 1000)
729         print(yurl)
731         return True
733     def search(self, *terms):
734         query = ' '.join(terms)
735         if not query:
736             return
738         directory = my.Directory()
739         results = directory.search(query)
740         self._show_directory_results(results)
742     def toplist(self):
743         directory = my.Directory()
744         results = directory.toplist()
745         self._show_directory_results(results, True)
747     def _show_directory_results(self, results, multiple=False):
748         if not results:
749             self._error(_('No podcasts found.'))
750             return
752         if not interactive_console or is_single_command:
753             print('\n'.join(url for title, url in results))
754             return
756         def show_list():
757             self._pager('\n'.join(
758                 '%3d: %s\n     %s' % (index + 1, title, url if title != url else '')
759                 for index, (title, url) in enumerate(results)))
761         show_list()
763         msg = _('Enter index to subscribe, ? for list')
764         while True:
765             index = input(msg + ': ')
767             if not index:
768                 return
770             if index == '?':
771                 show_list()
772                 continue
774             try:
775                 index = int(index)
776             except ValueError:
777                 self._error(_('Invalid value.'))
778                 continue
780             if not (1 <= index <= len(results)):
781                 self._error(_('Invalid value.'))
782                 continue
784             title, url = results[index - 1]
785             self._info(_('Adding %s...') % title)
786             self.subscribe(url)
787             if not multiple:
788                 break
790     @FirstArgumentIsPodcastURL
791     def rewrite(self, old_url, new_url):
792         podcast = self.get_podcast(old_url)
793         if podcast is None:
794             self._error(_('You are not subscribed to %s.') % old_url)
795         else:
796             result = podcast.rewrite_url(new_url)
797             if result is None:
798                 self._error(_('Invalid URL: %s') % new_url)
799             else:
800                 new_url = result
801                 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
802                             {'old_url': old_url,
803                              'new_url': new_url, })
804         return True
806     def help(self):
807         print(stylize(__doc__), file=sys.stderr, end='')
808         return True
810     def sync(self):
811         def ep_repr(episode):
812             return '{} / {}'.format(episode.channel.title, episode.title)
814         def msg_title(title, message):
815             if title:
816                 msg = '{}: {}'.format(title, message)
817             else:
818                 msg = '{}'.format(message)
819             return msg
821         def _notification(message, title=None, important=False, widget=None):
822             print(msg_title(message, title))
824         def _show_confirmation(message, title=None):
825             msg = msg_title(message, title)
826             msg = _("%(title)s: %(msg)s ([yes]/no): ") % dict(title=title, msg=message)
827             if not interactive_console:
828                 return True
829             line = input(msg)
830             return not line or (line.lower() == _('yes'))
832         def _delete_episode_list(episodes, confirm=True, callback=None):
833             if not episodes:
834                 return False
836             episodes = [e for e in episodes if not e.archive]
838             if not episodes:
839                 title = _('Episodes are locked')
840                 message = _(
841                     'The selected episodes are locked. Please unlock the '
842                     'episodes that you want to delete before trying '
843                     'to delete them.')
844                 _notification(message, title)
845                 return False
847             count = len(episodes)
848             title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
849                        count) % {'count': count}
850             message = _('Deleting episodes removes downloaded files.')
852             if confirm and not _show_confirmation(message, title):
853                 return False
855             print(_('Please wait while episodes are deleted'))
857             def finish_deletion(episode_urls, channel_urls):
858                 # Episodes have been deleted - persist the database
859                 self.db.commit()
861             episode_urls = set()
862             channel_urls = set()
864             episodes_status_update = []
865             for idx, episode in enumerate(episodes):
866                 if not episode.archive:
867                     self._start_action(_('Deleting episode: %(episode)s') % {
868                             'episode': episode.title})
869                     episode.delete_from_disk()
870                     self._finish_action(success=True)
871                     episode_urls.add(episode.url)
872                     channel_urls.add(episode.channel.url)
873                     episodes_status_update.append(episode)
875             # Notify the web service about the status update + upload
876             if self.mygpo_client.can_access_webservice():
877                 self.mygpo_client.on_delete(episodes_status_update)
878                 self.mygpo_client.flush()
880             if callback is None:
881                 util.idle_add(finish_deletion, episode_urls, channel_urls)
882             else:
883                 util.idle_add(callback, episode_urls, channel_urls, None)
885             return True
887         def _episode_selector(parent_window, title=None, instructions=None, episodes=None,
888                               selected=None, columns=None, callback=None, _config=None):
889             if not interactive_console:
890                 return callback([e for i, e in enumerate(episodes) if selected[i]])
892             def show_list():
893                 self._pager('\n'.join(
894                     '[%s] %3d: %s' % (('X' if selected[index] else ' '), index + 1, ep_repr(e))
895                     for index, e in enumerate(episodes)))
897             print("{}. {}".format(title, instructions))
898             show_list()
900             msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
901             while True:
902                 index = input(msg + ': ')
904                 if not index:
905                     return callback([e for i, e in enumerate(episodes) if selected[i]])
907                 if index == '?':
908                     show_list()
909                     continue
910                 elif index == 'X':
911                     selected = [True, ] * len(episodes)
912                     show_list()
913                     continue
914                 elif index == ' ':
915                     selected = [False, ] * len(episodes)
916                     show_list()
917                     continue
918                 else:
919                     try:
920                         index = int(index)
921                     except ValueError:
922                         self._error(_('Invalid value.'))
923                         continue
925                     if not (1 <= index <= len(episodes)):
926                         self._error(_('Invalid value.'))
927                         continue
929                     e = episodes[index - 1]
930                     selected[index - 1] = not selected[index - 1]
931                     if selected[index - 1]:
932                         self._info(_('Will delete %(episode)s') % dict(episode=ep_repr(e)))
933                     else:
934                         self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
936         def _not_applicable(*args, **kwargs):
937             pass
939         def _mount_volume_for_file(file):
940             result, message = util.mount_volume_for_file(file, None)
941             if not result:
942                 self._error(_('mounting volume for file %(file)s failed with: %(error)s'
943                     % dict(file=file.get_uri(), error=message)))
944             return result
946         class DownloadStatusModel(object):
947             def register_task(self, ask):
948                 pass
950         class DownloadQueueManager(object):
951             def queue_task(x, task):
952                 def progress_updated(progress):
953                     self._update_action(progress)
954                 with self._action(_('Syncing %s') % ep_repr(task.episode)):
955                     task.status = sync.SyncTask.DOWNLOADING
956                     task.add_progress_callback(progress_updated)
957                     task.run()
959                     if task.notify_as_finished():
960                         if self._config.device_sync.after_sync.mark_episodes_played:
961                             logger.info('Marking as played on transfer: %s', task.episode.url)
962                             task.episode.mark(is_played=True)
964                         if self._config.device_sync.after_sync.delete_episodes:
965                             logger.info('Removing episode after transfer: %s', task.episode.url)
966                             task.episode.delete_from_disk()
968                     task.recycle()
970         done_lock = threading.Lock()
971         self.mygpo_client = my.MygPoClient(self._config)
972         sync_ui = gPodderSyncUI(self._config,
973                                 _notification,
974                                 None,
975                                 _show_confirmation,
976                                 _not_applicable,
977                                 self._model.get_podcasts(),
978                                 DownloadStatusModel(),
979                                 DownloadQueueManager(),
980                                 _not_applicable,
981                                 self._db.commit,
982                                 _delete_episode_list,
983                                 _episode_selector,
984                                 _mount_volume_for_file)
985         done_lock.acquire()
986         sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
987         done_lock.acquire()  # block until done
989     def _extensions_list(self):
990         def by_category(ext):
991             return ext.metadata.category
993         def by_enabled_name(ext):
994             return ('0' if ext.enabled else '1') + ext.name
996         for cat, extensions in itertools.groupby(sorted(gpodder.user_extensions.get_extensions(), key=by_category), by_category):
997             print(_(cat))
998             for ext in sorted(extensions, key=by_enabled_name):
999                 if ext.enabled:
1000                     print('  ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
1001                 else:
1002                     print('  ', inblue(ext.name), ext.metadata.title)
1003         return True
1005     def _extensions_info(self, ext):
1006         if ext.enabled:
1007             print(inyellow(ext.name))
1008         else:
1009             print(inblue(ext.name))
1011         print(_('Title:'), ext.metadata.title)
1012         print(_('Category:'), _(ext.metadata.category))
1013         print(_('Description:'), ext.metadata.description)
1014         print(_('Authors:'), ext.metadata.authors)
1015         if ext.metadata.doc:
1016             print(_('Documentation:'), ext.metadata.doc)
1017         print(_('Enabled:'), _('yes') if ext.enabled else _('no'))
1019         return True
1021     def _extension_enable(self, container, new_enabled):
1022         if container.enabled == new_enabled:
1023             return True
1025         enabled_extensions = list(self._config.extensions.enabled)
1027         if new_enabled and container.name not in enabled_extensions:
1028             enabled_extensions.append(container.name)
1029         elif not new_enabled and container.name in enabled_extensions:
1030             enabled_extensions.remove(container.name)
1032         self._config.extensions.enabled = enabled_extensions
1034         now_enabled = (container.name in self._config.extensions.enabled)
1035         if new_enabled == now_enabled:
1036             if now_enabled:
1037                 if getattr(container, 'on_ui_initialized', None) is not None:
1038                     container.on_ui_initialized(
1039                         self.core.model,
1040                         self._extensions_podcast_update_cb,
1041                         self._extensions_episode_download_cb)
1042             enabled_str = _('enabled') if now_enabled else _('disabled')
1043             self._info(
1044                 inblue(
1045                     _('Extension %(name)s (%(title)s) %(enabled)s')
1046                     % dict(name=container.name, title=container.metadata.title, enabled=enabled_str)))
1047         elif container.error is not None:
1048             if hasattr(container.error, 'message'):
1049                 error_msg = container.error.message
1050             else:
1051                 error_msg = str(container.error)
1052             self._error(_('Extension cannot be activated'))
1053             self._error(error_msg)
1054         return True
1056     @ExtensionsFunction
1057     def extensions(self, action='list', extension_name=None):
1058         if action in ('enable', 'disable', 'info'):
1059             if not extension_name:
1060                 print(inred('E: extensions {} missing the extension name').format(action))
1061                 return False
1062             extension = None
1063             for ext in gpodder.user_extensions.get_extensions():
1064                 if ext.name == extension_name:
1065                     extension = ext
1066                     break
1067             if not extension:
1068                 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
1069                 return False
1071         if action == 'list':
1072             return self._extensions_list()
1073         elif action in ('enable', 'disable'):
1074             self._extension_enable(extension, action == 'enable')
1075         elif action == 'info':
1076             self._extensions_info(extension)
1077         return True
1078     # -------------------------------------------------------------------
1080     def _pager(self, output):
1081         if have_ansi:
1082             # Need two additional rows for command prompt
1083             rows_needed = len(output.splitlines()) + 2
1084             rows, cols = get_terminal_size()
1085             if rows_needed < rows:
1086                 print(output)
1087             else:
1088                 pydoc.pager(output)
1089         else:
1090             print(output)
1092     def _shell(self):
1093         print(os.linesep.join(x.strip() for x in ("""
1094         gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1095         %(__copyright__)s
1096         License: %(__license__)s
1098         Entering interactive shell. Type 'help' for help.
1099         Press Ctrl+D (EOF) or type 'quit' to quit.
1100         """ % gpodder.__dict__).splitlines()))
1102         cli._run_cleanups()
1104         if readline is not None:
1105             readline.parse_and_bind('tab: complete')
1106             readline.set_completer(self._tab_completion)
1107             readline.set_completer_delims(' ')
1109         while True:
1110             try:
1111                 line = input('gpo> ')
1112             except EOFError:
1113                 print('')
1114                 break
1115             except KeyboardInterrupt:
1116                 print('')
1117                 continue
1119             if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1120                 break
1122             try:
1123                 args = shlex.split(line)
1124             except ValueError as value_error:
1125                 self._error(_('Syntax error: %(error)s') %
1126                             {'error': value_error})
1127                 continue
1129             try:
1130                 self._parse(args)
1131             except KeyboardInterrupt:
1132                 self._error('Keyboard interrupt.')
1133             except EOFError:
1134                 self._error('EOF.')
1136         self._atexit()
1138     def _error(self, *args):
1139         print(inred(' '.join(args)), file=sys.stderr)
1141     # Warnings look like error messages for now
1142     _warn = _error
1144     def _info(self, *args):
1145         print(*args)
1147     def _checkargs(self, func, command_line):
1148         argspec = inspect.getfullargspec(func)
1149         assert not argspec.kwonlyargs  # keyword-only arguments are unsupported
1150         args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1151         args.pop(0)  # Remove "self" from args
1152         defaults = defaults or ()
1153         minarg, maxarg = len(args) - len(defaults), len(args)
1155         if (len(command_line) < minarg or
1156                 (len(command_line) > maxarg and varargs is None)):
1157             self._error('Wrong argument count for %s.' % func.__name__)
1158             return False
1160         return func(*command_line)
1162     def _tab_completion_podcast(self, text, count):
1163         """Tab completion for podcast URLs"""
1164         urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1165         if count < len(urls):
1166             return urls[count]
1168         return None
1170     def _tab_completion_in(self, text, count, choices):
1171         """Tab completion for a list of choices"""
1172         compat_choices = [c for c in choices if text in c]
1173         if count < len(compat_choices):
1174             return compat_choices[count]
1176         return None
1178     def _tab_completion_extensions(self, text, count):
1179         """Tab completion for extension names"""
1180         exts = [e.name for e in gpodder.user_extensions.get_extensions() if text in e.name]
1181         if count < len(exts):
1182             return exts[count]
1184         return None
1186     def _tab_completion(self, text, count):
1187         """Tab completion function for readline"""
1188         if readline is None:
1189             return None
1191         current_line = readline.get_line_buffer()
1192         if text == current_line:
1193             for name in self._valid_commands:
1194                 if name.startswith(text):
1195                     if count == 0:
1196                         return name
1197                     else:
1198                         count -= 1
1199         else:
1200             args = current_line.split()
1201             command = args.pop(0)
1202             command_function = getattr(self, command, None)
1203             if not command_function:
1204                 return None
1205             if getattr(command_function, '_first_arg_is_podcast', False):
1206                 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1207                     return self._tab_completion_podcast(text, count)
1208             first_in = getattr(command_function, '_first_arg_in', False)
1209             if first_in:
1210                 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1211                     return self._tab_completion_in(text, count, first_in)
1212             snd_ext = getattr(command_function, '_second_arg_is_extension', False)
1213             if snd_ext:
1214                 if (len(args) > 0 and len(args) < 2 and args[0] != 'list') or (len(args) == 2 and not current_line.endswith(' ')):
1215                     return self._tab_completion_extensions(text, count)
1217         return None
1219     def _parse_single(self, command_line):
1220         try:
1221             result = self._parse(command_line)
1222         except KeyboardInterrupt:
1223             self._error('Keyboard interrupt.')
1224             result = -1
1225         self._atexit()
1226         return result
1228     def _parse(self, command_line):
1229         if not command_line:
1230             return False
1232         command = command_line.pop(0)
1234         # Resolve command aliases
1235         command = self._prefixes.get(command, command)
1237         if command in self._commands:
1238             func = self._commands[command]
1239             if inspect.ismethod(func):
1240                 return self._checkargs(func, command_line)
1242         if command in self._expansions:
1243             print(_('Ambiguous command. Did you mean..'))
1244             for cmd in self._expansions[command]:
1245                 print('   ', inblue(cmd))
1246         else:
1247             self._error(_('The requested function is not available.'))
1249         return False
1252 def stylize(s):
1253     s = re.sub(r'    .{27}', lambda m: inblue(m.group(0)), s)
1254     s = re.sub(r'  - .*', lambda m: ingreen(m.group(0)), s)
1255     return s
1258 def main():
1259     global logger, cli
1260     logger = logging.getLogger(__name__)
1261     cli = gPodderCli()
1262     msg = model.check_root_folder_path()
1263     if msg:
1264         print(msg, file=sys.stderr)
1265     args = sys.argv[1:]
1266     if args:
1267         is_single_command = True
1268         cli._run_cleanups()
1269         cli._parse_single(args)
1270     elif interactive_console:
1271         cli._shell()
1272     else:
1273         print(__doc__, end='')
1276 if __name__ == '__main__':
1277     main()