Plex Webhooks


#1

So my hub turned up today and have been starting work to port over Plex integration.. I've got most of the wrinkles ironed out apart from being able to receive status updates from plex..

My code is below and I receive the error "java.lang.IllegalArgumentException: Text must not be null or empty on line 358 (plexWebHookHandler)"

Pulling "params" out to logging only responds with access_token=xxxxxxxxxxx, so no payload appears to be being passed back like I would get in SmartThings..

Any ideas or help welcome..

import groovy.json.JsonSlurper
mappings {
  path("/pwh") 						{ action: [POST: "plexWebHookHandler"] }
}

def plexWebHookHandler(){

  def jsonSlurper = new JsonSlurper()
  def plexJSON = jsonSlurper.parseText(params)
  //do some stuff here
}

#2

Just out of curiosity, what are you trying to do with Plex through Hubitat? I am a heavy Plex user being that we only have DSL available and use 4ktv's, but never thought about integrating it with Hubitat. I have a Synology DS918+ that I use as a whole house Plex media server.


#3

Basically it creates a device for every Plex device and passes playstate and media type... you can then use it to control your lighting when a film starts etc.. and bring the lights back up when you pause or stop


#4

params is a map of the query parameters, so it only contains values from the URL. If you are looking for the body of the request. try

log.debug request.JSON

or

log.debug request.XML

depending on the type of content you are sending to see what is in the body.


#5

Thanks I’ll give it a go later!


#6

OK so I'm still not able to obtain the content for whatever reason..

request.JSON returns null, and just getting the content of request returns only:

[HOST:192.168.25.111]

Any ideas?


#7

do you know what the headers are that the plex server is sending? I'm thinking the content-type is either not set or not set to something we recognize. I'm going to put in a ticket for us to add request.body to return the raw body from a request and you should be able to access it that way. unfortunately that means you need to wait until we can get that added unless you can configure the Content-Type header in the request.


#8

Thanks, the only details I have are from the link below, like I say params works via ST just not in hubitat.

I’ll see if I can find a tool to act as an endpoint and see what’s in the raw data.. if something exists..

Thanks more than happy to wait as long as I can get it working at some point :slight_smile:

https://support.plex.tv/articles/115002267687-webhooks/


#9

So here are the details of what's in the request..

From the header:
Content-Type: multipart/form-data; boundary=----------------------------5277aa8f4000

From the body:
Content-Type: application/json

Full details below:
https://requestbin.fullcontact.com/15cip361?inspect#u6xkge


#10

Thanks for that information, it looks like we don't handle the multipart/form-data correctly. I'll put in a ticket for that as well.


#11

Great thanks, is there anyway for me to track it so I know once it’s complete?


#12

Any update on this?

Thanks!


#13

Hi @chuck.schwer do you have an ETA for the fix at all?


#14

No ETA on the multpart parsing. But we did add the raw body to the request. Perhaps you can parse what you need that way? if you are on the latest build, you can use "request.body" to get it


#15

Great cheers, I managed to trim the content then extract the required content.. I'm sure there is a more elegant way but it works..


#16

@jpark if you want to have a play then give it a go.. I'm going to be busy the next few weeks moving house.. will post properly once I have some time..

This will only read state from Plex and not control it.. and works using webhooks at the moment and that has been very basic testing.. let me know if it works for you!

App:

import groovy.json.JsonSlurper
/**
 *  Plex Communicator
 *
 *  Copyright 2018 Jake Tebbett
 *	Credit To: Christian Hjelseth, iBeech & Ph4r as snippets of code taken and amended from their apps
 *
 *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 *  in compliance with the License. You may obtain a copy of the License at:
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *  for the specific language governing permissions and limitations under the License.
 * 
 * VERSION CONTROL
 * ###############
 *
 *  v0.1 - Test Release
 *
 */

definition(
    name: "Plex Communicator",
    namespace: "jebbett",
    author: "Jake Tebbett",
    description: "Allow SmartThings and Plex to Communicate",
    category: "My Apps",
    iconUrl: "https://github.com/jebbett/Plex-Communicator/raw/master/icon.png",
    iconX2Url: "https://github.com/jebbett/Plex-Communicator/raw/master/icon.png",
    iconX3Url: "https://github.com/jebbett/Plex-Communicator/raw/master/icon.png",
    oauth: [displayName: "PlexServer", displayLink: ""])


