1# Copyright 2016 - 2020 Nick Boultbee
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2 of the License, or
6# (at your option) any later version.
7
8import os
9import sys
10
11if os.name == "nt" or sys.platform == "darwin":
12    from quodlibet.plugins import PluginNotSupportedError
13    raise PluginNotSupportedError
14
15from quodlibet import _
16from quodlibet.formats import AudioFile
17from quodlibet.util import monospace, escape
18from quodlibet.util.tags import _TAGS
19
20_TOTAL_MQTT_ITEMS = 3
21
22try:
23    import paho.mqtt.client as mqtt
24except ImportError:
25    from quodlibet.plugins import MissingModulePluginException
26    raise MissingModulePluginException('paho-mqtt')
27
28from gi.repository import Gtk
29
30from quodlibet.pattern import Pattern
31from quodlibet.qltk.entry import UndoEntry, ValidatingEntry
32from quodlibet import qltk, app
33from quodlibet.util import copool
34
35from quodlibet.plugins.events import EventPlugin
36from quodlibet.plugins import PluginConfigMixin
37from quodlibet.util.dprint import print_d, print_w
38from quodlibet.qltk import Icons, ErrorMessage, Message
39
40EXPAND = Gtk.AttachOptions.EXPAND
41FILL = Gtk.AttachOptions.FILL
42
43
44class Config(object):
45    STATUS_SONGLESS = 'no_song_text', ""
46    PAT_PLAYING = 'playing_pattern', "♫ <~artist~title> ♫"
47    PAT_PAUSED = 'paused_pattern', "<~artist~title> [%s]" % _("paused")
48    HOST = 'host', "localhost"
49    PORT = 'port', 1883
50    TOPIC = 'topic', 'quodlibet/now-playing'
51    EMPTY_STATUS = ""
52
53
54_ACCEPTS_PATTERNS = (_("Accepts QL Patterns e.g. %s") %
55                     monospace(escape('<~artist~title>')))
56
57
58class MqttPublisherPlugin(EventPlugin, PluginConfigMixin):
59    PLUGIN_ID = "MQTT Status"
60    PLUGIN_NAME = _("MQTT Publisher")
61    PLUGIN_DESC = _("Publishes status messages to an MQTT topic.")
62    PLUGIN_ICON = Icons.FACE_SMILE
63
64    def __init__(self) -> None:
65        super().__init__()
66        self.song = self.host = self.port = self.topic = None
67        self.status = Config.EMPTY_STATUS
68
69    def on_connect(self, client, userdata, flags, rc):
70        """Callback for when the client receives a
71        CONNACK response from the server."""
72        print_d("Connected to %s at %s:%d with result code %s"
73                % (self.topic, self.host, self.port, rc))
74
75    def on_message(self, client, userdata, msg):
76        """The callback for messages received from the server."""
77        print_d("%s: %s" % (msg.topic, msg.payload))
78
79    def _set_up_mqtt_client(self):
80        self.client = client = mqtt.Client()
81        client.on_connect = self.on_connect
82        client.on_message = self.on_message
83        client.connect(self.host, self.port, 60)
84        # Uses Threading.Thread internally, so we don't have to...
85        self.client.loop_start()
86
87    def _set_status(self, text):
88        print_d("Setting status to \"%s\"..." % text)
89        result, mid = self.client.publish(self.topic, text, retain=True)
90        if result != mqtt.MQTT_ERR_SUCCESS:
91            print_w("Couldn't publish to %s at %s:%d (%s)"
92                    % (self.topic, self.host, self.port,
93                       mqtt.error_string(result)))
94        self.status = text
95
96    def plugin_on_song_started(self, song):
97        self.song = song
98        pat_str = self.config_get(*Config.PAT_PLAYING)
99        pattern = Pattern(pat_str)
100        status = (pattern.format(song) if song
101                  else self.config_get(Config.STATUS_SONGLESS, ""))
102        self._set_status(status)
103
104    def plugin_on_paused(self):
105        pat_str = self.config_get(*Config.PAT_PAUSED)
106        pattern = Pattern(pat_str)
107        self.status = (pattern.format(self.song) if self.song
108                       else Config.EMPTY_STATUS)
109        self._set_status(self.status)
110
111    def plugin_on_unpaused(self):
112        self.plugin_on_song_started(self.song)
113
114    def disabled(self):
115        if self.status:
116            self._set_status(self.config_get(Config.STATUS_SONGLESS))
117        self.client.on_connect = None
118        self.client.on_message = None
119        self.client.disconnect()
120
121    def enabled(self):
122        self.host = self.config_get(*Config.HOST) or 'localhost'
123        self.port = int(self.config_get(*Config.PORT)) or 1883
124        self.topic = self.config_get(*Config.TOPIC)
125        self._set_up_mqtt_client()
126
127    _CONFIG = [
128        (_("Broker hostname"), Config.HOST,
129         _("broker hostname / IP (defaults to localhost)")),
130
131        (_("Broker port"), Config.PORT, _("broker port (defaults to 1883)")),
132
133        (_("Topic"), Config.TOPIC, _("Topic")),
134
135        (_("Playing Pattern"),
136         Config.PAT_PLAYING,
137         _("Status text when a song is started.") + ' ' + _ACCEPTS_PATTERNS),
138
139        (_("Paused Pattern"),
140         Config.PAT_PAUSED,
141         _("Text when a song is paused.") + ' ' + _ACCEPTS_PATTERNS),
142
143        (_("No-song Text"),
144         Config.STATUS_SONGLESS,
145         _("Plain text for when there is no current song"))
146    ]
147
148    @staticmethod
149    def _is_pattern(cfg):
150        return cfg[0] in (Config.PAT_PLAYING[0], Config.PAT_PAUSED[0])
151
152    def PluginPreferences(self, parent):
153        outer_vb = Gtk.VBox(spacing=12)
154
155        t = self.config_table_for(self._CONFIG[:_TOTAL_MQTT_ITEMS])
156        frame = qltk.Frame(_("MQTT Configuration"), child=t)
157        outer_vb.pack_start(frame, False, True, 0)
158
159        t = self.config_table_for(self._CONFIG[_TOTAL_MQTT_ITEMS:])
160        frame = qltk.Frame(_("Status Text"), child=t)
161        outer_vb.pack_start(frame, False, True, 0)
162
163        return outer_vb
164
165    def config_table_for(self, config_data):
166        t = Gtk.Table(n_rows=2, n_columns=len(config_data))
167        t.set_col_spacings(6)
168        t.set_row_spacings(6)
169        for i, (label, cfg, tooltip) in enumerate(config_data):
170            entry = (ValidatingEntry(validator=validator)
171                     if self._is_pattern(cfg) else UndoEntry())
172            entry.set_text(str(self.config_get(*cfg)))
173            entry.connect('changed', self._on_changed, cfg)
174            lbl = Gtk.Label(label=label + ":")
175            lbl.set_size_request(140, -1)
176            lbl.set_alignment(xalign=0.0, yalign=0.5)
177            entry.set_tooltip_markup(tooltip)
178            lbl.set_mnemonic_widget(entry)
179            t.attach(lbl, 0, 1, i, i + 1, xoptions=FILL)
180            t.attach(entry, 1, 2, i, i + 1, xoptions=FILL | EXPAND)
181        return t
182
183    def _on_changed(self, entry, cfg):
184        self.config_entry_changed(entry, cfg[0])
185        if cfg in [Config.HOST, Config.PORT]:
186            self.disabled()
187            copool.add(self.try_connecting, funcid="connect", timeout=1000)
188
189    def try_connecting(self):
190        try:
191            self.enabled()
192            msg = (_("Connected to broker at %(host)s:%(port)d")
193                   % {'host': self.host, 'port': self.port})
194            Message(Gtk.MessageType.INFO, app.window, "Success", msg).run()
195        except IOError as e:
196            template = _("Couldn't connect to %(host)s:%(port)d (%(msg)s)")
197            msg = template % {'host': self.host, 'port': self.port, 'msg': e}
198            print_w(msg)
199            ErrorMessage(app.window, _("Connection error"), msg).run()
200        yield
201
202
203def validator(pat):
204    """Validates Patterns a bit.
205    TODO: Extract to somewhere good - see #1983"""
206    try:
207        return bool(Pattern(pat).format(DUMMY_AF))
208    except Exception as e:
209        print_d("Problem with pattern (%s)" % e)
210        return False
211
212
213class FakeAudioFile(AudioFile):
214
215    def __call__(self, *args, **kwargs):
216        real = super(FakeAudioFile, self).__call__(*args, **kwargs)
217        tag = args[0]
218        return real or self.fake_value(tag)
219
220    def get(self, key, default=None):
221        if key not in self:
222            return default or self.fake_value(key)
223        return super(FakeAudioFile, self).get(key, default)
224
225    def fake_value(self, key):
226        if key.replace('~', '').replace('#', '') in _TAGS:
227            if key.startswith('~#'):
228                return 0
229            elif key.startswith('~'):
230                return "The %s" % key
231        if key.startswith('~'):
232            raise ValueError("Unknown tag %s" % key)
233        return "The %s" % key
234
235
236DUMMY_AF = FakeAudioFile({'~filename': '/dev/null'})
237