1# -*- coding: utf-8 -*- 2# Extension script to add a context menu item for enqueueing episodes in a player 3# Requirements: gPodder 3.x (or "tres" branch newer than 2011-06-08) 4# (c) 2011-06-08 Thomas Perl <thp.io/about> 5# Released under the same license terms as gPodder itself. 6import functools 7import logging 8import subprocess 9 10import gpodder 11from gpodder import util 12 13logger = logging.getLogger(__name__) 14 15_ = gpodder.gettext 16 17__title__ = _('Enqueue/Resume in media players') 18__description__ = _('Add a context menu item for enqueueing/resuming playback of episodes in installed media players') 19__authors__ = 'Thomas Perl <thp@gpodder.org>, Bernd Schlapsi <brot@gmx.info>' 20__doc__ = 'https://gpodder.github.io/docs/extensions/enqueueinmediaplayer.html' 21__payment__ = 'https://flattr.com/submit/auto?user_id=BerndSch&url=http://wiki.gpodder.org/wiki/Extensions/EnqueueInMediaplayer' 22__category__ = 'interface' 23__only_for__ = 'gtk' 24 25 26DefaultConfig = { 27 'enqueue_after_download': False, # Set to True to enqueue an episode right after downloading 28 'default_player': '', # Set to the player to be used for auto-enqueueing (otherwise pick first installed) 29} 30 31 32class Player(object): 33 def __init__(self, slug, application, command): 34 self.slug = slug 35 self.application = application 36 self.title = '/'.join((_('Enqueue in'), application)) 37 self.command = command 38 self.gpodder = None 39 40 def is_installed(self): 41 raise NotImplemented('Must be implemented by subclass') 42 43 def open_files(self, filenames): 44 raise NotImplemented('Must be implemented by subclass') 45 46 def enqueue_episodes(self, episodes, config=None): 47 filenames = [episode.get_playback_url(config=config) for episode in episodes] 48 49 self.open_files(filenames) 50 51 for episode in episodes: 52 episode.playback_mark() 53 if self.gpodder is not None: 54 self.gpodder.update_episode_list_icons(selected=True) 55 56 57class FreeDesktopPlayer(Player): 58 def is_installed(self): 59 return util.find_command(self.command[0]) is not None 60 61 def open_files(self, filenames): 62 util.Popen(self.command + filenames) 63 64 65class Win32Player(Player): 66 def is_installed(self): 67 if not gpodder.ui.win32: 68 return False 69 70 from gpodder.gtkui.desktopfile import win32_read_registry_key 71 try: 72 self.command = win32_read_registry_key(self.command) 73 return True 74 except Exception as e: 75 logger.warn('Win32 player not found: %s (%s)', self.command, e) 76 77 return False 78 79 def open_files(self, filenames): 80 for cmd in util.format_desktop_command(self.command, filenames): 81 util.Popen(cmd, close_fds=True) 82 83 84class MPRISResumer(FreeDesktopPlayer): 85 """ 86 resume episod playback at saved time 87 """ 88 OBJECT_PLAYER = '/org/mpris/MediaPlayer2' 89 OBJECT_DBUS = '/org/freedesktop/DBus' 90 INTERFACE_PLAYER = 'org.mpris.MediaPlayer2.Player' 91 INTERFACE_PROPS = 'org.freedesktop.DBus.Properties' 92 SIGNAL_PROP_CHANGE = 'PropertiesChanged' 93 NAME_DBUS = 'org.freedesktop.DBus' 94 95 def __init__(self, slug, application, command, bus_name): 96 super(MPRISResumer, self).__init__(slug, application, command) 97 self.title = '/'.join((_('Resume in'), application)) 98 self.bus_name = bus_name 99 self.player = None 100 self.position_us = None 101 self.url = None 102 103 def is_installed(self): 104 if gpodder.ui.win32: 105 return False 106 return util.find_command(self.command[0]) is not None 107 108 def enqueue_episodes(self, episodes, config=None): 109 self.do_enqueue(episodes[0].get_playback_url(config=config), 110 episodes[0].current_position) 111 112 for episode in episodes: 113 episode.playback_mark() 114 if self.gpodder is not None: 115 self.gpodder.update_episode_list_icons(selected=True) 116 117 def init_dbus(self): 118 bus = gpodder.dbus_session_bus 119 120 if not bus.name_has_owner(self.bus_name): 121 logger.debug('MPRISResumer %s is not there...', self.bus_name) 122 return False 123 124 self.player = bus.get_object(self.bus_name, self.OBJECT_PLAYER) 125 self.signal_match = self.player.connect_to_signal(self.SIGNAL_PROP_CHANGE, 126 self.on_prop_change, 127 dbus_interface=self.INTERFACE_PROPS) 128 return True 129 130 def enqueue_when_ready(self, filename, pos): 131 def name_owner_changed(name, old_owner, new_owner): 132 logger.debug('name_owner_changed "%s" "%s" "%s"', 133 name, old_owner, new_owner) 134 if name == self.bus_name: 135 logger.debug('MPRISResumer player %s is there', name) 136 cancel.remove() 137 util.idle_add(lambda: self.do_enqueue(filename, pos)) 138 139 bus = gpodder.dbus_session_bus 140 obj = bus.get_object(self.NAME_DBUS, self.OBJECT_DBUS) 141 cancel = obj.connect_to_signal('NameOwnerChanged', name_owner_changed, dbus_interface=self.NAME_DBUS) 142 143 def do_enqueue(self, filename, pos): 144 def on_reply(): 145 logger.debug('MPRISResumer opened %s', self.url) 146 147 def on_error(exception): 148 logger.error('MPRISResumer error %s', repr(exception)) 149 self.signal_match.remove() 150 151 if filename.startswith('/'): 152 try: 153 import pathlib 154 self.url = pathlib.Path(filename).as_uri() 155 except ImportError: 156 self.url = 'file://' + filename 157 self.position_us = pos * 1000 * 1000 # pos in microseconds 158 if self.init_dbus(): 159 # async to not freeze the ui waiting for the application to answer 160 self.player.OpenUri(self.url, 161 dbus_interface=self.INTERFACE_PLAYER, 162 reply_handler=on_reply, 163 error_handler=on_error) 164 else: 165 self.enqueue_when_ready(filename, pos) 166 logger.debug('MPRISResumer launching player %s', self.application) 167 super(MPRISResumer, self).open_files([]) 168 169 def on_prop_change(self, interface, props, invalidated_props): 170 def on_reply(): 171 pass 172 173 def on_error(exception): 174 logger.error('MPRISResumer SetPosition error %s', repr(exception)) 175 self.signal_match.remove() 176 177 metadata = props.get('Metadata', {}) 178 url = metadata.get('xesam:url') 179 track_id = metadata.get('mpris:trackid') 180 if url is not None and track_id is not None: 181 if url == self.url: 182 logger.info('Enqueue %s setting track %s position=%d', 183 url, track_id, self.position_us) 184 self.player.SetPosition(str(track_id), self.position_us, 185 dbus_interface=self.INTERFACE_PLAYER, 186 reply_handler=on_reply, 187 error_handler=on_error) 188 else: 189 logger.debug('Changed but wrong url: %s, giving up', url) 190 self.signal_match.remove() 191 192 193PLAYERS = [ 194 # Amarok, http://amarok.kde.org/ 195 FreeDesktopPlayer('amarok', 'Amarok', ['amarok', '--play', '--append']), 196 197 # VLC, http://videolan.org/ 198 FreeDesktopPlayer('vlc', 'VLC', ['vlc', '--started-from-file', '--playlist-enqueue']), 199 200 # Totem, https://live.gnome.org/Totem 201 FreeDesktopPlayer('totem', 'Totem', ['totem', '--enqueue']), 202 203 # DeaDBeeF, http://deadbeef.sourceforge.net/ 204 FreeDesktopPlayer('deadbeef', 'DeaDBeeF', ['deadbeef', '--queue']), 205 206 # gmusicbrowser, http://gmusicbrowser.org/ 207 FreeDesktopPlayer('gmusicbrowser', 'gmusicbrowser', ['gmusicbrowser', '-enqueue']), 208 209 # Audacious, http://audacious-media-player.org/ 210 FreeDesktopPlayer('audacious', 'Audacious', ['audacious', '--enqueue']), 211 212 # Clementine, http://www.clementine-player.org/ 213 FreeDesktopPlayer('clementine', 'Clementine', ['clementine', '--append']), 214 215 # Parole, http://docs.xfce.org/apps/parole/start 216 FreeDesktopPlayer('parole', 'Parole', ['parole', '-a']), 217 218 # Winamp 2.x, http://www.oldversion.com/windows/winamp/ 219 Win32Player('winamp', 'Winamp', r'HKEY_CLASSES_ROOT\Winamp.File\shell\Enqueue\command'), 220 221 # VLC media player, http://videolan.org/vlc/ 222 Win32Player('vlc', 'VLC', r'HKEY_CLASSES_ROOT\VLC.mp3\shell\AddToPlaylistVLC\command'), 223 224 # foobar2000, http://www.foobar2000.org/ 225 Win32Player('foobar2000', 'foobar2000', r'HKEY_CLASSES_ROOT\foobar2000.MP3\shell\enqueue\command'), 226] 227 228 229RESUMERS = [ 230 # doesn't play on my system, but the track is appended. 231 MPRISResumer('amarok', 'Amarok', ['amarok', '--play'], 'org.mpris.MediaPlayer2.amarok'), 232 233 MPRISResumer('vlc', 'VLC', ['vlc', '--started-from-file'], 'org.mpris.MediaPlayer2.vlc'), 234 235 # totem mpris2 plugin is broken for me: it raises AttributeError: 236 # File "/usr/lib/totem/plugins/dbus/dbusservice.py", line 329, in OpenUri 237 # self.totem.add_to_playlist_and_play (uri) 238 # MPRISResumer('totem', 'Totem', ['totem'], 'org.mpris.MediaPlayer2.totem'), 239 240 # with https://github.com/Serranya/deadbeef-mpris2-plugin 241 MPRISResumer('resume in deadbeef', 'DeaDBeeF', ['deadbeef'], 'org.mpris.MediaPlayer2.DeaDBeeF'), 242 243 # the gPodder Dowloads directory must be in gmusicbrowser's library 244 MPRISResumer('resume in gmusicbrowser', 'gmusicbrowser', ['gmusicbrowser'], 'org.mpris.MediaPlayer2.gmusicbrowser'), 245 246 # Audacious doesn't implement MPRIS2.OpenUri 247 # MPRISResumer('audacious', 'resume in Audacious', ['audacious', '--enqueue'], 'org.mpris.MediaPlayer2.audacious'), 248 249 # beware: clementine never exits on my system (even when launched from cmdline) 250 # so the zombie clementine process will get all the bus messages and never answer 251 # resulting in freezes and timeouts! 252 MPRISResumer('clementine', 'Clementine', ['clementine'], 'org.mpris.MediaPlayer2.clementine'), 253 254 # just enable the plugin 255 MPRISResumer('parole', 'Parole', ['parole'], 'org.mpris.MediaPlayer2.parole'), 256] 257 258 259class gPodderExtension: 260 def __init__(self, container): 261 self.container = container 262 self.config = container.config 263 self.gpodder_config = self.container.manager.core.config 264 265 # Only display media players that can be found at extension load time 266 self.players = [player for player in PLAYERS if player.is_installed()] 267 self.resumers = [r for r in RESUMERS if r.is_installed()] 268 269 def on_ui_object_available(self, name, ui_object): 270 if name == 'gpodder-gtk': 271 for p in self.players + self.resumers: 272 p.gpodder = ui_object 273 274 def on_episodes_context_menu(self, episodes): 275 if not any(e.file_exists() for e in episodes): 276 return None 277 278 ret = [(p.title, functools.partial(p.enqueue_episodes, config=self.gpodder_config)) 279 for p in self.players] 280 281 # needs dbus, doesn't handle more than 1 episode 282 # and no point in using DBus when episode is not played. 283 if not hasattr(gpodder.dbus_session_bus, 'fake') and \ 284 len(episodes) == 1 and episodes[0].current_position > 0: 285 ret.extend([(p.title, functools.partial(p.enqueue_episodes, config=self.gpodder_config)) 286 for p in self.resumers]) 287 288 return ret 289 290 def on_episode_downloaded(self, episode): 291 if self.config.enqueue_after_download: 292 if not self.config.default_player and len(self.players): 293 player = self.players[0] 294 logger.info('Picking first installed player: %s (%s)', player.slug, player.application) 295 else: 296 player = next((player for player in self.players if self.config.default_player == player.slug), None) 297 if player is None: 298 logger.info('No player set, use one of: %r', [player.slug for player in self.players]) 299 return 300 301 logger.info('Enqueueing downloaded file in %s', player.application) 302 player.enqueue_episodes([episode]) 303