Jon Ellis

The musings of an "engineer"

Jon Ellis

Emby Electron

Emby

Emby is a self-hosted personal media platform - think Netflix and Spotify, but using your own media libraries running on your own server. It is written in C#, but will happily run on Linux under Mono. In fact, the Emby server is released for Windows, macOS, Linux (packaged for major distributions), Android (6.0+), NVidia Shield, FreeBSD, several NAS devices, and as a Docker image.

There are client apps for Android, iOS, Windows 10, Windows Phone. The default web interface provided by the server which will work in any modern browser. Additionally, there are several TV apps for other devices such as the Amazon FireTV, Apple TV, Chromecast, Kodi, games consoles and a couple of native apps for some televisions themselves.

I first started using Emby when I began building my replacement home server in mid-2016, though I don't think I actually had the server fully set up and used until maybe 2018.

I have used the Emby Fire TV app on my Fire Stick (2nd generation). It is generally pretty good, though it does occasionally fail to start, requiring the Fire Stick to be power cycled before it will load correctly.

The problem

I often listen to my music collection through Emby's web interface while working. In general, this works really well - with the exception that you can't use your media keys to control the player. When I wanted to skip a track (or heaven forbid talk with someone) I would have to switch to the Emby tab in my browser to operate the playback controls.

The official Emby solution is to pay their monthly subscription for "Emby Premiere" to use their Electron app (I am assuming it implements keyboard shortcuts).

Back in early June 2019 I grew tired of this 1st world hardship, so I decided to write my own Electron app to listen for media keys and and interact with Emby to control the playback. I suppose that similar functionality may also have been achievable by writing a browser extension. Perhaps that would have been simpler!

The solution

Normally I'm not keen on Electron apps because they seem rather wasteful as each Electron app ships with an entire copy of the Chromium browser, then renders the interface using this browser. While this is fantastic for interface development if you're primary experience is with HTML/CSS/JavaScript it does make your app use quite a lot of memory for even a simple user interface. It also allows your interface to be the same across platforms, and it ought to make the app cross platform as it won't need to depend on system libraries to draw the interface.

However, as my main goal was to wrap an existing HTML interface, this was fine. Writing my first Electron app was surprisingly straight forward.

A basic player app

I started with a simple BrowserWindow instance, and used the loadURL function to pass my Emby web interface URL in. This gave me a window with a workable Emby app, and on the plus side, it came with it's own application icon in the Dock so if nothing else, I'd be able to easily select my Emby instance to be able to interact with it if required.

I implemented my windows in a rather object oriented way that feels more familiar to me, which I now understand is not how you're supposed to do it (issues here and here) however I've not encountered any problems doing it so far...

src/windows/player.js
const {BrowserWindow, ipcMain} = require('electron');
const path = require('path');

class PlayerWindow extends BrowserWindow {

  constructor(embyURL) {
    super({
      icon: __dirname + '../renderer/icons/emby.icns',
      useContentSize: true,
      backgroundColor: '#202124',
      webPreferences: {
        nodeIntegration: false,
        preload: path.join(__dirname, '../renderer/js/integration.js')
      }
    });

    this.embyURL = embyURL;
    this.nowPlaying;

    this.loadURL(embyURL);
    this.handleIpc();
  }

  getEmbyURL() {
    return this.embyURL;
  }

  getNowPlaying() {
    return this.nowPlaying;
  }

  handleIpc() {
    let self = this;

    ipcMain.on('new-track', function(event, nowPlaying) {
      self.nowPlaying = nowPlaying;
      self.emit('new-track', nowPlaying);
    });
  }

  stop() {
    this.webContents.send('playback-command', 'stop');
  }

  playPause() {
    this.webContents.send('playback-command', 'playPause');
  }

  nextTrack() {
    this.webContents.send('playback-command', 'nextTrack');
  }

  previousTrack() {
    this.webContents.send('playback-command', 'previousTrack');
  }

}

module.exports = PlayerWindow;

Communicating with the Emby

The nodeIntegration: false preference prevents Electron from adding symbols such as module, exports, require to the DOM. I did this because their presence causes problems when Emby loads, and I have no need for the integration beyond the integration.js script.

preload: path.join(__dirname, '../renderer/js/integration.js') is a script that is included and run before other scripts on the page are executed. It always has access to the Node APIs even if it is disabled using nodeIntegration: false. I used it here to inject some code to wait for various dependencies to be loaded by Emby, then bind some control functions and listeners to changes in state.