def installed() {
    initialize()
}

def updated() {
    unsubscribe()
    initialize()
}

def initialize() {
	// sub to plex now playing response
    subscribe(location, null, response, [filterEvents:false])
    // Add New Devices
    def storedDevices = state.plexClients
    settings.devices.each {deviceId ->
        try {
            def existingDevice = getChildDevice(deviceId)
            if(!existingDevice) {
                def theHub = location.hubs[0]
            	log.warn "${deviceId} and ${theHub}"
                //def childDevice = addChildDevice("jebbett", "Plex Communicator Device", deviceId, theHub.id, [name: "${deviceId}", isComponent: false])
                def childDevice = addChildDevice("jebbett", "Plex Communicator Device", deviceId, theHub.id, [name: deviceId, label: storedDevices."$deviceId".name, completedSetup: false])
            }
        } catch (e) { log.error "Error creating device: ${e}" }
    }
    // Clean up child devices
    if(settings?.devices) {
    	getChildDevices().each { if(settings.devices.contains(it.deviceNetworkId)){}else{deleteChildDevice("${it.deviceNetworkId}")} }
    }else{
    	getChildDevices().each { deleteChildDevice("${it.deviceNetworkId}") }
    }
    // Just in case plexPoller has gasped it's last breath (in case it's used)
    if(settings?.stPoller){runEvery3Hours(plexPoller)}
}

preferences {
	page name: "mainMenu"
    page name: "noAuthPage"
    page name: "authPage"
    page name: "authPage2"
    page name: "clientPage"
    page name: "clearClients"
    page name: "mainPage"
    page name: "ApiSettings"
}

mappings {
  path("/statechanged/:command") 	{ action: [GET: "plexExeHandler"] }
  path("/p2stset") 					{ action: [GET: "p2stset"]   }
  path("/pwhset") 					{ action: [GET: "pwhset"]   }
  path("/pwh") 						{ action: [POST: "plexWebHookHandler"] }
}


/***********************************************************
** Main Pages
************************************************************/

def mainMenu() {
	// Get ST Token
    try { if (!state.accessToken) {createAccessToken()} }
    catch (Exception e) {
    	log.info "Unable to create access token, OAuth has probably not been enabled in IDE: $e"
        return noAuthPage()
    }

	if (state?.authenticationToken) { return mainPage() }
    else { return authPage() }
}

def noAuthPage() {
	
	return dynamicPage(name: "noAuthPage", uninstall: true, install: true) {
		section("*Error* You have not enabled OAuth when installing the app code, please enable OAuth")
    }
}

def mainPage() {
	return dynamicPage(name: "mainPage", uninstall: true, install: true) {
		section("Main Menu") {
        	href "clientPage", title:"Select Your Devices", description: "Select the devices you want to monitor" 
            href "authPage", title:"Plex Account Details", description: "Update Plex Account Details"
        	href(name: "ApiSettings", title: "Connection Methods", required: false, page: "ApiSettings", description: "Select your method for connecting to Plex")
    	}
        section("If you want to control lighting scenes then the 'MediaScene' SmartApp is ideal for this purpose"){}
        section("This app is developed by jebbett, additional credit goes to Christian H (Plex2SmartThings), iBeech (Plex Home Theatre) & Ph4r (Improved Plex control)."){}
    }
}

