Plex Webhooks

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

Any update on this?

Thanks!

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

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

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..

@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:

4 Likes

Awesome! I'll install it tonight

1 Like

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.
1 Like

hmm not working for me :frowning:

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?

When I try to control play back nothing happens. I'll get some logs today.

@anon61068208

Quoted from my post with the code..

Control is a real PITA as there are 3 or 4 different control methods and each device will usually only support one of them if any at all.. I would have to port over Ph4r's code from his fork of Plex Manager, and there is a lot of code required to do this.. and I don't really have the time to invest in doing this..

Plex Communicator is largely based on PlexPlus which is for subscribing to Plex device states only.. but adds device handling similar to Plex HT Manager, but much better as gets around some of the limitations of the plex API, by discovering some devices that do not appear in the devices list, however the ability to control was not ported.

Personally I didn't see the need for control, which is why I haven't invested the time in to it, I just needed it to control my lighting based on what was playing.. I've also got another app that I need to port which is specifically for the lighting side of things, but this can be achieved via rule machine.. just much easier via my app as it handles a lot of things which are less easy to cater for, such as the small gap between TV episodes can cause issues unless you build in a buffer delay..

Sorry probably not the answer you were looking for, and have no issue if someone wants to do a pull request on the code when I load it to Git to add the functionality.. but unless Ph4r moves to hubitat, then I'm not sure many people will have the time / knowledge to do this easily..

1 Like

Ok.. well it works half way then. Don't give up ! It's a awesome start !

Haha, I haven't given up, just busy with buying a house, moving and work.. and I have 4 more apps I need to port over at some point that I haven't yet got round to.. just to get my system ported over and off ST..

I honestly wish I had the free time to work on this stuff, the only reason I had time to put in this update was because I was off sick from work with food poisoning :nauseated_face:

1 Like

I've got the app installed and working. Curious what everyone is using to in conjunction with it to set things like light levels, etc?

Thanks!

1 Like

Currently I’m not, (because I’m waiting for Google assistant and I’ve been moving house) however I have a custom written app on ST designed specifically for this purpose... I’ll port over if I have some free time to do so...

Otherwise I expect everyone else is using rule machine..

1 Like

Thanks!

I looked into using Rule Machine. Looks like it would mostly work, with one exception. In Smartthings I have it setting different lights based on the playback type. Unfortunately I don’t see that capability in Rule Machine as an option.

I’m pretty sure even if it doesn’t exist you can just use a custom event and do it that way.. but not that I’ve used RM since the ST days

I don't see a way to make "playbackType" attribute a rule within RM. I can see "playbackType" being set to either moive/tv depending on the content in the logs but no way to use that info within RM.

Is there a way to turn on/off some virtual switches to indicate content type so we can use it within RM?

Sorry I forgot to reply to this message, if RM doesn’t support then at present no. I would perhaps check in the RM thread though, if it doesn’t exist then it probably should.

Otherwise you have a couple of options, either write an app to handle your use case or wait for me to port mine over.. which I will definitely do, but just moved in to a new house and very busy on that at the moment, will likely be before Christmas though.. or if you feel confident to port it over I can share the ST code with you?