This class sets up functions to stop, play, pause, play next or previous track by sending playback-command events with the action as the argument. It emits new-track events when the track is changed in Emby (this even works if the user clicks the next track button in the Emby controls themselves).

To be able to interact with Emby's media player, I assumed that I would have to do something nasty like trigger click events on the controls' HTML elements, but it turned out to be pretty easy to work out how to use the API.

src/rendered/js/integration.js
(function() {

  const {ipcRenderer} = require('electron');

  function waitForRequire() {
    return new Promise(function(resolve, reject) {
      let requireInterval = setInterval(function() {
        if (typeof window.require == 'function') {
          clearInterval(requireInterval);
          resolve();
        }
      }, 500);
    });
  }

  function waitForModule(moduleName) {
    return new Promise(function(resolve, reject) {
      let moduleInterval = setInterval(function() {
        try {
          let module = window.require(moduleName);
          clearInterval(moduleInterval);
          resolve(module);
        } catch(err) {}
      }, 500);
    });
  }

  function waitForDependencies() {
    return waitForRequire().then(function() {
      return Promise.all([
        waitForModule('events'),
        waitForModule('playbackManager')
      ]);
    });
  }

  waitForDependencies().then(function(values){
    events = values[0];
    playbackManager = values[1];

    bindControls();
    bindPlayerManagerEvents(events, playbackManager);
  });

  function bindControls() {
    ipcRenderer.on('playback-command', function(event, command) {
      playbackManager[command]();
    });
  }

  function bindPlayerManagerEvents(events, playbackManager) {
    events.on(playbackManager, 'playerchange', function(e, player) {
      events.on(player, 'playbackstart', function(e, state) {
        ipcRenderer.send('new-track', state.NowPlayingItem);
      });
    })
  }

}());

Most of this script is waiting for various dependencies to be loaded before we can start using them. First, we need require so that we can then try loading the other dependencies. Once require is loaded, we can check and wait for the events and playbackManager to load so we can start using them.

The bindControls function sets up the listener for the playback-commands from the player window. When it receives them, it simply uses the command argument as the function name to invoke. I'm sure this could be abused in some horrible way to do something nasty, but I wasn't expecting this to be used by anyone other than me. Had I decided to release it, I would have tidied this up to only invoke expected functions.

Lastly, the bindPlayerManagerEvents function binds a handler to the playbackstart event which is fired when the playing track is changed. The handler function simply emits the new-track event back to the player window.

Adding playback controls

I started off writing menu controls to be able to ensure the communication between the player window and Emby was actually working before I attempted to get the media keys to work. I didn't want to spend ages trying to get media keys working when it might turn out to be the Electron-Emby communication that was broken. Looking back on this, I should have just gone straight for the media key integration as it's mostly pretty easy.

There were two menus to implement - one for the application menu bar, and the other for the dock or taskbar. As both would have the playback controls, I implemented the playback controls in a class that would then be extended and used by the menu classes for the application and dock menus. The specific implementations could also add whatever other menu items made sense for their contexts.

src/menus/emby.js
const {app, Menu, shell} = require('electron');
const EventEmitter = require('events');

class EmbyMenu extends EventEmitter {

  createPlaybackMenuTemplate() {
    let self = this;

    return {
      label: 'Playback',
      submenu: [
        {
          label: 'Stop',
          click: function() {
            self.emit('playback-stop');
          }
        },
        {
          label: 'Play/Pause',
          click: function() {
            self.emit('playback-play-pause');
          }
        },
        {
          label: 'Next Track',
          click: function() {
            self.emit('playback-next-track');
          }
        },
        {
          label: 'Previous Track',
          click: function() {
            self.emit('playback-previous-track');
          }
        }
      ]
    }
  }

}

module.exports = EmbyMenu;

All this does is emit various playback events which are listened for in the main app. Simple, huh.

Looking back at this, I wonder if the whole self thing is necessary here, I could probably have used arrow functions instead.

I'm not going to include the whole code from src/menus/main.js as it's quite long (about 200 lines) and is mostly boring glue code to tie the various components together.

from src/menus/main.js
menu.on('playback-next-track', function() {
  if (playerWindow) {
    playerWindow.nextTrack();
  }
});

Next was to listen for keyboard media key presses.

from src/menus/main.js
let nextTrack = globalShortcut.register('MediaNextTrack', function() {
  if (playerWindow) {
    playerWindow.nextTrack();
  }
});

This is very straight forward, but there is a caveat for macOS 10.14 where the Electron app must be authorised as a trusted accessibility client before media keys can be registered for global shortcuts.