def ApiSettings() {
    dynamicPage(name: "ApiSettings", title: "Select Your Connection Method", install: false, uninstall: false) {      
        section("1. Plex Webhooks - Plex Pass Only (Best)") {
        	paragraph("Plex Webhooks is the best method for connecting Plex to SmartThings, however you will need an active Plex Pass Subscription to use it")
        	href url: "${getLocalApiServerUrl()}/${app.id}/pwhset?access_token=${state.accessToken}", style:"embedded", required:false, title:"Plex Webhooks Settings", description: ""  		
        }
        section("2. Plex2SmartThings Program") {
        	paragraph("This involves running a program on an always on computer")
            href url: "${getLocalApiServerUrl()}/${app.id}/p2stset?access_token=${state.accessToken}", style:"embedded", required:false, title:"Plex2SmartThings Program Settings", description: ""    		
        }
        section("3. SmartThings Polling *Not Recommended*") {
        	paragraph("SmartThings will poll every 10 seconds and request the status from Plex, however this method is unreliable and puts increased load on SmartThings and your network (Don't complain to me that it stops working occasionally)")
            input "stPoller", "bool", title: "Enable - At your own risk", defaultValue:false, submitOnChange: true
        }
        if(settings?.stPoller){plexPoller()}
        
        
        section("NOTE: The settings for both of the above have also been sent to Live Logging, ffor easy access from a computer."){}
        log.debug(
        		"\n ## URL FOR USE IN PLEX WEBHOOKS ##\n${getFullLocalApiServerUrl()}/pwh?access_token=${state.accessToken}"+
        		"\n\n ## SETTINGS TO USE IN THE EXE ##\n"+
        		"<!ENTITY accessToken '${state.accessToken}'>\n"+
				"<!ENTITY appId '${app.id}'>\n"+
				"<!ENTITY ide '${getFullLocalApiServerUrl()}'>\n"+
				"<!ENTITY plexStatusUrl 'http://${settings.plexServerIP}:32400/status/sessions?X-Plex-Token=${state.authenticationToken}'>\n")
    }
}

def pwhset() {
    def html = """<html><head><title>Plex2SmartThings Settings</title></head><body><h1>
        ${getFullLocalApiServerUrl()}/pwh?access_token=${state.accessToken}<br />

    </h1></body></html>"""
    render contentType: "text/html", data: html, status: 200
}

def p2stset() {
    def html = """
    <!DOCTYPE html>
    <html><head><title>Plex Webhooks Settings</title></head><body><p>
        &lt;!ENTITY accessToken '${state.accessToken}'><br />
        &lt;!ENTITY appId '${app.id}'><br />
        &lt;!ENTITY ide '${getFullLocalApiServerUrl()}'><br />
        &lt;!ENTITY plexStatusUrl 'http://${settings.plexServerIP}:32400/status/sessions?X-Plex-Token=${state.authenticationToken}'>
    </p></body></html>"""
    render contentType: "text/html", data: html, status: 200
}



/***********************************************************
** Plex Authentication
************************************************************/

def authPage() {
    return dynamicPage(name: "authPage", nextPage: authPage2, install: false) {
        def hub = location.hubs[0]
        section("Plex Login Details") {
        	input "plexUserName", "text", "title": "Plex Username", multiple: false, required: true
    		input "plexPassword", "password", "title": "Plex Password", multiple: false, required: true
            input "plexServerIP", "text", "title": "Server IP", multiple: false, required: true
		}
    }
}
def authPage2() {
    getAuthenticationToken()
    clientPage()
}

def getAuthenticationToken() {
    log.debug "Getting authentication token for Plex Server " + settings.plexServerIP      
    def paramsp = [
    	uri: "https://plex.tv/users/sign_in.json?user%5Blogin%5D=" + settings.plexUserName + "&user%5Bpassword%5D=" + URLEncoder.encode(settings.plexPassword),
        requestContentType: "application/json",
        headers: [
            'X-Plex-Client-Identifier': 'PlexCommunicator',
			'X-Plex-Product': 'Plex Communicator',
			'X-Plex-Version': '1.0'
        ]
   	]    
	try {    
		httpPost(paramsp) { resp ->          
        	state.authenticationToken = resp.data.user.authentication_token;
        	log.debug "Congratulations Token recieved: " + state.authenticationToken + " & your Plex Pass status is " + resp.data.user.subscription.status }
	}
	catch (Exception e) { log.warn "Hit Exception $e on $paramsp" }
}

/***********************************************************
** CLIENTS
************************************************************/

def clientPage() {
    getClients()
    def devs = getClientList()
	return dynamicPage(name: "clientPage", title: "NOTE:", nextPage: mainPage, uninstall: false, install: true) {
        section("If your device does not appear in the list"){}
        section("Devices currently in use by plex will have a [►] icon next to them, this can be helpful when multiple devices share the same name, if a device is playing but not shown then press Save above and come back to this screen"){
        	input "devices", "enum", title: "Select Your Devices", options: devs, multiple: true, required: false, submitOnChange: true
  		}
        if(!devices){
            section("*CAUTION*"){
            	href(name: "clearClients", title:"RESET Devices List", description: "", page: "clearClients", required: false)
            }
        }else{
        section("To Reset Devices List - Untick all devices in the list above, and the option to reset will appear"){}
        }
    }
}


