diff --git a/Dockerfile b/Dockerfile index f8599b4..48be762 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,10 @@ FROM python:3.10-alpine ARG USERNAME=app -ARG USER_UID=10000 -ARG USER_GID=$USER_UID -RUN addgroup -g $USER_GID $USERNAME && adduser -u $USER_UID -G $USERNAME -D $USERNAME +ARG USER_UID_GID=10000 +RUN addgroup -g $USER_UID_GID $USERNAME && adduser -u $USER_UID_GID -G $USERNAME -D $USERNAME WORKDIR /app COPY requirements.txt . -RUN pip install -r requirements.txt --no-cache-dir +RUN pip install -U -r requirements.txt --no-cache-dir COPY . . -ENV DRPP_CONTAINER_DEMOTION_UID=$USER_UID +ENV DRPP_CONTAINER_DEMOTION_UID_GID=$USER_UID_GID CMD ["python", "main.py"] diff --git a/README.md b/README.md index 904c307..5b1be22 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The script must be running on the same machine as your Discord client. ## Configuration -A directory named `data` is used for storing the configuration file. +The config file is stored in a directory named `data`. ### Supported Formats @@ -164,7 +164,7 @@ The "Display current activity as a status message" setting must be enabled in Di ## Configuration - Environment Variables -* `PLEX_SERVER_NAME` - Name of the Plex Media Server you wish to connect to. Used only during the initial setup (when there are no users in the config) for adding a server to the config after authentication. If this isn't set, in interactive environments, the user is prompted for an input, and in non-interactive environments, "ServerName" is used as a placeholder, which can later be changed by editing the configuration file and restarting the script. +* `PLEX_SERVER_NAME` - Name of the Plex Media Server you wish to connect to. Used only during the initial setup (when there are no users in the config) for adding a server to the config after authentication. If this isn't set, in interactive environments, the user is prompted for an input, and in non-interactive environments, "ServerName" is used as a placeholder, which can later be changed by editing the config file and restarting the script. ## Run with Docker @@ -174,7 +174,7 @@ The "Display current activity as a status message" setting must be enabled in Di ### Volumes -Mount a directory for persistent data (configuration file, cache file and log file) at `/app/data`. +Mount a directory for persistent data (config file, cache file and log file) at `/app/data`. The directory where Discord stores its inter-process communication Unix socket file needs to be mounted into the container at `/run/app`. The path for this would be the first non-null value from the values of the following environment variables: ([source](https://github.com/discord/discord-rpc/blob/963aa9f3e5ce81a4682c6ca3d136cddda614db33/src/connection_unix.cpp#L29C33-L29C33)) diff --git a/config/constants.py b/config/constants.py index d33691c..3cc7be0 100644 --- a/config/constants.py +++ b/config/constants.py @@ -15,4 +15,4 @@ logFilePath = os.path.join(dataDirectoryPath, "console.log") isUnix = sys.platform in ["linux", "darwin"] processID = os.getpid() isInteractive = sys.stdin and sys.stdin.isatty() -containerDemotionUid = os.environ.get("DRPP_CONTAINER_DEMOTION_UID", "") +containerDemotionUidGid = os.environ.get("DRPP_CONTAINER_DEMOTION_UID_GID", "") diff --git a/core/config.py b/core/config.py index 8e584aa..e9f6ad3 100644 --- a/core/config.py +++ b/core/config.py @@ -33,18 +33,18 @@ configFileType = "" configFilePath = "" def loadConfig() -> None: - global configFileType, configFileExtension, configFilePath - for fileExtension, fileType in supportedConfigFileExtensions.items(): - filePath = f"{configFilePathRoot}.{fileExtension}" - if os.path.isfile(filePath): + global configFileExtension, configFileType, configFilePath + doesFileExist = False + for i, (fileExtension, fileType) in enumerate(supportedConfigFileExtensions.items()): + doesFileExist = os.path.isfile(f"{configFilePathRoot}.{fileExtension}") + isFirstItem = i == 0 + if doesFileExist or isFirstItem: configFileExtension = fileExtension configFileType = fileType - break - else: - configFileExtension, configFileType = list(supportedConfigFileExtensions.items())[0] - filePath = f"{configFilePathRoot}.{configFileExtension}" - configFilePath = filePath - if os.path.isfile(configFilePath): + configFilePath = f"{configFilePathRoot}.{configFileExtension}" + if doesFileExist: + break + if doesFileExist: try: with open(configFilePath, "r", encoding = "UTF-8") as configFile: if configFileType == "yaml": @@ -53,7 +53,7 @@ def loadConfig() -> None: loadedConfig = json.load(configFile) or {} except: os.rename(configFilePath, f"{configFilePathRoot}-{time.time():.0f}.{configFileExtension}") - logger.exception("Failed to parse the application's config file. A new one will be created.") + logger.exception("Failed to parse the config file. A new one will be created.") else: copyDict(loadedConfig, config) saveConfig() @@ -71,4 +71,4 @@ def saveConfig() -> None: json.dump(config, configFile, indent = "\t") configFile.write("\n") except: - logger.exception("Failed to write to the application's config file.") + logger.exception("Failed to write to the config file") diff --git a/core/plex.py b/core/plex.py index 56109b5..800ca3c 100644 --- a/core/plex.py +++ b/core/plex.py @@ -3,6 +3,7 @@ from .config import config from .discord import DiscordIpcService from .imgur import uploadToImgur +from config.constants import name, plexClientID from plexapi.alert import AlertListener from plexapi.base import Playable, PlexPartialObject from plexapi.media import Genre, GuidTag @@ -14,8 +15,24 @@ from utils.text import formatSeconds import models.config import models.discord import models.plex +import requests import threading import time +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"] class PlexAlertListener(threading.Thread): @@ -67,8 +84,7 @@ class PlexAlertListener(threading.Thread): connected = True break if not self.server: - self.logger.error("%s \"%s\" not found", self.productName, self.serverConfig["name"]) - break + raise Exception("Server not found") except Exception as e: self.logger.error("Failed to connect to %s \"%s\": %s", self.productName, self.serverConfig["name"], e) # pyright: ignore[reportTypedDictNotRequiredAccess] self.logger.error("Reconnecting in 10 seconds") diff --git a/main.py b/main.py index b31dbb4..2e51d8f 100644 --- a/main.py +++ b/main.py @@ -1,37 +1,42 @@ -from config.constants import isUnix, containerDemotionUid +from config.constants import isUnix, containerDemotionUidGid import os -import subprocess import sys -if isUnix and containerDemotionUid: - uid = int(containerDemotionUid) - os.system(f"chown -R {uid}:{uid} /app") - os.setuid(uid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] +if isUnix and containerDemotionUidGid: + uidGid = int(containerDemotionUidGid) + os.system(f"chown -R {uidGid}:{uidGid} {os.path.dirname(os.path.realpath(__file__))}") + os.setgid(uidGid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] + os.setuid(uidGid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] else: - def parsePipPackages(packagesStr: str) -> dict[str, str]: - return { packageSplit[0]: packageSplit[1] for packageSplit in [package.split("==") for package in packagesStr.splitlines()] } - pipFreezeResult = subprocess.run([sys.executable, "-m", "pip", "freeze"], capture_output = True, text = True) - installedPackages = parsePipPackages(pipFreezeResult.stdout) - with open("requirements.txt", "r", encoding = "UTF-8") as requirementsFile: - requiredPackages = parsePipPackages(requirementsFile.read()) - for packageName, packageVersion in requiredPackages.items(): - if packageName not in installedPackages: - print(f"Required package '{packageName}' not found, installing...") - subprocess.run([sys.executable, "-m", "pip", "install", f"{packageName}=={packageVersion}"], check = True) + try: + import subprocess + def parsePipPackages(packagesStr: str) -> dict[str, str]: + return { packageSplit[0]: packageSplit[1] if len(packageSplit) > 1 else "" for packageSplit in [package.split("==") for package in packagesStr.splitlines()] } + pipFreezeResult = subprocess.run([sys.executable, "-m", "pip", "freeze"], stdout = subprocess.PIPE, text = True, check = True) + installedPackages = parsePipPackages(pipFreezeResult.stdout) + with open("requirements.txt", "r", encoding = "UTF-8") as requirementsFile: + requiredPackages = parsePipPackages(requirementsFile.read()) + for packageName, packageVersion in requiredPackages.items(): + if packageName not in installedPackages: + package = f"{packageName}{f'=={packageVersion}' if packageVersion else ''}" + print(f"Installing missing dependency: {package}") + subprocess.run([sys.executable, "-m", "pip", "install", "-U", package], check = True) + except Exception as e: + import traceback + traceback.print_exception(e) + print("An unexpected error occured during automatic installation of dependencies. Install them manually by running the following command: python -m pip install -U -r requirements.txt") -from config.constants import dataDirectoryPath, logFilePath, name, plexClientID, version, isInteractive +from config.constants import dataDirectoryPath, logFilePath, name, version, isInteractive from core.config import config, loadConfig, saveConfig from core.discord import DiscordIpcService -from core.plex import PlexAlertListener +from core.plex import PlexAlertListener, initiateAuth, getAuthToken from typing import Optional from utils.cache import loadCache from utils.logging import formatter, logger from utils.text import formatSeconds import logging import models.config -import requests import time -import urllib.parse def main() -> None: if not os.path.exists(dataDirectoryPath): @@ -50,7 +55,7 @@ def main() -> None: loadCache() if not config["users"]: logger.info("No users found in the config file") - user = authUser() + user = authNewUser() if not user: exit() config["users"].append(user) @@ -69,25 +74,20 @@ def main() -> None: for plexAlertListener in plexAlertListeners: plexAlertListener.disconnect() -def authUser() -> Optional[models.config.User]: - response = requests.post("https://plex.tv/api/v2/pins.json?strong=true", headers = { - "X-Plex-Product": name, - "X-Plex-Client-Identifier": plexClientID, - }).json() +def authNewUser() -> Optional[models.config.User]: + id, code, url = initiateAuth() logger.info("Open the below URL in your web browser and sign in:") - logger.info("https://app.plex.tv/auth#?clientID=%s&code=%s&context%%5Bdevice%%5D%%5Bproduct%%5D=%s", plexClientID, response["code"], urllib.parse.quote(name)) + logger.info(url) time.sleep(5) for i in range(35): - logger.info(f"Checking whether authentication is successful... ({formatSeconds((i + 1) * 5)})") - authCheckResponse = requests.get(f"https://plex.tv/api/v2/pins/{response['id']}.json?code={response['code']}", headers = { - "X-Plex-Client-Identifier": plexClientID, - }).json() - if authCheckResponse["authToken"]: + logger.info(f"Checking whether authentication is successful ({formatSeconds((i + 1) * 5)})") + authToken = getAuthToken(id, code) + if authToken: logger.info("Authentication successful") serverName = os.environ.get("PLEX_SERVER_NAME") if not serverName: serverName = input("Enter the name of the Plex Media Server you wish to connect to: ") if isInteractive else "ServerName" - return { "token": authCheckResponse["authToken"], "servers": [{ "name": serverName }] } + return { "token": authToken, "servers": [{ "name": serverName }] } time.sleep(5) else: logger.info(f"Authentication timed out ({formatSeconds(180)})") diff --git a/requirements.txt b/requirements.txt index c874798..cc7d864 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ PlexAPI==4.10.1 -requests==2.26.0 +requests==2.31.0 websocket-client==1.3.2 PyYAML==6.0.1 diff --git a/utils/cache.py b/utils/cache.py index 59e799a..cbd48e0 100644 --- a/utils/cache.py +++ b/utils/cache.py @@ -14,8 +14,9 @@ def loadCache() -> None: with open(cacheFilePath, "r", encoding = "UTF-8") as cacheFile: cache.update(json.load(cacheFile)) except: - os.rename(cacheFilePath, cacheFilePath.replace(".json", f"-{time.time():.0f}.json")) - logger.exception("Failed to parse the application's cache file. A new one will be created.") + root, ext = os.path.splitext(cacheFilePath) + os.rename(cacheFilePath, f"{root}-{time.time():.0f}.{ext}") + logger.exception("Failed to parse the cache file. A new one will be created.") def getCacheKey(key: str) -> Any: return cache.get(key) @@ -26,4 +27,4 @@ def setCacheKey(key: str, value: Any) -> None: with open(cacheFilePath, "w", encoding = "UTF-8") as cacheFile: json.dump(cache, cacheFile, separators = (",", ":")) except: - logger.exception("Failed to write to the application's cache file.") + logger.exception("Failed to write to the cache file")