2023-11-04 18:13:42 +00:00
|
|
|
# pyright: reportUnknownArgumentType=none,reportUnknownMemberType=none,reportUnknownVariableType=none
|
2022-05-10 20:23:12 +00:00
|
|
|
|
2022-05-12 07:14:23 +00:00
|
|
|
from .config import config
|
2023-11-04 18:13:42 +00:00
|
|
|
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
|
2022-05-14 09:43:02 +00:00
|
|
|
from plexapi.base import Playable, PlexPartialObject
|
2022-08-25 20:37:50 +00:00
|
|
|
from plexapi.media import Genre, GuidTag
|
2022-05-14 09:43:02 +00:00
|
|
|
from plexapi.myplex import MyPlexAccount, PlexServer
|
|
|
|
from typing import Optional
|
2023-11-04 18:13:42 +00:00
|
|
|
from utils.cache import getCacheKey, setCacheKey
|
2022-05-12 07:14:23 +00:00
|
|
|
from utils.logging import LoggerWithPrefix
|
2022-05-10 20:23:12 +00:00
|
|
|
from utils.text import formatSeconds
|
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
|
|
|
|
2022-05-12 07:14:23 +00:00
|
|
|
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
|
2022-05-10 20:23:12 +00:00
|
|
|
connectionTimeoutTimerInterval = 60
|
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):
|
2022-05-12 07:14:23 +00:00
|
|
|
super().__init__()
|
|
|
|
self.daemon = True
|
2022-05-10 20:23:12 +00:00
|
|
|
self.token = token
|
|
|
|
self.serverConfig = serverConfig
|
2023-11-04 18:13:42 +00:00
|
|
|
self.logger = LoggerWithPrefix(f"[{self.serverConfig['name']}] ") # pyright: ignore[reportTypedDictNotRequiredAccess]
|
2023-11-05 10:24:36 +00:00
|
|
|
self.discordIpcService = DiscordIpcService(self.serverConfig.get("ipcPipeNumber"))
|
2022-05-14 09:43:02 +00:00
|
|
|
self.updateTimeoutTimer: Optional[threading.Timer] = None
|
|
|
|
self.connectionTimeoutTimer: Optional[threading.Timer] = None
|
|
|
|
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
|
2022-05-12 07:14:23 +00:00
|
|
|
self.start()
|
2022-05-10 20:23:12 +00:00
|
|
|
|
2022-05-14 09:43:02 +00:00
|
|
|
def run(self) -> None:
|
2022-05-10 20:23:12 +00:00
|
|
|
connected = False
|
2022-05-10 21:57:06 +00:00
|
|
|
while not connected:
|
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)
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.info("Signed in as Plex user '%s'", self.account.username)
|
2023-11-04 18:13:42 +00:00
|
|
|
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():
|
2022-05-11 00:03:51 +00:00
|
|
|
if resource.product == self.productName and resource.name.lower() == self.serverConfig["name"].lower():
|
2023-11-05 10:24:36 +00:00
|
|
|
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
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.info("Connected to %s '%s'", self.productName, resource.name)
|
2023-11-04 19:39:30 +00:00
|
|
|
self.alertListener = AlertListener(self.server, self.handleAlert, self.reconnect)
|
2022-05-14 09:43:02 +00:00
|
|
|
self.alertListener.start()
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.info("Listening for alerts from user '%s'", self.listenForUser)
|
2022-05-10 20:23:12 +00:00
|
|
|
self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout)
|
|
|
|
self.connectionTimeoutTimer.start()
|
|
|
|
connected = True
|
|
|
|
break
|
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:
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.error("Failed to connect to %s '%s': %s", self.productName, self.serverConfig["name"], e) # pyright: ignore[reportTypedDictNotRequiredAccess]
|
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
|
2022-05-12 08:56:08 +00:00
|
|
|
self.disconnectRpc()
|
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")
|
2022-05-12 07:14:23 +00:00
|
|
|
self.run()
|
2022-05-10 20:23:12 +00:00
|
|
|
|
2022-05-14 09:43:02 +00:00
|
|
|
def disconnectRpc(self) -> None:
|
2022-05-12 08:56:08 +00:00
|
|
|
self.lastState, self.lastSessionKey, self.lastRatingKey = "", 0, 0
|
2023-11-05 10:24:36 +00:00
|
|
|
if self.discordIpcService.connected:
|
|
|
|
self.discordIpcService.disconnect()
|
2022-05-12 08:56:08 +00:00
|
|
|
self.cancelTimers()
|
|
|
|
|
2022-05-14 09:43:02 +00:00
|
|
|
def cancelTimers(self) -> None:
|
2022-05-10 21:57:06 +00:00
|
|
|
if self.updateTimeoutTimer:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.updateTimeoutTimer.cancel()
|
2022-05-10 21:57:06 +00:00
|
|
|
if self.connectionTimeoutTimer:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.connectionTimeoutTimer.cancel()
|
2022-05-14 09:43:02 +00:00
|
|
|
self.updateTimeoutTimer, self.connectionTimeoutTimer = None, 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)
|
2022-05-12 08:56:08 +00:00
|
|
|
self.disconnectRpc()
|
2022-05-10 20:23:12 +00:00
|
|
|
|
2022-05-14 09:43:02 +00:00
|
|
|
def connectionTimeout(self) -> None:
|
2022-05-10 20:23:12 +00:00
|
|
|
try:
|
2022-05-14 09:43:02 +00:00
|
|
|
assert self.server
|
|
|
|
self.logger.debug("Request for list of clients to check connection: %s", 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:
|
|
|
|
self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout)
|
|
|
|
self.connectionTimeoutTimer.start()
|
|
|
|
|
2023-11-04 19:39:30 +00:00
|
|
|
def handleAlert(self, alert: models.plex.Alert) -> None:
|
2022-05-10 20:23:12 +00:00
|
|
|
try:
|
2022-05-14 09:43:02 +00:00
|
|
|
if alert["type"] == "playing" and "PlaySessionStateNotification" in alert:
|
|
|
|
stateNotification = alert["PlaySessionStateNotification"][0]
|
|
|
|
state = stateNotification["state"]
|
|
|
|
sessionKey = int(stateNotification["sessionKey"])
|
|
|
|
ratingKey = int(stateNotification["ratingKey"])
|
|
|
|
viewOffset = int(stateNotification["viewOffset"])
|
|
|
|
self.logger.debug("Received alert: %s", stateNotification)
|
|
|
|
assert self.server
|
|
|
|
item: PlexPartialObject = self.server.fetchItem(ratingKey)
|
|
|
|
libraryName: str = item.section().title
|
2022-05-10 21:57:06 +00:00
|
|
|
if "blacklistedLibraries" in self.serverConfig and libraryName in self.serverConfig["blacklistedLibraries"]:
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.debug("Library '%s' is blacklisted, ignoring", libraryName)
|
2022-05-10 20:23:12 +00:00
|
|
|
return
|
2022-05-10 21:57:06 +00:00
|
|
|
if "whitelistedLibraries" in self.serverConfig and libraryName not in self.serverConfig["whitelistedLibraries"]:
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.debug("Library '%s' is not whitelisted, ignoring", libraryName)
|
2022-05-10 20:23:12 +00:00
|
|
|
return
|
2022-05-10 21:57:06 +00:00
|
|
|
if self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey:
|
|
|
|
if self.updateTimeoutTimer:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.updateTimeoutTimer.cancel()
|
|
|
|
self.updateTimeoutTimer = None
|
2022-05-10 21:57:06 +00:00
|
|
|
if self.lastState == state and self.ignoreCount < self.maximumIgnores:
|
2022-05-10 20:23:12 +00:00
|
|
|
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
|
2022-05-10 21:57:06 +00:00
|
|
|
if state == "stopped":
|
2022-05-12 08:56:08 +00:00
|
|
|
self.disconnectRpc()
|
2022-05-10 20:23:12 +00:00
|
|
|
return
|
2022-05-10 21:57:06 +00:00
|
|
|
elif state == "stopped":
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.debug("Received 'stopped' state alert from unknown session, ignoring")
|
2022-05-10 20:23:12 +00:00
|
|
|
return
|
2022-05-10 21:57:06 +00:00
|
|
|
if self.isServerOwner:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.logger.debug("Searching sessions for session key %s", sessionKey)
|
2022-05-14 09:43:02 +00:00
|
|
|
sessions: list[Playable] = self.server.sessions()
|
|
|
|
if len(sessions) < 1:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.logger.debug("Empty session list, ignoring")
|
|
|
|
return
|
2022-05-14 09:43:02 +00:00
|
|
|
for session in sessions:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.logger.debug("%s, Session Key: %s, Usernames: %s", session, session.sessionKey, session.usernames)
|
2022-05-10 21:57:06 +00:00
|
|
|
if session.sessionKey == sessionKey:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.logger.debug("Session found")
|
2022-05-14 09:43:02 +00:00
|
|
|
sessionUsername: str = session.usernames[0]
|
2022-05-11 02:19:49 +00:00
|
|
|
if sessionUsername.lower() == self.listenForUser.lower():
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.debug("Username '%s' matches '%s', continuing", sessionUsername, self.listenForUser)
|
2022-05-10 20:23:12 +00:00
|
|
|
break
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.debug("Username '%s' doesn't match '%s', ignoring", sessionUsername, self.listenForUser)
|
2022-05-11 02:19:49 +00:00
|
|
|
return
|
2022-05-10 20:23:12 +00:00
|
|
|
else:
|
|
|
|
self.logger.debug("No matching session found, ignoring")
|
|
|
|
return
|
2022-05-10 21:57:06 +00:00
|
|
|
if self.updateTimeoutTimer:
|
2022-05-10 20:23:12 +00:00
|
|
|
self.updateTimeoutTimer.cancel()
|
|
|
|
self.updateTimeoutTimer = threading.Timer(self.updateTimeoutTimerInterval, self.updateTimeout)
|
|
|
|
self.updateTimeoutTimer.start()
|
|
|
|
self.lastState, self.lastSessionKey, self.lastRatingKey = state, sessionKey, ratingKey
|
2022-05-22 04:37:09 +00:00
|
|
|
mediaType: str = item.type
|
|
|
|
title: str
|
|
|
|
thumb: str
|
2022-05-14 09:43:02 +00:00
|
|
|
if mediaType in ["movie", "episode"]:
|
2022-09-05 19:35:22 +00:00
|
|
|
stateStrings: list[str] = [] if config["display"]["hideTotalTime"] else [formatSeconds(item.duration / 1000)]
|
2022-05-14 09:43:02 +00:00
|
|
|
if mediaType == "movie":
|
|
|
|
title = f"{item.title} ({item.year})"
|
2022-08-25 20:37:50 +00:00
|
|
|
genres: list[Genre] = item.genres[:3]
|
|
|
|
stateStrings.append(f"{', '.join(genre.tag for genre in genres)}")
|
2022-05-14 09:43:02 +00:00
|
|
|
largeText = "Watching a movie"
|
|
|
|
thumb = item.thumb
|
|
|
|
else:
|
|
|
|
title = item.grandparentTitle
|
|
|
|
stateStrings.append(f"S{item.parentIndex:02}E{item.index:02}")
|
|
|
|
stateStrings.append(item.title)
|
|
|
|
largeText = "Watching a TV show"
|
|
|
|
thumb = item.grandparentThumb
|
|
|
|
if state != "playing":
|
2022-09-05 19:52:18 +00:00
|
|
|
if config["display"]["useRemainingTime"]:
|
|
|
|
stateStrings.append(f"{formatSeconds((item.duration - viewOffset) / 1000, ':')} left")
|
|
|
|
else:
|
|
|
|
stateStrings.append(f"{formatSeconds(viewOffset / 1000, ':')} elapsed")
|
2022-05-14 09:43:02 +00:00
|
|
|
stateText = " · ".join(stateString for stateString in stateStrings if stateString)
|
2022-05-10 21:57:06 +00:00
|
|
|
elif mediaType == "track":
|
|
|
|
title = item.title
|
2022-05-14 09:43:02 +00:00
|
|
|
stateText = f"{item.originalTitle or item.grandparentTitle} - {item.parentTitle} ({self.server.fetchItem(item.parentRatingKey).year})"
|
2022-05-10 20:23:12 +00:00
|
|
|
largeText = "Listening to music"
|
2022-05-14 09:43:02 +00:00
|
|
|
thumb = item.thumb
|
2022-05-10 20:23:12 +00:00
|
|
|
else:
|
2023-11-05 10:24:36 +00:00
|
|
|
self.logger.debug("Unsupported media type '%s', ignoring", mediaType)
|
2022-05-10 20:23:12 +00:00
|
|
|
return
|
2022-05-12 08:56:08 +00:00
|
|
|
thumbUrl = ""
|
2022-06-27 15:32:52 +00:00
|
|
|
if thumb and config["display"]["posters"]["enabled"]:
|
2023-11-04 18:13:42 +00:00
|
|
|
thumbUrl = getCacheKey(thumb)
|
2022-05-14 09:43:02 +00:00
|
|
|
if not thumbUrl:
|
2023-11-04 18:13:42 +00:00
|
|
|
self.logger.debug("Uploading image to Imgur")
|
2024-02-10 07:12:58 +00:00
|
|
|
thumbUrl = uploadToImgur(self.server.url(thumb, True), config["display"]["posters"]["maxSize"])
|
2023-11-04 18:13:42 +00:00
|
|
|
setCacheKey(thumb, thumbUrl)
|
2022-05-14 09:43:02 +00:00
|
|
|
activity: models.discord.Activity = {
|
2022-05-10 20:23:12 +00:00
|
|
|
"details": title[:128],
|
|
|
|
"state": stateText[:128],
|
|
|
|
"assets": {
|
|
|
|
"large_text": largeText,
|
2022-05-12 07:14:23 +00:00
|
|
|
"large_image": thumbUrl or "logo",
|
2022-05-10 20:23:12 +00:00
|
|
|
"small_text": state.capitalize(),
|
2022-05-10 21:57:06 +00:00
|
|
|
"small_image": state,
|
2022-05-10 20:23:12 +00:00
|
|
|
},
|
|
|
|
}
|
2022-08-25 20:37:50 +00:00
|
|
|
if config["display"]["buttons"]:
|
|
|
|
guidTags: list[GuidTag] = []
|
|
|
|
if mediaType == "movie":
|
|
|
|
guidTags = item.guids
|
|
|
|
elif mediaType == "episode":
|
|
|
|
guidTags = self.server.fetchItem(item.grandparentRatingKey).guids
|
|
|
|
guids: list[str] = [guid.id for guid in guidTags]
|
|
|
|
buttons: list[models.discord.ActivityButton] = []
|
|
|
|
for button in config["display"]["buttons"]:
|
|
|
|
if button["url"].startswith("dynamic:"):
|
|
|
|
if guids:
|
|
|
|
newUrl = button["url"]
|
|
|
|
if button["url"] == "dynamic:imdb":
|
|
|
|
for guid in guids:
|
|
|
|
if guid.startswith("imdb://"):
|
|
|
|
newUrl = guid.replace("imdb://", "https://www.imdb.com/title/")
|
|
|
|
break
|
|
|
|
elif button["url"] == "dynamic:tmdb":
|
|
|
|
for guid in guids:
|
|
|
|
if guid.startswith("tmdb://"):
|
|
|
|
tmdbPathSegment = "movie" if mediaType == "movie" else "tv"
|
|
|
|
newUrl = guid.replace("tmdb://", f"https://www.themoviedb.org/{tmdbPathSegment}/")
|
|
|
|
break
|
|
|
|
if newUrl:
|
|
|
|
buttons.append({ "label": button["label"], "url": newUrl })
|
|
|
|
else:
|
|
|
|
buttons.append(button)
|
|
|
|
if buttons:
|
|
|
|
activity["buttons"] = buttons[:2]
|
2022-05-10 21:57:06 +00:00
|
|
|
if state == "playing":
|
2022-05-10 20:23:12 +00:00
|
|
|
currentTimestamp = int(time.time())
|
2022-05-12 07:14:23 +00:00
|
|
|
if config["display"]["useRemainingTime"]:
|
2022-05-10 21:57:06 +00:00
|
|
|
activity["timestamps"] = {"end": round(currentTimestamp + ((item.duration - viewOffset) / 1000))}
|
2022-05-10 20:23:12 +00:00
|
|
|
else:
|
|
|
|
activity["timestamps"] = {"start": round(currentTimestamp - (viewOffset / 1000))}
|
2023-11-04 18:13:42 +00:00
|
|
|
if not self.discordIpcService.connected:
|
|
|
|
self.discordIpcService.connect()
|
|
|
|
if self.discordIpcService.connected:
|
|
|
|
self.discordIpcService.setActivity(activity)
|
2022-05-10 20:23:12 +00:00
|
|
|
except:
|
2023-11-04 18:13:42 +00:00
|
|
|
self.logger.exception("An unexpected error occured in the Plex alert handler")
|