update dependencies
[gpodder.git] / bin / gpo
blob2850783dec178b1f96f136fa9184390573f8b308
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     @contextlib.contextmanager
220     def _action(self, msg):
221         self._start_action(msg)
222         try:
223             yield
224             self._finish_action()
225         except Exception as ex:
226             logger.warning('Action could not be completed', exc_info=True)
227             self._finish_action(False)
229     def _run_cleanups(self):
230         # Find expired (old) episodes and delete them
231         old_episodes = list(common.get_expired_episodes(self._model.get_podcasts(), self._config))
232         if old_episodes:
233             with self._action('Cleaning up old downloads'):
234                 for old_episode in old_episodes:
235                     old_episode.delete_from_disk()
237     def _build_prefixes_expansions(self):
238         prefixes = {}
239         expansions = collections.defaultdict(list)
240         names = sorted(self._commands.keys())
241         names.extend(self.EXIT_COMMANDS)
243         # Generator for all prefixes of a given string (longest first)
244         # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
245         def mkprefixes(n):
246             return (n[:x] for x in range(len(n), 0, -1))
248         # Return True if the given prefix is unique in "names"
249         def is_unique(p):
250             return len([n for n in names if n.startswith(p)]) == 1
252         for name in names:
253             is_still_unique = True
254             unique_expansion = None
255             for prefix in mkprefixes(name):
256                 if is_unique(prefix):
257                     unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
258                     prefixes[prefix] = name
259                     continue
261                 if unique_expansion is not None:
262                     expansions[prefix].append(unique_expansion)
263                     continue
265         return prefixes, expansions
267     def _extensions_podcast_update_cb(self, podcast):
268         self._info(_('Podcast update requested by extensions.'))
269         self._update_podcast(podcast)
271     def _extensions_episode_download_cb(self, episode):
272         self._info(_('Episode download requested by extensions.'))
273         self._download_episode(episode)
275     def _start_action(self, msg):
276         line = util.convert_bytes(msg)
277         if len(line) > self.COLUMNS - 7:
278             line = line[:self.COLUMNS - 7 - 3] + '...'
279         else:
280             line = line + (' ' * (self.COLUMNS - 7 - len(line)))
281         self._current_action = line
282         print(self._current_action, end='')
284     def _update_action(self, progress):
285         if have_ansi:
286             progress = '%3.0f%%' % (progress * 100.,)
287             result = '[' + inblue(progress) + ']'
288             print('\r' + self._current_action + result, end='')
290     def _finish_action(self, success=True, skip=False):
291         if skip:
292             result = '[' + inyellow('SKIP') + ']'
293         elif success:
294             result = '[' + ingreen('DONE') + ']'
295         else:
296             result = '[' + inred('FAIL') + ']'
298         if have_ansi:
299             print('\r' + self._current_action + result)
300         else:
301             print(result)
302         self._current_action = ''
304     def _atexit(self):
305         self.core.shutdown()
307     # -------------------------------------------------------------------
309     def import_(self, url):
310         for channel in opml.Importer(url).items:
311             self.subscribe(channel['url'], channel.get('title'))
313     def export(self, filename):
314         podcasts = self._model.get_podcasts()
315         opml.Exporter(filename).write(podcasts)
317     def get_podcast(self, original_url, create=False, check_only=False):
318         """Get a specific podcast by URL
320         Returns a podcast object for the URL or None if
321         the podcast has not been subscribed to.
322         """
323         url = util.normalize_feed_url(original_url)
324         if url is None:
325             self._error(_('Invalid url: %s') % original_url)
326             return None
328         # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
329         url = youtube.parse_youtube_url(url)
331         # Subscribe to new podcast
332         if create:
333             auth_tokens = {}
334             while True:
335                 try:
336                     return self._model.load_podcast(
337                         url, create=True,
338                         authentication_tokens=auth_tokens.get(url, None),
339                         max_episodes=self._config.limit.episodes)
340                 except feedcore.AuthenticationRequired as e:
341                     if e.url in auth_tokens:
342                         print(inred(_('Wrong username/password')))
343                         return None
344                     else:
345                         print(inyellow(_('Podcast requires authentication')))
346                         print(inyellow(_('Please login to %s:') % (url,)))
347                         username = input(_('User name:') + ' ')
348                         if username:
349                             password = input(_('Password:') + ' ')
350                             if password:
351                                 auth_tokens[e.url] = (username, password)
352                                 url = e.url
353                             else:
354                                 return None
355                         else:
356                             return None
358         # Load existing podcast
359         for podcast in self._model.get_podcasts():
360             if podcast.url == url:
361                 return podcast
363         if not check_only:
364             self._error(_('You are not subscribed to %s.') % url)
365         return None
367     def subscribe(self, url, title=None):
368         existing = self.get_podcast(url, check_only=True)
369         if existing is not None:
370             self._error(_('Already subscribed to %s.') % existing.url)
371             return True
373         try:
374             podcast = self.get_podcast(url, create=True)
375             if podcast is None:
376                 self._error(_('Cannot subscribe to %s.') % url)
377                 return True
379             if title is not None:
380                 podcast.rename(title)
381             podcast.save()
382         except Exception as e:
383             logger.warning('Cannot subscribe: %s', e, exc_info=True)
384             if hasattr(e, 'strerror'):
385                 self._error(e.strerror)
386             else:
387                 self._error(str(e))
388             return True
390         self._db.commit()
392         self._info(_('Successfully added %s.' % url))
393         return True
395     def _print_config(self, search_for):
396         for key in self._config.all_keys():
397             if search_for is None or search_for.lower() in key.lower():
398                 value = config_value_to_string(self._config._lookup(key))
399                 print(key, '=', value)
401     def set(self, key=None, value=None):
402         if value is None:
403             self._print_config(key)
404             return
406         try:
407             current_value = self._config._lookup(key)
408             current_type = type(current_value)
409         except KeyError:
410             self._error(_('This configuration option does not exist.'))
411             return
413         if current_type == dict:
414             self._error(_('Can only set leaf configuration nodes.'))
415             return
417         self._config.update_field(key, value)
418         self.set(key)
420     @FirstArgumentIsPodcastURL
421     def rename(self, url, title):
422         podcast = self.get_podcast(url)
424         if podcast is not None:
425             old_title = podcast.title
426             podcast.rename(title)
427             self._db.commit()
428             self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
429                 'old_title': util.convert_bytes(old_title),
430                 'new_title': util.convert_bytes(title),
431             })
433         return True
435     @FirstArgumentIsPodcastURL
436     def unsubscribe(self, url):
437         podcast = self.get_podcast(url)
439         if podcast is None:
440             self._error(_('You are not subscribed to %s.') % url)
441         else:
442             # Clean up downloads and download directories
443             common.clean_up_downloads()
445             podcast.delete()
446             self._db.commit()
447             self._error(_('Unsubscribed from %s.') % url)
449             # Delete downloaded episodes
450             podcast.remove_downloaded()
452             # TODO: subscribe and unsubscribe need to sync with mygpo
453             # Upload subscription list changes to the web service
454 #            self.mygpo_client.on_unsubscribe([podcast.url])
456         return True
458     def is_episode_new(self, episode):
459         return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
461     def _episodesList(self, podcast, show_guid=False):
462         def status_str(episode):
463             # is new
464             if self.is_episode_new(episode):
465                 return ' * '
466             # is downloaded
467             if (episode.state == gpodder.STATE_DOWNLOADED):
468                 return ' ▉ '
469             # is deleted
470             if (episode.state == gpodder.STATE_DELETED):
471                 return ' ░ '
473             return '   '
475         def guid_str(episode):
476             return ((' %s' % episode.guid) if show_guid else '')
478         episodes = ('%3d.%s %s %s' % (i + 1, guid_str(e),
479                                       status_str(e), e.title)
480                     for i, e in enumerate(podcast.get_all_episodes()))
481         return episodes
483     @FirstArgumentIsPodcastURL
484     def info(self, url):
485         podcast = self.get_podcast(url)
487         if podcast is None:
488             self._error(_('You are not subscribed to %s.') % url)
489         else:
490             def feed_update_status_msg(podcast):
491                 if podcast.pause_subscription:
492                     return "disabled"
493                 return "enabled"
495             title, url, description, link, status = (
496                 podcast.title, podcast.url, podcast.description, podcast.link,
497                 feed_update_status_msg(podcast))
498             description = '\n'.join(textwrap.wrap(description, subsequent_indent=' ' * 8))
499             episodes = self._episodesList(podcast)
500             episodes = '\n      '.join(episodes)
501             self._pager("""
502     Title: %(title)s
503     URL: %(url)s
504     Description:
505         %(description)s
506     Link: %(link)s
507     Feed update is %(status)s
509     Episodes:
510       %(episodes)s
511             """ % locals())
513         return True
515     @FirstArgumentIsPodcastURL
516     def episodes(self, *args):
517         show_guid = False
518         args = list(args)
519         # TODO: Start using argparse for things like that
520         if '--guid' in args:
521             args.remove('--guid')
522             show_guid = True
524         if len(args) > 1:
525             self._error(_('Invalid command.'))
526             return
527         elif len(args) == 1:
528             url = args[0]
529             if url.startswith('-'):
530                 self._error(_('Invalid option: %s.') % (url,))
531                 return
532         else:
533             url = None
535         output = []
536         for podcast in self._model.get_podcasts():
537             podcast_printed = False
538             if url is None or podcast.url == url:
539                 episodes = self._episodesList(podcast, show_guid=show_guid)
540                 episodes = '\n      '.join(episodes)
541                 output.append("""
542     Episodes from %s:
543       %s
544 """ % (podcast.url, episodes))
546         self._pager('\n'.join(output))
547         return True
549     def list(self):
550         for podcast in self._model.get_podcasts():
551             if not podcast.pause_subscription:
552                 print('#', ingreen(podcast.title))
553             else:
554                 print('#', inred(podcast.title),
555                       '-', _('Updates disabled'))
557             print(podcast.url)
559         return True
561     def _update_podcast(self, podcast):
562         with self._action(' %s' % podcast.title):
563             podcast.update()
565     def _pending_message(self, count):
566         return N_('%(count)d new episode', '%(count)d new episodes',
567                   count) % {'count': count}
569     @FirstArgumentIsPodcastURL
570     def update(self, url=None):
571         count = 0
572         print(_('Checking for new episodes'))
573         for podcast in self._model.get_podcasts():
574             if url is not None and podcast.url != url:
575                 continue
577             if not podcast.pause_subscription:
578                 self._update_podcast(podcast)
579                 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
580             else:
581                 self._start_action(_('Skipping %(podcast)s') % {
582                     'podcast': podcast.title})
583                 self._finish_action(skip=True)
585         util.delete_empty_folders(gpodder.downloads)
586         print(inblue(self._pending_message(count)))
587         return True
589     @FirstArgumentIsPodcastURL
590     def pending(self, url=None):
591         count = 0
592         for podcast in self._model.get_podcasts():
593             podcast_printed = False
594             if url is None or podcast.url == url:
595                 for episode in podcast.get_all_episodes():
596                     if self.is_episode_new(episode):
597                         if not podcast_printed:
598                             print('#', ingreen(podcast.title))
599                             podcast_printed = True
600                         print(' ', episode.title)
601                         count += 1
603         util.delete_empty_folders(gpodder.downloads)
604         print(inblue(self._pending_message(count)))
605         return True
607     @FirstArgumentIsPodcastURL
608     def partial(self, *args):
609         def by_channel(e):
610             return e.channel.title
612         def guid_str(episode):
613             return (('%s ' % episode.guid) if show_guid else '')
615         def on_finish(resumable_episodes):
616             count = len(resumable_episodes)
617             resumable_episodes = sorted(resumable_episodes, key=by_channel)
618             last_channel = None
619             for e in resumable_episodes:
620                 if e.channel != last_channel:
621                     print('#', ingreen(e.channel.title))
622                     last_channel = e.channel
623                 print('  %s%s' % (guid_str(e), e.title))
624             print(inblue(N_('%(count)d partial file',
625                    '%(count)d partial files',
626                    count) % {'count': count}))
628         show_guid = '--guid' in args
630         common.find_partial_downloads(self._model.get_podcasts(),
631                                       noop,
632                                       noop,
633                                       noop,
634                                       on_finish)
635         return True
637     def _download_episode(self, episode):
638         with self._action('Downloading %s' % episode.title):
639             if episode.download_task is None:
640                 task = download.DownloadTask(episode, self._config)
641             else:
642                 task = episode.download_task
643             task.add_progress_callback(self._update_action)
644             task.status = download.DownloadTask.DOWNLOADING
645             task.run()
646             task.recycle()
648     def _download_episodes(self, episodes):
649         if self._config.downloads.chronological_order:
650             # download older episodes first
651             episodes = list(model.Model.sort_episodes_by_pubdate(episodes))
653         if episodes:
654             # Queue episodes to create partial files
655             for e in episodes:
656                 if e.download_task is None:
657                     download.DownloadTask(e, self._config)
659             last_podcast = None
660             for episode in episodes:
661                 if episode.channel != last_podcast:
662                     print(inblue(episode.channel.title))
663                     last_podcast = episode.channel
664                 self._download_episode(episode)
666             util.delete_empty_folders(gpodder.downloads)
667         print(len(episodes), 'episodes downloaded.')
668         return True
670     @FirstArgumentIsPodcastURL
671     def download(self, url=None, guid=None):
672         episodes = []
673         for podcast in self._model.get_podcasts():
674             if url is None or podcast.url == url:
675                 for episode in podcast.get_all_episodes():
676                     if (not guid and self.is_episode_new(episode)) or (guid and episode.guid == guid):
677                         episodes.append(episode)
678         return self._download_episodes(episodes)
680     @FirstArgumentIsPodcastURL
681     def resume(self, guid=None):
682         def guid_str(episode):
683             return (('%s ' % episode.guid) if show_guid else '')
685         def on_finish(episodes):
686             if guid:
687                 episodes = [e for e in episodes if e.guid == guid]
688             self._download_episodes(episodes)
690         common.find_partial_downloads(self._model.get_podcasts(),
691                                       noop,
692                                       noop,
693                                       noop,
694                                       on_finish)
695         return True
697     @FirstArgumentIsPodcastURL
698     def delete(self, url, guid):
699         podcast = self.get_podcast(url)
700         episode_to_delete = None
702         if podcast is None:
703             self._error(_('You are not subscribed to %s.') % url)
704         else:
705             for episode in podcast.get_all_episodes():
706                 if (episode.guid == guid):
707                     episode_to_delete = episode
709             if not episode_to_delete:
710                 self._error(_('No episode with the specified GUID found.'))
711             else:
712                 if episode_to_delete.state != gpodder.STATE_DELETED:
713                     episode_to_delete.delete_from_disk()
714                     self._info(_('Deleted episode "%s".') % episode_to_delete.title)
715                 else:
716                     self._error(_('Episode has already been deleted.'))
718         return True
720     @FirstArgumentIsPodcastURL
721     def disable(self, url):
722         podcast = self.get_podcast(url)
724         if podcast is None:
725             self._error(_('You are not subscribed to %s.') % url)
726         else:
727             if not podcast.pause_subscription:
728                 podcast.pause_subscription = True
729                 podcast.save()
730             self._db.commit()
731             self._error(_('Disabling feed update from %s.') % url)
733         return True
735     @FirstArgumentIsPodcastURL
736     def enable(self, url):
737         podcast = self.get_podcast(url)
739         if podcast is None:
740             self._error(_('You are not subscribed to %s.') % url)
741         else:
742             if podcast.pause_subscription:
743                 podcast.pause_subscription = False
744                 podcast.save()
745             self._db.commit()
746             self._error(_('Enabling feed update from %s.') % url)
748         return True
750     def youtube(self, url):
751         fmt_ids = youtube.get_fmt_ids(self._config.youtube, False)
752         yurl, duration = youtube.get_real_download_url(url, False, fmt_ids)
753         if duration is not None:
754             episode.total_time = int(int(duration) / 1000)
755         print(yurl)
757         return True
759     def search(self, *terms):
760         query = ' '.join(terms)
761         if not query:
762             return
764         directory = my.Directory()
765         results = directory.search(query)
766         self._show_directory_results(results)
768     def toplist(self):
769         directory = my.Directory()
770         results = directory.toplist()
771         self._show_directory_results(results, True)
773     def _show_directory_results(self, results, multiple=False):
774         if not results:
775             self._error(_('No podcasts found.'))
776             return
778         if not interactive_console or is_single_command:
779             print('\n'.join(url for title, url in results))
780             return
782         def show_list():
783             self._pager('\n'.join(
784                 '%3d: %s\n     %s' % (index + 1, title, url if title != url else '')
785                 for index, (title, url) in enumerate(results)))
787         show_list()
789         msg = _('Enter index to subscribe, ? for list')
790         while True:
791             index = input(msg + ': ')
793             if not index:
794                 return
796             if index == '?':
797                 show_list()
798                 continue
800             try:
801                 index = int(index)
802             except ValueError:
803                 self._error(_('Invalid value.'))
804                 continue
806             if not (1 <= index <= len(results)):
807                 self._error(_('Invalid value.'))
808                 continue
810             title, url = results[index - 1]
811             self._info(_('Adding %s...') % title)
812             self.subscribe(url)
813             if not multiple:
814                 break
816     @FirstArgumentIsPodcastURL
817     def rewrite(self, old_url, new_url):
818         podcast = self.get_podcast(old_url)
819         if podcast is None:
820             self._error(_('You are not subscribed to %s.') % old_url)
821         else:
822             result = podcast.rewrite_url(new_url)
823             if result is None:
824                 self._error(_('Invalid URL: %s') % new_url)
825             else:
826                 new_url = result
827                 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
828                             {'old_url': old_url,
829                              'new_url': new_url, })
830         return True
832     def help(self):
833         print(stylize(__doc__), file=sys.stderr, end='')
834         return True
836     def sync(self):
837         def ep_repr(episode):
838             return '{} / {}'.format(episode.channel.title, episode.title)
840         def msg_title(title, message):
841             if title:
842                 msg = '{}: {}'.format(title, message)
843             else:
844                 msg = '{}'.format(message)
845             return msg
847         def _notification(message, title=None, important=False, widget=None):
848             print(msg_title(message, title))
850         def _show_confirmation(message, title=None):
851             msg = msg_title(message, title)
852             msg = _("%(title)s: %(msg)s ([yes]/no): ") % dict(title=title, msg=message)
853             if not interactive_console:
854                 return True
855             line = input(msg)
856             return not line or (line.lower() == _('yes'))
858         def _delete_episode_list(episodes, confirm=True, callback=None):
859             if not episodes:
860                 return False
862             episodes = [e for e in episodes if not e.archive]
864             if not episodes:
865                 title = _('Episodes are locked')
866                 message = _(
867                     'The selected episodes are locked. Please unlock the '
868                     'episodes that you want to delete before trying '
869                     'to delete them.')
870                 _notification(message, title)
871                 return False
873             count = len(episodes)
874             title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
875                        count) % {'count': count}
876             message = _('Deleting episodes removes downloaded files.')
878             if confirm and not _show_confirmation(message, title):
879                 return False
881             print(_('Please wait while episodes are deleted'))
883             def finish_deletion(episode_urls, channel_urls):
884                 # Episodes have been deleted - persist the database
885                 self.db.commit()
887             episode_urls = set()
888             channel_urls = set()
890             episodes_status_update = []
891             for idx, episode in enumerate(episodes):
892                 if not episode.archive:
893                     self._start_action(_('Deleting episode: %(episode)s') % {
894                             'episode': episode.title})
895                     episode.delete_from_disk()
896                     self._finish_action(success=True)
897                     episode_urls.add(episode.url)
898                     channel_urls.add(episode.channel.url)
899                     episodes_status_update.append(episode)
901             # Notify the web service about the status update + upload
902             if self.mygpo_client.can_access_webservice():
903                 self.mygpo_client.on_delete(episodes_status_update)
904                 self.mygpo_client.flush()
906             if callback is None:
907                 util.idle_add(finish_deletion, episode_urls, channel_urls)
908             else:
909                 util.idle_add(callback, episode_urls, channel_urls, None)
911             return True
913         def _episode_selector(parent_window, title=None, instructions=None, episodes=None,
914                               selected=None, columns=None, callback=None, _config=None):
915             if not interactive_console:
916                 return callback([e for i, e in enumerate(episodes) if selected[i]])
918             def show_list():
919                 self._pager('\n'.join(
920                     '[%s] %3d: %s' % (('X' if selected[index] else ' '), index + 1, ep_repr(e))
921                     for index, e in enumerate(episodes)))
923             print("{}. {}".format(title, instructions))
924             show_list()
926             msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
927             while True:
928                 index = input(msg + ': ')
930                 if not index:
931                     return callback([e for i, e in enumerate(episodes) if selected[i]])
933                 if index == '?':
934                     show_list()
935                     continue
936                 elif index == 'X':
937                     selected = [True, ] * len(episodes)
938                     show_list()
939                     continue
940                 elif index == ' ':
941                     selected = [False, ] * len(episodes)
942                     show_list()
943                     continue
944                 else:
945                     try:
946                         index = int(index)
947                     except ValueError:
948                         self._error(_('Invalid value.'))
949                         continue
951                     if not (1 <= index <= len(episodes)):
952                         self._error(_('Invalid value.'))
953                         continue
955                     e = episodes[index - 1]
956                     selected[index - 1] = not selected[index - 1]
957                     if selected[index - 1]:
958                         self._info(_('Will delete %(episode)s') % dict(episode=ep_repr(e)))
959                     else:
960                         self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
962         def _not_applicable(*args, **kwargs):
963             pass
965         def _mount_volume_for_file(file):
966             result, message = util.mount_volume_for_file(file, None)
967             if not result:
968                 self._error(_('mounting volume for file %(file)s failed with: %(error)s'
969                     % dict(file=file.get_uri(), error=message)))
970             return result
972         class DownloadStatusModel(object):
973             def register_task(self, ask):
974                 pass
976         class DownloadQueueManager(object):
977             def queue_task(x, task):
978                 def progress_updated(progress):
979                     self._update_action(progress)
980                 with self._action(_('Syncing %s') % ep_repr(task.episode)):
981                     task.status = sync.SyncTask.DOWNLOADING
982                     task.add_progress_callback(progress_updated)
983                     task.run()
985                     if task.notify_as_finished():
986                         if self._config.device_sync.after_sync.mark_episodes_played:
987                             logger.info('Marking as played on transfer: %s', task.episode.url)
988                             task.episode.mark(is_played=True)
990                         if self._config.device_sync.after_sync.delete_episodes:
991                             logger.info('Removing episode after transfer: %s', task.episode.url)
992                             task.episode.delete_from_disk()
994                     task.recycle()
996         done_lock = threading.Lock()
997         self.mygpo_client = my.MygPoClient(self._config)
998         sync_ui = gPodderSyncUI(self._config,
999                                 _notification,
1000                                 None,
1001                                 _show_confirmation,
1002                                 _not_applicable,
1003                                 self._model.get_podcasts(),
1004                                 DownloadStatusModel(),
1005                                 DownloadQueueManager(),
1006                                 _not_applicable,
1007                                 self._db.commit,
1008                                 _delete_episode_list,
1009                                 _episode_selector,
1010                                 _mount_volume_for_file)
1011         done_lock.acquire()
1012         sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
1013         done_lock.acquire()  # block until done
1015     def _extensions_list(self):
1016         def by_category(ext):
1017             return ext.metadata.category
1019         def by_enabled_name(ext):
1020             return ('0' if ext.enabled else '1') + ext.name
1022         for cat, extensions in itertools.groupby(sorted(gpodder.user_extensions.get_extensions(), key=by_category), by_category):
1023             print(_(cat))
1024             for ext in sorted(extensions, key=by_enabled_name):
1025                 if ext.enabled:
1026                     print('  ', inyellow(ext.name), ext.metadata.title, inyellow(_('(enabled)')))
1027                 else:
1028                     print('  ', inblue(ext.name), ext.metadata.title)
1029         return True
1031     def _extensions_info(self, ext):
1032         if ext.enabled:
1033             print(inyellow(ext.name))
1034         else:
1035             print(inblue(ext.name))
1037         print(_('Title:'), ext.metadata.title)
1038         print(_('Category:'), _(ext.metadata.category))
1039         print(_('Description:'), ext.metadata.description)
1040         print(_('Authors:'), ext.metadata.authors)
1041         if ext.metadata.doc:
1042             print(_('Documentation:'), ext.metadata.doc)
1043         print(_('Enabled:'), _('yes') if ext.enabled else _('no'))
1045         return True
1047     def _extension_enable(self, container, new_enabled):
1048         if container.enabled == new_enabled:
1049             return True
1051         enabled_extensions = list(self._config.extensions.enabled)
1053         if new_enabled and container.name not in enabled_extensions:
1054             enabled_extensions.append(container.name)
1055         elif not new_enabled and container.name in enabled_extensions:
1056             enabled_extensions.remove(container.name)
1058         self._config.extensions.enabled = enabled_extensions
1060         now_enabled = (container.name in self._config.extensions.enabled)
1061         if new_enabled == now_enabled:
1062             if now_enabled:
1063                 if getattr(container, 'on_ui_initialized', None) is not None:
1064                     container.on_ui_initialized(
1065                         self.core.model,
1066                         self._extensions_podcast_update_cb,
1067                         self._extensions_episode_download_cb)
1068             enabled_str = _('enabled') if now_enabled else _('disabled')
1069             self._info(
1070                 inblue(
1071                     _('Extension %(name)s (%(title)s) %(enabled)s')
1072                     % dict(name=container.name, title=container.metadata.title, enabled=enabled_str)))
1073         elif container.error is not None:
1074             if hasattr(container.error, 'message'):
1075                 error_msg = container.error.message
1076             else:
1077                 error_msg = str(container.error)
1078             self._error(_('Extension cannot be activated'))
1079             self._error(error_msg)
1080         return True
1082     @ExtensionsFunction
1083     def extensions(self, action='list', extension_name=None):
1084         if action in ('enable', 'disable', 'info'):
1085             if not extension_name:
1086                 print(inred('E: extensions {} missing the extension name').format(action))
1087                 return False
1088             extension = None
1089             for ext in gpodder.user_extensions.get_extensions():
1090                 if ext.name == extension_name:
1091                     extension = ext
1092                     break
1093             if not extension:
1094                 print(inred('E: extensions {} called with unknown extension name "{}"').format(action, extension_name))
1095                 return False
1097         if action == 'list':
1098             return self._extensions_list()
1099         elif action in ('enable', 'disable'):
1100             self._extension_enable(extension, action == 'enable')
1101         elif action == 'info':
1102             self._extensions_info(extension)
1103         return True
1104     # -------------------------------------------------------------------
1106     def _pager(self, output):
1107         if have_ansi:
1108             # Need two additional rows for command prompt
1109             rows_needed = len(output.splitlines()) + 2
1110             rows, cols = get_terminal_size()
1111             if rows_needed < rows:
1112                 print(output)
1113             else:
1114                 pydoc.pager(output)
1115         else:
1116             print(output)
1118     def _shell(self):
1119         print(os.linesep.join(x.strip() for x in ("""
1120         gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1121         %(__copyright__)s
1122         License: %(__license__)s
1124         Entering interactive shell. Type 'help' for help.
1125         Press Ctrl+D (EOF) or type 'quit' to quit.
1126         """ % gpodder.__dict__).splitlines()))
1128         cli._run_cleanups()
1130         if readline is not None:
1131             readline.parse_and_bind('tab: complete')
1132             readline.set_completer(self._tab_completion)
1133             readline.set_completer_delims(' ')
1135         while True:
1136             try:
1137                 line = input('gpo> ')
1138             except EOFError:
1139                 print('')
1140                 break
1141             except KeyboardInterrupt:
1142                 print('')
1143                 continue
1145             if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1146                 break
1148             try:
1149                 args = shlex.split(line)
1150             except ValueError as value_error:
1151                 self._error(_('Syntax error: %(error)s') %
1152                             {'error': value_error})
1153                 continue
1155             try:
1156                 self._parse(args)
1157             except KeyboardInterrupt:
1158                 self._error('Keyboard interrupt.')
1159             except EOFError:
1160                 self._error('EOF.')
1162         self._atexit()
1164     def _error(self, *args):
1165         print(inred(' '.join(args)), file=sys.stderr)
1167     # Warnings look like error messages for now
1168     _warn = _error
1170     def _info(self, *args):
1171         print(*args)
1173     def _checkargs(self, func, command_line):
1174         argspec = inspect.getfullargspec(func)
1175         assert not argspec.kwonlyargs  # keyword-only arguments are unsupported
1176         args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1177         args.pop(0)  # Remove "self" from args
1178         defaults = defaults or ()
1179         minarg, maxarg = len(args) - len(defaults), len(args)
1181         if (len(command_line) < minarg
1182                 or (len(command_line) > maxarg and varargs is None)):
1183             self._error('Wrong argument count for %s.' % func.__name__)
1184             return False
1186         return func(*command_line)
1188     def _tab_completion_podcast(self, text, count):
1189         """Tab completion for podcast URLs"""
1190         urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1191         if count < len(urls):
1192             return urls[count]
1194         return None
1196     def _tab_completion_in(self, text, count, choices):
1197         """Tab completion for a list of choices"""
1198         compat_choices = [c for c in choices if text in c]
1199         if count < len(compat_choices):
1200             return compat_choices[count]
1202         return None
1204     def _tab_completion_extensions(self, text, count):
1205         """Tab completion for extension names"""
1206         exts = [e.name for e in gpodder.user_extensions.get_extensions() if text in e.name]
1207         if count < len(exts):
1208             return exts[count]
1210         return None
1212     def _tab_completion(self, text, count):
1213         """Tab completion function for readline"""
1214         if readline is None:
1215             return None
1217         current_line = readline.get_line_buffer()
1218         if text == current_line:
1219             for name in self._valid_commands:
1220                 if name.startswith(text):
1221                     if count == 0:
1222                         return name
1223                     else:
1224                         count -= 1
1225         else:
1226             args = current_line.split()
1227             command = args.pop(0)
1228             command_function = getattr(self, command, None)
1229             if not command_function:
1230                 return None
1231             if getattr(command_function, '_first_arg_is_podcast', False):
1232                 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1233                     return self._tab_completion_podcast(text, count)
1234             first_in = getattr(command_function, '_first_arg_in', False)
1235             if first_in:
1236                 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1237                     return self._tab_completion_in(text, count, first_in)
1238             snd_ext = getattr(command_function, '_second_arg_is_extension', False)
1239             if snd_ext:
1240                 if (len(args) > 0 and len(args) < 2 and args[0] != 'list') or (len(args) == 2 and not current_line.endswith(' ')):
1241                     return self._tab_completion_extensions(text, count)
1243         return None
1245     def _parse_single(self, command_line):
1246         try:
1247             result = self._parse(command_line)
1248         except KeyboardInterrupt:
1249             self._error('Keyboard interrupt.')
1250             result = -1
1251         self._atexit()
1252         return result
1254     def _parse(self, command_line):
1255         if not command_line:
1256             return False
1258         command = command_line.pop(0)
1260         # Resolve command aliases
1261         command = self._prefixes.get(command, command)
1263         if command in self._commands:
1264             func = self._commands[command]
1265             if inspect.ismethod(func):
1266                 return self._checkargs(func, command_line)
1268         if command in self._expansions:
1269             print(_('Ambiguous command. Did you mean..'))
1270             for cmd in self._expansions[command]:
1271                 print('   ', inblue(cmd))
1272         else:
1273             self._error(_('The requested function is not available.'))
1275         return False
1278 def stylize(s):
1279     s = re.sub(r'    .{27}', lambda m: inblue(m.group(0)), s)
1280     s = re.sub(r'  - .*', lambda m: ingreen(m.group(0)), s)
1281     return s
1284 def main():
1285     global logger, cli
1286     logger = logging.getLogger(__name__)
1287     cli = gPodderCli()
1288     msg = model.check_root_folder_path()
1289     if msg:
1290         print(msg, file=sys.stderr)
1291     args = sys.argv[1:]
1292     if args:
1293         is_single_command = True
1294         cli._run_cleanups()
1295         cli._parse_single(args)
1296     elif interactive_console:
1297         cli._shell()
1298     else:
1299         print(__doc__, end='')
1302 if __name__ == '__main__':
1303     main()