def clearClients() {
	state.plexClients = [:]
    mainPage()
}

def getClientList() {
    def devList = [:]
    state.plexClients.each { id, details -> devList << [ "$id": "${details.name}" ] }
    state.playingClients.each { id, details -> devList << [ "$id": "${details.name} [►]" ] }
    return devList.sort { a, b -> a.value.toLowerCase() <=> b.value.toLowerCase() }
}

def getClients(){
    // set lists
	def isMap = state.plexClients instanceof Map
    if(!isMap){state.plexClients = [:]}
    def isMap2 = state.playingClients instanceof Map
    if(!isMap2){state.playingClients = [:]}
    // Get devices.xml clients
    getClientsXML()
    // Request server:32400/status/sessions clients - chrome cast for example is not in devices.
	executeRequest("/status/sessions", "GET")
}

def executeRequest(Path, method) {    
	def headers = [:] 
	headers.put("HOST", "$settings.plexServerIP:32400")
    headers.put("X-Plex-Token", state.authenticationToken)	
	try {    
		def actualAction = new hubitat.device.HubAction(
		    method: method,
		    path: Path,
		    headers: headers)
		sendHubCommand(actualAction)   
	}
	catch (Exception e) {log.debug "Hit Exception $e on $hubAction"}
}

def response(evt) {	 
	// Reponse to hub from now playing request    
    def msg = parseLanMessage(evt.description);
    def stillPlaying = []
    if(msg && msg.body && msg.body.startsWith("<?xml")){
    	log.debug "Parsing status/sessions"
    	def whatToCallMe = ""
    	def playingDevices = [:]
    	def mediaContainer = new XmlSlurper().parseText(msg.body)
		mediaContainer.Video.each { thing ->
            if(thing.Player.@title.text() != "") 		{whatToCallMe = "${thing.Player.@title.text()}-${thing.Player.@product.text()}"}
        	else if(thing.Player.@device.text()!="")	{whatToCallMe = "${thing.Player.@device.text()}-${thing.Player.@product.text()}"}
            playingDevices << [ (thing.Player.@machineIdentifier.text()): [name: "${whatToCallMe}", id: "${thing.Player.@machineIdentifier.text()}"]]
            
            if(settings?.stPoller){
    			def plexEvent = [:] << [ id: "${thing.Player.@machineIdentifier.text()}", type: "${thing.@type.text()}", status: "${thing.Player.@state.text()}", user: "${thing.User.@title.text()}" ]
                stillPlaying << "${thing.Player.@machineIdentifier.text()}"
        		eventHandler(plexEvent)
            }
        }
        if(settings?.stPoller){
        	//stop anything that's no long visible in the playing list but was playing before
        	state.playingClients.each { id, data ->
            	if(!stillPlaying.contains("$id")){
                	def plexEvent2 = [:] << [ id: "${id}", type: "--", status: "stopped", user: "--" ]
                    eventHandler(plexEvent2)
                }
            }
        }
        state.plexClients << playingDevices
        state.playingClients = playingDevices
    }
}


def getClientsXML() {
    //getAuthenticationToken()
    log.warn state.authenticationToken
    def xmlDevices = [:]
    // Get from Devices List
    def paramsg = [
		uri: "https://plex.tv/devices.xml",
		contentType: 'application/xml',
		headers: [ 'X-Plex-Token': state.authenticationToken ]
	]
	httpGet(paramsg) { resp ->
        log.debug "Parsing plex.tv/devices.xml"
        def devices = resp.data.Device
        devices.each { thing ->        
        	// If not these things
        	if(thing.@name.text()!="Plex Communicator" && !thing.@provides.text().contains("server")){      	
            	//Define name based on name unless blank then use device name
                def whatToCallMe = "Unknown"
                if(thing.@name.text() != "") 		{whatToCallMe = "${thing.@name.text()}-${thing.@product.text()}"}
                else if(thing.@device.text()!="")	{whatToCallMe = "${thing.@device.text()}-${thing.@product.text()}"}  
                xmlDevices << [ (thing.@clientIdentifier.text()): [name: "${whatToCallMe}", id: "${thing.@clientIdentifier.text()}"]]
        	}
    	}   
    }
    //Get from status
    state.plexClients << xmlDevices
}

