2 # -*- coding: utf-8 -*-
5 # gPodder - A media aggregator and podcast client
6 # Copyright (c) 2005-2012 Thomas Perl and the gPodder Team
8 # gPodder is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
13 # gPodder is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 # gpo - A better command-line interface to gPodder using the gPodder API
24 # by Thomas Perl <thp@gpodder.org>; 2009-05-07
28 Usage: gpo [--verbose|-v] [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] Download new episodes (all or only from URL)
51 pending [URL] List new episodes (all or only from URL)
52 episodes [URL] List episodes (all or only from URL)
56 set [key] [value] List one (all) keys or set to a new value
60 youtube URL Resolve the YouTube URL to a download URL
61 rewrite OLDURL NEWURL Change the feed URL of [OLDURL] to [NEWURL]
62 webui [public] Start gPodder's Web UI server
63 (public = listen on all network interfaces)
90 # A poor man's argparse/getopt - but it works for our use case :)
92 for flag
in ('-v', '--verbose'):
98 gpodder_script
= sys
.argv
[0]
99 if os
.path
.islink(gpodder_script
):
100 gpodder_script
= os
.readlink(gpodder_script
)
101 gpodder_dir
= os
.path
.join(os
.path
.dirname(gpodder_script
), '..')
102 prefix
= os
.path
.abspath(os
.path
.normpath(gpodder_dir
))
104 src_dir
= os
.path
.join(prefix
, 'src')
106 if os
.path
.exists(os
.path
.join(src_dir
, 'gpodder', '__init__.py')):
107 # Run gPodder from local source folder (not installed)
108 sys
.path
.insert(0, src_dir
)
112 N_
= gpodder
.ngettext
114 gpodder
.prefix
= prefix
116 # This is the command-line UI variant
117 gpodder
.ui
.cli
= True
119 # Platform detection (i.e. Maemo 5, etc..)
120 gpodder
.detect_platform()
122 have_ansi
= sys
.stdout
.isatty() and not gpodder
.win32
123 interactive_console
= sys
.stdin
.isatty() and sys
.stdout
.isatty()
124 is_single_command
= False
126 from gpodder
import log
129 from gpodder
import api
130 from gpodder
import my
131 from gpodder
import opml
132 from gpodder
import util
133 from gpodder
.config
import config_value_to_string
135 def incolor(color_id
, s
):
136 if have_ansi
and cli
.config
.ui
.cli
.colors
:
137 return '\033[9%dm%s\033[0m' % (color_id
, s
)
140 def safe_print(*args
, **kwargs
):
142 return unicode(util
.convert_bytes(arg
))
144 ofile
= kwargs
.get('file', sys
.stdout
)
145 output
= u
' '.join(map(convert
, args
))
146 if ofile
.encoding
is None:
147 output
= util
.sanitize_encoding(output
)
149 output
= output
.encode(ofile
.encoding
, 'replace')
155 *** ENCODING FAIL ***
157 Please report this to http://bugs.gpodder.org/:
160 map(convert, args) = %s
163 """ % (repr(args
), repr(map(convert
, args
)), e
)
165 if kwargs
.get('newline', True):
166 ofile
.write(os
.linesep
)
169 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
170 inred
, ingreen
, inyellow
, inblue
= (functools
.partial(incolor
, x
)
171 for x
in range(1, 5))
173 def FirstArgumentIsPodcastURL(function
):
174 """Decorator for functions that take a podcast URL as first arg"""
175 setattr(function
, '_first_arg_is_podcast', True)
178 def get_terminal_size():
179 if None in (termios
, fcntl
, struct
):
182 s
= struct
.pack('HHHH', 0, 0, 0, 0)
183 stdout
= sys
.stdout
.fileno()
184 x
= fcntl
.ioctl(stdout
, termios
.TIOCGWINSZ
, s
)
185 rows
, cols
, xp
, yp
= struct
.unpack('HHHH', x
)
188 class gPodderCli(object):
190 EXIT_COMMANDS
= ('quit', 'exit', 'bye')
193 self
.client
= api
.PodcastClient()
194 self
.config
= self
.client
._config
196 self
._current
_action
= ''
197 self
._commands
= dict((name
.rstrip('_'), func
)
198 for name
, func
in inspect
.getmembers(self
)
199 if inspect
.ismethod(func
) and not name
.startswith('_'))
200 self
._prefixes
, self
._expansions
= self
._build
_prefixes
_expansions
()
201 self
._prefixes
.update({'?': 'help'})
202 self
._valid
_commands
= sorted(self
._prefixes
.values())
203 gpodder
.user_extensions
.on_ui_initialized(self
.client
.core
.model
,
204 self
._extensions
_podcast
_update
_cb
,
205 self
._extensions
_episode
_download
_cb
)
207 def _build_prefixes_expansions(self
):
209 expansions
= collections
.defaultdict(list)
210 names
= sorted(self
._commands
.keys())
211 names
.extend(self
.EXIT_COMMANDS
)
213 # Generator for all prefixes of a given string (longest first)
214 # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
215 mkprefixes
= lambda n
: (n
[:x
] for x
in xrange(len(n
), 0, -1))
217 # Return True if the given prefix is unique in "names"
218 is_unique
= lambda p
: len([n
for n
in names
if n
.startswith(p
)]) == 1
221 is_still_unique
= True
222 unique_expansion
= None
223 for prefix
in mkprefixes(name
):
224 if is_unique(prefix
):
225 unique_expansion
= '[%s]%s' % (prefix
, name
[len(prefix
):])
226 prefixes
[prefix
] = name
229 if unique_expansion
is not None:
230 expansions
[prefix
].append(unique_expansion
)
233 return prefixes
, expansions
235 def _extensions_podcast_update_cb(self
, podcast
):
236 self
._info
(_('Podcast update requested by extensions.'))
237 self
._update
_podcast
(podcast
)
239 def _extensions_episode_download_cb(self
, episode
):
240 self
._info
(_('Episode download requested by extensions.'))
241 self
._download
_episode
(episode
)
243 def _start_action(self
, msg
, *args
):
244 line
= util
.convert_bytes(msg
% args
)
245 if len(line
) > self
.COLUMNS
-7:
246 line
= line
[:self
.COLUMNS
-7-3] + '...'
248 line
= line
+ (' '*(self
.COLUMNS
-7-len(line
)))
249 self
._current
_action
= line
250 safe_print(self
._current
_action
, newline
=False)
252 def _update_action(self
, progress
):
254 progress
= '%3.0f%%' % (progress
*100.,)
255 result
= '['+inblue(progress
)+']'
256 safe_print('\r' + self
._current
_action
+ result
, newline
=False)
258 def _finish_action(self
, success
=True, skip
=False):
260 result
= '['+inyellow('SKIP')+']'
262 result
= '['+ingreen('DONE')+']'
264 result
= '['+inred('FAIL')+']'
267 safe_print('\r' + self
._current
_action
+ result
)
270 self
._current
_action
= ''
275 # -------------------------------------------------------------------
277 def import_(self
, url
):
278 for channel
in opml
.Importer(url
).items
:
279 self
.subscribe(channel
['url'], channel
.get('title'))
281 def export(self
, filename
):
282 podcasts
= self
.client
._model
.get_podcasts()
283 opml
.Exporter(filename
).write(podcasts
)
285 def subscribe(self
, url
, title
=None):
286 url
= util
.normalize_feed_url(url
)
288 self
._error
(_('Invalid URL.'))
291 if self
.client
.get_podcast(url
) is not None:
292 self
._info
(_('You are already subscribed to %s.') % url
)
296 if self
.client
.create_podcast(url
, title
) is None:
297 self
._error
(_('Cannot subscribe to %s.') % url
)
300 if hasattr(e
, 'strerror'):
301 self
._error
(e
.strerror
)
308 self
._info
(_('Successfully added %s.' % url
))
311 def _print_config(self
, search_for
):
312 for key
in self
.config
.all_keys():
313 if search_for
is None or search_for
.lower() in key
.lower():
314 value
= config_value_to_string(self
.config
._lookup
(key
))
315 safe_print(key
, '=', value
)
317 def set(self
, key
=None, value
=None):
319 self
._print
_config
(key
)
323 current_value
= self
.config
._lookup
(key
)
324 current_type
= type(current_value
)
326 self
._error
(_('This configuration option does not exist.'))
329 if current_type
== dict:
330 self
._error
(_('Can only set leaf configuration nodes.'))
333 self
.config
.update_field(key
, value
)
336 @FirstArgumentIsPodcastURL
337 def rename(self
, url
, title
):
338 podcast
= self
.client
.get_podcast(url
)
341 self
._error
(_('You are not subscribed to %s.') % url
)
343 old_title
= podcast
.title
344 podcast
.rename(title
)
346 self
._info
(_('Renamed %(old_title)s to %(new_title)s.') % {
347 'old_title': util
.convert_bytes(old_title
),
348 'new_title': util
.convert_bytes(title
),
353 @FirstArgumentIsPodcastURL
354 def unsubscribe(self
, url
):
355 podcast
= self
.client
.get_podcast(url
)
358 self
._error
(_('You are not subscribed to %s.') % url
)
362 self
._error
(_('Unsubscribed from %s.') % url
)
366 def _episodesList(self
, podcast
):
367 def status_str(episode
):
370 if episode
.is_downloaded
:
372 if episode
.is_deleted
:
377 episodes
= (u
'%3d. %s %s' % (i
+1, status_str(e
), e
.title
)
378 for i
, e
in enumerate(podcast
.get_episodes()))
381 @FirstArgumentIsPodcastURL
383 podcast
= self
.client
.get_podcast(url
)
386 self
._error
(_('You are not subscribed to %s.') % url
)
388 title
, url
, status
= podcast
.title
, podcast
.url
, podcast
.feed_update_status_msg()
389 episodes
= self
._episodesList
(podcast
)
390 episodes
= u
'\n '.join(episodes
)
394 Feed update is %(status)s
402 @FirstArgumentIsPodcastURL
403 def episodes(self
, url
=None):
405 for podcast
in self
.client
.get_podcasts():
406 podcast_printed
= False
407 if url
is None or podcast
.url
== url
:
408 episodes
= self
._episodesList
(podcast
)
409 episodes
= u
'\n '.join(episodes
)
413 """ % (podcast
.url
, episodes
))
415 self
._pager
(u
'\n'.join(output
))
419 for podcast
in self
.client
.get_podcasts():
420 if podcast
.update_enabled():
421 safe_print('#', ingreen(podcast
.title
))
423 safe_print('#', inred(podcast
.title
),
424 '-', _('Updates disabled'))
426 safe_print(podcast
.url
)
430 def _update_podcast(self
, podcast
):
431 self
._start
_action
(' %s', podcast
.title
)
434 self
._finish
_action
()
436 self
._finish
_action
(False)
438 def _pending_message(self
, count
):
439 return N_('%(count)d new episode', '%(count)d new episodes',
440 count
) % {'count': count
}
442 @FirstArgumentIsPodcastURL
443 def update(self
, url
=None):
445 safe_print(_('Checking for new episodes'))
446 for podcast
in self
.client
.get_podcasts():
447 if url
is not None and podcast
.url
!= url
:
450 if podcast
.update_enabled():
451 self
._update
_podcast
(podcast
)
452 count
+= sum(1 for e
in podcast
.get_episodes() if e
.is_new
)
454 self
._start
_action
(_('Skipping %(podcast)s') % {
455 'podcast': podcast
.title
})
456 self
._finish
_action
(skip
=True)
458 safe_print(inblue(self
._pending
_message
(count
)))
461 @FirstArgumentIsPodcastURL
462 def pending(self
, url
=None):
464 for podcast
in self
.client
.get_podcasts():
465 podcast_printed
= False
466 if url
is None or podcast
.url
== url
:
467 for episode
in podcast
.get_episodes():
469 if not podcast_printed
:
470 safe_print('#', ingreen(podcast
.title
))
471 podcast_printed
= True
472 safe_print(' ', episode
.title
)
475 safe_print(inblue(self
._pending
_message
(count
)))
478 def _download_episode(self
, episode
):
479 self
._start
_action
('Downloading %s', episode
.title
)
480 episode
.download(self
._update
_action
)
481 self
._finish
_action
()
483 @FirstArgumentIsPodcastURL
484 def download(self
, url
=None):
486 for podcast
in self
.client
.get_podcasts():
487 podcast_printed
= False
488 if url
is None or podcast
.url
== url
:
489 for episode
in podcast
.get_episodes():
491 if not podcast_printed
:
492 safe_print(inblue(podcast
.title
))
493 podcast_printed
= True
494 self
._download
_episode
(episode
)
497 safe_print(count
, 'episodes downloaded.')
500 @FirstArgumentIsPodcastURL
501 def disable(self
, url
):
502 podcast
= self
.client
.get_podcast(url
)
505 self
._error
(_('You are not subscribed to %s.') % url
)
509 self
._error
(_('Disabling feed update from %s.') % url
)
513 @FirstArgumentIsPodcastURL
514 def enable(self
, url
):
515 podcast
= self
.client
.get_podcast(url
)
518 self
._error
(_('You are not subscribed to %s.') % url
)
522 self
._error
(_('Enabling feed update from %s.') % url
)
526 def youtube(self
, url
):
527 yurl
= self
.client
.youtube_url_resolver(url
)
531 def webui(self
, public
=None):
532 from gpodder
import webui
533 if public
== 'public':
534 # Warn the user that the web UI is listening on all network
535 # interfaces, which could lead to problems.
536 # Only use this on a trusted, private network!
537 self
._warn
(_('Listening on ALL network interfaces.'))
538 webui
.main(only_localhost
=False, core
=self
.client
.core
)
540 webui
.main(core
=self
.client
.core
)
542 def search(self
, *terms
):
543 query
= ' '.join(terms
)
547 directory
= my
.Directory()
548 results
= directory
.search(query
)
549 self
._show
_directory
_results
(results
)
552 directory
= my
.Directory()
553 results
= directory
.toplist()
554 self
._show
_directory
_results
(results
, True)
556 def _show_directory_results(self
, results
, multiple
=False):
558 self
._error
(_('No podcasts found.'))
561 if not interactive_console
or is_single_command
:
562 safe_print('\n'.join(url
for title
, url
in results
))
566 self
._pager
('\n'.join(u
'%3d: %s\n %s' %
567 (index
+1, title
, url
if title
!= url
else '')
568 for index
, (title
, url
) in enumerate(results
)))
572 msg
= _('Enter index to subscribe, ? for list')
574 index
= raw_input(msg
+ ': ')
586 self
._error
(_('Invalid value.'))
589 if not (1 <= index
<= len(results
)):
590 self
._error
(_('Invalid value.'))
593 title
, url
= results
[index
-1]
594 self
._info
(_('Adding %s...') % title
)
599 @FirstArgumentIsPodcastURL
600 def rewrite(self
, old_url
, new_url
):
601 podcast
= self
.client
.get_podcast(old_url
)
603 self
._error
(_('You are not subscribed to %s.') % old_url
)
605 result
= podcast
.rewrite_url(new_url
)
607 self
._error
(_('Invalid URL: %s') % new_url
)
610 self
._error
(_('Changed URL from %(old_url)s to %(new_url)s.') %
618 safe_print(stylize(__doc__
), file=sys
.stderr
, newline
=False)
621 # -------------------------------------------------------------------
623 def _pager(self
, output
):
625 # Need two additional rows for command prompt
626 rows_needed
= len(output
.splitlines()) + 2
627 rows
, cols
= get_terminal_size()
628 if rows_needed
< rows
:
631 pydoc
.pager(util
.sanitize_encoding(output
))
636 safe_print(os
.linesep
.join(x
.strip() for x
in ("""
637 gPodder %(__version__)s "%(__relname__)s" (%(__date__)s) - %(__url__)s
639 License: %(__license__)s
641 Entering interactive shell. Type 'help' for help.
642 Press Ctrl+D (EOF) or type 'quit' to quit.
643 """ % gpodder
.__dict
__).splitlines()))
645 if readline
is not None:
646 readline
.parse_and_bind('tab: complete')
647 readline
.set_completer(self
._tab
_completion
)
648 readline
.set_completer_delims(' ')
652 line
= raw_input('gpo> ')
656 except KeyboardInterrupt:
660 if self
._prefixes
.get(line
, line
) in self
.EXIT_COMMANDS
:
664 args
= shlex
.split(line
)
665 except ValueError, value_error
:
666 self
._error
(_('Syntax error: %(error)s') %
667 {'error': value_error
})
672 except KeyboardInterrupt:
673 self
._error
('Keyboard interrupt.')
679 def _error(self
, *args
):
680 safe_print(inred(' '.join(args
)), file=sys
.stderr
)
682 # Warnings look like error messages for now
685 def _info(self
, *args
):
688 def _checkargs(self
, func
, command_line
):
689 args
, varargs
, keywords
, defaults
= inspect
.getargspec(func
)
690 args
.pop(0) # Remove "self" from args
691 defaults
= defaults
or ()
692 minarg
, maxarg
= len(args
)-len(defaults
), len(args
)
694 if len(command_line
) < minarg
or (len(command_line
) > maxarg
and \
696 self
._error
('Wrong argument count for %s.' % func
.__name
__)
699 return func(*command_line
)
701 def _tab_completion_podcast(self
, text
, count
):
702 """Tab completion for podcast URLs"""
703 urls
= [p
.url
for p
in self
.client
.get_podcasts() if text
in p
.url
]
704 if count
< len(urls
):
710 def _tab_completion(self
, text
, count
):
711 """Tab completion function for readline"""
715 current_line
= readline
.get_line_buffer()
716 if text
== current_line
:
717 for name
in self
._valid
_commands
:
718 if name
.startswith(text
):
724 args
= current_line
.split()
725 command
= args
.pop(0)
726 command_function
= getattr(self
, command
, None)
727 if not command_function
:
729 if getattr(command_function
, '_first_arg_is_podcast', False):
730 if not args
or (len(args
) == 1 and not current_line
.endswith(' ')):
731 return self
._tab
_completion
_podcast
(text
, count
)
736 def _parse_single(self
, command_line
):
738 result
= self
._parse
(command_line
)
739 except KeyboardInterrupt:
740 self
._error
('Keyboard interrupt.')
745 def _parse(self
, command_line
):
749 command
= command_line
.pop(0)
751 # Resolve command aliases
752 command
= self
._prefixes
.get(command
, command
)
754 if command
in self
._commands
:
755 func
= self
._commands
[command
]
756 if inspect
.ismethod(func
):
757 return self
._checkargs
(func
, command_line
)
759 if command
in self
._expansions
:
760 safe_print(_('Ambigous command. Did you mean..'))
761 for cmd
in self
._expansions
[command
]:
762 safe_print(' ', inblue(cmd
))
764 self
._error
(_('The requested function is not available.'))
770 s
= re
.sub(r
' .{27}', lambda m
: inblue(m
.group(0)), s
)
771 s
= re
.sub(r
' - .*', lambda m
: ingreen(m
.group(0)), s
)
774 if __name__
== '__main__':
778 is_single_command
= True
779 cli
._parse
_single
(args
)
780 elif interactive_console
:
783 safe_print(__doc__
, newline
=False)