2 # -*- coding: utf-8 -*-
5 # gPodder - A media aggregator and podcast client
6 # Copyright (c) 2005-2013 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)
64 pipe Start gPodder in pipe-based IPC server mode
68 from __future__
import print_function
93 # A poor man's argparse/getopt - but it works for our use case :)
95 for flag
in ('-v', '--verbose'):
101 gpodder_script
= sys
.argv
[0]
102 if os
.path
.islink(gpodder_script
):
103 gpodder_script
= os
.readlink(gpodder_script
)
104 gpodder_dir
= os
.path
.join(os
.path
.dirname(gpodder_script
), '..')
105 prefix
= os
.path
.abspath(os
.path
.normpath(gpodder_dir
))
107 src_dir
= os
.path
.join(prefix
, 'src')
109 if os
.path
.exists(os
.path
.join(src_dir
, 'gpodder', '__init__.py')):
110 # Run gPodder from local source folder (not installed)
111 sys
.path
.insert(0, src_dir
)
115 N_
= gpodder
.ngettext
117 gpodder
.images_folder
= os
.path
.join(prefix
, 'share', 'gpodder', 'images')
118 gpodder
.prefix
= prefix
120 # This is the command-line UI variant
121 gpodder
.ui
.cli
= True
123 # Platform detection (i.e. MeeGo 1.2 Harmattan, etc..)
124 gpodder
.detect_platform()
126 have_ansi
= sys
.stdout
.isatty() and not gpodder
.ui
.win32
127 interactive_console
= sys
.stdin
.isatty() and sys
.stdout
.isatty()
128 is_single_command
= False
130 from gpodder
import log
133 from gpodder
import core
134 from gpodder
import download
135 from gpodder
import my
136 from gpodder
import opml
137 from gpodder
import util
138 from gpodder
import youtube
139 from gpodder
.config
import config_value_to_string
141 def incolor(color_id
, s
):
142 if have_ansi
and cli
._config
.ui
.cli
.colors
:
143 return '\033[9%dm%s\033[0m' % (color_id
, s
)
146 def safe_print(*args
, **kwargs
):
148 return unicode(util
.convert_bytes(arg
))
150 ofile
= kwargs
.get('file', sys
.stdout
)
151 output
= u
' '.join(map(convert
, args
))
152 if ofile
.encoding
is None:
153 output
= util
.sanitize_encoding(output
)
155 output
= output
.encode(ofile
.encoding
, 'replace')
161 *** ENCODING FAIL ***
163 Please report this to http://bugs.gpodder.org/:
166 map(convert, args) = %s
169 """ % (repr(args
), repr(map(convert
, args
)), e
))
171 ofile
.write(kwargs
.get('end', os
.linesep
))
174 # On Python 3 the encoding insanity is gone, so our safe_print()
175 # function simply becomes the normal print() function. Good stuff!
176 if sys
.version_info
>= (3,):
179 # ANSI Colors: red = 1, green = 2, yellow = 3, blue = 4
180 inred
, ingreen
, inyellow
, inblue
= (functools
.partial(incolor
, x
)
181 for x
in range(1, 5))
183 def FirstArgumentIsPodcastURL(function
):
184 """Decorator for functions that take a podcast URL as first arg"""
185 setattr(function
, '_first_arg_is_podcast', True)
188 def get_terminal_size():
189 if None in (termios
, fcntl
, struct
):
192 s
= struct
.pack('HHHH', 0, 0, 0, 0)
193 stdout
= sys
.stdout
.fileno()
194 x
= fcntl
.ioctl(stdout
, termios
.TIOCGWINSZ
, s
)
195 rows
, cols
, xp
, yp
= struct
.unpack('HHHH', x
)
198 class gPodderCli(object):
200 EXIT_COMMANDS
= ('quit', 'exit', 'bye')
203 self
.core
= core
.Core()
204 self
._db
= self
.core
.db
205 self
._config
= self
.core
.config
206 self
._model
= self
.core
.model
208 self
._current
_action
= ''
209 self
._commands
= dict((name
.rstrip('_'), func
)
210 for name
, func
in inspect
.getmembers(self
)
211 if inspect
.ismethod(func
) and not name
.startswith('_'))
212 self
._prefixes
, self
._expansions
= self
._build
_prefixes
_expansions
()
213 self
._prefixes
.update({'?': 'help'})
214 self
._valid
_commands
= sorted(self
._prefixes
.values())
215 gpodder
.user_extensions
.on_ui_initialized(self
.core
.model
,
216 self
._extensions
_podcast
_update
_cb
,
217 self
._extensions
_episode
_download
_cb
)
219 def _build_prefixes_expansions(self
):
221 expansions
= collections
.defaultdict(list)
222 names
= sorted(self
._commands
.keys())
223 names
.extend(self
.EXIT_COMMANDS
)
225 # Generator for all prefixes of a given string (longest first)
226 # e.g. ['gpodder', 'gpodde', 'gpodd', 'gpod', 'gpo', 'gp', 'g']
227 mkprefixes
= lambda n
: (n
[:x
] for x
in xrange(len(n
), 0, -1))
229 # Return True if the given prefix is unique in "names"
230 is_unique
= lambda p
: len([n
for n
in names
if n
.startswith(p
)]) == 1
233 is_still_unique
= True
234 unique_expansion
= None
235 for prefix
in mkprefixes(name
):
236 if is_unique(prefix
):
237 unique_expansion
= '[%s]%s' % (prefix
, name
[len(prefix
):])
238 prefixes
[prefix
] = name
241 if unique_expansion
is not None:
242 expansions
[prefix
].append(unique_expansion
)
245 return prefixes
, expansions
247 def _extensions_podcast_update_cb(self
, podcast
):
248 self
._info
(_('Podcast update requested by extensions.'))
249 self
._update
_podcast
(podcast
)
251 def _extensions_episode_download_cb(self
, episode
):
252 self
._info
(_('Episode download requested by extensions.'))
253 self
._download
_episode
(episode
)
255 def _start_action(self
, msg
, *args
):
256 line
= util
.convert_bytes(msg
% args
)
257 if len(line
) > self
.COLUMNS
-7:
258 line
= line
[:self
.COLUMNS
-7-3] + '...'
260 line
= line
+ (' '*(self
.COLUMNS
-7-len(line
)))
261 self
._current
_action
= line
262 safe_print(self
._current
_action
, end
='')
264 def _update_action(self
, progress
):
266 progress
= '%3.0f%%' % (progress
*100.,)
267 result
= '['+inblue(progress
)+']'
268 safe_print('\r' + self
._current
_action
+ result
, end
='')
270 def _finish_action(self
, success
=True, skip
=False):
272 result
= '['+inyellow('SKIP')+']'
274 result
= '['+ingreen('DONE')+']'
276 result
= '['+inred('FAIL')+']'
279 safe_print('\r' + self
._current
_action
+ result
)
282 self
._current
_action
= ''
287 # -------------------------------------------------------------------
289 def import_(self
, url
):
290 for channel
in opml
.Importer(url
).items
:
291 self
.subscribe(channel
['url'], channel
.get('title'))
293 def export(self
, filename
):
294 podcasts
= self
._model
.get_podcasts()
295 opml
.Exporter(filename
).write(podcasts
)
297 def get_podcast(self
, url
, create
=False):
298 """Get a specific podcast by URL
300 Returns a podcast object for the URL or None if
301 the podcast has not been subscribed to.
303 url
= util
.normalize_feed_url(url
)
305 self
._error
(_('Invalid url: %s') % url
)
308 podcast
= self
._model
.load_podcast(url
, create
=create
, \
309 max_episodes
=self
._config
.max_episodes_per_feed
)
311 self
._error
(_('You are not subscribed to %s.') % url
)
316 def subscribe(self
, url
, title
=None):
318 podcast
= self
.get_podcast(url
, create
=True)
320 self
._error
(_('Cannot subscribe to %s.') % url
)
323 if title
is not None:
324 podcast
.rename(title
)
327 if hasattr(e
, 'strerror'):
328 self
._error
(e
.strerror
)
335 self
._info
(_('Successfully added %s.' % url
))
338 def _print_config(self
, search_for
):
339 for key
in self
._config
.all_keys():
340 if search_for
is None or search_for
.lower() in key
.lower():
341 value
= config_value_to_string(self
._config
._lookup
(key
))
342 safe_print(key
, '=', value
)
344 def set(self
, key
=None, value
=None):
346 self
._print
_config
(key
)
350 current_value
= self
._config
._lookup
(key
)
351 current_type
= type(current_value
)
353 self
._error
(_('This configuration option does not exist.'))
356 if current_type
== dict:
357 self
._error
(_('Can only set leaf configuration nodes.'))
360 self
._config
.update_field(key
, value
)
363 @FirstArgumentIsPodcastURL
364 def rename(self
, url
, title
):
365 podcast
= self
.get_podcast(url
)
367 if podcast
is not None:
368 old_title
= podcast
.title
369 podcast
.rename(title
)
371 self
._info
(_('Renamed %(old_title)s to %(new_title)s.') % {
372 'old_title': util
.convert_bytes(old_title
),
373 'new_title': util
.convert_bytes(title
),
378 @FirstArgumentIsPodcastURL
379 def unsubscribe(self
, url
):
380 podcast
= self
.get_podcast(url
)
383 self
._error
(_('You are not subscribed to %s.') % url
)
387 self
._error
(_('Unsubscribed from %s.') % url
)
391 def is_episode_new(self
, episode
):
392 return (episode
.state
== gpodder
.STATE_NORMAL
and episode
.is_new
)
394 def _episodesList(self
, podcast
):
395 def status_str(episode
):
397 if self
.is_episode_new(episode
):
400 if (episode
.state
== gpodder
.STATE_DOWNLOADED
):
403 if (episode
.state
== gpodder
.STATE_DELETED
):
408 episodes
= (u
'%3d. %s %s' % (i
+1, status_str(e
), e
.title
)
409 for i
, e
in enumerate(podcast
.get_all_episodes()))
412 @FirstArgumentIsPodcastURL
414 podcast
= self
.get_podcast(url
)
417 self
._error
(_('You are not subscribed to %s.') % url
)
419 def feed_update_status_msg(podcast
):
420 if podcast
.pause_subscription
:
424 title
, url
, status
= podcast
.title
, podcast
.url
, \
425 feed_update_status_msg(podcast
)
426 episodes
= self
._episodesList
(podcast
)
427 episodes
= u
'\n '.join(episodes
)
431 Feed update is %(status)s
439 @FirstArgumentIsPodcastURL
440 def episodes(self
, url
=None):
442 for podcast
in self
._model
.get_podcasts():
443 podcast_printed
= False
444 if url
is None or podcast
.url
== url
:
445 episodes
= self
._episodesList
(podcast
)
446 episodes
= u
'\n '.join(episodes
)
450 """ % (podcast
.url
, episodes
))
452 self
._pager
(u
'\n'.join(output
))
456 for podcast
in self
._model
.get_podcasts():
457 if not podcast
.pause_subscription
:
458 safe_print('#', ingreen(podcast
.title
))
460 safe_print('#', inred(podcast
.title
),
461 '-', _('Updates disabled'))
463 safe_print(podcast
.url
)
467 def _update_podcast(self
, podcast
):
468 self
._start
_action
(' %s', podcast
.title
)
471 self
._finish
_action
()
473 self
._finish
_action
(False)
475 def _pending_message(self
, count
):
476 return N_('%(count)d new episode', '%(count)d new episodes',
477 count
) % {'count': count
}
479 @FirstArgumentIsPodcastURL
480 def update(self
, url
=None):
482 safe_print(_('Checking for new episodes'))
483 for podcast
in self
._model
.get_podcasts():
484 if url
is not None and podcast
.url
!= url
:
487 if not podcast
.pause_subscription
:
488 self
._update
_podcast
(podcast
)
489 count
+= sum(1 for e
in podcast
.get_all_episodes() if self
.is_episode_new(e
))
491 self
._start
_action
(_('Skipping %(podcast)s') % {
492 'podcast': podcast
.title
})
493 self
._finish
_action
(skip
=True)
495 safe_print(inblue(self
._pending
_message
(count
)))
498 @FirstArgumentIsPodcastURL
499 def pending(self
, url
=None):
501 for podcast
in self
._model
.get_podcasts():
502 podcast_printed
= False
503 if url
is None or podcast
.url
== url
:
504 for episode
in podcast
.get_all_episodes():
505 if self
.is_episode_new(episode
):
506 if not podcast_printed
:
507 safe_print('#', ingreen(podcast
.title
))
508 podcast_printed
= True
509 safe_print(' ', episode
.title
)
512 safe_print(inblue(self
._pending
_message
(count
)))
515 def _download_episode(self
, episode
):
516 self
._start
_action
('Downloading %s', episode
.title
)
518 task
= download
.DownloadTask(episode
, self
._config
)
519 task
.add_progress_callback(self
._update
_action
)
520 task
.status
= download
.DownloadTask
.QUEUED
523 self
._finish
_action
()
525 @FirstArgumentIsPodcastURL
526 def download(self
, url
=None):
528 for podcast
in self
._model
.get_podcasts():
529 podcast_printed
= False
530 if url
is None or podcast
.url
== url
:
531 for episode
in podcast
.get_all_episodes():
532 if self
.is_episode_new(episode
):
533 if not podcast_printed
:
534 safe_print(inblue(podcast
.title
))
535 podcast_printed
= True
536 self
._download
_episode
(episode
)
539 safe_print(count
, 'episodes downloaded.')
542 @FirstArgumentIsPodcastURL
543 def disable(self
, url
):
544 podcast
= self
.get_podcast(url
)
547 self
._error
(_('You are not subscribed to %s.') % url
)
549 if not podcast
.pause_subscription
:
550 podcast
.pause_subscription
= True
553 self
._error
(_('Disabling feed update from %s.') % url
)
557 @FirstArgumentIsPodcastURL
558 def enable(self
, url
):
559 podcast
= self
.get_podcast(url
)
562 self
._error
(_('You are not subscribed to %s.') % url
)
564 if podcast
.pause_subscription
:
565 podcast
.pause_subscription
= False
568 self
._error
(_('Enabling feed update from %s.') % url
)
572 def youtube(self
, url
):
573 fmt_ids
= youtube
.get_fmt_ids(self
._config
.youtube
)
574 yurl
= youtube
.get_real_download_url(url
, fmt_ids
)
579 def webui(self
, public
=None):
580 from gpodder
import webui
581 if public
== 'public':
582 # Warn the user that the web UI is listening on all network
583 # interfaces, which could lead to problems.
584 # Only use this on a trusted, private network!
585 self
._warn
(_('Listening on ALL network interfaces.'))
586 webui
.main(only_localhost
=False, core
=self
.core
)
588 webui
.main(core
=self
.core
)
591 from gpodder
import pipe
592 pipe
.main(core
=self
.core
)
594 def search(self
, *terms
):
595 query
= ' '.join(terms
)
599 directory
= my
.Directory()
600 results
= directory
.search(query
)
601 self
._show
_directory
_results
(results
)
604 directory
= my
.Directory()
605 results
= directory
.toplist()
606 self
._show
_directory
_results
(results
, True)
608 def _show_directory_results(self
, results
, multiple
=False):
610 self
._error
(_('No podcasts found.'))
613 if not interactive_console
or is_single_command
:
614 safe_print('\n'.join(url
for title
, url
in results
))
618 self
._pager
('\n'.join(u
'%3d: %s\n %s' %
619 (index
+1, title
, url
if title
!= url
else '')
620 for index
, (title
, url
) in enumerate(results
)))
624 msg
= _('Enter index to subscribe, ? for list')
626 index
= raw_input(msg
+ ': ')
638 self
._error
(_('Invalid value.'))
641 if not (1 <= index
<= len(results
)):
642 self
._error
(_('Invalid value.'))
645 title
, url
= results
[index
-1]
646 self
._info
(_('Adding %s...') % title
)
651 @FirstArgumentIsPodcastURL
652 def rewrite(self
, old_url
, new_url
):
653 podcast
= self
.get_podcast(old_url
)
655 self
._error
(_('You are not subscribed to %s.') % old_url
)
657 result
= podcast
.rewrite_url(new_url
)
659 self
._error
(_('Invalid URL: %s') % new_url
)
662 self
._error
(_('Changed URL from %(old_url)s to %(new_url)s.') %
670 safe_print(stylize(__doc__
), file=sys
.stderr
, end
='')
673 # -------------------------------------------------------------------
675 def _pager(self
, output
):
677 # Need two additional rows for command prompt
678 rows_needed
= len(output
.splitlines()) + 2
679 rows
, cols
= get_terminal_size()
680 if rows_needed
< rows
:
683 pydoc
.pager(util
.sanitize_encoding(output
))
688 safe_print(os
.linesep
.join(x
.strip() for x
in ("""
689 gPodder %(__version__)s "%(__relname__)s" (%(__date__)s) - %(__url__)s
691 License: %(__license__)s
693 Entering interactive shell. Type 'help' for help.
694 Press Ctrl+D (EOF) or type 'quit' to quit.
695 """ % gpodder
.__dict
__).splitlines()))
697 if readline
is not None:
698 readline
.parse_and_bind('tab: complete')
699 readline
.set_completer(self
._tab
_completion
)
700 readline
.set_completer_delims(' ')
704 line
= raw_input('gpo> ')
708 except KeyboardInterrupt:
712 if self
._prefixes
.get(line
, line
) in self
.EXIT_COMMANDS
:
716 args
= shlex
.split(line
)
717 except ValueError, value_error
:
718 self
._error
(_('Syntax error: %(error)s') %
719 {'error': value_error
})
724 except KeyboardInterrupt:
725 self
._error
('Keyboard interrupt.')
731 def _error(self
, *args
):
732 safe_print(inred(' '.join(args
)), file=sys
.stderr
)
734 # Warnings look like error messages for now
737 def _info(self
, *args
):
740 def _checkargs(self
, func
, command_line
):
741 args
, varargs
, keywords
, defaults
= inspect
.getargspec(func
)
742 args
.pop(0) # Remove "self" from args
743 defaults
= defaults
or ()
744 minarg
, maxarg
= len(args
)-len(defaults
), len(args
)
746 if len(command_line
) < minarg
or (len(command_line
) > maxarg
and \
748 self
._error
('Wrong argument count for %s.' % func
.__name
__)
751 return func(*command_line
)
753 def _tab_completion_podcast(self
, text
, count
):
754 """Tab completion for podcast URLs"""
755 urls
= [p
.url
for p
in self
._model
.get_podcasts() if text
in p
.url
]
756 if count
< len(urls
):
762 def _tab_completion(self
, text
, count
):
763 """Tab completion function for readline"""
767 current_line
= readline
.get_line_buffer()
768 if text
== current_line
:
769 for name
in self
._valid
_commands
:
770 if name
.startswith(text
):
776 args
= current_line
.split()
777 command
= args
.pop(0)
778 command_function
= getattr(self
, command
, None)
779 if not command_function
:
781 if getattr(command_function
, '_first_arg_is_podcast', False):
782 if not args
or (len(args
) == 1 and not current_line
.endswith(' ')):
783 return self
._tab
_completion
_podcast
(text
, count
)
788 def _parse_single(self
, command_line
):
790 result
= self
._parse
(command_line
)
791 except KeyboardInterrupt:
792 self
._error
('Keyboard interrupt.')
797 def _parse(self
, command_line
):
801 command
= command_line
.pop(0)
803 # Resolve command aliases
804 command
= self
._prefixes
.get(command
, command
)
806 if command
in self
._commands
:
807 func
= self
._commands
[command
]
808 if inspect
.ismethod(func
):
809 return self
._checkargs
(func
, command_line
)
811 if command
in self
._expansions
:
812 safe_print(_('Ambiguous command. Did you mean..'))
813 for cmd
in self
._expansions
[command
]:
814 safe_print(' ', inblue(cmd
))
816 self
._error
(_('The requested function is not available.'))
822 s
= re
.sub(r
' .{27}', lambda m
: inblue(m
.group(0)), s
)
823 s
= re
.sub(r
' - .*', lambda m
: ingreen(m
.group(0)), s
)
826 if __name__
== '__main__':
830 is_single_command
= True
831 cli
._parse
_single
(args
)
832 elif interactive_console
:
835 safe_print(__doc__
, end
='')