Once this is done, the media key events then cause functions to be invoked on Emby's playback manager, and the desired action would be done.

There don't appear to be similar restrictions in Windows or Linux.

Popup messages

I thought that it would be nice to be able to show a popup message when the playing track changed to show some information about the new track. Luckily Electron has a native notification library to show OS desktop notifications, though it should be simple to add a new window to use for the popup. I thought the native options would be simpler and less likely to have weird display edge cases when trying to make it animate in and out or what have you.

src/utils/notifications.js
const {Notification, nativeImage} = require('electron');
const got = require('got');

let imageSize = 70;
let imageQuality = 90;

function notifyTrackChange(embyURL, trackData, includeImage) {
  let id = trackData.Id;
  let trackName = trackData.Name;
  let albumName = trackData.Album;
  let albumArtist = trackData.AlbumArtist;
  let imageTags = trackData.ImageTags;
  let imageTagName, imageTagValue, imageURL, image;

  if (includeImage && imageTags) {
    if (imageTags.hasOwnProperty('Primary')) {
      imageTagName = 'Primary';
      imageTagValue = imageTags.Primary;
    } else if (Object.keys(imageTags).length > 0) {
      imageTagName = imageTags.keys()[0];
      imageTagValue = imageTags[imageTagName];
    }
  }

  if (imageTagName && imageTagValue) {
    imageURL = embyURL + '/Items/' + id + '/Images/' + imageTagName + '?tag=' + imageTagValue + '&quality=' + imageQuality + '&height=' + imageSize;
    imagePromise = createNativeImage(imageURL);
  } else {
    imagePromise = Promise.resolve(null);
  }

  imagePromise
    .then(function(image) {
      createTrackNotification(trackName, albumName, albumArtist, image);
    })
    .catch(function(e) {
      console.log('Could not get image', e);
    });
}

function createNativeImage(imageURL) {
  return new Promise(function(resolve, reject) {
    got(imageURL, {
      encoding: null
    }).then(function(response) {
      let image = nativeImage.createFromBuffer(response.body);
      resolve(image);
    });
  });
}

function createTrackNotification(trackName, albumName, artistName, image) {
  let notification = new Notification({
    title: trackName,
    subtitle: artistName,
    body: albumName,
    icon: image,
    silent: true
  });

  notification.on('click', function() {
    notification.close();
  });

  notification.on('close', function() {
    notification = null;
  });

  notification.show();
}

module.exports = notifyTrackChange;

It turns out that you can display quite a lot of information in Electron notifications, including an image! This function attempts to select an image from the info about the currently playing track supplied by the new-track event. My music library isn't particularly complete with it's meta-data, so for some reason some tracks don't have a primary image associated with them, but have other images. If it is able to determine which image should be used, it builds the path to the image file and requests it. Assuming that request is successful, a notification is shown. I have just noticed that it fails to create the notification if the image fails to load - surely it would be better to create the notification without an image in this case...

The createNativeImage function fetches and converts it into a NativeImage instance, the object required by the notification library.

Server selection

At this point, I entertained the idea of releasing this app on GitHub, and maybe even trying to submit it for inclusion in Brew once Emby Electron was finished - enough. I added more features to make it better for those who don't want to connect to my Emby instance.

I built a server selection window to enter the hostname of your Emby instance.

src/windows/server.js
const {app, BrowserWindow, ipcMain} = require('electron');
const got = require('got');

class ServerWindow extends BrowserWindow {

  constructor(config) {
    super({
      icon: __dirname + '../renderer/icons/emby.icns',
      width: 525,
      height: 350,
      resizable: false,
      maximizable: false,
      fullscreenable: false,
      backgroundColor: '#202124',
      titleBarStyle: 'hiddenInset',
      webPreferences: {
        nodeIntegration: true
      }
    });

    this.config = config;

    this.loadFile('app/renderer/server.html');
    this.handleIpc();

    this.onSelectServer = null;
  }

  handleIpc() {
    let self = this;

    ipcMain.on('select-server', function(event, embyURL) {
      embyURL = embyURL.replace(/\/+$/, '');

      if (embyURL == 'https://app.emby.media') {
        event.reply('select-server-response', [true, 'success']);
        self.emit('server-selected', embyURL);
        self.setLastServerURL(embyURL);
        return;
      }

      let pingURL = embyURL + '/System/Ping';

      got.post(pingURL)
        .then(function(response) {

          if (response.body != 'Emby Server') {
            throw embyURL + ' does not look like an emby server.';
          }

          event.reply('select-server-response', [true, 'success']);
          self.emit('server-selected', embyURL);
          self.setLastServerURL(embyURL);
        })
        .catch(function(e) {
          console.log(e);

          let error;

          if (typeof e == 'string') {
            error = e;
          } else {
            error = 'Could not determine if ' + embyURL + ' is an emby server.';
          }

          event.reply('select-server-response', [false, error]);
        });
    });
  }

