mirror of
https://github.com/phin05/discord-rich-presence-plex
synced 2024-11-22 01:23:02 +00:00
Major refactor
This commit is contained in:
parent
e58a39b483
commit
6467ae2a80
17 changed files with 525 additions and 428 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
config.json
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2018 phin05
|
||||
Copyright (c) 2018-2022 phin05
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
90
README.md
90
README.md
|
@ -1,43 +1,71 @@
|
|||
# Discord Rich Presence for Plex
|
||||
|
||||
A Python script that displays your [Plex](https://www.plex.tv) status on [Discord](https://discordapp.com) using [Rich Presence](https://discordapp.com/developers/docs/rich-presence/how-to).
|
||||
A Python script that displays your [Plex](https://www.plex.tv) status on [Discord](https://discord.com) using [Rich Presence](https://discord.com/developers/docs/rich-presence/how-to).
|
||||
|
||||
## Requirements
|
||||
## Getting Started
|
||||
|
||||
* [Python 3.6.7](https://www.python.org/downloads/release/python-367/)
|
||||
* [plexapi](https://github.com/pkkid/python-plexapi)
|
||||
* Use [websocket-client](https://github.com/websocket-client/websocket-client) version 0.48.0 (`pip install websocket-client==0.48.0`) as an issue with newer versions breaks the plexapi module's alert listener.
|
||||
* The script must be running on the same machine as your Discord client.
|
||||
1. Install [Python 3.10](https://www.python.org/downloads/)
|
||||
2. [Download](archive/refs/heads/master.zip) the ZIP file containing the files in this repository
|
||||
3. Extract the contents of the above ZIP file into a new directory
|
||||
4. Navigate a command-line interface (cmd.exe, PowerShell, bash, etc.) to the above directory
|
||||
5. Install the required Python modules by running `python -m pip install -r requirements.txt`
|
||||
6. Start the script by running `python main.py`
|
||||
|
||||
## Configuration
|
||||
When the script runs for the first time, a `config.json` file will be created in the working directory and you will be prompted to complete the authentication flow to allow the script to retrieve your username and an access token.
|
||||
|
||||
Add your configuration(s) into the `plexConfigs` list on line 30.
|
||||
The script must be running on the same machine as your Discord client.
|
||||
|
||||
#### Example
|
||||
## Configuration - `config.json`
|
||||
|
||||
```python
|
||||
plexConfigs = [
|
||||
plexConfig(serverName = "ABC", username = "xyz", password = "0tYD4UIC4Tb8X0nt"),
|
||||
plexConfig(serverName = "DEF", username = "pqr@pqr.pqr", token = "70iU3GZrI54S76Tn", listenForUser = "xyz"),
|
||||
plexConfig(serverName = "GHI", username = "xyz", password = "0tYD4UIC4Tb8X0nt", blacklistedLibraries = ["TV Shows", "Music"])
|
||||
]
|
||||
### Reference
|
||||
|
||||
* `logging`
|
||||
* `debug` - Output more information to the console
|
||||
* `display`
|
||||
* `useRemainingTime` - Display remaining time in your Rich Presence instead of elapsed time
|
||||
* `users` (list)
|
||||
* `username` - Username or e-mail
|
||||
* `token` - A [X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token)
|
||||
* `servers` (list)
|
||||
* `name` - Friendly name of the Plex Media Server you wish to connect to.
|
||||
* `blacklistedLibraries` (optional list) - Alerts originating from libraries in this list are ignored.
|
||||
* `whitelistedLibraries` (optional list) - If set, alerts originating from libraries that are not in this list are ignored.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"logging": {
|
||||
"debug": true
|
||||
},
|
||||
"display": {
|
||||
"useRemainingTime": false
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "bob",
|
||||
"token": "HPbrz2NhfLRjU888Rrdt",
|
||||
"servers": [
|
||||
{
|
||||
"name": "Bob's Home Media Server",
|
||||
},
|
||||
{
|
||||
"name": "A Friend's Server",
|
||||
"whitelistedLibraries": ["Movies"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
* `serverName` - Name of the Plex Media Server to connect to.
|
||||
* `username` - Your account's username or e-mail.
|
||||
* `password` (not required if `token` is set) - The password associated with the above account.
|
||||
* `token` (not required if `password` is set) - A [X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token) associated with the above account. In some cases, `myPlexAccessToken` from Plex Web App's HTML5 Local Storage must be used. To retrieve this token in Google Chrome, open Plex Web App, press F12, go to "Application", expand "Local Storage" and select the relevant entry. Ignores `password` if set.
|
||||
* `listenForUser` (optional) - The script will respond to alerts originating only from this username. Defaults to `username` if not set.
|
||||
* `blacklistedLibraries` (list, optional) - Alerts originating from blacklisted libraries are ignored.
|
||||
* `whitelistedLibraries` (list, optional) - If set, alerts originating from libraries that are not in the whitelist are ignored.
|
||||
|
||||
### Other Variables
|
||||
|
||||
* Line 16: `extraLogging` - The script outputs more information if this is set to `True`.
|
||||
* Line 17: `timeRemaining` - Set this to `True` to display time remaining instead of time elapsed while media is playing.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](https://github.com/phin05/discord-rich-presence-plex/blob/master/LICENSE) file for details.
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Credits
|
||||
|
||||
* [Discord](https://discord.com)
|
||||
* [Plex](https://www.plex.tv)
|
||||
* [plexapi](https://github.com/pkkid/python-plexapi)
|
||||
* [websocket-client](https://github.com/websocket-client/websocket-client)
|
||||
|
|
|
@ -1,396 +0,0 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import plexapi.myplex
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
class plexConfig:
|
||||
|
||||
extraLogging = True
|
||||
timeRemaining = False
|
||||
|
||||
def __init__(self, serverName = "", username = "", password = "", token = "", listenForUser = "", blacklistedLibraries = None, whitelistedLibraries = None, clientID = "413407336082833418"):
|
||||
self.serverName = serverName
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.token = token
|
||||
self.listenForUser = (username if listenForUser == "" else listenForUser).lower()
|
||||
self.blacklistedLibraries = blacklistedLibraries
|
||||
self.whitelistedLibraries = whitelistedLibraries
|
||||
self.clientID = clientID
|
||||
|
||||
plexConfigs = [
|
||||
# plexConfig(serverName = "", username = "", password = "", token = "")
|
||||
]
|
||||
|
||||
class discordRichPresence:
|
||||
|
||||
def __init__(self, clientID, child):
|
||||
self.IPCPipe = ((os.environ.get("XDG_RUNTIME_DIR", None) or os.environ.get("TMPDIR", None) or os.environ.get("TMP", None) or os.environ.get("TEMP", None) or "/tmp") + "/discord-ipc-0") if isLinux else "\\\\?\\pipe\\discord-ipc-0"
|
||||
self.clientID = clientID
|
||||
self.pipeReader = None
|
||||
self.pipeWriter = None
|
||||
self.process = None
|
||||
self.running = False
|
||||
self.child = child
|
||||
|
||||
async def read(self):
|
||||
try:
|
||||
data = await self.pipeReader.read(1024)
|
||||
self.child.log("[READ] " + str(json.loads(data[8:].decode("utf-8"))))
|
||||
except Exception as e:
|
||||
self.child.log("[READ] " + str(e))
|
||||
self.stop()
|
||||
|
||||
def write(self, op, payload):
|
||||
payload = json.dumps(payload)
|
||||
self.child.log("[WRITE] " + str(payload))
|
||||
data = self.pipeWriter.write(struct.pack("<ii", op, len(payload)) + payload.encode("utf-8"))
|
||||
|
||||
async def handshake(self):
|
||||
try:
|
||||
if (isLinux):
|
||||
self.pipeReader, self.pipeWriter = await asyncio.open_unix_connection(self.IPCPipe, loop = self.loop)
|
||||
else:
|
||||
self.pipeReader = asyncio.StreamReader(loop = self.loop)
|
||||
self.pipeWriter, _ = await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader, loop = self.loop), self.IPCPipe)
|
||||
self.write(0, {"v": 1, "client_id": self.clientID})
|
||||
await self.read()
|
||||
self.running = True
|
||||
except Exception as e:
|
||||
self.child.log("[HANDSHAKE] " + str(e))
|
||||
|
||||
def start(self):
|
||||
self.child.log("Opening Discord IPC Pipe")
|
||||
emptyProcessFilePath = tempfile.gettempdir() + ("/" if isLinux else "\\") + "discordRichPresencePlex-emptyProcess.py"
|
||||
if (not os.path.exists(emptyProcessFilePath)):
|
||||
with open(emptyProcessFilePath, "w") as emptyProcessFile:
|
||||
emptyProcessFile.write("import time\n\ntry:\n\twhile (True):\n\t\ttime.sleep(3600)\nexcept:\n\tpass")
|
||||
self.process = subprocess.Popen(["python3" if isLinux else "pythonw", emptyProcessFilePath])
|
||||
self.loop = asyncio.new_event_loop() if isLinux else asyncio.ProactorEventLoop()
|
||||
self.loop.run_until_complete(self.handshake())
|
||||
|
||||
def stop(self):
|
||||
self.child.log("Closing Discord IPC Pipe")
|
||||
self.child.lastState, self.child.lastSessionKey, self.child.lastRatingKey = None, None, None
|
||||
self.process.kill()
|
||||
if (self.child.stopTimer):
|
||||
self.child.stopTimer.cancel()
|
||||
self.child.stopTimer = None
|
||||
if (self.child.stopTimer2):
|
||||
self.child.stopTimer2.cancel()
|
||||
self.child.stopTimer2 = None
|
||||
if (self.pipeWriter):
|
||||
try:
|
||||
self.pipeWriter.close()
|
||||
except:
|
||||
pass
|
||||
self.pipeWriter = None
|
||||
if (self.pipeReader):
|
||||
try:
|
||||
self.loop.run_until_complete(self.pipeReader.read(1024))
|
||||
except:
|
||||
pass
|
||||
self.pipeReader = None
|
||||
try:
|
||||
self.loop.close()
|
||||
except:
|
||||
pass
|
||||
self.running = False
|
||||
|
||||
def send(self, activity):
|
||||
payload = {
|
||||
"cmd": "SET_ACTIVITY",
|
||||
"args": {
|
||||
"activity": activity,
|
||||
"pid": self.process.pid
|
||||
},
|
||||
"nonce": "{0:.20f}".format(time.time())
|
||||
}
|
||||
self.write(1, payload)
|
||||
self.loop.run_until_complete(self.read())
|
||||
|
||||
class discordRichPresencePlex(discordRichPresence):
|
||||
|
||||
productName = "Plex Media Server"
|
||||
stopTimerInterval = 5
|
||||
stopTimer2Interval = 35
|
||||
checkConnectionTimerInterval = 60
|
||||
maximumIgnores = 3
|
||||
|
||||
def __init__(self, plexConfig):
|
||||
self.plexConfig = plexConfig
|
||||
self.instanceID = hashlib.md5(str(id(self)).encode("UTF-8")).hexdigest()[:5]
|
||||
super().__init__(plexConfig.clientID, self)
|
||||
self.plexAccount = None
|
||||
self.plexServer = None
|
||||
self.isServerOwner = False
|
||||
self.plexAlertListener = None
|
||||
self.lastState = None
|
||||
self.lastSessionKey = None
|
||||
self.lastRatingKey = None
|
||||
self.stopTimer = None
|
||||
self.stopTimer2 = None
|
||||
self.checkConnectionTimer = None
|
||||
self.ignoreCount = 0
|
||||
|
||||
def run(self):
|
||||
self.reset()
|
||||
connected = False
|
||||
while (not connected):
|
||||
try:
|
||||
if (self.plexConfig.token):
|
||||
self.plexAccount = plexapi.myplex.MyPlexAccount(self.plexConfig.username, token = self.plexConfig.token)
|
||||
else:
|
||||
self.plexAccount = plexapi.myplex.MyPlexAccount(self.plexConfig.username, self.plexConfig.password)
|
||||
self.log("Logged in as Plex User \"" + self.plexAccount.username + "\"")
|
||||
self.plexServer = None
|
||||
for resource in self.plexAccount.resources():
|
||||
if (resource.product == self.productName and resource.name == self.plexConfig.serverName):
|
||||
self.plexServer = resource.connect()
|
||||
try:
|
||||
self.plexServer.account()
|
||||
self.isServerOwner = True
|
||||
except:
|
||||
pass
|
||||
self.log("Connected to " + self.productName + " \"" + self.plexConfig.serverName + "\"")
|
||||
self.plexAlertListener = self.plexServer.startAlertListener(self.onPlexServerAlert)
|
||||
self.log("Listening for PlaySessionStateNotification alerts from user \"" + self.plexConfig.listenForUser + "\"")
|
||||
if (self.checkConnectionTimer):
|
||||
self.checkConnectionTimer.cancel()
|
||||
self.checkConnectionTimer = None
|
||||
self.checkConnectionTimer = threading.Timer(self.checkConnectionTimerInterval, self.checkConnection)
|
||||
self.checkConnectionTimer.start()
|
||||
connected = True
|
||||
break
|
||||
if (not self.plexServer):
|
||||
self.log(self.productName + " \"" + self.plexConfig.serverName + "\" not found")
|
||||
break
|
||||
except Exception as e:
|
||||
self.log("Failed to connect to Plex: " + str(e))
|
||||
self.log("Reconnecting in 10 seconds")
|
||||
time.sleep(10)
|
||||
|
||||
def reset(self):
|
||||
if (self.running):
|
||||
self.stop()
|
||||
self.plexAccount, self.plexServer = None, None
|
||||
if (self.plexAlertListener):
|
||||
try:
|
||||
self.plexAlertListener.stop()
|
||||
except:
|
||||
pass
|
||||
self.plexAlertListener = None
|
||||
if (self.stopTimer):
|
||||
self.stopTimer.cancel()
|
||||
self.stopTimer = None
|
||||
if (self.stopTimer2):
|
||||
self.stopTimer2.cancel()
|
||||
self.stopTimer2 = None
|
||||
if (self.checkConnectionTimer):
|
||||
self.checkConnectionTimer.cancel()
|
||||
self.checkConnectionTimer = None
|
||||
|
||||
def checkConnection(self):
|
||||
try:
|
||||
self.log("Request for clients list to check connection: " + str(self.plexServer.clients()), extra = True)
|
||||
self.checkConnectionTimer = threading.Timer(self.checkConnectionTimerInterval, self.checkConnection)
|
||||
self.checkConnectionTimer.start()
|
||||
except Exception as e:
|
||||
self.log("Connection to Plex lost: " + str(e))
|
||||
self.log("Reconnecting")
|
||||
self.run()
|
||||
|
||||
def log(self, text, colour = "", extra = False):
|
||||
timestamp = datetime.datetime.now().strftime("%I:%M:%S %p")
|
||||
prefix = "[" + timestamp + "] [" + self.plexConfig.serverName + "/" + self.instanceID + "] "
|
||||
lock.acquire()
|
||||
if (extra):
|
||||
if (self.plexConfig.extraLogging):
|
||||
print(prefix + colourText(str(text), colour))
|
||||
else:
|
||||
print(prefix + colourText(str(text), colour))
|
||||
lock.release()
|
||||
|
||||
def onPlexServerAlert(self, data):
|
||||
if (not self.plexServer):
|
||||
return
|
||||
try:
|
||||
if (data["type"] == "playing" and "PlaySessionStateNotification" in data):
|
||||
sessionData = data["PlaySessionStateNotification"][0]
|
||||
state = sessionData["state"]
|
||||
sessionKey = int(sessionData["sessionKey"])
|
||||
ratingKey = int(sessionData["ratingKey"])
|
||||
viewOffset = int(sessionData["viewOffset"])
|
||||
self.log("Received Update: " + colourText(sessionData, "yellow").replace("'", "\""), extra = True)
|
||||
metadata = self.plexServer.fetchItem(ratingKey)
|
||||
libraryName = metadata.section().title
|
||||
if (isinstance(self.plexConfig.blacklistedLibraries, list)):
|
||||
if (libraryName in self.plexConfig.blacklistedLibraries):
|
||||
self.log("Library \"" + libraryName + "\" is blacklisted, ignoring", "yellow", True)
|
||||
return
|
||||
if (isinstance(self.plexConfig.whitelistedLibraries, list)):
|
||||
if (libraryName not in self.plexConfig.whitelistedLibraries):
|
||||
self.log("Library \"" + libraryName + "\" is not whitelisted, ignoring", "yellow", True)
|
||||
return
|
||||
if (self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey):
|
||||
if (self.stopTimer2):
|
||||
self.stopTimer2.cancel()
|
||||
self.stopTimer2 = None
|
||||
if (self.lastState == state):
|
||||
if (self.ignoreCount == self.maximumIgnores):
|
||||
self.ignoreCount = 0
|
||||
else:
|
||||
self.log("Nothing changed, ignoring", "yellow", True)
|
||||
self.ignoreCount += 1
|
||||
self.stopTimer2 = threading.Timer(self.stopTimer2Interval, self.stopOnNoUpdate)
|
||||
self.stopTimer2.start()
|
||||
return
|
||||
elif (state == "stopped"):
|
||||
self.lastState, self.lastSessionKey, self.lastRatingKey = None, None, None
|
||||
self.stopTimer = threading.Timer(self.stopTimerInterval, self.stop)
|
||||
self.stopTimer.start()
|
||||
self.log("Started stopTimer", "yellow", True)
|
||||
return
|
||||
elif (state == "stopped"):
|
||||
self.log("\"stopped\" state update from unknown session key, ignoring", "yellow", True)
|
||||
return
|
||||
if (self.isServerOwner):
|
||||
self.log("Checking Sessions for Session Key " + colourText(sessionKey, "yellow"), extra = True)
|
||||
plexServerSessions = self.plexServer.sessions()
|
||||
if (len(plexServerSessions) < 1):
|
||||
self.log("Empty session list, ignoring", "red", True)
|
||||
return
|
||||
for session in plexServerSessions:
|
||||
self.log(str(session) + ", Session Key: " + colourText(session.sessionKey, "yellow") + ", Users: " + colourText(session.usernames, "yellow").replace("'", "\""), extra = True)
|
||||
sessionFound = False
|
||||
if (session.sessionKey == sessionKey):
|
||||
sessionFound = True
|
||||
self.log("Session found", "green", True)
|
||||
if (session.usernames[0].lower() == self.plexConfig.listenForUser):
|
||||
self.log("Username \"" + session.usernames[0].lower() + "\" matches \"" + self.plexConfig.listenForUser + "\", continuing", "green", True)
|
||||
break
|
||||
else:
|
||||
self.log("Username \"" + session.usernames[0].lower() + "\" doesn't match \"" + self.plexConfig.listenForUser + "\", ignoring", "red", True)
|
||||
return
|
||||
if (not sessionFound):
|
||||
self.log("No matching session found", "red", True)
|
||||
return
|
||||
if (self.stopTimer):
|
||||
self.stopTimer.cancel()
|
||||
self.stopTimer = None
|
||||
if (self.stopTimer2):
|
||||
self.stopTimer2.cancel()
|
||||
self.stopTimer2 = threading.Timer(self.stopTimer2Interval, self.stopOnNoUpdate)
|
||||
self.stopTimer2.start()
|
||||
self.lastState, self.lastSessionKey, self.lastRatingKey = state, sessionKey, ratingKey
|
||||
mediaType = metadata.type
|
||||
if (state != "playing"):
|
||||
extra = secondsToText(viewOffset / 1000, ":") + "/" + secondsToText(metadata.duration / 1000, ":")
|
||||
else:
|
||||
extra = secondsToText(metadata.duration / 1000)
|
||||
if (mediaType == "movie"):
|
||||
title = metadata.title + " (" + str(metadata.year) + ")"
|
||||
extra = extra + " · " + ", ".join([genre.tag for genre in metadata.genres[:3]])
|
||||
largeText = "Watching a Movie"
|
||||
elif (mediaType == "episode"):
|
||||
title = metadata.grandparentTitle
|
||||
extra = extra + " · S" + str(metadata.parentIndex) + " · E" + str(metadata.index) + " - " + metadata.title
|
||||
largeText = "Watching a TV Show"
|
||||
elif (mediaType == "track"):
|
||||
title = metadata.title
|
||||
artist = metadata.originalTitle
|
||||
if (not artist):
|
||||
artist = metadata.grandparentTitle
|
||||
extra = artist + " · " + metadata.parentTitle
|
||||
largeText = "Listening to Music"
|
||||
else:
|
||||
self.log("Unsupported media type \"" + mediaType + "\", ignoring", "red", True)
|
||||
return
|
||||
activity = {
|
||||
"details": title,
|
||||
"state": extra,
|
||||
"assets": {
|
||||
"large_text": largeText,
|
||||
"large_image": "logo",
|
||||
"small_text": state.capitalize(),
|
||||
"small_image": state
|
||||
},
|
||||
}
|
||||
if (state == "playing"):
|
||||
currentTimestamp = int(time.time())
|
||||
if (self.plexConfig.timeRemaining):
|
||||
activity["timestamps"] = {"end": round(currentTimestamp + ((metadata.duration - viewOffset) / 1000))}
|
||||
else:
|
||||
activity["timestamps"] = {"start": round(currentTimestamp - (viewOffset / 1000))}
|
||||
if (not self.running):
|
||||
self.start()
|
||||
if (self.running):
|
||||
self.send(activity)
|
||||
else:
|
||||
self.stop()
|
||||
except Exception as e:
|
||||
self.log("onPlexServerAlert Error: " + str(e))
|
||||
|
||||
def stopOnNoUpdate(self):
|
||||
self.log("No updates from session key " + str(self.lastSessionKey) + ", stopping", "red", True)
|
||||
self.stop()
|
||||
|
||||
isLinux = sys.platform in ["linux", "darwin"]
|
||||
lock = threading.Semaphore(value = 1)
|
||||
|
||||
os.system("clear" if isLinux else "cls")
|
||||
|
||||
if (len(plexConfigs) == 0):
|
||||
print("Error: plexConfigs list is empty")
|
||||
sys.exit()
|
||||
|
||||
colours = {
|
||||
"red": "91",
|
||||
"green": "92",
|
||||
"yellow": "93",
|
||||
"blue": "94",
|
||||
"magenta": "96",
|
||||
"cyan": "97"
|
||||
}
|
||||
|
||||
def colourText(text, colour = ""):
|
||||
prefix = ""
|
||||
suffix = ""
|
||||
colour = colour.lower()
|
||||
if (colour in colours):
|
||||
prefix = "\033[" + colours[colour] + "m"
|
||||
suffix = "\033[0m"
|
||||
return prefix + str(text) + suffix
|
||||
|
||||
def secondsToText(seconds, joiner = ""):
|
||||
seconds = round(seconds)
|
||||
text = {"h": seconds // 3600, "m": seconds // 60 % 60, "s": seconds % 60}
|
||||
if (joiner == ""):
|
||||
text = [str(v) + k for k, v in text.items() if v > 0]
|
||||
else:
|
||||
if (text["h"] == 0):
|
||||
del text["h"]
|
||||
text = [str(v).rjust(2, "0") for k, v in text.items()]
|
||||
return joiner.join(text)
|
||||
|
||||
discordRichPresencePlexInstances = []
|
||||
for config in plexConfigs:
|
||||
discordRichPresencePlexInstances.append(discordRichPresencePlex(config))
|
||||
try:
|
||||
for discordRichPresencePlexInstance in discordRichPresencePlexInstances:
|
||||
discordRichPresencePlexInstance.run()
|
||||
while (True):
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
for discordRichPresencePlexInstance in discordRichPresencePlexInstances:
|
||||
discordRichPresencePlexInstance.reset()
|
||||
except Exception as e:
|
||||
print("Error: " + str(e))
|
34
main.py
Normal file
34
main.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from services import ConfigService, PlexAlertListener
|
||||
from store.constants import isUnix
|
||||
from utils.logs import logger
|
||||
import logging
|
||||
import os
|
||||
|
||||
os.system("clear" if isUnix else "cls")
|
||||
|
||||
configService = ConfigService("config.json")
|
||||
config = configService.config
|
||||
|
||||
if config["logging"]["debug"]:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
PlexAlertListener.useRemainingTime = config["display"]["useRemainingTime"]
|
||||
|
||||
if len(config["users"]) == 0:
|
||||
logger.info("No users in config. Initiating authorisation flow. ! TBD !") # TODO
|
||||
exit()
|
||||
|
||||
plexAlertListeners: list[PlexAlertListener] = []
|
||||
try:
|
||||
for user in config["users"]:
|
||||
for server in user["servers"]:
|
||||
plexAlertListeners.append(PlexAlertListener(user["username"], user["token"], server))
|
||||
while True:
|
||||
userInput = input()
|
||||
if userInput in ["exit", "quit"]:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
for plexAlertListener in plexAlertListeners:
|
||||
plexAlertListener.disconnect()
|
||||
except:
|
||||
logger.exception("An unexpected error occured")
|
0
models/__init__.py
Normal file
0
models/__init__.py
Normal file
22
models/config.py
Normal file
22
models/config.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from typing import TypedDict
|
||||
|
||||
class Logging(TypedDict):
|
||||
debug: bool
|
||||
|
||||
class Display(TypedDict):
|
||||
useRemainingTime: bool
|
||||
|
||||
class Server(TypedDict):
|
||||
name: str
|
||||
blacklistedLibraries: list[str]
|
||||
whitelistedLibraries: list[str]
|
||||
|
||||
class User(TypedDict):
|
||||
username: str
|
||||
token: str
|
||||
servers: list[Server]
|
||||
|
||||
class Config(TypedDict):
|
||||
logging: Logging
|
||||
display: Display
|
||||
users: list[User]
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
plexapi
|
||||
websocket-client
|
41
services/ConfigService.py
Normal file
41
services/ConfigService.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
from datetime import datetime
|
||||
from models.config import Config
|
||||
from utils.logs import logger
|
||||
import json
|
||||
import os
|
||||
|
||||
class ConfigService:
|
||||
|
||||
config: Config
|
||||
|
||||
def __init__(self, configFilePath: str) -> None:
|
||||
self.configFilePath = configFilePath
|
||||
if os.path.isfile(self.configFilePath):
|
||||
try:
|
||||
with open(self.configFilePath, "r", encoding = "UTF-8") as configFile:
|
||||
self.config = json.load(configFile)
|
||||
except:
|
||||
os.rename(configFilePath, configFilePath.replace(".json", f"-{datetime.now().timestamp():.0f}.json"))
|
||||
logger.exception("Failed to parse the application's config file. A new one will be created.")
|
||||
self.resetConfig()
|
||||
else:
|
||||
self.resetConfig()
|
||||
|
||||
def resetConfig(self) -> None:
|
||||
self.config = {
|
||||
"logging": {
|
||||
"debug": True
|
||||
},
|
||||
"display": {
|
||||
"useRemainingTime": False
|
||||
},
|
||||
"users": []
|
||||
}
|
||||
self.saveConfig()
|
||||
|
||||
def saveConfig(self) -> None:
|
||||
try:
|
||||
with open(self.configFilePath, "w", encoding = "UTF-8") as configFile:
|
||||
json.dump(self.config, configFile, indent = "\t")
|
||||
except:
|
||||
logger.exception("Failed to write to the application's config file.\n%s")
|
90
services/DiscordRpcService.py
Normal file
90
services/DiscordRpcService.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
# type: ignore
|
||||
|
||||
from store.constants import isUnix, processID
|
||||
from utils.logs import logger
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
class DiscordRpcService:
|
||||
|
||||
clientID = "413407336082833418"
|
||||
ipcPipe = ((os.environ.get("XDG_RUNTIME_DIR", None) or os.environ.get("TMPDIR", None) or os.environ.get("TMP", None) or os.environ.get("TEMP", None) or "/tmp") + "/discord-ipc-0") if isUnix else r"\\?\pipe\discord-ipc-0"
|
||||
|
||||
def __init__(self):
|
||||
self.loop = None
|
||||
self.pipeReader = None
|
||||
self.pipeWriter = None
|
||||
self.connected = False
|
||||
|
||||
def connect(self):
|
||||
logger.info("Connecting Discord IPC Pipe")
|
||||
self.loop = asyncio.new_event_loop() if isUnix else asyncio.ProactorEventLoop()
|
||||
self.loop.run_until_complete(self.handshake())
|
||||
|
||||
async def handshake(self):
|
||||
try:
|
||||
if isUnix:
|
||||
self.pipeReader, self.pipeWriter = await asyncio.open_unix_connection(self.ipcPipe, loop = self.loop)
|
||||
else:
|
||||
self.pipeReader = asyncio.StreamReader(loop = self.loop)
|
||||
self.pipeWriter, _ = await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader, loop = self.loop), self.ipcPipe)
|
||||
self.write(0, { "v": 1, "client_id": self.clientID })
|
||||
if await self.read():
|
||||
self.connected = True
|
||||
except:
|
||||
logger.exception("An unexpected error occured during a RPC handshake operation")
|
||||
|
||||
async def read(self):
|
||||
try:
|
||||
dataBytes = await self.pipeReader.read(1024)
|
||||
data = json.loads(dataBytes[8:].decode("utf-8"))
|
||||
logger.debug("[READ] %s", data)
|
||||
return data
|
||||
except:
|
||||
logger.exception("An unexpected error occured during a RPC read operation")
|
||||
self.disconnect()
|
||||
|
||||
def write(self, op, payload):
|
||||
try:
|
||||
logger.debug("[WRITE] %s", payload)
|
||||
payload = json.dumps(payload)
|
||||
self.pipeWriter.write(struct.pack("<ii", op, len(payload)) + payload.encode("utf-8"))
|
||||
except:
|
||||
logger.exception("An unexpected error occured during a RPC write operation")
|
||||
self.disconnect()
|
||||
|
||||
def disconnect(self):
|
||||
logger.info("Disconnecting Discord IPC Pipe")
|
||||
if (self.pipeWriter):
|
||||
try:
|
||||
self.pipeWriter.close()
|
||||
except:
|
||||
logger.exception("An unexpected error occured while closing an IPC pipe writer")
|
||||
self.pipeWriter = None
|
||||
if (self.pipeReader):
|
||||
try:
|
||||
self.loop.run_until_complete(self.pipeReader.read())
|
||||
except:
|
||||
logger.exception("An unexpected error occured while closing an IPC pipe reader")
|
||||
self.pipeReader = None
|
||||
try:
|
||||
self.loop.close()
|
||||
except:
|
||||
logger.exception("An unexpected error occured while closing an asyncio event loop")
|
||||
self.connected = False
|
||||
|
||||
def sendActivity(self, activity):
|
||||
logger.info("Activity update: %s", activity)
|
||||
payload = {
|
||||
"cmd": "SET_ACTIVITY",
|
||||
"args": {
|
||||
"pid": processID,
|
||||
"activity": activity,
|
||||
},
|
||||
"nonce": "{0:.20f}".format(time.time())
|
||||
}
|
||||
self.write(1, payload)
|
||||
self.loop.run_until_complete(self.read())
|
231
services/PlexAlertListener.py
Normal file
231
services/PlexAlertListener.py
Normal file
|
@ -0,0 +1,231 @@
|
|||
# type: ignore
|
||||
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
from services import DiscordRpcService
|
||||
from utils.logs import LoggerWithPrefix
|
||||
from utils.text import formatSeconds
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
|
||||
class PlexAlertListener:
|
||||
|
||||
productName = "Plex Media Server"
|
||||
stopTimeoutTimerInterval = 5
|
||||
updateTimeoutTimerInterval = 35
|
||||
connectionTimeoutTimerInterval = 60
|
||||
maximumIgnores = 3
|
||||
useRemainingTime = False
|
||||
|
||||
def __init__(self, username, token, serverConfig):
|
||||
self.username = username
|
||||
self.token = token
|
||||
self.serverConfig = serverConfig
|
||||
self.logger = LoggerWithPrefix(f"[{self.serverConfig['name']}/{hashlib.md5(str(id(self)).encode('UTF-8')).hexdigest()[:5].upper()}] ")
|
||||
self.discordRpcService = DiscordRpcService()
|
||||
self.stopTimeoutTimer = None
|
||||
self.updateTimeoutTimer = None
|
||||
self.connectionTimeoutTimer = None
|
||||
self.reset()
|
||||
self.connect()
|
||||
|
||||
def reset(self):
|
||||
self.plexAccount = None
|
||||
self.plexServer = None
|
||||
self.isServerOwner = False
|
||||
self.plexAlertListener = None
|
||||
self.lastState = None
|
||||
self.lastSessionKey = None
|
||||
self.lastRatingKey = None
|
||||
self.ignoreCount = 0
|
||||
|
||||
def connect(self):
|
||||
connected = False
|
||||
while (not connected):
|
||||
try:
|
||||
self.plexAccount = MyPlexAccount(self.username, token = self.token)
|
||||
self.logger.info("Logged in as Plex User \"%s\"", self.plexAccount.username)
|
||||
self.plexServer = None
|
||||
for resource in self.plexAccount.resources():
|
||||
if (resource.product == self.productName and resource.name == self.serverConfig["name"]):
|
||||
self.logger.info("Connecting to %s \"%s\"", self.productName, self.serverConfig["name"])
|
||||
self.plexServer = resource.connect()
|
||||
try:
|
||||
self.plexServer.account()
|
||||
self.isServerOwner = True
|
||||
except:
|
||||
pass
|
||||
self.logger.info("Connected to %s \"%s\"", self.productName, self.serverConfig["name"])
|
||||
self.plexAlertListener = self.plexServer.startAlertListener(self.handlePlexAlert)
|
||||
self.logger.info("Listening for alerts from user \"%s\"", self.username)
|
||||
self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout)
|
||||
self.connectionTimeoutTimer.start()
|
||||
connected = True
|
||||
break
|
||||
if (not self.plexServer):
|
||||
self.logger.error("%s \"%s\" not found", self.productName, self.serverConfig["name"])
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to connect to %s \"%s\": %s", self.productName, self.serverConfig["name"], e)
|
||||
self.logger.error("Reconnecting in 10 seconds")
|
||||
time.sleep(10)
|
||||
|
||||
def disconnect(self):
|
||||
self.disconnectRpc()
|
||||
self.cancelTimers()
|
||||
if (self.plexAlertListener):
|
||||
try:
|
||||
self.plexAlertListener.stop()
|
||||
except:
|
||||
pass
|
||||
self.reset()
|
||||
self.logger.info("Stopped listening for alerts")
|
||||
|
||||
def disconnectRpc(self):
|
||||
if (self.discordRpcService.connected):
|
||||
self.discordRpcService.disconnect()
|
||||
|
||||
def cancelTimers(self):
|
||||
if (self.stopTimeoutTimer):
|
||||
self.stopTimeoutTimer.cancel()
|
||||
self.stopTimeoutTimer = None
|
||||
if (self.updateTimeoutTimer):
|
||||
self.updateTimeoutTimer.cancel()
|
||||
self.updateTimeoutTimer = None
|
||||
if (self.connectionTimeoutTimer):
|
||||
self.connectionTimeoutTimer.cancel()
|
||||
self.connectionTimeoutTimer = None
|
||||
|
||||
def stopTimeout(self):
|
||||
self.disconnectRpc()
|
||||
self.cancelTimers()
|
||||
|
||||
def updateTimeout(self):
|
||||
self.logger.debug("No recent updates from session key %s", self.lastSessionKey)
|
||||
self.stopTimeout()
|
||||
|
||||
def connectionTimeout(self):
|
||||
try:
|
||||
self.logger.debug("Request for list of clients to check connection: %s", self.plexServer.clients())
|
||||
except Exception as e:
|
||||
self.logger.error("Connection to Plex lost: %s", e)
|
||||
self.disconnect()
|
||||
self.logger.error("Reconnecting")
|
||||
self.connect()
|
||||
else:
|
||||
self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout)
|
||||
self.connectionTimeoutTimer.start()
|
||||
|
||||
def handlePlexAlert(self, data):
|
||||
try:
|
||||
if (data["type"] == "playing" and "PlaySessionStateNotification" in data):
|
||||
sessionData = data["PlaySessionStateNotification"][0]
|
||||
state = sessionData["state"]
|
||||
sessionKey = int(sessionData["sessionKey"])
|
||||
ratingKey = int(sessionData["ratingKey"])
|
||||
viewOffset = int(sessionData["viewOffset"])
|
||||
self.logger.debug("Received alert: %s", sessionData)
|
||||
metadata = self.plexServer.fetchItem(ratingKey)
|
||||
libraryName = metadata.section().title
|
||||
if ("blacklistedLibraries" in self.serverConfig and libraryName in self.serverConfig["blacklistedLibraries"]):
|
||||
self.logger.debug("Library \"%s\" is blacklisted, ignoring", libraryName)
|
||||
return
|
||||
if ("whitelistedLibraries" in self.serverConfig and libraryName not in self.serverConfig["whitelistedLibraries"]):
|
||||
self.logger.debug("Library \"%s\" is not whitelisted, ignoring", libraryName)
|
||||
return
|
||||
if (self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey):
|
||||
if (self.updateTimeoutTimer):
|
||||
self.updateTimeoutTimer.cancel()
|
||||
self.updateTimeoutTimer = None
|
||||
if (self.lastState == state and self.ignoreCount < self.maximumIgnores):
|
||||
self.logger.debug("Nothing changed, ignoring")
|
||||
self.ignoreCount += 1
|
||||
self.updateTimeoutTimer = threading.Timer(self.updateTimeoutTimerInterval, self.updateTimeout)
|
||||
self.updateTimeoutTimer.start()
|
||||
return
|
||||
else:
|
||||
self.ignoreCount = 0
|
||||
if (state == "stopped"):
|
||||
self.lastState, self.lastSessionKey, self.lastRatingKey = None, None, None
|
||||
self.stopTimeout()
|
||||
self.stopTimeoutTimer = threading.Timer(self.stopTimeoutTimerInterval, self.stopTimeout)
|
||||
self.stopTimeoutTimer.start()
|
||||
self.logger.debug("Started stopTimeoutTimer")
|
||||
return
|
||||
elif (state == "stopped"):
|
||||
self.logger.debug("Received \"stopped\" state alert from unknown session key, ignoring")
|
||||
return
|
||||
if (self.isServerOwner):
|
||||
self.logger.debug("Searching sessions for session key %s", sessionKey)
|
||||
plexServerSessions = self.plexServer.sessions()
|
||||
if (len(plexServerSessions) < 1):
|
||||
self.logger.debug("Empty session list, ignoring")
|
||||
return
|
||||
for session in plexServerSessions:
|
||||
self.logger.debug("%s, Session Key: %s, Usernames: %s", session, session.sessionKey, session.usernames)
|
||||
if (session.sessionKey == sessionKey):
|
||||
self.logger.debug("Session found")
|
||||
sessionUsername = session.usernames[0].lower()
|
||||
if (sessionUsername == self.username):
|
||||
self.logger.debug("Username \"%s\" matches \"%s\", continuing", sessionUsername, self.username)
|
||||
break
|
||||
else:
|
||||
self.logger.debug("Username \"%s\" doesn't match \"%s\", ignoring", sessionUsername, self.username)
|
||||
return
|
||||
else:
|
||||
self.logger.debug("No matching session found, ignoring")
|
||||
return
|
||||
if (self.stopTimeoutTimer):
|
||||
self.stopTimeoutTimer.cancel()
|
||||
self.stopTimeoutTimer = None
|
||||
if (self.updateTimeoutTimer):
|
||||
self.updateTimeoutTimer.cancel()
|
||||
self.updateTimeoutTimer = threading.Timer(self.updateTimeoutTimerInterval, self.updateTimeout)
|
||||
self.updateTimeoutTimer.start()
|
||||
self.lastState, self.lastSessionKey, self.lastRatingKey = state, sessionKey, ratingKey
|
||||
mediaType = metadata.type
|
||||
if (state != "playing"):
|
||||
stateText = f"{formatSeconds(viewOffset / 1000, ':')}/{formatSeconds(metadata.duration / 1000, ':')}"
|
||||
else:
|
||||
stateText = formatSeconds(metadata.duration / 1000)
|
||||
if (mediaType == "movie"):
|
||||
title = f"{metadata.title} ({metadata.year})"
|
||||
stateText += f" · {', '.join(genre.tag for genre in metadata.genres[:3])}"
|
||||
largeText = "Watching a movie"
|
||||
elif (mediaType == "episode"):
|
||||
title = metadata.grandparentTitle
|
||||
stateText += f" · S{metadata.parentIndex:02}E{metadata.index:02} - {metadata.title}"
|
||||
largeText = "Watching a TV show"
|
||||
elif (mediaType == "track"):
|
||||
print(metadata)
|
||||
title = metadata.title
|
||||
artist = metadata.originalTitle
|
||||
if (not artist):
|
||||
artist = metadata.grandparentTitle
|
||||
stateText = f"{artist} - {metadata.parentTitle}"
|
||||
largeText = "Listening to music"
|
||||
else:
|
||||
self.logger.debug("Unsupported media type \"%s\", ignoring", mediaType)
|
||||
return
|
||||
activity = {
|
||||
"details": title[:128],
|
||||
"state": stateText[:128],
|
||||
"assets": {
|
||||
"large_text": largeText,
|
||||
"large_image": "logo",
|
||||
"small_text": state.capitalize(),
|
||||
"small_image": state
|
||||
},
|
||||
}
|
||||
if (state == "playing"):
|
||||
currentTimestamp = int(time.time())
|
||||
if (self.useRemainingTime):
|
||||
activity["timestamps"] = {"end": round(currentTimestamp + ((metadata.duration - viewOffset) / 1000))}
|
||||
else:
|
||||
activity["timestamps"] = {"start": round(currentTimestamp - (viewOffset / 1000))}
|
||||
if (not self.discordRpcService.connected):
|
||||
self.discordRpcService.connect()
|
||||
if (self.discordRpcService.connected):
|
||||
self.discordRpcService.sendActivity(activity)
|
||||
except:
|
||||
self.logger.exception("An unexpected error occured in the alert handler")
|
3
services/__init__.py
Normal file
3
services/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .ConfigService import ConfigService as ConfigService
|
||||
from .DiscordRpcService import DiscordRpcService as DiscordRpcService
|
||||
from .PlexAlertListener import PlexAlertListener as PlexAlertListener
|
0
store/__init__.py
Normal file
0
store/__init__.py
Normal file
5
store/constants.py
Normal file
5
store/constants.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
import sys
|
||||
import os
|
||||
|
||||
isUnix = sys.platform in ["linux", "darwin"]
|
||||
processID = os.getpid()
|
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
28
utils/logs.py
Normal file
28
utils/logs.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
from typing import Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("discord-rich-presence-plex")
|
||||
logger.setLevel(logging.INFO)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt = "%d-%m-%Y %I:%M:%S %p"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
class LoggerWithPrefix:
|
||||
|
||||
def __init__(self, prefix: str) -> None:
|
||||
self.prefix = prefix
|
||||
|
||||
def info(self, obj: Any, *args: Any, **kwargs: Any) -> None:
|
||||
logger.info(self.prefix + str(obj), *args, **kwargs)
|
||||
|
||||
def warning(self, obj: Any, *args: Any, **kwargs: Any) -> None:
|
||||
logger.warning(self.prefix + str(obj), *args, **kwargs)
|
||||
|
||||
def error(self, obj: Any, *args: Any, **kwargs: Any) -> None:
|
||||
logger.error(self.prefix + str(obj), *args, **kwargs)
|
||||
|
||||
def exception(self, obj: Any, *args: Any, **kwargs: Any) -> None:
|
||||
logger.exception(self.prefix + str(obj), *args, **kwargs)
|
||||
|
||||
def debug(self, obj: Any, *args: Any, **kwargs: Any) -> None:
|
||||
logger.debug(self.prefix + str(obj), *args, **kwargs)
|
8
utils/text.py
Normal file
8
utils/text.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
def formatSeconds(seconds: int, joiner: str = "") -> str:
|
||||
seconds = round(seconds)
|
||||
timeValues = {"h": seconds // 3600, "m": seconds // 60 % 60, "s": seconds % 60}
|
||||
if (not joiner):
|
||||
return "".join(str(v) + k for k, v in timeValues.items() if v > 0)
|
||||
if (timeValues["h"] == 0):
|
||||
del timeValues["h"]
|
||||
return joiner.join(str(v).rjust(2, "0") for v in timeValues.values())
|
Loading…
Reference in a new issue