/***********************************************************
** INPUT HANDLERS
************************************************************/
def plexExeHandler() {
	def status = params.command
	def userName = params.user
	//def playerName = params.player
    //def playerIP = params.ipadd
	def mediaType = params.type
    def playerID = params.id
	//log.debug "$playerID / $status / $userName / $playerName / $playerIP / $mediaType"
    def plexEvent = [:] << [ id: "$playerID", type: "$mediaType", status: "$status", user: "$userName" ]
    eventHandler(plexEvent)
	return
}


def plexWebHookHandler(){
    
    def payloadStart = request.body.indexOf('application/json') + 17    
    def newBody = request.body.substring(payloadStart)
    //log.debug newBody
	
    def jsonSlurper = new JsonSlurper()
	def plexJSON = jsonSlurper.parseText(newBody)
    
    //log.debug "Metadata JSON: ${plexJSON.Metadata as String}"
    //log.debug "Player JSON: ${plexJSON.Player as String}"
    //log.debug "Account JSON: ${plexJSON.Account}"
    log.debug "Event JSON: ${plexJSON.event}"
	def playerID = plexJSON.Player.uuid
    def userName = plexJSON.Account.title
	def mediaType = plexJSON.Metadata.type
    def status = plexJSON.event
    def plexEvent = [:] << [ id: "$playerID", type: "$mediaType", status: "$status", user: "$userName" ]

    eventHandler(plexEvent)
}

def plexPoller(){
	if(settings?.stPoller){
    	executeRequest("/status/sessions", "GET")
    	log.warn "Plex Poller Update"
    	runOnce( new Date(now() + 10000L), plexPoller)
    }
}


/***********************************************************
** DTH OUTPUT
************************************************************/

def eventHandler(event) {
    def status = event.status as String
    // change command to right format
    switch(status) {
		case ["media.play","media.resume","media.scrobble","onplay","play"]:	status = "playing"; break;
        case ["media.pause","onpause","pause"]:									status = "paused"; 	break;
        case ["media.stop","onstop","stop"]:									status = "stopped"; break;
    }
    getChildDevices().each { pcd ->
        if (event.id == pcd.deviceNetworkId){
        	pcd.setPlayStatus(status)
            pcd.playbackType(event.type)
        }
    }
} 

Driver:

/**
 *  Plex Communicator Device
 *
 *  Copyright 2018 Jake Tebbett (jebbett)
 *
 *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 *  in compliance with the License. You may obtain a copy of the License at:
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *  for the specific language governing permissions and limitations under the License.
 *
 * 2018-11-17	2.0		Updated to report playbackType Correctly		
 */

metadata {
	definition (name: "Plex Communicator Device", namespace: "jebbett", author: "jebbett") {
	capability "Music Player"
    command "playbackType", ["string"]
	attribute "playbackType", "string"
	}
}

// External
def playbackType(type) {
	sendEvent(name: "playbackType", value: type);
    log.debug "Playback type set as $type"
}

def setPlayStatus(type){
    // Value needs to be playing, paused or stopped
    sendEvent(name: "status", value: "$type")
	log.debug "Status set to $type"
}

def play() {	        
    sendEvent(name: "status", value: "playing");
}

def pause() {
    sendEvent(name: "status", value: "paused");
}

def stop() {
    sendEvent(name: "status", value: "stopped");    
}

Additionally if you want to control lighting, check out "Media Scene" which can take any player device and control lighting scenes:


#17

Awesome! I'll install it tonight


#18

Just tested and it works great! Exactly what I was looking for!

Thank you!

Setup tips for others:

  • Enable OAuth for this app code in HE
  • Once initial setup is done, copy the webhooks address from this app to your Plex account Webhooks section.

#19

hmm not working for me :frowning:


#20

You’re going to need to be a lot more descriptive if you need help? Firstly I assume you have Plex pass and you have configured Plex?

If so what are you getting in habitat logs? What is actually not working? Finding devices, or updating their status?