  setLastServerURL(embyURL) {
    this.config.set('lastServer', embyURL);
  }

  getLastServerURL() {
    return this.config.get('lastServer');
  }

}

module.exports = ServerWindow;

The app/renderer/server.html file first checks that the entered URL is actually a valid URL before it even submits it to the server window. The standard https://app.emby.media general Emby login page is always accepted.

To validate that a given URL is actually an Emby server, it calls the ping API endpoint which does not require any authentication. If the response contains "Emby Server", then it is most likely an Emby server. This would be pretty trivial to work around, but I don't see what it would achieve really. This check was meant to prevent typos or whatever from loading the wrong pages into the player window.

Preferences

I thought it'd be useful to have some basic settings controlling some features in case someone didn't like my choices:

  • Toggle showing the server selection window on application start or whether it takes you straight to the player window
  • Controlling whether the media keys are bound
  • Toggling notifications
  • Whether images are added to the notifications

Preferences are saved using electron-store as part of a config script. It also saves the last successfully used Emby URL from the server page.

config.js
const Store = require('electron-store');

let schema = {
  lastServer: {
    type: 'string',
    format: 'url',
    default: 'https://app.emby.media/'
  },
  startWithServerWindow: {
    type: 'boolean',
    default: true
  },
  bindMediaKeys: {
    type: 'boolean',
    default: true
  },
  showNotifications: {
    type: 'boolean',
    default: true
  },
  showNotificationImages: {
    type: 'boolean',
    default: true
  }
};

let config = new Store({schema});

module.exports = config;

About window

I added a simple about window to have a link to the GitHub repository that I planned to upload it to.

Styling

To make the windows that I had to style directly look as much like the Emby server application as possible. I added material-icons and emby-webcomponents to the package dependency list.

I wrote a short Bash script to download the various dependencies that it used that I couldn't find otherwise.

gather-dependencies.sh
#!/bin/bash

set -e

node_modules/.bin/bower install

script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"

emby_site='https://app.emby.media'
emby_css_dir="$script_dir/../app/renderer/css/"
emby_css_file="$emby_css_dir"'vendor.css'
emby_webcomponents_dir="$script_dir/../bower_components/emby-webcomponents"

cat "$script_dir/../node_modules/reset-css/reset.css" > "$emby_css_file"

bower_components=(
  "/themes/dark/theme.css"
  "/fonts/fonts.css"
  "/fonts/material-icons/style.css"
  "/loading/loading-lite.css"
)

for script in "${bower_components[@]}"; do
  cat "$emby_webcomponents_dir$script" >> "$emby_css_file"
done

emby_scripts=(
  "/modules/emby-elements/emby-input/emby-input.css"
  "/modules/emby-elements/emby-button/emby-button.css"
  "/modules/emby-elements/emby-checkbox/emby-checkbox.css"
)

for script in "${emby_scripts[@]}"; do
  curl -s "$emby_site$script" >> "$emby_css_file"
done

cp "$emby_webcomponents_dir/fonts/material-icons/"*.woff* "$emby_css_dir"

I'm sure there is a far nicer way to include some of these scripts, but it does work.

Moving on

I decided not to publish this Electron app after all as I didn't want to cause unnecessary problems with the Emby community/company. It may have not be appreciated that it was possible to have media controls without paying their subscription fee.

However, I recently updated my Emby server (I think around the 5th April?), and now when I load my Emby web interface I am served the Emby Theatre app instead. This does look quite a bit nicer than the standard Emby web interface, but requires a subscription to actually use it (beyond one minute tests).

As I've experienced some similar shenanigans when I used Plex to push logging in through their servers and generally making it harder to host the service independently, I see this as the beginning of the end of my time using Emby.

I had planned to go and find the code responsible for this change in case it was a mistake, but it turns out that the Emby GitHub repository has not been updated since September 2018, and is now a closed source application! I remember they changed how their app was distributed - switching from an APT repository to simply distributing .deb files (for Ubuntu/Debian systems at least). Presumably this was when it became a closed-source application.

I have been looking at switching to Jellyfin, which is a fork of the original Emby project at version 3.5.2 - the last open source version of Emby. Once I've given it a try, I'll write a quick post about my experiences with Jellyfin.