fix unittest on python 3.5
[gpodder.git] / bin / gpo
blobd3ed847d69941f7fd7a63b33e01e5ee7f24aa106
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] [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   - Other commands -
67     youtube URL                Resolve the YouTube URL to a download URL
68     youtubefix                 Migrate old YouTube subscriptions to new feeds
69     rewrite OLDURL NEWURL      Change the feed URL of [OLDURL] to [NEWURL]
71 """
74 import collections
75 import contextlib
76 import functools
77 import inspect
78 import logging
79 import os
80 import pydoc
81 import re
82 import shlex
83 import sys
84 import threading
86 try:
87     import readline
88 except ImportError:
89     readline = None
91 try:
92     import termios
93     import fcntl
94     import struct
95 except ImportError:
96     termios = None
97     fcntl = None
98     struct = None
100 # A poor man's argparse/getopt - but it works for our use case :)
101 verbose = False
102 for flag in ('-v', '--verbose'):
103     if flag in sys.argv:
104         sys.argv.remove(flag)
105         verbose = True
106         break
108 gpodder_script = sys.argv[0]
109 gpodder_script = os.path.realpath(gpodder_script)
110 gpodder_dir = os.path.join(os.path.dirname(gpodder_script), '..')
111 # TODO: Read parent directory links as well (/bin -> /usr/bin, like on Fedora, see Bug #1618)
112 # This would allow /usr/share/gpodder/ (not /share/gpodder/) to be found from /bin/gpodder
113 prefix = os.path.abspath(os.path.normpath(gpodder_dir))
115 src_dir = os.path.join(prefix, 'src')
117 if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
118     # Run gPodder from local source folder (not installed)
119     sys.path.insert(0, src_dir)
121 import gpodder  # isort:skip
122 from gpodder import common, core, download, feedcore, log, model, my, opml, sync, util, youtube  # isort:skip
123 from gpodder.config import config_value_to_string  # isort:skip
124 from gpodder.syncui import gPodderSyncUI  # isort:skip
126 _ = gpodder.gettext
127 N_ = gpodder.ngettext
129 gpodder.images_folder = os.path.join(prefix, 'share', 'gpodder', 'images')
130 gpodder.prefix = prefix
132 # This is the command-line UI variant
133 gpodder.ui.cli = True
135 have_ansi = sys.stdout.isatty() and not gpodder.ui.win32
136 interactive_console = sys.stdin.isatty() and sys.stdout.isatty()
137 is_single_command = False
139 log.setup(verbose)
142 def noop(*args, **kwargs):
143     pass
146 def incolor(color_id, s):
147     if have_ansi and cli._config.ui.cli.colors:
148         return '\033[9%dm%s\033[0m' % (color_id, s)
149     return s
152 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
153 inred, ingreen, inyellow, inblue = (functools.partial(incolor, x) for x in range(1, 5))
156 def FirstArgumentIsPodcastURL(function):
157     """Decorator for functions that take a podcast URL as first arg"""
158     setattr(function, '_first_arg_is_podcast', True)
159     return function
162 def get_terminal_size():
163     if None in (termios, fcntl, struct):
164         return (80, 24)
166     s = struct.pack('HHHH', 0, 0, 0, 0)
167     stdout = sys.stdout.fileno()
168     x = fcntl.ioctl(stdout, termios.TIOCGWINSZ, s)
169     rows, cols, xp, yp = struct.unpack('HHHH', x)
170     return rows, cols
173 class gPodderCli(object):
174     COLUMNS = 80
175     EXIT_COMMANDS = ('quit', 'exit', 'bye')
177     def __init__(self):
178         self.core = core.Core()
179         self._db = self.core.db
180         self._config = self.core.config
181         self._model = self.core.model
183         self._current_action = ''
184         self._commands = dict(
185             (name.rstrip('_'), func)
186             for name, func in inspect.getmembers(self)
187             if inspect.ismethod(func) and not name.startswith('_'))
188         self._prefixes, self._expansions = self._build_prefixes_expansions()
189         self._prefixes.update({'?': 'help'})
190         self._valid_commands = sorted(self._prefixes.values())
191         gpodder.user_extensions.on_ui_initialized(
192             self.core.model,
193             self._extensions_podcast_update_cb,
194             self._extensions_episode_download_cb)
196     @contextlib.contextmanager
197     def _action(self, msg, *args):
198         self._start_action(msg, *args)
199         try:
200             yield
201             self._finish_action()
202         except Exception as ex:
203             logger.warning('Action could not be completed', exc_info=True)
204             self._finish_action(False)
206     def _run_cleanups(self):
207         # Find expired (old) episodes and delete them
208         old_episodes = list(common.get_expired_episodes(self._model.get_podcasts(), self._config))
209         if old_episodes:
210             with self._action('Cleaning up old downloads'):
211                 for old_episode in old_episodes:
212                     old_episode.delete_from_disk()
214     def _build_prefixes_expansions(self):
215         prefixes = {}
216         expansions = collections.defaultdict(list)
217         names = sorted(self._commands.keys())
218         names.extend(self.EXIT_COMMANDS)
220         # Generator for all prefixes of a given string (longest first)
221         # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
222         def mkprefixes(n):
223             return (n[:x] for x in range(len(n), 0, -1))
225         # Return True if the given prefix is unique in "names"
226         def is_unique(p):
227             return len([n for n in names if n.startswith(p)]) == 1
229         for name in names:
230             is_still_unique = True
231             unique_expansion = None
232             for prefix in mkprefixes(name):
233                 if is_unique(prefix):
234                     unique_expansion = '[%s]%s' % (prefix, name[len(prefix):])
235                     prefixes[prefix] = name
236                     continue
238                 if unique_expansion is not None:
239                     expansions[prefix].append(unique_expansion)
240                     continue
242         return prefixes, expansions
244     def _extensions_podcast_update_cb(self, podcast):
245         self._info(_('Podcast update requested by extensions.'))
246         self._update_podcast(podcast)
248     def _extensions_episode_download_cb(self, episode):
249         self._info(_('Episode download requested by extensions.'))
250         self._download_episode(episode)
252     def _start_action(self, msg, *args):
253         line = util.convert_bytes(msg % args)
254         if len(line) > self.COLUMNS - 7:
255             line = line[:self.COLUMNS - 7 - 3] + '...'
256         else:
257             line = line + (' ' * (self.COLUMNS - 7 - len(line)))
258         self._current_action = line
259         print(self._current_action, end='')
261     def _update_action(self, progress):
262         if have_ansi:
263             progress = '%3.0f%%' % (progress * 100.,)
264             result = '[' + inblue(progress) + ']'
265             print('\r' + self._current_action + result, end='')
267     def _finish_action(self, success=True, skip=False):
268         if skip:
269             result = '[' + inyellow('SKIP') + ']'
270         elif success:
271             result = '[' + ingreen('DONE') + ']'
272         else:
273             result = '[' + inred('FAIL') + ']'
275         if have_ansi:
276             print('\r' + self._current_action + result)
277         else:
278             print(result)
279         self._current_action = ''
281     def _atexit(self):
282         self.core.shutdown()
284     # -------------------------------------------------------------------
286     def import_(self, url):
287         for channel in opml.Importer(url).items:
288             self.subscribe(channel['url'], channel.get('title'))
290     def export(self, filename):
291         podcasts = self._model.get_podcasts()
292         opml.Exporter(filename).write(podcasts)
294     def get_podcast(self, original_url, create=False, check_only=False):
295         """Get a specific podcast by URL
297         Returns a podcast object for the URL or None if
298         the podcast has not been subscribed to.
299         """
300         url = util.normalize_feed_url(original_url)
301         if url is None:
302             self._error(_('Invalid url: %s') % original_url)
303             return None
305         # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
306         url = youtube.parse_youtube_url(url)
308         # Subscribe to new podcast
309         if create:
310             auth_tokens = {}
311             while True:
312                 try:
313                     return self._model.load_podcast(
314                         url, create=True,
315                         authentication_tokens=auth_tokens.get(url, None),
316                         max_episodes=self._config.max_episodes_per_feed)
317                 except feedcore.AuthenticationRequired as e:
318                     if e.url in auth_tokens:
319                         print(inred(_('Wrong username/password')))
320                         return None
321                     else:
322                         print(inyellow(_('Podcast requires authentication')))
323                         print(inyellow(_('Please login to %s:') % (url,)))
324                         username = input(_('User name:') + ' ')
325                         if username:
326                             password = input(_('Password:') + ' ')
327                             if password:
328                                 auth_tokens[e.url] = (username, password)
329                                 url = e.url
330                             else:
331                                 return None
332                         else:
333                             return None
335         # Load existing podcast
336         for podcast in self._model.get_podcasts():
337             if podcast.url == url:
338                 return podcast
340         if not check_only:
341             self._error(_('You are not subscribed to %s.') % url)
342         return None
344     def subscribe(self, url, title=None):
345         existing = self.get_podcast(url, check_only=True)
346         if existing is not None:
347             self._error(_('Already subscribed to %s.') % existing.url)
348             return True
350         try:
351             podcast = self.get_podcast(url, create=True)
352             if podcast is None:
353                 self._error(_('Cannot subscribe to %s.') % url)
354                 return True
356             if title is not None:
357                 podcast.rename(title)
358             podcast.save()
359         except Exception as e:
360             logger.warn('Cannot subscribe: %s', e, exc_info=True)
361             if hasattr(e, 'strerror'):
362                 self._error(e.strerror)
363             else:
364                 self._error(str(e))
365             return True
367         self._db.commit()
369         self._info(_('Successfully added %s.' % url))
370         return True
372     def _print_config(self, search_for):
373         for key in self._config.all_keys():
374             if search_for is None or search_for.lower() in key.lower():
375                 value = config_value_to_string(self._config._lookup(key))
376                 print(key, '=', value)
378     def set(self, key=None, value=None):
379         if value is None:
380             self._print_config(key)
381             return
383         try:
384             current_value = self._config._lookup(key)
385             current_type = type(current_value)
386         except KeyError:
387             self._error(_('This configuration option does not exist.'))
388             return
390         if current_type == dict:
391             self._error(_('Can only set leaf configuration nodes.'))
392             return
394         self._config.update_field(key, value)
395         self.set(key)
397     @FirstArgumentIsPodcastURL
398     def rename(self, url, title):
399         podcast = self.get_podcast(url)
401         if podcast is not None:
402             old_title = podcast.title
403             podcast.rename(title)
404             self._db.commit()
405             self._info(_('Renamed %(old_title)s to %(new_title)s.') % {
406                 'old_title': util.convert_bytes(old_title),
407                 'new_title': util.convert_bytes(title),
408             })
410         return True
412     @FirstArgumentIsPodcastURL
413     def unsubscribe(self, url):
414         podcast = self.get_podcast(url)
416         if podcast is None:
417             self._error(_('You are not subscribed to %s.') % url)
418         else:
419             podcast.delete()
420             self._db.commit()
421             self._error(_('Unsubscribed from %s.') % url)
423         return True
425     def is_episode_new(self, episode):
426         return (episode.state == gpodder.STATE_NORMAL and episode.is_new)
428     def _episodesList(self, podcast, show_guid=False):
429         def status_str(episode):
430             # is new
431             if self.is_episode_new(episode):
432                 return ' * '
433             # is downloaded
434             if (episode.state == gpodder.STATE_DOWNLOADED):
435                 return ' ▉ '
436             # is deleted
437             if (episode.state == gpodder.STATE_DELETED):
438                 return ' ░ '
440             return '   '
442         def guid_str(episode):
443             return ((' %s' % episode.guid) if show_guid else '')
445         episodes = ('%3d.%s %s %s' % (i + 1, guid_str(e),
446                                       status_str(e), e.title)
447                     for i, e in enumerate(podcast.get_all_episodes()))
448         return episodes
450     @FirstArgumentIsPodcastURL
451     def info(self, url):
452         podcast = self.get_podcast(url)
454         if podcast is None:
455             self._error(_('You are not subscribed to %s.') % url)
456         else:
457             def feed_update_status_msg(podcast):
458                 if podcast.pause_subscription:
459                     return "disabled"
460                 return "enabled"
462             title, url, status = podcast.title, podcast.url, \
463                 feed_update_status_msg(podcast)
464             episodes = self._episodesList(podcast)
465             episodes = '\n      '.join(episodes)
466             self._pager("""
467     Title: %(title)s
468     URL: %(url)s
469     Feed update is %(status)s
471     Episodes:
472       %(episodes)s
473             """ % locals())
475         return True
477     @FirstArgumentIsPodcastURL
478     def episodes(self, *args):
479         show_guid = False
480         args = list(args)
481         # TODO: Start using argparse for things like that
482         if '--guid' in args:
483             args.remove('--guid')
484             show_guid = True
486         if len(args) > 1:
487             self._error(_('Invalid command.'))
488             return
489         elif len(args) == 1:
490             url = args[0]
491             if url.startswith('-'):
492                 self._error(_('Invalid option: %s.') % (url,))
493                 return
494         else:
495             url = None
497         output = []
498         for podcast in self._model.get_podcasts():
499             podcast_printed = False
500             if url is None or podcast.url == url:
501                 episodes = self._episodesList(podcast, show_guid=show_guid)
502                 episodes = '\n      '.join(episodes)
503                 output.append("""
504     Episodes from %s:
505       %s
506 """ % (podcast.url, episodes))
508         self._pager('\n'.join(output))
509         return True
511     def list(self):
512         for podcast in self._model.get_podcasts():
513             if not podcast.pause_subscription:
514                 print('#', ingreen(podcast.title))
515             else:
516                 print('#', inred(podcast.title),
517                       '-', _('Updates disabled'))
519             print(podcast.url)
521         return True
523     def _update_podcast(self, podcast):
524         with self._action(' %s', podcast.title):
525             podcast.update()
527     def _pending_message(self, count):
528         return N_('%(count)d new episode', '%(count)d new episodes',
529                   count) % {'count': count}
531     @FirstArgumentIsPodcastURL
532     def update(self, url=None):
533         count = 0
534         print(_('Checking for new episodes'))
535         for podcast in self._model.get_podcasts():
536             if url is not None and podcast.url != url:
537                 continue
539             if not podcast.pause_subscription:
540                 self._update_podcast(podcast)
541                 count += sum(1 for e in podcast.get_all_episodes() if self.is_episode_new(e))
542             else:
543                 self._start_action(_('Skipping %(podcast)s') % {
544                     'podcast': podcast.title})
545                 self._finish_action(skip=True)
547         util.delete_empty_folders(gpodder.downloads)
548         print(inblue(self._pending_message(count)))
549         return True
551     @FirstArgumentIsPodcastURL
552     def pending(self, url=None):
553         count = 0
554         for podcast in self._model.get_podcasts():
555             podcast_printed = False
556             if url is None or podcast.url == url:
557                 for episode in podcast.get_all_episodes():
558                     if self.is_episode_new(episode):
559                         if not podcast_printed:
560                             print('#', ingreen(podcast.title))
561                             podcast_printed = True
562                         print(' ', episode.title)
563                         count += 1
565         util.delete_empty_folders(gpodder.downloads)
566         print(inblue(self._pending_message(count)))
567         return True
569     @FirstArgumentIsPodcastURL
570     def partial(self, *args):
571         def by_channel(e):
572             return e.channel.title
574         def guid_str(episode):
575             return (('%s ' % episode.guid) if show_guid else '')
577         def on_finish(resumable_episodes):
578             count = len(resumable_episodes)
579             resumable_episodes = sorted(resumable_episodes, key=by_channel)
580             last_channel = None
581             for e in resumable_episodes:
582                 if e.channel != last_channel:
583                     print('#', ingreen(e.channel.title))
584                     last_channel = e.channel
585                 print('  %s%s' % (guid_str(e), e.title))
586             print(inblue(N_('%(count)d partial file',
587                    '%(count)d partial files',
588                    count) % {'count': count}))
590         show_guid = '--guid' in args
592         common.find_partial_downloads(self._model.get_podcasts(),
593                                       noop,
594                                       noop,
595                                       on_finish)
596         return True
598     def _download_episode(self, episode):
599         with self._action('Downloading %s', episode.title):
600             task = download.DownloadTask(episode, self._config)
601             task.add_progress_callback(self._update_action)
602             task.status = download.DownloadTask.DOWNLOADING
603             task.run()
605     def _download_episodes(self, episodes):
606         if self._config.downloads.chronological_order:
607             # download older episodes first
608             episodes = list(model.Model.sort_episodes_by_pubdate(episodes))
610         if episodes:
611             last_podcast = None
612             for episode in episodes:
613                 if episode.channel != last_podcast:
614                     print(inblue(episode.channel.title))
615                     last_podcast = episode.channel
616                 self._download_episode(episode)
618             util.delete_empty_folders(gpodder.downloads)
619         print(len(episodes), 'episodes downloaded.')
620         return True
622     @FirstArgumentIsPodcastURL
623     def download(self, url=None, guid=None):
624         episodes = []
625         for podcast in self._model.get_podcasts():
626             if url is None or podcast.url == url:
627                 for episode in podcast.get_all_episodes():
628                     if (not guid and self.is_episode_new(episode)) or (guid and episode.guid == guid):
629                         episodes.append(episode)
630         return self._download_episodes(episodes)
632     @FirstArgumentIsPodcastURL
633     def resume(self, guid=None):
634         def guid_str(episode):
635             return (('%s ' % episode.guid) if show_guid else '')
637         def on_finish(episodes):
638             if guid:
639                 episodes = [e for e in episodes if e.guid == guid]
640             self._download_episodes(episodes)
642         common.find_partial_downloads(self._model.get_podcasts(),
643                                       noop,
644                                       noop,
645                                       on_finish)
646         return True
648     @FirstArgumentIsPodcastURL
649     def delete(self, url, guid):
650         podcast = self.get_podcast(url)
651         episode_to_delete = None
653         if podcast is None:
654             self._error(_('You are not subscribed to %s.') % url)
655         else:
656             for episode in podcast.get_all_episodes():
657                 if (episode.guid == guid):
658                     episode_to_delete = episode
660             if not episode_to_delete:
661                 self._error(_('No episode with the specified GUID found.'))
662             else:
663                 if episode_to_delete.state != gpodder.STATE_DELETED:
664                     episode_to_delete.delete_from_disk()
665                     self._info(_('Deleted episode "%s".') % episode_to_delete.title)
666                 else:
667                     self._error(_('Episode has already been deleted.'))
669         return True
671     @FirstArgumentIsPodcastURL
672     def disable(self, url):
673         podcast = self.get_podcast(url)
675         if podcast is None:
676             self._error(_('You are not subscribed to %s.') % url)
677         else:
678             if not podcast.pause_subscription:
679                 podcast.pause_subscription = True
680                 podcast.save()
681             self._db.commit()
682             self._error(_('Disabling feed update from %s.') % url)
684         return True
686     @FirstArgumentIsPodcastURL
687     def enable(self, url):
688         podcast = self.get_podcast(url)
690         if podcast is None:
691             self._error(_('You are not subscribed to %s.') % url)
692         else:
693             if podcast.pause_subscription:
694                 podcast.pause_subscription = False
695                 podcast.save()
696             self._db.commit()
697             self._error(_('Enabling feed update from %s.') % url)
699         return True
701     def youtube(self, url):
702         fmt_ids = youtube.get_fmt_ids(self._config.youtube)
703         yurl = youtube.get_real_download_url(url, fmt_ids)
704         print(yurl)
706         return True
708     def youtubefix(self):
709         if not self._config.youtube.api_key_v3:
710             self._error(_('Please register a YouTube API key and set it using %(command)s.') % {
711                 'command': 'set youtube.api_key_v3 KEY',
712             })
713             return False
715         reported_anything = False
716         for podcast in self._model.get_podcasts():
717             url, user = youtube.for_each_feed_pattern(lambda url, channel: (url, channel), podcast.url, (None, None))
718             if url is not None and user is not None:
719                 try:
720                     logger.info('Getting channels for YouTube user %s (%s)', user, url)
721                     new_urls = youtube.get_channels_for_user(user, self._config.youtube.api_key_v3)
722                     logger.debug('YouTube channels retrieved: %r', new_urls)
724                     if len(new_urls) != 1:
725                         self._info('%s: %s' % (url, _('No unique URL found')))
726                         reported_anything = True
727                         continue
729                     new_url = new_urls[0]
730                     if new_url in set(x.url for x in self._model.get_podcasts()):
731                         self._info('%s: %s' % (url, _('Already subscribed')))
732                         reported_anything = True
733                         continue
735                     logger.info('New feed location: %s => %s', url, new_url)
737                     self._info(_('Changing: %(old_url)s => %(new_url)s') % {'old_url': url, 'new_url': new_url})
738                     reported_anything = True
739                     podcast.url = new_url
740                     podcast.save()
741                 except Exception as e:
742                     logger.error('Exception happened while updating download list.', exc_info=True)
743                     self._error(_('Make sure the API key is correct. Error: %(message)s') % {'message': str(e)})
744                     return False
746             if not reported_anything:
747                 self._info(_('Nothing to fix'))
748             return True
750     def search(self, *terms):
751         query = ' '.join(terms)
752         if not query:
753             return
755         directory = my.Directory()
756         results = directory.search(query)
757         self._show_directory_results(results)
759     def toplist(self):
760         directory = my.Directory()
761         results = directory.toplist()
762         self._show_directory_results(results, True)
764     def _show_directory_results(self, results, multiple=False):
765         if not results:
766             self._error(_('No podcasts found.'))
767             return
769         if not interactive_console or is_single_command:
770             print('\n'.join(url for title, url in results))
771             return
773         def show_list():
774             self._pager('\n'.join(
775                 '%3d: %s\n     %s' % (index + 1, title, url if title != url else '')
776                 for index, (title, url) in enumerate(results)))
778         show_list()
780         msg = _('Enter index to subscribe, ? for list')
781         while True:
782             index = input(msg + ': ')
784             if not index:
785                 return
787             if index == '?':
788                 show_list()
789                 continue
791             try:
792                 index = int(index)
793             except ValueError:
794                 self._error(_('Invalid value.'))
795                 continue
797             if not (1 <= index <= len(results)):
798                 self._error(_('Invalid value.'))
799                 continue
801             title, url = results[index - 1]
802             self._info(_('Adding %s...') % title)
803             self.subscribe(url)
804             if not multiple:
805                 break
807     @FirstArgumentIsPodcastURL
808     def rewrite(self, old_url, new_url):
809         podcast = self.get_podcast(old_url)
810         if podcast is None:
811             self._error(_('You are not subscribed to %s.') % old_url)
812         else:
813             result = podcast.rewrite_url(new_url)
814             if result is None:
815                 self._error(_('Invalid URL: %s') % new_url)
816             else:
817                 new_url = result
818                 self._error(_('Changed URL from %(old_url)s to %(new_url)s.') %
819                             {'old_url': old_url,
820                              'new_url': new_url, })
821         return True
823     def help(self):
824         print(stylize(__doc__), file=sys.stderr, end='')
825         return True
827     def sync(self):
828         def ep_repr(episode):
829             return '{} / {}'.format(episode.channel.title, episode.title)
831         def msg_title(title, message):
832             if title:
833                 msg = '{}: {}'.format(title, message)
834             else:
835                 msg = '{}'.format(message)
836             return msg
838         def _notification(message, title=None, important=False, widget=None):
839             print(msg_title(message, title))
841         def _show_confirmation(message, title=None):
842             msg = msg_title(message, title)
843             msg = _("%(title)s: %(msg)s ([yes]/no): ") % dict(title=title, msg=message)
844             if not interactive_console:
845                 return True
846             line = input(msg)
847             return not line or (line.lower() == _('yes'))
849         def _delete_episode_list(episodes, confirm=True, skip_locked=True, callback=None):
850             if not episodes:
851                 return False
853             if skip_locked:
854                 episodes = [e for e in episodes if not e.archive]
856                 if not episodes:
857                     title = _('Episodes are locked')
858                     message = _(
859                         'The selected episodes are locked. Please unlock the '
860                         'episodes that you want to delete before trying '
861                         'to delete them.')
862                     _notification(message, title)
863                     return False
865             count = len(episodes)
866             title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
867                        count) % {'count': count}
868             message = _('Deleting episodes removes downloaded files.')
870             if confirm and not _show_confirmation(message, title):
871                 return False
873             print(_('Please wait while episodes are deleted'))
875             def finish_deletion(episode_urls, channel_urls):
876                 # Episodes have been deleted - persist the database
877                 self.db.commit()
879             episode_urls = set()
880             channel_urls = set()
882             episodes_status_update = []
883             for idx, episode in enumerate(episodes):
884                 if not episode.archive or not skip_locked:
885                     self._start_action(_('Deleting episode: %(episode)s') % {
886                             'episode': episode.title})
887                     episode.delete_from_disk()
888                     self._finish_action(success=True)
889                     episode_urls.add(episode.url)
890                     channel_urls.add(episode.channel.url)
891                     episodes_status_update.append(episode)
893             # Notify the web service about the status update + upload
894             if self.mygpo_client.can_access_webservice():
895                 self.mygpo_client.on_delete(episodes_status_update)
896                 self.mygpo_client.flush()
898             if callback is None:
899                 util.idle_add(finish_deletion, episode_urls, channel_urls)
900             else:
901                 util.idle_add(callback, episode_urls, channel_urls, None)
903             return True
905         def _episode_selector(parent_window, title=None, instructions=None, episodes=None,
906                               selected=None, columns=None, callback=None, _config=None):
907             if not interactive_console:
908                 return callback([e for i, e in enumerate(episodes) if selected[i]])
910             def show_list():
911                 self._pager('\n'.join(
912                     '[%s] %3d: %s' % (('X' if selected[index] else ' '), index + 1, ep_repr(e))
913                     for index, e in enumerate(episodes)))
915             print("{}. {}".format(title, instructions))
916             show_list()
918             msg = _('Enter episode index to toggle, ? for list, X to select all, space to select none, empty when ready')
919             while True:
920                 index = input(msg + ': ')
922                 if not index:
923                     return callback([e for i, e in enumerate(episodes) if selected[i]])
925                 if index == '?':
926                     show_list()
927                     continue
928                 elif index == 'X':
929                     selected = [True, ] * len(episodes)
930                     show_list()
931                     continue
932                 elif index == ' ':
933                     selected = [False, ] * len(episodes)
934                     show_list()
935                     continue
936                 else:
937                     try:
938                         index = int(index)
939                     except ValueError:
940                         self._error(_('Invalid value.'))
941                         continue
943                     if not (1 <= index <= len(episodes)):
944                         self._error(_('Invalid value.'))
945                         continue
947                     e = episodes[index - 1]
948                     selected[index - 1] = not selected[index - 1]
949                     if selected[index - 1]:
950                         self._info(_('Will delete %(episode)s') % dict(episode=ep_repr(e)))
951                     else:
952                         self._info(_("Won't delete %(episode)s") % dict(episode=ep_repr(e)))
954         def _not_applicable(*args, **kwargs):
955             pass
957         class DownloadStatusModel(object):
958             def register_task(self, ask):
959                 pass
961         class DownloadQueueManager(object):
962             def queue_task(x, task):
963                 def progress_updated(progress):
964                     self._update_action(progress)
965                 with self._action(_('Syncing %s'), ep_repr(task.episode)):
966                     task.status = sync.SyncTask.DOWNLOADING
967                     task.add_progress_callback(progress_updated)
968                     task.run()
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         done_lock.acquire()
985         sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
986         done_lock.acquire()  # block until done
988     # -------------------------------------------------------------------
990     def _pager(self, output):
991         if have_ansi:
992             # Need two additional rows for command prompt
993             rows_needed = len(output.splitlines()) + 2
994             rows, cols = get_terminal_size()
995             if rows_needed < rows:
996                 print(output)
997             else:
998                 pydoc.pager(output)
999         else:
1000             print(output)
1002     def _shell(self):
1003         print(os.linesep.join(x.strip() for x in ("""
1004         gPodder %(__version__)s (%(__date__)s) - %(__url__)s
1005         %(__copyright__)s
1006         License: %(__license__)s
1008         Entering interactive shell. Type 'help' for help.
1009         Press Ctrl+D (EOF) or type 'quit' to quit.
1010         """ % gpodder.__dict__).splitlines()))
1012         cli._run_cleanups()
1014         if readline is not None:
1015             readline.parse_and_bind('tab: complete')
1016             readline.set_completer(self._tab_completion)
1017             readline.set_completer_delims(' ')
1019         while True:
1020             try:
1021                 line = input('gpo> ')
1022             except EOFError:
1023                 print('')
1024                 break
1025             except KeyboardInterrupt:
1026                 print('')
1027                 continue
1029             if self._prefixes.get(line, line) in self.EXIT_COMMANDS:
1030                 break
1032             try:
1033                 args = shlex.split(line)
1034             except ValueError as value_error:
1035                 self._error(_('Syntax error: %(error)s') %
1036                             {'error': value_error})
1037                 continue
1039             try:
1040                 self._parse(args)
1041             except KeyboardInterrupt:
1042                 self._error('Keyboard interrupt.')
1043             except EOFError:
1044                 self._error('EOF.')
1046         self._atexit()
1048     def _error(self, *args):
1049         print(inred(' '.join(args)), file=sys.stderr)
1051     # Warnings look like error messages for now
1052     _warn = _error
1054     def _info(self, *args):
1055         print(*args)
1057     def _checkargs(self, func, command_line):
1058         argspec = inspect.getfullargspec(func)
1059         assert not argspec.kwonlyargs  # keyword-only arguments are unsupported
1060         args, varargs, keywords, defaults = argspec.args, argspec.varargs, argspec.varkw, argspec.defaults
1061         args.pop(0)  # Remove "self" from args
1062         defaults = defaults or ()
1063         minarg, maxarg = len(args) - len(defaults), len(args)
1065         if (len(command_line) < minarg or
1066                 (len(command_line) > maxarg and varargs is None)):
1067             self._error('Wrong argument count for %s.' % func.__name__)
1068             return False
1070         return func(*command_line)
1072     def _tab_completion_podcast(self, text, count):
1073         """Tab completion for podcast URLs"""
1074         urls = [p.url for p in self._model.get_podcasts() if text in p.url]
1075         if count < len(urls):
1076             return urls[count]
1078         return None
1080     def _tab_completion(self, text, count):
1081         """Tab completion function for readline"""
1082         if readline is None:
1083             return None
1085         current_line = readline.get_line_buffer()
1086         if text == current_line:
1087             for name in self._valid_commands:
1088                 if name.startswith(text):
1089                     if count == 0:
1090                         return name
1091                     else:
1092                         count -= 1
1093         else:
1094             args = current_line.split()
1095             command = args.pop(0)
1096             command_function = getattr(self, command, None)
1097             if not command_function:
1098                 return None
1099             if getattr(command_function, '_first_arg_is_podcast', False):
1100                 if not args or (len(args) == 1 and not current_line.endswith(' ')):
1101                     return self._tab_completion_podcast(text, count)
1103         return None
1105     def _parse_single(self, command_line):
1106         try:
1107             result = self._parse(command_line)
1108         except KeyboardInterrupt:
1109             self._error('Keyboard interrupt.')
1110             result = -1
1111         self._atexit()
1112         return result
1114     def _parse(self, command_line):
1115         if not command_line:
1116             return False
1118         command = command_line.pop(0)
1120         # Resolve command aliases
1121         command = self._prefixes.get(command, command)
1123         if command in self._commands:
1124             func = self._commands[command]
1125             if inspect.ismethod(func):
1126                 return self._checkargs(func, command_line)
1128         if command in self._expansions:
1129             print(_('Ambiguous command. Did you mean..'))
1130             for cmd in self._expansions[command]:
1131                 print('   ', inblue(cmd))
1132         else:
1133             self._error(_('The requested function is not available.'))
1135         return False
1138 def stylize(s):
1139     s = re.sub(r'    .{27}', lambda m: inblue(m.group(0)), s)
1140     s = re.sub(r'  - .*', lambda m: ingreen(m.group(0)), s)
1141     return s
1144 def main():
1145     global logger, cli
1146     logger = logging.getLogger(__name__)
1147     cli = gPodderCli()
1148     msg = model.check_root_folder_path()
1149     if msg:
1150         print(msg, file=sys.stderr)
1151     args = sys.argv[1:]
1152     if args:
1153         is_single_command = True
1154         cli._run_cleanups()
1155         cli._parse_single(args)
1156     elif interactive_console:
1157         cli._shell()
1158     else:
1159         print(__doc__, end='')
1162 if __name__ == '__main__':
1163     main()