discord-rich-presence-plex/core/plex.py

367 lines
15 KiB
Python
Raw Normal View History

2024-08-30 14:59:02 +00:00
# pyright: reportUnknownArgumentType=none,reportUnknownMemberType=none,reportUnknownVariableType=none,reportTypedDictNotRequiredAccess=none,reportOptionalMemberAccess=none,reportMissingTypeStubs=none
2022-05-10 20:23:12 +00:00
from .config import config
from .discord import DiscordIpcService
from .imgur import uploadToImgur
2023-11-05 05:24:45 +00:00
from config.constants import name, plexClientID
2022-05-10 21:57:06 +00:00
from plexapi.alert import AlertListener
from plexapi.media import Genre, Guid
2022-05-14 09:43:02 +00:00
from plexapi.myplex import MyPlexAccount, PlexServer
from typing import Optional
from utils.cache import getCacheKey, setCacheKey
from utils.logging import LoggerWithPrefix
from utils.text import formatSeconds, truncate, stripNonAscii
2022-05-14 09:43:02 +00:00
import models.config
import models.discord
import models.plex
2023-11-05 05:24:45 +00:00
import requests
2022-05-10 20:23:12 +00:00
import threading
import time
2023-11-05 05:24:45 +00:00
import urllib.parse
def initiateAuth() -> tuple[str, str, str]:
response = requests.post("https://plex.tv/api/v2/pins.json?strong=true", headers = {
"X-Plex-Product": name,
"X-Plex-Client-Identifier": plexClientID,
}).json()
authUrl = f"https://app.plex.tv/auth#?clientID={plexClientID}&code={response['code']}&context%%5Bdevice%%5D%%5Bproduct%%5D={urllib.parse.quote(name)}"
return response["id"], response["code"], authUrl
def getAuthToken(id: str, code: str) -> Optional[str]:
response = requests.get(f"https://plex.tv/api/v2/pins/{id}.json?code={code}", headers = {
"X-Plex-Client-Identifier": plexClientID,
}).json()
return response["authToken"]
2022-05-10 20:23:12 +00:00
mediaTypeActivityTypeMap = {
"movie": models.discord.ActivityType.WATCHING,
"episode": models.discord.ActivityType.WATCHING,
"live_episode": models.discord.ActivityType.WATCHING,
"track": models.discord.ActivityType.LISTENING,
"clip": models.discord.ActivityType.WATCHING,
}
buttonTypeGuidTypeMap = {
"imdb": "imdb",
"tmdb": "tmdb",
"thetvdb": "tvdb",
"trakt": "tmdb",
"letterboxd": "tmdb",
"musicbrainz": "mbid",
}
class PlexAlertListener(threading.Thread):
2022-05-10 20:23:12 +00:00
productName = "Plex Media Server"
2022-05-10 21:57:06 +00:00
updateTimeoutTimerInterval = 30
2024-02-13 07:58:36 +00:00
connectionCheckTimerInterval = 60
disconnectTimerInterval = 3
2022-05-10 21:57:06 +00:00
maximumIgnores = 2
2022-05-10 20:23:12 +00:00
2022-05-14 09:43:02 +00:00
def __init__(self, token: str, serverConfig: models.config.Server):
super().__init__()
self.daemon = True
2022-05-10 20:23:12 +00:00
self.token = token
self.serverConfig = serverConfig
2024-08-30 14:59:02 +00:00
self.logger = LoggerWithPrefix(f"[{self.serverConfig['name']}] ")
self.discordIpcService = DiscordIpcService(self.serverConfig.get("ipcPipeNumber"))
2022-05-14 09:43:02 +00:00
self.updateTimeoutTimer: Optional[threading.Timer] = None
2024-02-13 07:58:36 +00:00
self.connectionCheckTimer: Optional[threading.Timer] = None
self.disconnectTimer: Optional[threading.Timer] = None
2022-05-14 09:43:02 +00:00
self.account: Optional[MyPlexAccount] = None
self.server: Optional[PlexServer] = None
self.alertListener: Optional[AlertListener] = None
self.lastState, self.lastSessionKey, self.lastRatingKey = "", 0, 0
self.listenForUser, self.isServerOwner, self.ignoreCount = "", False, 0
self.start()
2022-05-10 20:23:12 +00:00
2022-05-14 09:43:02 +00:00
def run(self) -> None:
2024-02-13 07:58:36 +00:00
while True:
2022-05-10 20:23:12 +00:00
try:
2022-05-22 17:03:17 +00:00
self.logger.info("Signing into Plex")
2022-05-14 09:43:02 +00:00
self.account = MyPlexAccount(token = self.token)
self.logger.info("Signed in as Plex user '%s'", self.account.username)
self.listenForUser = self.serverConfig.get("listenForUser", "") or self.account.username
2022-05-14 09:43:02 +00:00
self.server = None
for resource in self.account.resources():
if resource.product == self.productName and resource.name.lower() == self.serverConfig["name"].lower():
self.logger.info("Connecting to %s '%s'", self.productName, self.serverConfig["name"])
2022-05-14 09:43:02 +00:00
self.server = resource.connect()
2022-05-10 20:23:12 +00:00
try:
2022-05-14 09:43:02 +00:00
self.server.account()
2022-05-10 20:23:12 +00:00
self.isServerOwner = True
except:
pass
self.logger.info("Connected to %s '%s'", self.productName, resource.name)
2024-02-10 22:37:37 +00:00
self.alertListener = AlertListener(self.server, self.tryHandleAlert, self.reconnect)
2022-05-14 09:43:02 +00:00
self.alertListener.start()
self.logger.info("Listening for alerts from user '%s'", self.listenForUser)
2024-02-13 07:58:36 +00:00
self.connectionCheckTimer = threading.Timer(self.connectionCheckTimerInterval, self.connectionCheck)
self.connectionCheckTimer.start()
return
2022-05-14 09:43:02 +00:00
if not self.server:
2023-11-05 05:24:45 +00:00
raise Exception("Server not found")
2022-05-10 20:23:12 +00:00
except Exception as e:
2024-08-30 14:59:02 +00:00
self.logger.error("Failed to connect to %s '%s': %s", self.productName, self.serverConfig["name"], e)
2022-05-10 20:23:12 +00:00
self.logger.error("Reconnecting in 10 seconds")
time.sleep(10)
2022-05-14 09:43:02 +00:00
def disconnect(self) -> None:
if self.alertListener:
try:
self.alertListener.stop()
except:
pass
self.disconnectRpc()
2024-02-13 07:58:36 +00:00
if self.connectionCheckTimer:
self.connectionCheckTimer.cancel()
self.connectionCheckTimer = None
2022-05-14 09:43:02 +00:00
self.account, self.server, self.alertListener, self.listenForUser, self.isServerOwner, self.ignoreCount = None, None, None, "", False, 0
2022-05-10 20:23:12 +00:00
self.logger.info("Stopped listening for alerts")
2022-05-14 09:43:02 +00:00
def reconnect(self, exception: Exception) -> None:
2022-05-10 21:57:06 +00:00
self.logger.error("Connection to Plex lost: %s", exception)
self.disconnect()
self.logger.error("Reconnecting")
self.run()
2022-05-10 20:23:12 +00:00
2022-05-14 09:43:02 +00:00
def disconnectRpc(self) -> None:
self.lastState, self.lastSessionKey, self.lastRatingKey = "", 0, 0
if self.discordIpcService.connected:
self.discordIpcService.disconnect()
2022-05-10 21:57:06 +00:00
if self.updateTimeoutTimer:
2022-05-10 20:23:12 +00:00
self.updateTimeoutTimer.cancel()
2024-02-13 07:58:36 +00:00
self.updateTimeoutTimer = None
2022-05-10 20:23:12 +00:00
2022-05-14 09:43:02 +00:00
def updateTimeout(self) -> None:
2022-05-10 20:23:12 +00:00
self.logger.debug("No recent updates from session key %s", self.lastSessionKey)
self.disconnectRpc()
2022-05-10 20:23:12 +00:00
2024-02-13 07:58:36 +00:00
def connectionCheck(self) -> None:
2022-05-10 20:23:12 +00:00
try:
self.logger.debug("Running periodic connection check")
self.server.clients()
2022-05-10 20:23:12 +00:00
except Exception as e:
2022-05-10 21:57:06 +00:00
self.reconnect(e)
2022-05-10 20:23:12 +00:00
else:
2024-02-13 07:58:36 +00:00
self.connectionCheckTimer = threading.Timer(self.connectionCheckTimerInterval, self.connectionCheck)
self.connectionCheckTimer.start()
2022-05-10 20:23:12 +00:00
2024-02-10 22:37:37 +00:00
def tryHandleAlert(self, alert: models.plex.Alert) -> None:
try:
self.handleAlert(alert)
except:
self.logger.exception("An unexpected error occured in the Plex alert handler")
2024-02-13 07:58:36 +00:00
self.disconnectRpc()
2024-02-10 22:37:37 +00:00
def uploadToImgur(self, thumb: str) -> Optional[str]:
thumbUrl = getCacheKey(thumb)
if not thumbUrl or not isinstance(thumbUrl, str):
self.logger.debug("Uploading image to Imgur")
thumbUrl = uploadToImgur(self.server.url(thumb, True))
setCacheKey(thumb, thumbUrl)
return thumbUrl
2023-11-04 19:39:30 +00:00
def handleAlert(self, alert: models.plex.Alert) -> None:
2024-02-10 22:37:37 +00:00
if alert["type"] != "playing" or "PlaySessionStateNotification" not in alert:
return
stateNotification = alert["PlaySessionStateNotification"][0]
self.logger.debug("Received alert: %s", stateNotification)
ratingKey = int(stateNotification["ratingKey"])
2024-08-30 14:59:02 +00:00
item = self.server.fetchItem(ratingKey)
2024-02-12 11:30:32 +00:00
if item.key and item.key.startswith("/livetv"):
mediaType = "live_episode"
else:
2024-08-30 14:59:02 +00:00
mediaType = item.type
if mediaType not in mediaTypeActivityTypeMap:
2024-02-10 22:37:37 +00:00
self.logger.debug("Unsupported media type '%s', ignoring", mediaType)
return
state = stateNotification["state"]
sessionKey = int(stateNotification["sessionKey"])
viewOffset = int(stateNotification["viewOffset"])
2022-05-10 20:23:12 +00:00
try:
2024-08-30 14:59:02 +00:00
libraryName = item.section().title
2024-02-10 22:37:37 +00:00
except:
libraryName = "ERROR"
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
isIgnorableState = state == "stopped" or (state == "paused" and not config["display"]["paused"])
2024-02-10 22:37:37 +00:00
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
2022-05-10 20:23:12 +00:00
self.updateTimeoutTimer = threading.Timer(self.updateTimeoutTimerInterval, self.updateTimeout)
self.updateTimeoutTimer.start()
2024-02-10 22:37:37 +00:00
return
else:
self.ignoreCount = 0
if isIgnorableState:
if self.disconnectTimer:
self.disconnectTimer.cancel()
self.disconnectTimer = threading.Timer(self.disconnectTimerInterval, self.disconnectRpc)
self.disconnectTimer.start()
2024-02-10 22:37:37 +00:00
return
elif isIgnorableState:
self.logger.debug("Received '%s' state alert from unknown session, ignoring", state)
2024-02-10 22:37:37 +00:00
return
if self.isServerOwner:
self.logger.debug("Searching sessions for session key %s", sessionKey)
2024-08-30 14:59:02 +00:00
sessions = self.server.sessions()
2024-02-10 22:37:37 +00:00
if len(sessions) < 1:
self.logger.debug("Empty session list, ignoring")
return
for session in sessions:
self.logger.debug("%s, Session Key: %s, Usernames: %s", session, session.sessionKey, session.usernames)
if session.sessionKey == sessionKey:
self.logger.debug("Session found")
2024-08-30 14:59:02 +00:00
sessionUsername = session.usernames[0]
2024-02-10 22:37:37 +00:00
if sessionUsername.lower() == self.listenForUser.lower():
self.logger.debug("Username '%s' matches '%s', continuing", sessionUsername, self.listenForUser)
break
self.logger.debug("Username '%s' doesn't match '%s', ignoring", sessionUsername, self.listenForUser)
return
else:
self.logger.debug("No matching session found, ignoring")
return
if self.updateTimeoutTimer:
self.updateTimeoutTimer.cancel()
self.updateTimeoutTimer = threading.Timer(self.updateTimeoutTimerInterval, self.updateTimeout)
self.updateTimeoutTimer.start()
if self.disconnectTimer:
self.disconnectTimer.cancel()
self.disconnectTimer = None
2024-02-10 22:37:37 +00:00
self.lastState, self.lastSessionKey, self.lastRatingKey = state, sessionKey, ratingKey
2024-02-12 11:30:32 +00:00
stateStrings: list[str] = []
if config["display"]["duration"] and item.duration and mediaType != "track":
2024-02-12 11:30:32 +00:00
stateStrings.append(formatSeconds(item.duration / 1000))
largeText, thumb, smallText, smallThumb = "", "", "", ""
2024-02-12 11:30:32 +00:00
if mediaType == "movie":
title = shortTitle = item.title
if config["display"]["year"] and item.year:
2024-02-13 07:58:36 +00:00
title += f" ({item.year})"
if config["display"]["genres"] and item.genres:
2024-02-13 07:58:36 +00:00
genres: list[Genre] = item.genres[:3]
stateStrings.append(f"{', '.join(genre.tag for genre in genres)}")
2024-02-12 11:30:32 +00:00
thumb = item.thumb
elif mediaType == "episode":
title = shortTitle = item.grandparentTitle
if config["display"]["year"]:
grandparent = self.server.fetchItem(item.grandparentRatingKey)
if grandparent.year:
title += f" ({grandparent.year})"
2024-02-12 11:30:32 +00:00
stateStrings.append(f"S{item.parentIndex:02}E{item.index:02}")
stateStrings.append(item.title)
thumb = item.grandparentThumb
2024-02-13 09:03:47 +00:00
elif mediaType == "live_episode":
title = shortTitle = item.grandparentTitle
2024-02-12 11:30:32 +00:00
if item.title != item.grandparentTitle:
2024-02-10 22:37:37 +00:00
stateStrings.append(item.title)
2024-02-12 11:30:32 +00:00
thumb = item.grandparentThumb
elif mediaType == "track":
title = shortTitle = item.title
if config["display"]["album"]:
largeText = item.parentTitle
if config["display"]["year"]:
parent = self.server.fetchItem(item.parentRatingKey)
if parent.year:
largeText = f"{truncate(largeText, 110)} ({parent.year})"
thumb = item.thumb
smallText = item.originalTitle or item.grandparentTitle
stateStrings.append(smallText)
smallThumb = item.grandparentThumb
2024-02-12 11:30:32 +00:00
else:
title = shortTitle = item.title
2024-02-12 11:30:32 +00:00
thumb = item.thumb
if state != "playing" and mediaType != "track":
2024-09-23 11:53:02 +00:00
if config["display"]["progressMode"] == "remaining":
2024-02-12 11:30:32 +00:00
stateStrings.append(f"{formatSeconds((item.duration - viewOffset) / 1000, ':')} left")
else:
stateStrings.append(f"{formatSeconds(viewOffset / 1000, ':')} elapsed")
if not config["display"]["statusIcon"]:
stateStrings.append(state.capitalize())
2024-02-12 11:30:32 +00:00
stateText = " · ".join(stateString for stateString in stateStrings if stateString)
thumbUrl = self.uploadToImgur(thumb) if thumb and config["display"]["posters"]["enabled"] else ""
smallThumbUrl = self.uploadToImgur(smallThumb) if smallThumb and config["display"]["posters"]["enabled"] else ""
2024-02-10 22:37:37 +00:00
activity: models.discord.Activity = {
"type": mediaTypeActivityTypeMap[mediaType],
"details": truncate(title, 120),
2024-02-10 22:37:37 +00:00
}
if config["display"]["statusIcon"]:
smallText = smallText or state.capitalize()
smallThumbUrl = smallThumbUrl or state
if largeText or thumbUrl or smallText or smallThumbUrl:
activity["assets"] = {}
if largeText:
activity["assets"]["large_text"] = truncate(largeText, 120)
if thumbUrl:
activity["assets"]["large_image"] = thumbUrl
if smallText:
activity["assets"]["small_text"] = truncate(smallText, 120)
if smallThumbUrl:
activity["assets"]["small_image"] = smallThumbUrl
2024-02-10 22:37:37 +00:00
if stateText:
activity["state"] = truncate(stateText, 120)
2024-02-10 22:37:37 +00:00
if config["display"]["buttons"]:
guidsRaw: list[Guid] = []
if mediaType in ["movie", "track"]:
guidsRaw = item.guids
elif mediaType == "episode":
guidsRaw = self.server.fetchItem(item.grandparentRatingKey).guids
guids: dict[str, str] = { guidSplit[0]: guidSplit[1] for guidSplit in [guid.id.split("://") for guid in guidsRaw] if len(guidSplit) > 1 }
buttons: list[models.discord.ActivityButton] = []
for button in config["display"]["buttons"]:
if "mediaTypes" in button and mediaType not in button["mediaTypes"]:
continue
label = truncate(button["label"].format(title = stripNonAscii(shortTitle)), 30)
2024-02-10 22:37:37 +00:00
if not button["url"].startswith("dynamic:"):
buttons.append({ "label": label, "url": button["url"] })
2024-02-10 22:37:37 +00:00
continue
buttonType = button["url"][8:]
guidType = buttonTypeGuidTypeMap.get(buttonType)
if not guidType:
continue
guid = guids.get(guidType)
if not guid:
continue
url = ""
if buttonType == "imdb":
url = f"https://www.imdb.com/title/{guid}"
elif buttonType == "tmdb":
tmdbPathSegment = "movie" if mediaType == "movie" else "tv"
url = f"https://www.themoviedb.org/{tmdbPathSegment}/{guid}"
elif buttonType == "thetvdb":
theTvdbPathSegment = "movie" if mediaType == "movie" else "series"
url = f"https://www.thetvdb.com/dereferrer/{theTvdbPathSegment}/{guid}"
elif buttonType == "trakt":
idType = "movie" if mediaType == "movie" else "show"
url = f"https://trakt.tv/search/tmdb/{guid}?id_type={idType}"
elif buttonType == "letterboxd" and mediaType == "movie":
url = f"https://letterboxd.com/tmdb/{guid}"
elif buttonType == "musicbrainz":
url = f"https://musicbrainz.org/track/{guid}"
if url:
buttons.append({ "label": label, "url": url })
2024-02-10 22:37:37 +00:00
if buttons:
activity["buttons"] = buttons[:2]
if state == "playing":
currentTimestamp = int(time.time() * 1000)
2024-09-23 11:53:02 +00:00
match config["display"]["progressMode"]:
case "elapsed":
activity["timestamps"] = { "start": round(currentTimestamp - viewOffset) }
case "remaining":
activity["timestamps"] = { "end": round(currentTimestamp + (item.duration - viewOffset)) }
case "bar":
activity["timestamps"] = { "start": round(currentTimestamp - viewOffset), "end": round(currentTimestamp + (item.duration - viewOffset)) }
case _:
pass
2024-02-10 22:37:37 +00:00
if not self.discordIpcService.connected:
self.discordIpcService.connect()
if self.discordIpcService.connected:
self.discordIpcService.setActivity(activity)