Skip to content
This repository was archived by the owner on Dec 11, 2023. It is now read-only.
/ Perplex Public archive
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Perplex is a Discord Rich Presence implementation for Plex.
## Features

- Modern and beautiful Rich Presence for movies, TV shows, and music.
- [The Movie Database (TMDB)](https://www.themoviedb.org/) integration for enhanced media information.
- [The Movie Database (TMDB)](https://www.themoviedb.org/) or [Trakt.tv](https://www.trakt.tv/) integration for enhanced media information.
- Optional minimal mode for Rich Presence to hide granular information
- Lightweight console application that runs in the background.
- Support for two-factor authentication (2FA) at login.
Expand All @@ -25,6 +25,9 @@ Note: A Discord desktop client must be connected on the same device that Perplex
2. Rename `config_example.json` to `config.json`, then provide the configurable values.
3. Start Perplex: `python perplex.py`

**Trakt**:
You will need to create a new application on [Trakt.tv](https://trakt.tv/oauth/applications/new) to obtain a client ID. The application name and description can be anything you want. The application redirect URI must be `urn:ietf:wg:oauth:2.0:oob`.

**Configurable Values:**

- `logging`:`severity`: Minimum [Loguru](https://loguru.readthedocs.io/en/stable/api/logger.html) severity level to display in the console (do not modify unless necessary).
Expand All @@ -35,5 +38,7 @@ Note: A Discord desktop client must be connected on the same device that Perplex
- `plex`:`users`: List of Plex users, in order of priority.
- `tmdb`:`enable`: `true` or `false` toggle for enhanced media information in Rich Presence.
- `tmdb`:`apiKey`: [TMDB API](https://www.themoviedb.org/settings/api) key (only used if `tmdb` `enable` is `true`).
- `trakt`:`enable`: `true` or `false` toggle for enhanced media information in Rich Presence.
- `trakt`:`clientId`: [Trakt API app](https://trakt.tv/oauth/applications/new) client ID (only used if `trakt` `enable` is `true`).
- `discord`:`appId`: Discord application ID (do not modify unless necessary).
- `discord`:`minimal`: `true` or `false` toggle for minimal media information in Rich Presence.
4 changes: 4 additions & 0 deletions config_example.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"enable": true,
"apiKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
},
"trakt": {
"enabled": true,
"clientId": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
},
"discord": {
"appId": 923857760897101855,
"minimal": false
Expand Down
234 changes: 184 additions & 50 deletions perplex.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import json
import re
import urllib.parse
from datetime import datetime
from pathlib import Path
from sys import exit, stderr
from time import sleep
from typing import Any, Dict, List, Optional, Self, Union
from typing import Any, Dict, List, Literal, Optional, Self, Union

import httpx
from httpx import Response
from loguru import logger
from plexapi.audio import TrackSession
from plexapi.media import Media
from plexapi.myplex import MyPlexAccount, MyPlexResource, PlexServer
from plexapi.video import EpisodeSession, MovieSession
from plexapi.video import EpisodeSession, MovieSession, Show
from pypresence import Presence


Expand All @@ -36,6 +37,9 @@ def Initialize(self: Self) -> None:
plex: MyPlexAccount = Perplex.LoginPlex(self)
discord: Presence = Perplex.LoginDiscord(self)

Perplex.timer = None
Perplex.viewOffset = None

while True:
session: Optional[
Union[MovieSession, EpisodeSession, TrackSession]
Expand All @@ -51,6 +55,10 @@ def Initialize(self: Self) -> None:
elif type(session) is TrackSession:
status: Dict[str, Any] = Perplex.BuildTrackPresence(self, session)

if Perplex.IsInPause(self, session, plex):
logger.info("Media session is paused")
status: Dict[str, Any] = Perplex.BuildPausePresence(self, session)

success: bool = Perplex.SetPresence(self, discord, status)

# Reestablish a failed Discord Rich Presence connection
Expand All @@ -59,8 +67,8 @@ def Initialize(self: Self) -> None:
else:
try:
discord.clear()
except Exception:
pass
except Exception as e:
logger.error(f"An error occured while clearing status, {e}")

# Presence updates have a rate limit of 1 update per 15 seconds
# https://discord.com/developers/docs/rich-presence/how-to#updating-presence
Expand Down Expand Up @@ -168,18 +176,12 @@ def LoginDiscord(self: Self) -> Presence:

return client

def FetchSession(
def ConnectPlexMediaServer(
self: Self, client: MyPlexAccount
) -> Optional[Union[MovieSession, EpisodeSession, TrackSession]]:
"""
Connect to the configured Plex Media Server and return the active
media session.
"""

) -> Optional[PlexServer]:
settings: Dict[str, Any] = self.config["plex"]

resource: Optional[MyPlexResource] = None
server: Optional[PlexServer] = None

for entry in settings["servers"]:
for result in client.resources():
Expand All @@ -197,13 +199,22 @@ def FetchSession(
exit(1)

try:
server = resource.connect()
return resource.connect()
except Exception as e:
logger.critical(
f"Failed to connect to configured Plex Media Server ({resource.name}), {e}"
)

exit(1)
def FetchSession(
self: Self, client: MyPlexAccount
) -> Optional[Union[MovieSession, EpisodeSession, TrackSession]]:
"""
Connect to the configured Plex Media Server and return the active
media session.
"""
settings: Dict[str, Any] = self.config["plex"]

server = Perplex.ConnectPlexMediaServer(self, client)

sessions: List[Media] = server.sessions()
active: Optional[Union[MovieSession, EpisodeSession, TrackSession]] = None
Expand Down Expand Up @@ -234,6 +245,31 @@ def FetchSession(

logger.error(f"Fetched active media session of unknown type: {type(active)}")

def IsInPause(self: Self, active: Union[MovieSession, EpisodeSession, TrackSession], client: MyPlexAccount) -> bool:
"""Check if the active media session is paused."""
active = Perplex.FetchSession(self, client)
if active:
result = Perplex.viewOffset == active.viewOffset
Perplex.viewOffset = active.viewOffset
return result

def BuildPausePresence(self: Self, active: Union[MovieSession, EpisodeSession, TrackSession]) -> Dict[str, Any]:
if isinstance(active, MovieSession):
result = Perplex.BuildMoviePresence(self, active)
elif isinstance(active, EpisodeSession):
result = Perplex.BuildEpisodePresence(self, active)
elif isinstance(active, TrackSession):
result = Perplex.BuildTrackPresence(self, active)
else:
logger.error(f"Unknown session type: {type(active)}")
return

progress = active.viewOffset / active.duration * 100
result["secondary"] = f"Paused ⏸︎ | {progress:.0f}%/{active.duration / 1000 / 60:.0f} min"
result["remaining"] = -1

return result

def BuildMoviePresence(self: Self, active: MovieSession) -> Dict[str, Any]:
"""Build a Discord Rich Presence status for the active movie session."""

Expand All @@ -242,7 +278,7 @@ def BuildMoviePresence(self: Self, active: MovieSession) -> Dict[str, Any]:
result: Dict[str, Any] = {}

metadata: Optional[Dict[str, Any]] = Perplex.FetchMetadata(
self, active.title, active.year, "movie"
self, active.title, active.year, "movie", active
)

if minimal:
Expand All @@ -269,12 +305,18 @@ def BuildMoviePresence(self: Self, active: MovieSession) -> Dict[str, Any]:
mId: int = metadata["id"]
mType: str = metadata["media_type"]
imgPath: str = metadata["poster_path"]
traktId: str = metadata.get("trakt")

result["image"] = f"https://image.tmdb.org/t/p/original{imgPath}"

result["buttons"] = [
{"label": "TMDB", "url": f"https://themoviedb.org/{mType}/{mId}"}
]
if traktId:
result["image"] = f"https://image.tmdb.org/t/p/original{imgPath}" if imgPath else "tv"
result["buttons"] = [
{"label": "Trakt.tv", "url": f"https://trakt.tv/{mType+'s'}/{mId}"}
]
else:
result["image"] = f"https://image.tmdb.org/t/p/original{imgPath}"
result["buttons"] = [
{"label": "TMDB", "url": f"https://themoviedb.org/{mType}/{mId}"}
]

result["remaining"] = int((active.duration / 1000) - (active.viewOffset / 1000))
result["imageText"] = active.title
Expand All @@ -289,7 +331,7 @@ def BuildEpisodePresence(self: Self, active: EpisodeSession) -> Dict[str, Any]:
result: Dict[str, Any] = {}

metadata: Optional[Dict[str, Any]] = Perplex.FetchMetadata(
self, active.show().title, active.show().year, "tv"
self, active.show().title, active.show().year, "tv", active
)

result["primary"] = active.show().title
Expand All @@ -308,12 +350,18 @@ def BuildEpisodePresence(self: Self, active: EpisodeSession) -> Dict[str, Any]:
mId: int = metadata["id"]
mType: str = metadata["media_type"]
imgPath: str = metadata["poster_path"]
traktId: str = metadata.get("trakt")

result["image"] = f"https://image.tmdb.org/t/p/original{imgPath}"

result["buttons"] = [
{"label": "TMDB", "url": f"https://themoviedb.org/{mType}/{mId}"}
]
if traktId:
result["image"] = f"https://image.tmdb.org/t/p/original{imgPath}" if imgPath else "tv"
result["buttons"] = [
{"label": "Trakt.tv", "url": f"https://trakt.tv/{mType+'s'}/{mId}"}
]
else:
result["image"] = f"https://image.tmdb.org/t/p/original{imgPath}"
result["buttons"] = [
{"label": "TMDB", "url": f"https://themoviedb.org/{mType}/{mId}"}
]

logger.trace(result)

Expand All @@ -338,34 +386,105 @@ def BuildTrackPresence(self: Self, active: TrackSession) -> Dict[str, Any]:
return result

def FetchMetadata(
self: Self, title: str, year: int, format: str
self: Self, title: str, year: int, format: str, session: Union[MovieSession, EpisodeSession]
) -> Optional[Dict[str, Any]]:
"""Fetch metadata for the provided title from TMDB."""

settings: Dict[str, Any] = self.config["tmdb"]
key: str = settings["apiKey"]

if not settings["enable"]:
logger.warning(f"TMDB disabled, some features will not be available")
# if title has a "(year)" in it, removes it. https://github.com/EthanC/Perplex/issues/21
title = re.sub(r"\(\d{4}\)", "", title).strip()

return
if settings["enable"]:

try:
res: Response = httpx.get(
f"https://api.themoviedb.org/3/search/multi?api_key={key}&query={urllib.parse.quote(title)}"
)
res.raise_for_status()
try:
res: Response = httpx.get(
f"https://api.themoviedb.org/3/search/multi?api_key={key}&query={urllib.parse.quote(title)}"
)
res.raise_for_status()

logger.debug(f"(HTTP {res.status_code}) GET {res.url}")
logger.trace(res.text)
except Exception as e:
logger.error(f"Failed to fetch metadata for {title} ({year}), {e}")
logger.debug(f"(HTTP {res.status_code}) GET {res.url}")
logger.trace(res.text)
except Exception as e:
logger.error(f"Failed to fetch metadata for {title} ({year}), {e}")

return
return

data: Dict[str, Any] = res.json()

session.guids = session.source().guids # https://github.com/pkkid/python-plexapi/issues/1214
media_type: Literal['episode', 'movie'] = "episode" if isinstance(session, EpisodeSession) else "movie"

if session.guids and self.config["trakt"]["enabled"] and self.config["trakt"]["clientId"]: # if trakt is enabled we use it
database: str = session.guids[0].id.split(":")[0]
guid = session.guids[0].id.split("//")[-1]
headers = {"Content-Type": "application/json", "trakt-api-version": "2", "trakt-api-key": self.config["trakt"]["clientId"]}
try:
res: Response = httpx.get(
f"https://api.trakt.tv/search/{database}/{guid}?type={media_type}", headers=headers
)
res.raise_for_status()

logger.debug(f"(HTTP {res.status_code}) GET {res.url}")
logger.trace(res.text)
except Exception as e:
logger.error(f"Failed to fetch metadata for {title} ({year}) from Trakt, {e}")
res = None

if (res:=res.json()):
tmdb_guid: int = res[0][media_type]["ids"].get("tmdb")
poster_path = None
if tmdb_guid:
if media_type == "episode":
url = f"https://api.themoviedb.org/3/tv/{res[0]['show']['ids']['tmdb']}?api_key={key}"
else:
url = f"https://api.themoviedb.org/3/movie/{tmdb_guid}?api_key={key}"

res2: Response = httpx.get(url)
res2.raise_for_status()

if res2.json():
if media_type == "episode":
for season in res2.json()["seasons"]:
if season["season_number"] == res[0]["episode"]["season"]:
poster_path = season["poster_path"]
break
else:
poster_path = res2.json()["poster_path"]

# We filter only the needed data with correct key names
return {"id": res[0][media_type]["ids"]["trakt"], "media_type": media_type, "poster_path": poster_path, "trakt": True}
# else we fallback to default search method


if not settings["enable"]:
logger.warning("TMDB disabled, some features will not be available")

return

if tmdb_guid:= [guid.id.split("//")[-1] for guid in session.guids if "tmdb" in guid.id][0]:
plex: PlexServer = Perplex.ConnectPlexMediaServer(self, Perplex.LoginPlex(self))
show: Show = plex.fetchItem(session.grandparentRatingKey)
if media_type == "episode":
if tmdb_guid:= [guid.id.split("//")[-1] for guid in show.guids if "tmdb" in guid.id][0]:
url = f"https://api.themoviedb.org/3/tv/{tmdb_guid}?api_key={key}"
else:
url = f"https://api.themoviedb.org/3/movie/{tmdb_guid}?api_key={key}"

res: Response = httpx.get(url)
res.raise_for_status()

if res.json():
data = {"results": [res.json()]}
# we need to add the media_type to the data
data["results"][0]["media_type"] = "tv" if media_type == "episode" else "movie"
else:
tmdb_guid = None

for entry in data.get("results", []):
if entry["id"] == tmdb_guid: # We found the correct entry
break
if format == "movie":
if entry["media_type"] != format:
continue
Expand Down Expand Up @@ -395,16 +514,31 @@ def SetPresence(self: Self, client: Presence, data: Dict[str, Any]) -> bool:
)

try:
client.update(
details=title,
state=data.get("secondary"),
end=int(datetime.now().timestamp() + data["remaining"]),
large_image=data["image"],
large_text=data["imageText"],
small_image="plex",
small_text="Plex",
buttons=data["buttons"],
)
if data["remaining"] == -1:
if Perplex.timer is None:
Perplex.timer = int(datetime.now().timestamp())
client.update(
details=title,
state=data.get("secondary"),
start=Perplex.timer,
large_image=data["image"],
large_text=data["imageText"],
small_image="plex",
small_text="Plex",
buttons=data["buttons"],
)
else:
Perplex.timer = None
client.update(
details=title,
state=data.get("secondary"),
end=int(datetime.now().timestamp() + data["remaining"]),
large_image=data["image"],
large_text=data["imageText"],
small_image="plex",
small_text="Plex",
buttons=data["buttons"],
)
except Exception as e:
logger.error(f"Failed to set Discord Rich Presence to {title}, {e}")

Expand Down