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