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