# -*- Mode: python; coding: utf-8; tab-width: 8; indent-tabs-mode: t; -*- # # Copyright (C) 2016 Jonathan Matthew # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2, or (at your option) # any later version. # # The Rhythmbox authors hereby grant permission for non-GPL compatible # GStreamer plugins to be used and distributed together with GStreamer # and Rhythmbox. This permission is above and beyond the permissions granted # by the GPL license by which Rhythmbox is covered. If you modify this code # you may extend this exception to your version of the code, but you are not # obligated to do so. If you do not wish to do so, delete this exception # statement from your version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. import gi gi.require_version('Soup', '2.4') from gi.repository import GLib, GObject, Gio, Peas, PeasGtk, Soup, Gtk from gi.repository import RB import rb import sys import os.path import json import re import time import struct import siphash import gettext gettext.install('rhythmbox', RB.locale_dir()) def get_host_name(): try: p = Gio.DBusProxy.new_for_bus_sync(Gio.BusType.SYSTEM, 0, None, 'org.freedesktop.Avahi', '/', 'org.freedesktop.Avahi.Server') return p.GetHostNameFqdn() except Exception as e: # ignore import socket return socket.gethostname() class ClientSession(object): def __init__(self, plugin, connection, client, connid): print("new connection attached") self.connid = connid self.conn = connection self.conn.connect("message", self.message_cb) self.conn.connect("closed", self.closed_cb) self.client = client self.plugin = plugin self.actions = { 'status': self.plugin.client_status, 'next': self.plugin.client_next, 'previous': self.plugin.client_previous, 'playpause': self.plugin.client_playpause, 'seek': self.plugin.client_seek } def message_cb(self, conn, msgtype, message): if msgtype != Soup.WebsocketDataType.TEXT: print("binary message received?") return d = message.get_data().decode("utf-8") print("message received: %s" % d) try: m = json.loads(d) action = m.get('action') print("doing %s" % action) if action in self.actions: r = self.actions[action](m) else: r = {'result': 'what'} print("responding %s" % str(r)) self.conn.send_text(json.dumps(r)) except Exception as e: sys.excepthook(*sys.exc_info()) def closed_cb(self, conn): self.plugin.player_websocket_closed(self, self.connid) def dispatch(self, message): self.conn.send_text(message) def disconnect(self): self.conn.close(0, "") class TrackStreamer(object): def __init__(self, server, message, track, content_type): self.server = server self.message = message self.message.connect("wrote-chunk", self.wrote_chunk) self.trackfile = Gio.File.new_for_uri(track) self.content_type = content_type self.stream = None self.offset = 0 self.done = False print("streaming " + track) def wrote_chunk(self, msg): if not self.done: self.server.pause_message(self.message) def open(self): print("opening") self.trackfile.read_async(GLib.PRIORITY_DEFAULT, None, self.opened) self.server.pause_message(self.message) def opened(self, obj, result): try: print("track opened") headers = self.message.props.response_headers headers.set_content_type(self.content_type) headers.set_encoding(Soup.Encoding.CHUNKED) body = self.message.props.response_body body.set_accumulate(False) self.stream = self.trackfile.read_finish(result) self.message.set_status(200) self.read_more() except Exception as e: sys.excepthook(*sys.exc_info()) self.message.set_status(500) self.server.unpause_message(self.message) def read_more(self): self.stream.read_bytes_async(65536, GLib.PRIORITY_DEFAULT, None, self.read_done) def read_done(self, obj, result): body = self.message.props.response_body try: b = self.stream.read_bytes_finish(result) if b.get_size() == 0: self.done = True body.complete() else: self.offset = self.offset + b.get_size() body.append(b.get_data()) # uh.. self.read_more() self.server.unpause_message(self.message) except Exception as e: sys.excepthook(*sys.exc_info()) if (self.offset == 0): self.message.set_status(500) else: body.complete() self.server.unpause_message(self.message) class WebRemotePlugin(GObject.Object, Peas.Activatable): __gtype_name = 'WebRemotePlugin' object = GObject.property(type=GObject.GObject) signature_max_age = 60 # we don't really need a huge replay memory. # clients should only make requests every couple of minutes, # and signatures are only valid for (currently) up to two minutes. replay_memory = 20 string_props = { RB.RhythmDBPropType.TITLE: 'title', RB.RhythmDBPropType.ARTIST: 'artist', RB.RhythmDBPropType.ALBUM: 'album', RB.RhythmDBPropType.ALBUM_ARTIST: 'album-artist', RB.RhythmDBPropType.GENRE: 'genre', RB.RhythmDBPropType.COMPOSER: 'composer', RB.RhythmDBPropType.TITLE: 'title', } ulong_props = { RB.RhythmDBPropType.ENTRY_ID: 'id', RB.RhythmDBPropType.YEAR: 'year', #RB.RhythmDBPropType.BEATS_PER_MINUTE: 'bpm', RB.RhythmDBPropType.BITRATE: 'bitrate', RB.RhythmDBPropType.DURATION: 'duration', RB.RhythmDBPropType.TRACK_NUMBER: 'track-number', RB.RhythmDBPropType.TRACK_TOTAL: 'track-total', RB.RhythmDBPropType.DISC_NUMBER: 'disc-number', RB.RhythmDBPropType.DISC_TOTAL: 'disc-total' } def __init__(self): GObject.Object.__init__(self) self.settings = Gio.Settings.new("org.gnome.rhythmbox.plugins.webremote") self.settings.connect("changed", self.settings_changed_cb) self.server = None self.next_connid = 0 self.connections = {} self.replay = [] self.access_key = None self.listen_reset = False def get_sign_key(self, id): # some day there will be multiple keys a = self.settings['access-key'] ea = a.encode() pa = (a + 4 * '\0').encode() k = [0, 0, 0, 0] i = 0 ki = 0 uint = struct.Struct(" (now + max)): return False message = (path + "\n" + ts).encode() check = siphash.SipHash_2_4(self.get_sign_key(keyid), message).hexdigest() print("request signature: " + sig + ", expecting: " + check.decode()) if check == sig.encode(): self.replay.insert(0, sig) self.replay = self.replay[:self.replay_memory] return True return False except Exception as e: sys.excepthook(*sys.exc_info()) return False def check_http_msg_signature(self, msg): u = msg.get_uri() return self.check_http_signature(u.get_path(), u.get_query()) def player_websocket_cb(self, server, conn, path, client): (upath, query) = path.split(":", 1) if self.check_http_signature(upath, query) is False: conn.close(403, "whatever") return cs = ClientSession(self, conn, client, self.next_connid) self.connections[self.next_connid] = cs self.next_connid = self.next_connid + 1 def player_websocket_closed(self, connection, connid): self.connections.pop(connid) def dispatch(self, message): m = json.dumps(message) for c in self.connections.values(): c.dispatch(m) def album_art_filename(self, filename): if filename is None: return None if filename.startswith(self.artcache) is False: return None rfn = filename[len(self.artcache):].lstrip('/') return os.path.normpath(rfn) def entry_details(self, entry): m = {} if entry is not None: for (p, k) in self.string_props.items(): m[k] = entry.get_string(p) for (p, k) in self.ulong_props.items(): m[k] = entry.get_ulong(p) key = entry.create_ext_db_key(RB.RhythmDBPropType.ALBUM) (filename, lkey) = self.art_store.lookup(key) m['albumart'] = self.album_art_filename(filename) return m def set_playing_position(self, update): try: (r, pos) = self.shell_player.get_playing_time() update['position'] = pos * 1000 except Exception as e: pass def client_status(self, message): entry = self.shell_player.get_playing_entry() if entry: m = self.entry_details(entry) self.set_playing_position(m) p = self.shell_player.get_playing() m['playing'] = p[1] else: m = { 'playing': False, 'id': 0 } m['hostname'] = GLib.get_host_name() return m def client_next(self, message): try: self.shell_player.do_next() return {'result': 'ok'} except Exception as e: return {'result': str(e) } def client_previous(self, message): try: self.shell_player.do_previous() return {'result': 'ok'} except Exception as e: return {'result': str(e) } def client_playpause(self, message): try: self.shell_player.playpause() return {'result': 'ok'} except Exception as e: return {'result': str(e) } def client_seek(self, message): try: self.shell_player.set_playing_time(message['time']) return {'result': 'ok'} except Exception as e: return {'result': str(e) } def playing_song_changed_cb(self, player, entry): self.elapsed = 0 self.dispatch(self.entry_details(entry)) def playing_changed_cb(self, player, playing): u = { 'playing': playing } self.set_playing_position(u) self.dispatch(u) def playing_song_property_changed_cb(self, player, uri, prop, oldvalue, newvalue): if prop in self.string_props: self.dispatch({ self.string_props[prop]: newvalue }) if prop in self.ulong_props: self.dispatch({ self.ulong_props[prop]: newvalue }) def elapsed_nano_changed_cb(self, player, elapsed): if abs(elapsed - self.elapsed) > 1000000000: self.dispatch({'position': elapsed/1000000}) # ms self.elapsed = elapsed def art_added_cb(self, store, key, filename, data): entry = self.shell_player.get_playing_entry() if entry is not None and self.db.entry_matches_ext_db_key(entry, key): self.dispatch({'albumart': self.album_art_filename(filename)}) def send_file_response(self, msg, filename, content_type): try: fp = open(filename, 'rb') d = fp.read() if callable(content_type): content_type = content_type(d) msg.set_response(content_type, Soup.MemoryUse.COPY, d) msg.set_status(200) except Exception as e: sys.excepthook(*sys.exc_info()) msg.set_status(500) def image_content_type(self, data): # superhacky if data[1:4] == b'PNG': return "image/png" elif data[0:5] == b' 0: self.settings['listen-port'] = uris[0].get_port() self.listen_reset = False def do_activate(self): shell = self.object self.db = shell.props.db self.artcache = os.path.join(RB.user_cache_dir(), "album-art", "") self.art_store = RB.ExtDB(name="album-art") self.art_store.connect("added", self.art_added_cb) self.shell_player = shell.props.shell_player self.shell_player.connect("playing-song-changed", self.playing_song_changed_cb) self.shell_player.connect("playing-song-property-changed", self.playing_song_property_changed_cb) self.shell_player.connect("playing-changed", self.playing_changed_cb) self.shell_player.connect("elapsed-nano-changed", self.elapsed_nano_changed_cb) self.playing_song_changed_cb(self.shell_player, self.shell_player.get_playing_entry()) self.http_server = Soup.Server() self.http_server.add_handler(path="/art/", callback=self.http_art_cb) self.http_server.add_handler(path="/icon/", callback=self.http_icon_cb) self.http_server.add_handler(path="/entry/current/stream", callback=self.http_track_cb) self.http_server.add_handler(path="/css/", callback=self.http_static_css_cb) self.http_server.add_handler(path="/js/", callback=self.http_static_js_cb) self.http_server.add_websocket_handler("/ws/player", None, None, self.player_websocket_cb) self.http_server.add_handler(path="/", callback=self.http_root_cb) self.http_listen() def do_deactivate(self): self.dispatch({'shutdown': True }) self.server = None self.connections = {} class WebRemoteConfig(GObject.Object, PeasGtk.Configurable): __gtype_name__ = 'WebRemoteConfig' object = GObject.property(type=GObject.Object) def accesskey_focus_out_cb(self, widget, event): k = widget.get_text() if k != self.access_key: print("changing access key to %s" % k) self.access_key = k self.settings['access-key'] = k return False def update_port(self): hostname = get_host_name() port = self.settings['listen-port'] url = 'http://%s:%d/' % (hostname, port) label = _('Launch web remote control') self.launch_link.set_markup('%s' % (url, label)) self.portnumber.set_text("%d" % port) def settings_changed_cb(self, settings, key): if key == 'listen-port': self.update_port() def do_create_configure_widget(self): self.settings = Gio.Settings.new("org.gnome.rhythmbox.plugins.webremote") self.settings.connect("changed", self.settings_changed_cb) ui_file = rb.find_plugin_file(self, "webremote-config.ui") self.builder = Gtk.Builder() self.builder.add_from_file(ui_file) content = self.builder.get_object("webremote-config") self.portnumber = self.builder.get_object("portnumber") self.launch_link = self.builder.get_object("launch-link") self.update_port() self.key_entry = self.builder.get_object("accesskey") self.access_key = self.settings['access-key'] if self.access_key: self.key_entry.set_text(self.access_key) self.key_entry.connect("focus-out-event", self.accesskey_focus_out_cb) return content GObject.type_register(WebRemoteConfig)