├── .prettierignore ├── include ├── resources.h ├── main.h ├── version.h.in ├── uuid.h ├── single_instance.h ├── application.h ├── thread_utils.h ├── http_client.h ├── trayicon.h ├── models.h ├── discord_ipc.h ├── logger.h ├── plex.h ├── discord.h └── config.h ├── .gitattributes ├── assets ├── resources.rc └── icon.ico ├── Dockerfile ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── cmake-multi-platform.yml ├── org.plexpresence.json ├── LICENSE ├── src ├── uuid.cpp ├── main.cpp ├── single_instance.cpp ├── logger.cpp ├── config.cpp ├── http_client.cpp ├── application.cpp ├── trayicon.cpp ├── discord_ipc.cpp ├── discord.cpp └── plex.cpp ├── README.md ├── CMakePresets.json ├── CMakeLists.txt └── .gitignore /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.in -------------------------------------------------------------------------------- /include/resources.h: -------------------------------------------------------------------------------- 1 | #define IDI_APPICON 101 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.dll filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /assets/resources.rc: -------------------------------------------------------------------------------- 1 | #define IDI_APPICON 101 2 | IDI_APPICON ICON "icon.ico" -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abarnes6/presence-for-plex/HEAD/assets/icon.ico -------------------------------------------------------------------------------- /include/main.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | 7 | // Platform-specific headers 8 | #ifdef _WIN32 9 | #include 10 | #endif 11 | 12 | // Project headers 13 | #include "application.h" 14 | #include "logger.h" 15 | 16 | // Function declarations 17 | void signalHandler(int sig); -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/alpine:3.22 AS base 2 | 3 | FROM base AS build 4 | RUN apk add --no-cache build-base cmake samurai git openssl-dev 5 | 6 | COPY . /src 7 | WORKDIR /src 8 | ARG BUILD_TYPE=debug 9 | RUN cmake -B ./build -G Ninja -DCMAKE_BUILD_TYPE=$BUILD_TYPE && \ 10 | cmake --build ./build 11 | 12 | FROM base AS runtime 13 | RUN apk add --no-cache libstdc++ 14 | COPY --from=build --chmod=755 /src/build/PresenceForPlex /app/ 15 | 16 | ENV HOME=/app XDG_CONFIG_DIR=/config XDG_RUNTIME_DIR=/app/run 17 | VOLUME /config 18 | CMD ["/app/PresenceForPlex"] 19 | -------------------------------------------------------------------------------- /include/version.h.in: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Define version components 4 | #define VERSION_MAJOR @PROJECT_VERSION_MAJOR@ 5 | #define VERSION_MINOR @PROJECT_VERSION_MINOR@ 6 | #define VERSION_PATCH @PROJECT_VERSION_PATCH@ 7 | 8 | // Helper macros for string conversion 9 | #define STRINGIFY(x) #x 10 | #define TOSTRING(x) STRINGIFY(x) 11 | 12 | // Version as string in format "MAJOR.MINOR.PATCH" 13 | #define VERSION_STRING TOSTRING(VERSION_MAJOR) "." TOSTRING(VERSION_MINOR) "." TOSTRING(VERSION_PATCH) 14 | 15 | // Version as numeric value (10000*MAJOR + 100*MINOR + PATCH) 16 | #define VERSION_NUM ((VERSION_MAJOR * 10000) + (VERSION_MINOR * 100) + VERSION_PATCH) 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: abarnes6 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /include/uuid.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | // Platform-specific headers 10 | #if defined(__APPLE__) || defined(__linux__) || defined(__unix__) 11 | #include 12 | #include 13 | #endif 14 | 15 | namespace uuid 16 | { 17 | /** 18 | * Generates a random UUID (Universally Unique Identifier) version 4. 19 | * 20 | * UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 21 | * where x is any hexadecimal digit and y is one of 8, 9, A, or B. 22 | * 23 | * @return A string containing the generated UUID v4. 24 | */ 25 | std::string generate_uuid_v4(); 26 | } -------------------------------------------------------------------------------- /org.plexpresence.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "dev.abarnes.presenceforplex", 3 | "runtime": "org.freedesktop.Platform", 4 | "runtime-version": "24.08", 5 | "sdk": "org.freedesktop.Sdk", 6 | "command": "presenceforplex", 7 | "finish-args": [ 8 | "--share=ipc", 9 | "--socket=x11", 10 | "--socket=wayland", 11 | "--device=dri" 12 | ], 13 | "modules": [ 14 | { 15 | "name": "presenceforplex", 16 | "buildsystem": "cmake-ninja", 17 | "sources": [ 18 | { 19 | "type": "dir", 20 | "path": "." 21 | } 22 | ], 23 | "config-opts": [ 24 | "--preset=release" 25 | ] 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: abarnes6 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /include/single_instance.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | /** 7 | * @class SingleInstance 8 | * @brief Ensures only one instance of the application is running at a time 9 | * 10 | * Uses platform-specific mechanisms to create a mutex that prevents multiple 11 | * instances of the application from running simultaneously. 12 | */ 13 | class SingleInstance { 14 | public: 15 | /** 16 | * @brief Constructor that attempts to create a single instance lock 17 | * @param appName The name of the application, used for the lock name 18 | */ 19 | explicit SingleInstance(const std::string& appName); 20 | 21 | /** 22 | * @brief Destructor that releases the lock 23 | */ 24 | ~SingleInstance(); 25 | 26 | /** 27 | * @brief Check if this is the only instance 28 | * @return true if this is the only instance, false otherwise 29 | */ 30 | bool isFirstInstance() const; 31 | 32 | private: 33 | class Impl; 34 | std::unique_ptr pImpl; 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Andrew Barnes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /include/application.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | // Third-party headers 10 | #include 11 | using json = nlohmann::json; 12 | 13 | // Project headers 14 | #include "config.h" 15 | #include "discord.h" 16 | #include "plex.h" 17 | #include "trayicon.h" 18 | 19 | class Application 20 | { 21 | public: 22 | Application(); 23 | 24 | bool initialize(); 25 | void run(); 26 | void stop(); 27 | 28 | private: 29 | std::unique_ptr m_plex; 30 | std::unique_ptr m_discord; 31 | #ifdef _WIN32 32 | std::unique_ptr m_trayIcon; 33 | #endif 34 | std::atomic m_running{false}; 35 | std::atomic m_initialized{false}; 36 | PlaybackState m_lastState = PlaybackState::Stopped; 37 | std::condition_variable m_discordConnectCv; 38 | std::mutex m_discordConnectMutex; 39 | time_t m_lastStartTime = 0; 40 | 41 | // Helper methods for improved readability 42 | void setupLogging(); 43 | void setupDiscordCallbacks(); 44 | void updateTrayStatus(const MediaInfo &info); 45 | void processPlaybackInfo(const MediaInfo &info); 46 | void performCleanup(); 47 | void checkForUpdates(); 48 | }; 49 | -------------------------------------------------------------------------------- /.github/workflows/cmake-multi-platform.yml: -------------------------------------------------------------------------------- 1 | name: CMake on multiple platforms 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build: 11 | name: ${{ matrix.os }}-${{ matrix.label }}-${{ matrix.build_type }} 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, windows-latest, macos-latest] 18 | build_type: [Release] 19 | include: 20 | - os: ubuntu-latest 21 | cpp_compiler: g++ 22 | label: gcc 23 | 24 | - os: ubuntu-latest 25 | cpp_compiler: clang++ 26 | label: clang 27 | 28 | - os: windows-latest 29 | cpp_compiler: cl 30 | label: msvc 31 | 32 | - os: macos-latest 33 | cpp_compiler: clang++ 34 | label: clang 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set reusable strings 40 | id: strings 41 | shell: bash 42 | run: echo "build-output-dir=${{ github.workspace }}/build" >> "$GITHUB_OUTPUT" 43 | 44 | - name: Configure CMake 45 | shell: bash 46 | run: | 47 | cmake -B "${{ steps.strings.outputs.build-output-dir }}" \ 48 | -S "${{ github.workspace }}" \ 49 | -DCMAKE_CXX_COMPILER="${{ matrix.cpp_compiler }}" \ 50 | -DCMAKE_BUILD_TYPE="${{ matrix.build_type }}" 51 | 52 | - name: Build 53 | shell: bash 54 | run: cmake --build "${{ steps.strings.outputs.build-output-dir }}" --config "${{ matrix.build_type }}" 55 | -------------------------------------------------------------------------------- /include/thread_utils.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include "logger.h" 8 | 9 | namespace ThreadUtils { 10 | 11 | /** 12 | * Join a thread with timeout 13 | * 14 | * @param thread The thread to join 15 | * @param timeout Timeout duration 16 | * @param threadName Name for logging 17 | * @return true if the thread was joined, false if timeout occurred 18 | */ 19 | inline bool joinWithTimeout(std::thread& thread, std::chrono::milliseconds timeout, const std::string& threadName) { 20 | if (!thread.joinable()) { 21 | return true; 22 | } 23 | 24 | // Create a future to track the join operation 25 | auto joinFuture = std::async(std::launch::async, [&thread]() { 26 | thread.join(); 27 | }); 28 | 29 | // Wait for the join operation with timeout 30 | if (joinFuture.wait_for(timeout) == std::future_status::timeout) { 31 | LOG_WARNING("ThreadUtils", "Thread '" + threadName + "' join timed out after " + 32 | std::to_string(timeout.count()) + "ms"); 33 | return false; 34 | } 35 | 36 | return true; 37 | } 38 | 39 | /** 40 | * Execute a function with timeout 41 | * 42 | * @param func Function to execute 43 | * @param timeout Timeout duration 44 | * @param operationName Name for logging 45 | * @return true if function completed within timeout, false otherwise 46 | */ 47 | template 48 | inline bool executeWithTimeout(Func func, std::chrono::milliseconds timeout, const std::string& operationName) { 49 | auto future = std::async(std::launch::async, func); 50 | 51 | if (future.wait_for(timeout) == std::future_status::timeout) { 52 | LOG_WARNING("ThreadUtils", "Operation '" + operationName + "' timed out after " + 53 | std::to_string(timeout.count()) + "ms"); 54 | return false; 55 | } 56 | 57 | return true; 58 | } 59 | 60 | } // namespace ThreadUtils 61 | -------------------------------------------------------------------------------- /src/uuid.cpp: -------------------------------------------------------------------------------- 1 | #include "uuid.h" 2 | 3 | namespace uuid 4 | { 5 | namespace 6 | { 7 | // Random number generators 8 | static thread_local std::random_device rd; 9 | static thread_local std::mt19937 gen(rd()); 10 | static thread_local std::uniform_int_distribution hex_dist(0, 15); 11 | static thread_local std::uniform_int_distribution variant_dist(8, 11); 12 | 13 | // UUID v4 format constants 14 | constexpr char UUID_VERSION = '4'; 15 | constexpr char UUID_SEPARATOR = '-'; 16 | 17 | // Group sizes in characters 18 | constexpr int GROUP1_SIZE = 8; 19 | constexpr int GROUP2_SIZE = 4; 20 | constexpr int GROUP3_SIZE = 4; 21 | constexpr int GROUP4_SIZE = 4; 22 | constexpr int GROUP5_SIZE = 12; 23 | 24 | // Helper function to generate a random hex character 25 | char random_hex_char() 26 | { 27 | unsigned int val = hex_dist(gen); 28 | return val < 10 ? '0' + val : 'a' + (val - 10); 29 | } 30 | 31 | // Helper function to generate a sequence of random hex characters 32 | void append_random_hex(std::stringstream &ss, int count) 33 | { 34 | for (int i = 0; i < count; ++i) 35 | { 36 | ss << random_hex_char(); 37 | } 38 | } 39 | } 40 | 41 | std::string generate_uuid_v4() 42 | { 43 | std::stringstream ss; 44 | 45 | // Group 1: 8 random hex characters 46 | append_random_hex(ss, GROUP1_SIZE); 47 | ss << UUID_SEPARATOR; 48 | 49 | // Group 2: 4 random hex characters 50 | append_random_hex(ss, GROUP2_SIZE); 51 | ss << UUID_SEPARATOR; 52 | 53 | // Group 3: 4 hex characters with version 4 54 | ss << UUID_VERSION; 55 | append_random_hex(ss, GROUP3_SIZE - 1); 56 | ss << UUID_SEPARATOR; 57 | 58 | // Group 4: 4 hex characters with variant (8, 9, A, or B) 59 | ss << static_cast('8' + (variant_dist(gen) - 8)); 60 | append_random_hex(ss, GROUP4_SIZE - 1); 61 | ss << UUID_SEPARATOR; 62 | 63 | // Group 5: 12 random hex characters 64 | append_random_hex(ss, GROUP5_SIZE); 65 | 66 | return ss.str(); 67 | } 68 | } -------------------------------------------------------------------------------- /include/http_client.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | 15 | // Third-party headers 16 | #include 17 | #include 18 | 19 | // Project headers 20 | #include "logger.h" 21 | 22 | /** 23 | * @brief HTTP client with support for GET, POST, and Server-Sent Events (SSE) 24 | */ 25 | class HttpClient 26 | { 27 | public: 28 | HttpClient(); 29 | ~HttpClient(); 30 | 31 | // Callback type for SSE events 32 | using EventCallback = std::function; 33 | 34 | // Regular HTTP requests 35 | bool get(const std::string &url, 36 | const std::map &headers, 37 | std::string &response); 38 | 39 | bool post(const std::string &url, 40 | const std::map &headers, 41 | const std::string &body, 42 | std::string &response); 43 | 44 | // Server-Sent Events (SSE) 45 | bool startSSE(const std::string &url, 46 | const std::map &headers, 47 | EventCallback callback); 48 | bool stopSSE(); 49 | 50 | private: 51 | // CURL callback functions 52 | static size_t writeCallback(char *ptr, size_t size, size_t nmemb, void *userdata); 53 | static size_t sseCallback(char *ptr, size_t size, size_t nmemb, void *userdata); 54 | static int sseCallbackProgress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, 55 | curl_off_t ultotal, curl_off_t ulnow); 56 | 57 | // Helper methods 58 | bool setupCommonOptions(const std::string &url, const std::map &headers); 59 | struct curl_slist *createHeaderList(const std::map &headers); 60 | bool checkResponse(CURLcode res); 61 | 62 | // Member variables 63 | CURL *m_curl; 64 | std::thread m_sseThread; 65 | EventCallback m_eventCallback; 66 | std::string m_sseBuffer; 67 | 68 | // Thread synchronization for SSE 69 | std::atomic m_stopFlag{false}; 70 | std::atomic m_sseRunning{false}; 71 | std::mutex m_sseMutex; 72 | std::condition_variable m_sseCondVar; 73 | }; 74 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | #include "main.h" 2 | #include "config.h" 3 | #include "single_instance.h" 4 | 5 | /** 6 | * Global application instance used by signal handlers 7 | */ 8 | static Application *g_app = nullptr; 9 | 10 | /** 11 | * Signal handler for clean application shutdown 12 | * @param sig Signal number that triggered the handler 13 | */ 14 | void signalHandler(int sig) 15 | { 16 | LOG_INFO("Main", "Received signal " + std::to_string(sig) + ", shutting down..."); 17 | if (g_app) 18 | { 19 | g_app->stop(); 20 | } 21 | } 22 | 23 | /** 24 | * Main program entry point 25 | * @return Exit code (0 for success, non-zero for errors) 26 | */ 27 | int main() 28 | { 29 | // Check for an existing instance 30 | SingleInstance singleInstance("PresenceForPlex"); 31 | if (!singleInstance.isFirstInstance()) { 32 | // Another instance is already running 33 | #ifdef _WIN32 34 | MessageBoxA(NULL, 35 | "Another instance of Presence For Plex is already running.", 36 | "Presence For Plex", 37 | MB_ICONINFORMATION | MB_OK); 38 | #else 39 | // For non-Windows platforms, just print a message to stderr 40 | fprintf(stderr, "Another instance of Presence For Plex is already running.\n"); 41 | #endif 42 | return 1; 43 | } 44 | 45 | // Register signal handlers for graceful shutdown 46 | #ifndef _WIN32 47 | if (signal(SIGINT, signalHandler) == SIG_ERR || 48 | signal(SIGTERM, signalHandler) == SIG_ERR) 49 | { 50 | LOG_ERROR("Main", "Failed to register signal handlers"); 51 | return 1; 52 | } 53 | #else 54 | if (signal(SIGINT, signalHandler) == SIG_ERR || 55 | signal(SIGBREAK, signalHandler) == SIG_ERR) 56 | { 57 | LOG_ERROR("Main", "Failed to register signal handlers"); 58 | return 1; 59 | } 60 | #endif 61 | 62 | // Initialize application 63 | Application app; 64 | g_app = &app; 65 | 66 | auto &config = Config::getInstance(); 67 | LOG_INFO("Application", "Starting Presence For Plex v" + config.getVersionString()); 68 | 69 | if (!app.initialize()) 70 | { 71 | LOG_ERROR("Main", "Application failed to initialize"); 72 | return 1; 73 | } 74 | 75 | // Run main application loop 76 | app.run(); 77 | return 0; 78 | } 79 | 80 | #ifdef _WIN32 81 | /** 82 | * Windows-specific entry point 83 | */ 84 | int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) 85 | { 86 | // Delegate to the platform-independent main function 87 | return main(); 88 | } 89 | #endif -------------------------------------------------------------------------------- /include/trayicon.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | // Platform-specific headers 12 | #ifdef _WIN32 13 | #include 14 | #include 15 | #endif 16 | 17 | // Project headers 18 | #include "logger.h" 19 | #include "resources.h" 20 | #include "thread_utils.h" 21 | 22 | // Constants for Windows 23 | #ifdef _WIN32 24 | #define ID_TRAY_APP_ICON 1000 25 | #define ID_TRAY_EXIT 1001 26 | #define ID_TRAY_RELOAD_CONFIG 1002 27 | #define ID_TRAY_OPEN_CONFIG_LOCATION 1003 28 | #define ID_TRAY_STATUS 1004 29 | #define ID_TRAY_CHECK_UPDATES 1005 30 | #define WM_TRAYICON (WM_USER + 1) 31 | #endif 32 | 33 | #ifdef _WIN32 34 | /** 35 | * @brief Manages the system tray icon and menu for Windows. 36 | */ 37 | class TrayIcon 38 | { 39 | public: 40 | /** 41 | * @brief Creates a tray icon with the specified application name 42 | * @param appName The name of the application to display 43 | */ 44 | TrayIcon(const std::string &appName); 45 | 46 | /** 47 | * @brief Cleans up resources and removes the tray icon 48 | */ 49 | ~TrayIcon(); 50 | 51 | // Public interface 52 | void show(); 53 | void hide(); 54 | void setExitCallback(std::function callback); 55 | void setConnectionStatus(const std::string &status); 56 | void setUpdateCheckCallback(std::function callback); 57 | void showNotification(const std::string &title, const std::string &message, bool isError = false); 58 | void showUpdateNotification(const std::string &title, const std::string &message, const std::string &downloadUrl); 59 | 60 | private: 61 | // Windows window procedure 62 | static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); 63 | 64 | // UI thread and menu management 65 | void uiThreadFunction(); 66 | void updateMenu(); 67 | void executeExitCallback(); 68 | void executeUpdateCheckCallback(); 69 | void openDownloadUrl(); 70 | 71 | // Window and menu handles 72 | HWND m_hWnd; 73 | HMENU m_hMenu; 74 | NOTIFYICONDATAW m_nid; 75 | 76 | // Application data 77 | std::string m_appName; 78 | std::string m_connectionStatus; 79 | std::string m_downloadUrl; 80 | std::function m_exitCallback; 81 | std::function m_updateCheckCallback; 82 | 83 | // Thread management 84 | std::atomic m_running; 85 | std::thread m_uiThread; 86 | std::atomic m_iconShown; // Track if the icon is currently shown 87 | 88 | // Static instance for Windows callback 89 | static TrayIcon *s_instance; 90 | }; 91 | #endif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Presence For Plex 2 | 3 | A lightweight application written in C++ that displays your current Plex media activity in Discord's Rich Presence. 4 | 5 | ## Features 6 | 7 | - Show what you're watching on Plex in your Discord status 8 | - Displays show titles, episode information, and progress 9 | - Runs in the system tray for easy access (Windows only) 10 | 11 | ## Installation 12 | 13 | ### Download 14 | 15 | 1. (Windows only) Ensure you have the latest [C++ Redistributables](https://aka.ms/vs/17/release/vc_redist.x64.exe) 16 | 2. Download the latest release from the [Releases](https://github.com/abarnes6/presence-for-plex/releases) page. 17 | 18 | ### Setup 19 | 20 | 1. Run the executable 21 | 2. Connect your Plex account in a browser when prompted 22 | 3. The application will automatically connect to Plex/Discord 23 | 24 | ## Building from Source 25 | 26 | ### Requirements 27 | 28 | - C++17 compatible compiler 29 | - CMake 3.25+ 30 | - Ninja 31 | - (Windows only) Windows 11 SDK 32 | - (Windows only) [NSIS3](https://prdownloads.sourceforge.net/nsis/nsis-3.11-setup.exe?download) 33 | 34 | ### Build Instructions 35 | 36 | If on Windows, use a Visual Studio terminal with CMake tools and vcpkg components installed. This is also how you would need to open VS code to compile with it. 37 | 38 | ```bash 39 | git clone https://github.com/abarnes6/presence-for-plex.git 40 | cd presence-for-plex 41 | mkdir build && cd build 42 | cmake --preset=release .. 43 | cmake --build release 44 | ``` 45 | 46 | ## Troubleshooting 47 | 48 | Check the log file located at: 49 | 50 | - Windows: `%APPDATA%\Presence For Plex\log.txt` 51 | - macOS/Linux: `~/.config/presence-for-plex/log.txt` 52 | 53 | ## FAQ 54 | 55 | ### Why does Presence For Plex show "No active sessions"? 56 | 57 | If the application is connecting to your Plex server but not detecting your media playback: 58 | 59 | 1. Check your Plex server's network settings 60 | 2. Go to Plex server Settings → Network 61 | 3. Verify that "Preferred network interface" is set correctly 62 | - If set to "Auto", try selecting your specific network interface instead 63 | - This is particularly important for servers with multiple network interfaces 64 | 65 | ### Why do the buttons not work? 66 | 67 | They do, but only for others! For some reason Discord doesn't like to show you your own rich presence buttons. 68 | 69 | ### Could not connect to any Discord socket. Is Discord running? 70 | 71 | You need to have a Discord app running in the same machine as the application. 72 | 73 | It will try to connect to Discord using the following files: 74 | 75 | - Windows: `\\.pipe\discord-ipc-0` to `discord-ipc-9` 76 | - macOS: `$TMPDIR/discord-ipc-0` to `discord-ipc-9` 77 | - Linux: 78 | - `$XDG_RUNTIME_DIR/discord-ipc-0` to `discord-ipc-9` 79 | - `$HOME/.discord-ipc-0` to `discord-ipc-9` 80 | - `/var/run/$UID/snap.discord/discord-ipc-0` 81 | - `/var/run/$UID/app/com.discordapp.Discord/discord-ipc-0` 82 | 83 | If none of those succeed, it means it can not talk to your Discord local client. 84 | 85 | ## Attribution 86 | 87 | ![blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b](https://github.com/user-attachments/assets/38abfb34-72cf-46d9-9d17-724761aa570a) 88 | 89 | (image API) 90 | 91 | ## License 92 | 93 | [MIT License](LICENSE) 94 | -------------------------------------------------------------------------------- /include/models.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | // Project headers 13 | #include "http_client.h" 14 | 15 | // Forward declarations 16 | class HttpClient; 17 | 18 | // Define the PlexServer struct 19 | struct PlexServer 20 | { 21 | std::string name; 22 | std::string clientIdentifier; 23 | std::string localUri; 24 | std::string publicUri; 25 | std::string accessToken; 26 | std::chrono::system_clock::time_point lastUpdated; 27 | std::unique_ptr httpClient; 28 | std::atomic running; 29 | bool owned = false; 30 | }; 31 | 32 | enum class PlaybackState 33 | { 34 | Stopped, // No active session 35 | Playing, // Media is playing 36 | Paused, // Media is paused 37 | Buffering, // Media is buffering 38 | BadToken, // Server configuration issue 39 | NotInitialized // Server not initialized 40 | }; 41 | 42 | enum class MediaType 43 | { 44 | Movie, 45 | TVShow, 46 | Music, 47 | Unknown 48 | }; 49 | 50 | enum class LinkType 51 | { 52 | IMDB, 53 | MAL, 54 | TMDB, 55 | TVDB, 56 | Unknown 57 | }; 58 | 59 | // Playback information 60 | struct MediaInfo 61 | { 62 | // General 63 | std::string title; // Title of the media 64 | std::string originalTitle; // Original title (original language) 65 | MediaType type; // Type of media (movie, TV show) 66 | std::string artPath; // Path to art on the server (cover image) 67 | int year; // Year of release 68 | std::string summary; // Summary of the media 69 | std::vector genres; // List of genres 70 | std::string imdbId; // IMDB ID (if applicable) 71 | std::string tmdbId; // TMDB ID (if applicable) 72 | std::string tvdbId; // TVDB ID (if applicable) 73 | std::string malId; // MyAnimeList ID (if applicable) 74 | 75 | // TV Show specific 76 | std::string grandparentTitle; // Parent title (tv show name) 77 | std::string grandparentArt; // Parent art URL (tv show cover image) 78 | std::string grandparentKey; // Parent ID (tv show ID) 79 | int season; // Season number 80 | int episode; // Episode number 81 | 82 | // Music specific 83 | std::string album; // Album title 84 | std::string artist; // Artist name 85 | 86 | // Playback info 87 | std::string username; // Username of the person watching 88 | PlaybackState state; // Current playback state 89 | double progress; // Current progress in seconds 90 | double duration; // Total duration in seconds 91 | time_t startTime; // When the playback started 92 | 93 | // Misc 94 | std::string sessionKey; // Plex session key 95 | std::string serverId; // ID of the server hosting this content 96 | 97 | MediaInfo() : state(PlaybackState::Stopped), 98 | progress(0), 99 | duration(0), 100 | startTime(0), 101 | type(MediaType::Unknown) {} 102 | }; -------------------------------------------------------------------------------- /CMakePresets.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 6, 3 | "configurePresets": [ 4 | { 5 | "name": "base", 6 | "hidden": true, 7 | "generator": "Ninja", 8 | "binaryDir": "${sourceDir}/build" 9 | }, 10 | { 11 | "name": "debug", 12 | "displayName": "Debug", 13 | "description": "Debug build with symbols for development", 14 | "inherits": "base", 15 | "binaryDir": "${sourceDir}/build/debug", 16 | "cacheVariables": { 17 | "CMAKE_BUILD_TYPE": "Debug" 18 | } 19 | }, 20 | { 21 | "name": "release", 22 | "displayName": "Release", 23 | "description": "Optimized release build", 24 | "inherits": "base", 25 | "binaryDir": "${sourceDir}/build/release", 26 | "cacheVariables": { 27 | "CMAKE_BUILD_TYPE": "Release" 28 | } 29 | }, 30 | { 31 | "name": "github", 32 | "displayName": "GitHub CI", 33 | "description": "Configuration preset for GitHub Actions CI", 34 | "generator": "Ninja", 35 | "binaryDir": "${sourceDir}/build/github", 36 | "cacheVariables": { 37 | "CMAKE_BUILD_TYPE": "Release" 38 | } 39 | } 40 | ], 41 | "buildPresets": [ 42 | { 43 | "name": "debug", 44 | "displayName": "Debug Build", 45 | "description": "Debug build with symbols for development", 46 | "configurePreset": "debug", 47 | "targets": ["all"] 48 | }, 49 | { 50 | "name": "release", 51 | "displayName": "Release Build", 52 | "description": "Optimized release build", 53 | "configurePreset": "release", 54 | "targets": ["all"] 55 | }, 56 | { 57 | "name": "github", 58 | "displayName": "GitHub CI Build", 59 | "description": "Build preset for GitHub Actions CI", 60 | "configurePreset": "github", 61 | "targets": ["all"] 62 | } 63 | ], 64 | "packagePresets": [ 65 | { 66 | "name": "debug-windows", 67 | "displayName": "Debug Package", 68 | "description": "Create debug package with symbols", 69 | "configurePreset": "debug", 70 | "generators": ["NSIS"] 71 | }, 72 | { 73 | "name": "release-windows", 74 | "displayName": "Release Package", 75 | "description": "Create optimized release package", 76 | "configurePreset": "release", 77 | "generators": ["NSIS"] 78 | }, 79 | { 80 | "name": "github", 81 | "displayName": "GitHub CI Package", 82 | "description": "Create release package for GitHub Actions CI", 83 | "configurePreset": "github", 84 | "generators": ["NSIS"] 85 | }, 86 | { 87 | "name": "debug-linux", 88 | "displayName": "Debug Package (Linux)", 89 | "description": "Create debug package with symbols for Linux", 90 | "configurePreset": "debug", 91 | "generators": ["TGZ"] 92 | }, 93 | { 94 | "name": "release-linux", 95 | "displayName": "Release Package (Linux)", 96 | "description": "Create optimized release package for Linux", 97 | "configurePreset": "release", 98 | "generators": ["TGZ"] 99 | } 100 | ] 101 | } 102 | -------------------------------------------------------------------------------- /src/single_instance.cpp: -------------------------------------------------------------------------------- 1 | #include "single_instance.h" 2 | #include "logger.h" 3 | 4 | #ifdef _WIN32 5 | #include 6 | #else 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #endif 13 | 14 | class SingleInstance::Impl { 15 | public: 16 | explicit Impl(const std::string& appName); 17 | ~Impl(); 18 | bool isFirstInstance() const; 19 | 20 | private: 21 | #ifdef _WIN32 22 | HANDLE mutexHandle; 23 | #else 24 | int lockFileHandle; 25 | std::string lockFilePath; 26 | #endif 27 | bool isFirst; 28 | }; 29 | 30 | #ifdef _WIN32 31 | // Windows implementation 32 | SingleInstance::Impl::Impl(const std::string& appName) 33 | : isFirst(false) 34 | { 35 | // Create a named mutex 36 | std::string mutexName = "Global\\" + appName + "_SingleInstance_Mutex"; 37 | 38 | // Try to create/open the mutex 39 | mutexHandle = CreateMutexA(NULL, TRUE, mutexName.c_str()); 40 | 41 | // Check if we got the mutex 42 | if (mutexHandle != NULL) { 43 | // If GetLastError returns ERROR_ALREADY_EXISTS, another instance exists 44 | isFirst = (GetLastError() != ERROR_ALREADY_EXISTS); 45 | 46 | if (!isFirst) { 47 | // Release the mutex if we're not the first instance 48 | ReleaseMutex(mutexHandle); 49 | } 50 | } 51 | 52 | LOG_INFO("SingleInstance", isFirst ? 53 | "Application instance is unique" : 54 | "Another instance is already running"); 55 | } 56 | 57 | SingleInstance::Impl::~Impl() { 58 | if (mutexHandle != NULL) { 59 | if (isFirst) { 60 | ReleaseMutex(mutexHandle); 61 | } 62 | CloseHandle(mutexHandle); 63 | } 64 | } 65 | #else 66 | // Unix/Linux/MacOS implementation 67 | SingleInstance::Impl::Impl(const std::string& appName) 68 | : isFirst(false), lockFileHandle(-1) 69 | { 70 | // Determine lock file location 71 | const char* tmpDir = getenv("TMPDIR"); 72 | if (tmpDir == nullptr) { 73 | tmpDir = "/tmp"; 74 | } 75 | 76 | lockFilePath = std::string(tmpDir) + "/" + appName + ".lock"; 77 | 78 | // Open or create the lock file 79 | lockFileHandle = open(lockFilePath.c_str(), O_RDWR | O_CREAT, 0666); 80 | 81 | if (lockFileHandle != -1) { 82 | // Try to get an exclusive lock 83 | int lockResult = flock(lockFileHandle, LOCK_EX | LOCK_NB); 84 | isFirst = (lockResult != -1); 85 | 86 | if (!isFirst) { 87 | // Close the file if we couldn't get the lock 88 | close(lockFileHandle); 89 | lockFileHandle = -1; 90 | } 91 | } 92 | 93 | LOG_INFO("SingleInstance", isFirst ? 94 | "Application instance is unique" : 95 | "Another instance is already running"); 96 | } 97 | 98 | SingleInstance::Impl::~Impl() { 99 | if (lockFileHandle != -1) { 100 | // Release the lock and close the file 101 | flock(lockFileHandle, LOCK_UN); 102 | close(lockFileHandle); 103 | 104 | // Try to remove the lock file 105 | unlink(lockFilePath.c_str()); 106 | } 107 | } 108 | #endif 109 | 110 | bool SingleInstance::Impl::isFirstInstance() const { 111 | return isFirst; 112 | } 113 | 114 | // Public interface implementation 115 | SingleInstance::SingleInstance(const std::string& appName) 116 | : pImpl(std::make_unique(appName)) 117 | { 118 | } 119 | 120 | SingleInstance::~SingleInstance() = default; 121 | 122 | bool SingleInstance::isFirstInstance() const { 123 | return pImpl->isFirstInstance(); 124 | } 125 | -------------------------------------------------------------------------------- /include/discord_ipc.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | // Platform-specific headers 14 | #ifdef _WIN32 15 | #include 16 | #include 17 | #else 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #endif 24 | 25 | #if defined(_WIN32) && !defined(htole32) 26 | #define htole32(x) (x) // little-endian host 27 | #define le32toh(x) (x) 28 | #elif defined(__APPLE__) 29 | #include 30 | #define htole32(x) OSSwapHostToLittleInt32(x) 31 | #define le32toh(x) OSSwapLittleToHostInt32(x) 32 | #elif defined(__linux__) || defined(__unix__) 33 | #include 34 | #endif 35 | 36 | // Third-party headers 37 | #include 38 | 39 | // Project headers 40 | #include "logger.h" 41 | 42 | // Discord IPC opcodes 43 | enum DiscordOpcodes 44 | { 45 | OP_HANDSHAKE = 0, 46 | OP_FRAME = 1, 47 | OP_CLOSE = 2, 48 | OP_PING = 3, 49 | OP_PONG = 4 50 | }; 51 | 52 | /** 53 | * Class handling the low-level IPC communication with Discord 54 | * 55 | * This class manages the connection to Discord's local IPC socket/pipe, 56 | * allowing sending and receiving of Discord Rich Presence messages. 57 | */ 58 | class DiscordIPC 59 | { 60 | public: 61 | /** 62 | * Constructor initializes the Discord IPC connection state 63 | */ 64 | DiscordIPC(); 65 | 66 | /** 67 | * Destructor ensures the connection is properly closed 68 | */ 69 | ~DiscordIPC(); 70 | 71 | // Connection management 72 | /** 73 | * Attempts to establish a connection to Discord via IPC 74 | * 75 | * On Windows, tries to connect to Discord's named pipes 76 | * On macOS/Linux, tries to connect to Discord's Unix domain sockets 77 | * 78 | * @return true if connection was successful, false otherwise 79 | */ 80 | bool openPipe(); 81 | 82 | /** 83 | * Closes the current connection to Discord 84 | */ 85 | void closePipe(); 86 | 87 | /** 88 | * Checks if the connection to Discord is active 89 | * 90 | * @return true if connected, false otherwise 91 | */ 92 | bool isConnected() const; 93 | 94 | // IPC operations 95 | /** 96 | * Writes a framed message to Discord 97 | * 98 | * @param opcode The Discord IPC opcode for this message 99 | * @param payload The message content as a JSON string 100 | * @return true if write was successful, false if it failed 101 | */ 102 | bool writeFrame(int opcode, const std::string &payload); 103 | 104 | /** 105 | * Reads a framed message from Discord 106 | * 107 | * @param opcode Output parameter that will contain the received opcode 108 | * @param data Output parameter that will contain the received data 109 | * @return true if read was successful, false if it failed 110 | */ 111 | bool readFrame(int &opcode, std::string &data); 112 | 113 | /** 114 | * Sends the initial handshake message to Discord 115 | * 116 | * @param clientId The Discord application client ID to use for Rich Presence 117 | * @return true if handshake was sent successfully, false otherwise 118 | */ 119 | bool sendHandshake(uint64_t clientId); 120 | 121 | /** 122 | * Sends a ping message to check if Discord is still responsive 123 | * 124 | * @return true if ping was sent successfully, false otherwise 125 | */ 126 | bool sendPing(); 127 | 128 | private: 129 | /** Flag indicating whether there's an active connection to Discord */ 130 | std::atomic connected; 131 | 132 | #ifdef _WIN32 133 | /** Windows-specific handle to the Discord IPC pipe */ 134 | HANDLE pipe_handle; 135 | #else 136 | /** Unix-specific file descriptor for the Discord IPC socket */ 137 | int pipe_fd; 138 | #endif 139 | }; -------------------------------------------------------------------------------- /include/logger.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | // Platform-specific headers 14 | #ifdef _WIN32 15 | #include 16 | #endif 17 | 18 | // ANSI color codes 19 | #define ANSI_RESET "\033[0m" 20 | #define ANSI_RED "\033[31m" 21 | #define ANSI_YELLOW "\033[33m" 22 | #define ANSI_GREEN "\033[32m" 23 | #define ANSI_BLUE "\033[36m" 24 | 25 | /** 26 | * Log severity levels in ascending order of importance 27 | */ 28 | enum class LogLevel 29 | { 30 | Debug, // Detailed information for debugging 31 | Info, // General information about program execution 32 | Warning, // Potential issues that don't prevent execution 33 | Error, // Critical issues that may prevent proper execution 34 | None // Disable logging completely 35 | }; 36 | 37 | /** 38 | * Singleton logger class supporting console output with colors and file output 39 | */ 40 | class Logger 41 | { 42 | public: 43 | static Logger &getInstance(); 44 | 45 | // Configuration methods 46 | void setLogLevel(LogLevel level); 47 | LogLevel getLogLevel() const; 48 | void initFileLogging(const std::filesystem::path &logFilePath, bool clearExisting = true); 49 | 50 | // Logging methods 51 | void debug(const std::string &component, const std::string &message); 52 | void info(const std::string &component, const std::string &message); 53 | void warning(const std::string &component, const std::string &message); 54 | void error(const std::string &component, const std::string &message); 55 | 56 | private: 57 | Logger(); 58 | Logger(const Logger &) = delete; 59 | Logger &operator=(const Logger &) = delete; 60 | 61 | void log(LogLevel level, const std::string &component, const std::string &message); 62 | std::string getTimestamp() const; 63 | std::string getLevelString(LogLevel level) const; 64 | std::string colorize(const std::string &text, LogLevel level) const; 65 | 66 | LogLevel logLevel; 67 | std::mutex logMutex; 68 | 69 | // File logging 70 | bool logToFile; 71 | std::ofstream logFile; 72 | bool useColorOutput; 73 | }; 74 | 75 | // Convenience macros for logging 76 | #define LOG_DEBUG(component, message) Logger::getInstance().debug(component, message) 77 | #define LOG_INFO(component, message) Logger::getInstance().info(component, message) 78 | #define LOG_WARNING(component, message) Logger::getInstance().warning(component, message) 79 | #define LOG_ERROR(component, message) Logger::getInstance().error(component, message) 80 | 81 | // Stream-style logging 82 | #define LOG_DEBUG_STREAM(component, message) \ 83 | { \ 84 | std::ostringstream oss; \ 85 | oss << message; \ 86 | Logger::getInstance().debug(component, oss.str()); \ 87 | } 88 | #define LOG_INFO_STREAM(component, message) \ 89 | { \ 90 | std::ostringstream oss; \ 91 | oss << message; \ 92 | Logger::getInstance().info(component, oss.str()); \ 93 | } 94 | #define LOG_WARNING_STREAM(component, message) \ 95 | { \ 96 | std::ostringstream oss; \ 97 | oss << message; \ 98 | Logger::getInstance().warning(component, oss.str()); \ 99 | } 100 | #define LOG_ERROR_STREAM(component, message) \ 101 | { \ 102 | std::ostringstream oss; \ 103 | oss << message; \ 104 | Logger::getInstance().error(component, oss.str()); \ 105 | } 106 | -------------------------------------------------------------------------------- /include/plex.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | // Platform-specific headers 17 | #ifdef _WIN32 18 | #include 19 | #include 20 | #endif 21 | 22 | // Third-party headers 23 | #include 24 | #include 25 | 26 | // Project headers 27 | #include "config.h" 28 | #include "http_client.h" 29 | #include "logger.h" 30 | #include "models.h" 31 | #include "uuid.h" 32 | 33 | // Forward declarations for cache structures 34 | struct TMDBCacheEntry; 35 | struct MALCacheEntry; 36 | struct MediaCacheEntry; 37 | struct SessionUserCacheEntry; 38 | struct ServerUriCacheEntry; 39 | 40 | class Plex 41 | { 42 | public: 43 | Plex(); 44 | ~Plex(); 45 | 46 | // Initialize connection to Plex 47 | bool init(); 48 | 49 | // Get current playback status 50 | MediaInfo getCurrentPlayback(); 51 | 52 | // Stop all connections 53 | void stop(); 54 | 55 | private: 56 | // Helper methods 57 | std::map getStandardHeaders(const std::string &token = ""); 58 | 59 | // State variables 60 | std::atomic m_initialized; 61 | std::atomic m_shuttingDown; 62 | 63 | // Cache mutexes and maps 64 | std::mutex m_cacheMutex; 65 | std::map m_tmdbArtworkCache; 66 | std::map m_malIdCache; 67 | std::map m_mediaInfoCache; 68 | std::map m_sessionUserCache; 69 | std::map m_serverUriCache; 70 | 71 | // Active sessions 72 | std::mutex m_sessionMutex; 73 | std::map m_activeSessions; 74 | 75 | // Authentication methods 76 | bool acquireAuthToken(); 77 | bool requestPlexPin(std::string &pinId, std::string &pin, HttpClient &client, 78 | const std::map &headers); 79 | void openAuthorizationUrl(const std::string &pin, const std::string &clientId); 80 | bool pollForPinAuthorization(const std::string &pinId, const std::string &pin, 81 | const std::string &clientId, HttpClient &client, 82 | const std::map &headers); 83 | bool fetchAndSaveUsername(const std::string &authToken, const std::string &clientId); 84 | std::string getClientIdentifier(); 85 | 86 | // Server methods 87 | bool fetchServers(); 88 | bool parseServerJson(const std::string &jsonStr); 89 | void setupServerConnections(); 90 | void setupServerSSEConnection(const std::shared_ptr &server); 91 | 92 | // Event handling methods 93 | void handleSSEEvent(const std::string &serverId, const std::string &event); 94 | void processPlaySessionStateNotification(const std::string &serverId, const nlohmann::json ¬ification); 95 | void updateSessionInfo(const std::string &serverId, const std::string &sessionKey, 96 | const std::string &state, const std::string &mediaKey, 97 | int64_t viewOffset, const std::shared_ptr &server); 98 | void updatePlaybackState(MediaInfo &info, const std::string &state, int64_t viewOffset); 99 | std::string urlEncode(const std::string &value); 100 | 101 | // Media info methods 102 | MediaInfo fetchMediaDetails(const std::string &serverUri, const std::string &accessToken, 103 | const std::string &mediaKey); 104 | void extractBasicMediaInfo(const nlohmann::json &metadata, MediaInfo &info); 105 | void extractMovieSpecificInfo(const nlohmann::json &metadata, MediaInfo &info); 106 | void extractTVShowSpecificInfo(const nlohmann::json &metadata, MediaInfo &info); 107 | void fetchGrandparentMetadata(const std::string &serverUrl, const std::string &accessToken, 108 | MediaInfo &info); 109 | void parseGuid(const nlohmann::json &metadata, MediaInfo &info); 110 | void parseGenres(const nlohmann::json &metadata, MediaInfo &info); 111 | bool isAnimeContent(const nlohmann::json &metadata); 112 | void fetchAnimeMetadata(const nlohmann::json &metadata, MediaInfo &info); 113 | void fetchTMDBArtwork(const std::string &tmdbId, MediaInfo &info); 114 | std::string fetchSessionUsername(const std::string &serverUri, const std::string &accessToken, 115 | const std::string &sessionKey); 116 | std::string getPreferredServerUri(const std::shared_ptr &server); 117 | void extractMusicSpecificInfo(const nlohmann::json &metadata, MediaInfo &info, 118 | const std::string &serverUri, const std::string &accessToken); 119 | }; -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required (VERSION 3.25) 2 | 3 | set(CMAKE_SUPPRESS_REGENERATION true) 4 | 5 | # Enable Hot Reload for MSVC compilers if supported. 6 | if (POLICY CMP0141) 7 | cmake_policy(SET CMP0141 NEW) 8 | set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$,$>,$<$:EditAndContinue>,$<$:ProgramDatabase>>") 9 | endif() 10 | 11 | project ("PresenceForPlex" VERSION 0.3.6 LANGUAGES CXX) 12 | 13 | # Configure version.h from template 14 | configure_file( 15 | ${CMAKE_CURRENT_LIST_DIR}/include/version.h.in 16 | ${CMAKE_CURRENT_LIST_DIR}/include/version.h 17 | @ONLY 18 | ) 19 | 20 | # Add both project include directories and binary directory to include paths 21 | include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_BINARY_DIR}) 22 | 23 | file(GLOB_RECURSE SOURCES src/*.cpp) 24 | file(GLOB_RECURSE HEADERS include/*.h) 25 | 26 | # Add resource file for Windows 27 | if(WIN32) 28 | set(RES_FILES assets/resources.rc) 29 | endif() 30 | 31 | # Allow to use system libraries 32 | if(USE_DYNAMIC_LINKS) 33 | find_package(CURL REQUIRED) 34 | find_package(nlohmann_json REQUIRED) 35 | find_package(yaml-cpp REQUIRED) 36 | else() 37 | # Packages 38 | include(FetchContent) 39 | set(BUILD_SHARED_LIBS OFF) 40 | if(WIN32) 41 | set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE) 42 | elseif(APPLE) 43 | set(CURL_USE_SECTRANSP ON CACHE BOOL "" FORCE) 44 | endif() 45 | 46 | set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE) 47 | set(BUILD_TESTING OFF CACHE BOOL "" FORCE) 48 | 49 | ## yaml-cpp 50 | FetchContent_Declare( 51 | yaml-cpp 52 | GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git 53 | GIT_TAG master 54 | ) 55 | FetchContent_MakeAvailable(yaml-cpp) 56 | 57 | ## nlohmann_json 58 | FetchContent_Declare( 59 | json 60 | URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz 61 | ) 62 | FetchContent_MakeAvailable(json) 63 | 64 | ## curl 65 | FetchContent_Declare( 66 | curl 67 | URL https://github.com/curl/curl/releases/download/curl-7_86_0/curl-7.86.0.tar.gz 68 | ) 69 | FetchContent_MakeAvailable(curl) 70 | endif() 71 | 72 | # Add source to this project's executable. 73 | if(WIN32) 74 | set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /SUBSYSTEM:WINDOWS") 75 | add_compile_definitions(WIN32_LEAN_AND_MEAN) 76 | add_executable(PresenceForPlex WIN32 src/main.cpp ${SOURCES} ${HEADERS} ${RES_FILES}) 77 | else() 78 | add_executable(PresenceForPlex src/main.cpp ${SOURCES} ${HEADERS} ${RES_FILES}) 79 | endif() 80 | 81 | target_include_directories(PresenceForPlex PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) 82 | target_link_libraries(PresenceForPlex PRIVATE CURL::libcurl yaml-cpp::yaml-cpp nlohmann_json::nlohmann_json) 83 | 84 | if (CMAKE_VERSION VERSION_GREATER 3.25) 85 | set_property(TARGET PresenceForPlex PROPERTY CXX_STANDARD 17) 86 | endif() 87 | 88 | install(TARGETS PresenceForPlex 89 | RUNTIME DESTINATION .) # Root of staging dir 90 | install(FILES LICENSE README.md 91 | DESTINATION .) 92 | if (WIN32) 93 | install(FILES 94 | $ 95 | DESTINATION . 96 | ) 97 | endif() 98 | 99 | # Platform-specific packaging options 100 | if(WIN32) 101 | set(CPACK_GENERATOR "NSIS") 102 | # Set icon for NSIS installer 103 | set(CPACK_NSIS_MUI_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon.ico") 104 | set(CPACK_NSIS_MUI_UNIICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon.ico") 105 | 106 | # Add option for startup 107 | set(CPACK_NSIS_EXTRA_INSTALL_COMMANDS " 108 | CreateShortCut \\\"$DESKTOP\\\\Presence For Plex.lnk\\\" \\\"$INSTDIR\\\\PresenceForPlex.exe\\\" 109 | CreateDirectory \\\"$SMPROGRAMS\\\\Presence For Plex\\\" 110 | CreateShortCut \\\"$SMPROGRAMS\\\\Presence For Plex\\\\Presence For Plex.lnk\\\" \\\"$INSTDIR\\\\PresenceForPlex.exe\\\" 111 | WriteRegStr HKCU \\\"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\" \\\"PresenceForPlex\\\" \\\"$INSTDIR\\\\PresenceForPlex.exe\\\" 112 | ") 113 | set(CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS " 114 | Delete \\\"$DESKTOP\\\\Presence For Plex.lnk\\\" 115 | Delete \\\"$SMPROGRAMS\\\\Presence For Plex\\\\Presence For Plex.lnk\\\" 116 | RMDir \\\"$SMPROGRAMS\\\\Presence For Plex\\\" 117 | DeleteRegValue HKCU \\\"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\" \\\"PresenceForPlex\\\" 118 | ") 119 | set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) 120 | set(CPACK_PACKAGE_INSTALL_DIRECTORY "Presence For Plex") 121 | elseif(APPLE) 122 | set(CPACK_GENERATOR "DragNDrop") 123 | set(CPACK_DMG_VOLUME_NAME "PresenceForPlex") 124 | elseif(UNIX) 125 | set(CPACK_GENERATOR "TGZ") 126 | set(CPACK_PACKAGE_INSTALL_DIRECTORY "PresenceForPlex") 127 | 128 | # Install to standard Linux locations 129 | set(CPACK_SET_DESTDIR ON) 130 | set(CPACK_INSTALL_PREFIX "/usr") 131 | endif() 132 | 133 | set(CPACK_PACKAGE_NAME "PresenceForPlex") 134 | set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") 135 | set(CPACK_PACKAGE_VENDOR "Andrew Barnes") 136 | set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Discord Rich Presence for Plex") 137 | set(CPACK_PACKAGE_EXECUTABLES "PresenceForPlex" "Presence For Plex") 138 | set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE") 139 | 140 | include(CPack) 141 | -------------------------------------------------------------------------------- /include/discord.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #define NOMINMAX 3 | 4 | // Standard library headers 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | 19 | // Platform-specific headers 20 | #ifdef _WIN32 21 | #include 22 | #else 23 | #include 24 | #endif 25 | 26 | // Third-party headers 27 | #include 28 | 29 | // Project headers 30 | #include "config.h" 31 | #include "discord_ipc.h" 32 | #include "logger.h" 33 | #include "models.h" 34 | #include "thread_utils.h" 35 | 36 | /** 37 | * Main interface for Discord Rich Presence integration 38 | * 39 | * This class handles all high-level communication with Discord, including: 40 | * - Connection and reconnection management 41 | * - Presence updates for media playback 42 | * - Status monitoring and error handling 43 | */ 44 | class Discord 45 | { 46 | public: 47 | Discord(); 48 | ~Discord(); 49 | 50 | /** 51 | * Starts the Discord Rich Presence background thread 52 | * This initiates connection to Discord and begins monitoring 53 | */ 54 | void start(); 55 | 56 | /** 57 | * Stops the Discord Rich Presence service 58 | * Closes connections and terminates the background thread 59 | */ 60 | void stop(); 61 | 62 | /** 63 | * Checks if currently connected to Discord 64 | * 65 | * @return true if connected, false otherwise 66 | */ 67 | bool isConnected() const; 68 | 69 | /** 70 | * Updates Discord Rich Presence with current media information 71 | * 72 | * @param info The media information to display in Discord 73 | */ 74 | void updatePresence(const MediaInfo &info); 75 | 76 | /** 77 | * Clears the current Discord Rich Presence 78 | * Called when media playback stops 79 | */ 80 | void clearPresence(); 81 | 82 | // Add callback typedefs and setters 83 | /** 84 | * Callback type for connection state changes 85 | */ 86 | typedef std::function ConnectionCallback; 87 | 88 | /** 89 | * Sets the callback to invoke when connection to Discord is established 90 | * 91 | * @param callback Function to call when connected 92 | */ 93 | void setConnectedCallback(ConnectionCallback callback); 94 | 95 | /** 96 | * Sets the callback to invoke when connection to Discord is lost 97 | * 98 | * @param callback Function to call when disconnected 99 | */ 100 | void setDisconnectedCallback(ConnectionCallback callback); 101 | 102 | private: 103 | 104 | DiscordIPC ipc; 105 | std::thread conn_thread; 106 | std::mutex mutex; 107 | bool running; 108 | bool needs_reconnect; 109 | int reconnect_attempts; 110 | bool is_playing; 111 | int64_t nonce_counter; 112 | 113 | // New members for frame queue 114 | std::mutex frame_queue_mutex; 115 | std::string queued_frame; 116 | bool has_queued_frame; 117 | int64_t last_frame_write_time; 118 | 119 | ConnectionCallback onConnected; 120 | ConnectionCallback onDisconnected; 121 | 122 | // For rate limiting 123 | std::deque frame_write_times; 124 | bool canSendFrame(int64_t current_time); 125 | 126 | /** 127 | * Persistent connection thread to Discord IPC 128 | * 129 | * This thread handles the connection to Discord and keeps it alive. 130 | * It also handles reconnections in case of disconnection with exponential backoff. 131 | * 132 | * Connection flow: 133 | * 1. Attempt to open pipe connection to Discord client 134 | * 2. Send handshake message with configured client ID 135 | * 3. Wait for and validate handshake response 136 | * 4. If successful, connection is established 137 | * 5. Periodically check connection health with pings 138 | */ 139 | void connectionThread(); 140 | 141 | /** 142 | * Checks if Discord connection is still alive by sending a ping 143 | * 144 | * @return true if connection is healthy, false otherwise 145 | */ 146 | bool isStillAlive(); 147 | 148 | /** 149 | * Sends a presence update message to Discord 150 | * 151 | * @param message The JSON payload to send 152 | */ 153 | void sendPresenceMessage(const std::string &message); 154 | 155 | /** 156 | * Attempts to establish a connection to Discord 157 | * 158 | * @return true if connection was successful, false otherwise 159 | */ 160 | bool attemptConnection(); 161 | 162 | /** 163 | * Creates the activity portion of the Discord Rich Presence payload 164 | * 165 | * @param info Media information to display 166 | * @return JSON object representing the activity 167 | */ 168 | nlohmann::json createActivity(const MediaInfo &info); 169 | 170 | /** 171 | * Creates the primary Discord presence payload 172 | * 173 | * @param info Media information to display 174 | * @param nonce Unique identifier for this update 175 | * @return JSON string representation of the presence payload 176 | */ 177 | std::string createPresence(const MediaInfo &info, const std::string &nonce); 178 | 179 | /** 180 | * Creates the metadata portion of the Discord presence payload 181 | * 182 | * @param info Media information to display 183 | * @param nonce Unique identifier for this update 184 | * @return JSON string representation of the metadata payload 185 | */ 186 | std::string createPresenceMetadata(const MediaInfo &info, const std::string &nonce); 187 | 188 | /** 189 | * Generates a unique nonce string for Discord messages 190 | * 191 | * @return A unique string identifier 192 | */ 193 | std::string generateNonce(); 194 | 195 | /** 196 | * Queues a presence message to be sent to Discord 197 | * 198 | * @param message The JSON payload to queue 199 | */ 200 | void queuePresenceMessage(const std::string &message); 201 | 202 | /** 203 | * Processes the queued frame and sends it to Discord 204 | * 205 | * This method is called when a frame is available in the queue. 206 | */ 207 | void processQueuedFrame(); 208 | 209 | }; 210 | -------------------------------------------------------------------------------- /include/config.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Standard library headers 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | // Third-party headers 13 | #include 14 | 15 | // Project headers 16 | #include "logger.h" 17 | #include "models.h" 18 | #include "version.h" 19 | 20 | /** 21 | * @class Config 22 | * @brief Singleton class to manage application configuration 23 | */ 24 | class Config 25 | { 26 | public: 27 | /** 28 | * @brief Get the singleton instance 29 | * @return Reference to the Config instance 30 | */ 31 | static Config &getInstance(); 32 | 33 | /** 34 | * @brief Get the configuration directory path 35 | * @return Path to the configuration directory 36 | */ 37 | static std::filesystem::path getConfigDirectory(); 38 | 39 | // 40 | // Configuration file operations 41 | // 42 | 43 | /** 44 | * @brief Load configuration from file 45 | * @return True if successful, false otherwise 46 | */ 47 | bool loadConfig(); 48 | 49 | /** 50 | * @brief Save configuration to file 51 | * @return True if successful, false otherwise 52 | */ 53 | bool saveConfig(); 54 | 55 | // 56 | // General settings 57 | // 58 | 59 | /** 60 | * @brief Get current log level 61 | * @return Current log level 62 | */ 63 | int getLogLevel() const; 64 | 65 | /** 66 | * @brief Set log level 67 | * @param level New log level 68 | */ 69 | void setLogLevel(int level); 70 | 71 | // 72 | // Plex settings 73 | // 74 | 75 | /** 76 | * @brief Get Plex authentication token 77 | * @return Plex auth token 78 | */ 79 | std::string getPlexAuthToken() const; 80 | 81 | /** 82 | * @brief Set Plex authentication token 83 | * @param token New auth token 84 | */ 85 | void setPlexAuthToken(const std::string &token); 86 | 87 | /** 88 | * @brief Get Plex client identifier 89 | * @return Client identifier 90 | */ 91 | std::string getPlexClientIdentifier() const; 92 | 93 | /** 94 | * @brief Set Plex client identifier 95 | * @param id New client identifier 96 | */ 97 | void setPlexClientIdentifier(const std::string &id); 98 | 99 | /** 100 | * @brief Get Plex username 101 | * @return Plex username 102 | */ 103 | std::string getPlexUsername() const; 104 | 105 | /** 106 | * @brief Set Plex username 107 | * @param username New username 108 | */ 109 | void setPlexUsername(const std::string &username); 110 | 111 | /** 112 | * @brief Get TMDB access token 113 | * @return TMDB access token 114 | */ 115 | std::string getTMDBAccessToken() const; 116 | 117 | /** 118 | * @brief Set TMDB access token 119 | * @param token New TMDB access token 120 | */ 121 | void setTMDBAccessToken(const std::string &token); 122 | 123 | // 124 | // Plex server management 125 | // 126 | 127 | /** 128 | * @brief Get all configured Plex servers 129 | * @return Map of server client ID to server object 130 | */ 131 | const std::map> &getPlexServers() const; 132 | 133 | /** 134 | * @brief Add or update a Plex server 135 | * @param name Server name 136 | * @param clientId Server client identifier 137 | * @param localUri Local network URI 138 | * @param publicUri Public network URI 139 | * @param accessToken Access token for this server 140 | * @param owned Whether this server is owned by the user 141 | */ 142 | void addPlexServer(const std::string &name, const std::string &clientId, 143 | const std::string &localUri, const std::string &publicUri, 144 | const std::string &accessToken, bool owned = false); 145 | 146 | /** 147 | * @brief Remove all configured Plex servers 148 | */ 149 | void clearPlexServers(); 150 | 151 | // 152 | // Discord settings 153 | // 154 | 155 | /** 156 | * @brief Get Discord client ID 157 | * @return Discord client ID 158 | */ 159 | uint64_t getDiscordClientId() const; 160 | 161 | /** 162 | * @brief Set Discord client ID 163 | * @param id New Discord client ID 164 | */ 165 | void setDiscordClientId(uint64_t id); 166 | 167 | // 168 | // Version information 169 | // 170 | 171 | /** 172 | * @brief Get application version as string 173 | * @return Version string in format "MAJOR.MINOR.PATCH" 174 | */ 175 | std::string getVersionString() const; 176 | 177 | /** 178 | * @brief Get major version number 179 | * @return Major version component 180 | */ 181 | int getVersionMajor() const; 182 | 183 | /** 184 | * @brief Get minor version number 185 | * @return Minor version component 186 | */ 187 | int getVersionMinor() const; 188 | 189 | /** 190 | * @brief Get patch version number 191 | * @return Patch version component 192 | */ 193 | int getVersionPatch() const; 194 | 195 | private: 196 | // Singleton pattern implementation 197 | Config(); 198 | ~Config(); 199 | Config(const Config &) = delete; 200 | Config &operator=(const Config &) = delete; 201 | 202 | // Helper methods for YAML conversion 203 | void loadFromYaml(const YAML::Node &config); 204 | YAML::Node saveToYaml() const; 205 | 206 | // Configuration path 207 | std::filesystem::path configPath; 208 | 209 | // Configuration values 210 | std::atomic logLevel{1}; 211 | std::atomic discordClientId{1359742002618564618}; 212 | 213 | // Complex types need mutex protection 214 | std::string plexAuthToken; 215 | std::string plexClientIdentifier; 216 | std::string plexUsername; 217 | std::map> plexServers; 218 | std::string tmdbAccessToken{ 219 | "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIzNmMxOTI3ZjllMTlkMzUxZWFmMjAxNGViN2JmYjNkZiIsIm5iZiI6MTc0NTQzMTA3NC4yMjcsInN1YiI6IjY4MDkyYTIyNmUxYTc2OWU4MWVmMGJhOSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.Td6eAbW7SgQOMmQpRDwVM-_3KIMybGRqWNK8Yqw1Zzs"}; 220 | 221 | // Thread safety mutex 222 | mutable std::shared_mutex mutex; 223 | }; 224 | -------------------------------------------------------------------------------- /src/logger.cpp: -------------------------------------------------------------------------------- 1 | #include "logger.h" 2 | 3 | // Constructor 4 | Logger::Logger() : logLevel(LogLevel::Info), logToFile(false) 5 | { 6 | #ifdef _WIN32 7 | // Enable ANSI color codes on Windows 8 | HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); 9 | DWORD dwMode = 0; 10 | GetConsoleMode(hOut, &dwMode); 11 | dwMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING; 12 | SetConsoleMode(hOut, dwMode); 13 | useColorOutput = true; 14 | #else 15 | useColorOutput = true; 16 | #endif 17 | } 18 | 19 | // Get singleton instance 20 | Logger &Logger::getInstance() 21 | { 22 | static Logger instance; 23 | return instance; 24 | } 25 | 26 | // Set the current log level 27 | void Logger::setLogLevel(LogLevel level) 28 | { 29 | logLevel = level; 30 | } 31 | 32 | // Get the current log level 33 | LogLevel Logger::getLogLevel() const 34 | { 35 | return logLevel; 36 | } 37 | 38 | // Get formatted timestamp with milliseconds 39 | std::string Logger::getTimestamp() const 40 | { 41 | auto now = std::chrono::system_clock::now(); 42 | auto now_ms = std::chrono::duration_cast( 43 | now.time_since_epoch()) 44 | .count() % 45 | 1000; 46 | auto now_c = std::chrono::system_clock::to_time_t(now); 47 | std::tm now_tm; 48 | 49 | #ifdef _WIN32 50 | localtime_s(&now_tm, &now_c); 51 | #else 52 | now_tm = *localtime(&now_c); 53 | #endif 54 | 55 | std::ostringstream oss; 56 | oss << std::put_time(&now_tm, "%H:%M:%S") << "." 57 | << std::setfill('0') << std::setw(3) << now_ms; 58 | return oss.str(); 59 | } 60 | 61 | // Get string representation of log level 62 | std::string Logger::getLevelString(LogLevel level) const 63 | { 64 | switch (level) 65 | { 66 | case LogLevel::Debug: 67 | return "DEBUG"; 68 | case LogLevel::Info: 69 | return "INFO"; 70 | case LogLevel::Warning: 71 | return "WARN"; 72 | case LogLevel::Error: 73 | return "ERROR"; 74 | default: 75 | return "NONE"; 76 | } 77 | } 78 | 79 | // Apply color to text based on log level 80 | std::string Logger::colorize(const std::string &text, LogLevel level) const 81 | { 82 | if (!useColorOutput) 83 | return text; 84 | 85 | switch (level) 86 | { 87 | case LogLevel::Debug: 88 | return ANSI_BLUE + text + ANSI_RESET; 89 | case LogLevel::Info: 90 | return ANSI_GREEN + text + ANSI_RESET; 91 | case LogLevel::Warning: 92 | return ANSI_YELLOW + text + ANSI_RESET; 93 | case LogLevel::Error: 94 | return ANSI_RED + text + ANSI_RESET; 95 | default: 96 | return text; 97 | } 98 | } 99 | 100 | // Initialize file logging 101 | void Logger::initFileLogging(const std::filesystem::path &logFilePath, bool clearExisting) 102 | { 103 | std::lock_guard lock(logMutex); 104 | 105 | // Create the directory if it doesn't exist 106 | if (!std::filesystem::exists(logFilePath.parent_path())) 107 | { 108 | std::filesystem::create_directories(logFilePath.parent_path()); 109 | } 110 | 111 | // Open the log file with appropriate mode 112 | auto openMode = std::ios::out; 113 | if (clearExisting) 114 | { 115 | openMode |= std::ios::trunc; // Clear the file 116 | } 117 | else 118 | { 119 | openMode |= std::ios::app; // Append to the file 120 | } 121 | 122 | // Close the file if it's already open 123 | if (logFile.is_open()) 124 | { 125 | logFile.close(); 126 | } 127 | 128 | logFile.open(logFilePath, openMode); 129 | 130 | if (logFile.is_open()) 131 | { 132 | logToFile = true; 133 | 134 | // Log the start of the session 135 | auto now = std::chrono::system_clock::now(); 136 | auto now_c = std::chrono::system_clock::to_time_t(now); 137 | std::tm now_tm; 138 | 139 | #ifdef _WIN32 140 | localtime_s(&now_tm, &now_c); 141 | #else 142 | now_tm = *localtime(&now_c); 143 | #endif 144 | 145 | logFile << "==================================================================" << std::endl; 146 | logFile << "Log session started at " << std::put_time(&now_tm, "%Y-%m-%d %H:%M:%S") << std::endl; 147 | logFile << "==================================================================" << std::endl; 148 | logFile.flush(); // Ensure the header is written immediately 149 | } 150 | else 151 | { 152 | std::cerr << "Failed to open log file: " << logFilePath.string() << std::endl; 153 | logToFile = false; 154 | } 155 | } 156 | 157 | // Log a debug message 158 | void Logger::debug(const std::string &component, const std::string &message) 159 | { 160 | if (logLevel <= LogLevel::Debug) 161 | { 162 | log(LogLevel::Debug, component, message); 163 | } 164 | } 165 | 166 | // Log an info message 167 | void Logger::info(const std::string &component, const std::string &message) 168 | { 169 | if (logLevel <= LogLevel::Info) 170 | { 171 | log(LogLevel::Info, component, message); 172 | } 173 | } 174 | 175 | // Log a warning message 176 | void Logger::warning(const std::string &component, const std::string &message) 177 | { 178 | if (logLevel <= LogLevel::Warning) 179 | { 180 | log(LogLevel::Warning, component, message); 181 | } 182 | } 183 | 184 | // Log an error message 185 | void Logger::error(const std::string &component, const std::string &message) 186 | { 187 | if (logLevel <= LogLevel::Error) 188 | { 189 | log(LogLevel::Error, component, message); 190 | } 191 | } 192 | 193 | // Internal logging implementation 194 | void Logger::log(LogLevel level, const std::string &component, const std::string &message) 195 | { 196 | std::lock_guard lock(logMutex); 197 | 198 | std::string timestamp = getTimestamp(); 199 | std::string levelStr = getLevelString(level); 200 | 201 | // Format the log message 202 | std::stringstream formatted; 203 | formatted << "[" << timestamp << "] " 204 | << "[" << levelStr << "] " 205 | << "[" << component << "] " 206 | << message; 207 | 208 | std::string consoleOutput = formatted.str(); 209 | std::string fileOutput = formatted.str(); 210 | 211 | #if !defined(NDEBUG) || !defined(_WIN32) 212 | // Apply color for console output 213 | std::cout << colorize(consoleOutput, level) << std::endl; 214 | #endif 215 | 216 | // Write to log file if enabled (without color codes) 217 | if (logToFile && logFile.is_open()) 218 | { 219 | logFile << fileOutput << std::endl; 220 | logFile.flush(); // Ensure immediate writing 221 | } 222 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | CMakeLists.txt.user 2 | CMakeCache.txt 3 | CMakeFiles 4 | CMakeScripts 5 | Testing 6 | Makefile 7 | cmake_install.cmake 8 | install_manifest.txt 9 | compile_commands.json 10 | CTestTestfile.cmake 11 | _deps 12 | CMakeUserPresets.json 13 | ## Ignore Visual Studio temporary files, build results, and 14 | ## files generated by popular Visual Studio add-ons. 15 | ## 16 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 17 | 18 | # User-specific files 19 | *.rsuser 20 | *.suo 21 | *.user 22 | *.userosscache 23 | *.sln.docstates 24 | 25 | # User-specific files (MonoDevelop/Xamarin Studio) 26 | *.userprefs 27 | 28 | # Mono auto generated files 29 | mono_crash.* 30 | 31 | # Build results 32 | [Dd]ebug/ 33 | [Dd]ebugPublic/ 34 | [Rr]elease/ 35 | [Rr]eleases/ 36 | x64/ 37 | x86/ 38 | [Ww][Ii][Nn]32/ 39 | [Aa][Rr][Mm]/ 40 | [Aa][Rr][Mm]64/ 41 | bld/ 42 | [Bb]in/ 43 | [Oo]bj/ 44 | [Ll]og/ 45 | [Ll]ogs/ 46 | 47 | # Visual Studio 2015/2017 cache/options directory 48 | .vs/ 49 | # Uncomment if you have tasks that create the project's static files in wwwroot 50 | #wwwroot/ 51 | 52 | # Visual Studio 2017 auto generated files 53 | Generated\ Files/ 54 | 55 | # MSTest test Results 56 | [Tt]est[Rr]esult*/ 57 | [Bb]uild[Ll]og.* 58 | 59 | # NUnit 60 | *.VisualState.xml 61 | TestResult.xml 62 | nunit-*.xml 63 | 64 | # Build Results of an ATL Project 65 | [Dd]ebugPS/ 66 | [Rr]eleasePS/ 67 | dlldata.c 68 | 69 | # Benchmark Results 70 | BenchmarkDotNet.Artifacts/ 71 | 72 | # .NET Core 73 | project.lock.json 74 | project.fragment.lock.json 75 | artifacts/ 76 | 77 | # ASP.NET Scaffolding 78 | ScaffoldingReadMe.txt 79 | 80 | # StyleCop 81 | StyleCopReport.xml 82 | 83 | # Files built by Visual Studio 84 | *_i.c 85 | *_p.c 86 | *_h.h 87 | *.ilk 88 | *.meta 89 | *.obj 90 | *.iobj 91 | *.pch 92 | *.pdb 93 | *.ipdb 94 | *.pgc 95 | *.pgd 96 | *.rsp 97 | # but not Directory.Build.rsp, as it configures directory-level build defaults 98 | !Directory.Build.rsp 99 | *.sbr 100 | *.tlb 101 | *.tli 102 | *.tlh 103 | *.tmp 104 | *.tmp_proj 105 | *_wpftmp.csproj 106 | *.log 107 | *.tlog 108 | *.vspscc 109 | *.vssscc 110 | .builds 111 | *.pidb 112 | *.svclog 113 | *.scc 114 | 115 | # Chutzpah Test files 116 | _Chutzpah* 117 | 118 | # Visual C++ cache files 119 | ipch/ 120 | *.aps 121 | *.ncb 122 | *.opendb 123 | *.opensdf 124 | *.sdf 125 | *.cachefile 126 | *.VC.db 127 | *.VC.VC.opendb 128 | 129 | # Visual Studio profiler 130 | *.psess 131 | *.vsp 132 | *.vspx 133 | *.sap 134 | 135 | # Visual Studio Trace Files 136 | *.e2e 137 | 138 | # TFS 2012 Local Workspace 139 | $tf/ 140 | 141 | # Guidance Automation Toolkit 142 | *.gpState 143 | 144 | # ReSharper is a .NET coding add-in 145 | _ReSharper*/ 146 | *.[Rr]e[Ss]harper 147 | *.DotSettings.user 148 | 149 | # TeamCity is a build add-in 150 | _TeamCity* 151 | 152 | # DotCover is a Code Coverage Tool 153 | *.dotCover 154 | 155 | # AxoCover is a Code Coverage Tool 156 | .axoCover/* 157 | !.axoCover/settings.json 158 | 159 | # Coverlet is a free, cross platform Code Coverage Tool 160 | coverage*.json 161 | coverage*.xml 162 | coverage*.info 163 | 164 | # Visual Studio code coverage results 165 | *.coverage 166 | *.coveragexml 167 | 168 | # NCrunch 169 | _NCrunch_* 170 | .*crunch*.local.xml 171 | nCrunchTemp_* 172 | 173 | # MightyMoose 174 | *.mm.* 175 | AutoTest.Net/ 176 | 177 | # Web workbench (sass) 178 | .sass-cache/ 179 | 180 | # Installshield output folder 181 | [Ee]xpress/ 182 | 183 | # DocProject is a documentation generator add-in 184 | DocProject/buildhelp/ 185 | DocProject/Help/*.HxT 186 | DocProject/Help/*.HxC 187 | DocProject/Help/*.hhc 188 | DocProject/Help/*.hhk 189 | DocProject/Help/*.hhp 190 | DocProject/Help/Html2 191 | DocProject/Help/html 192 | 193 | # Click-Once directory 194 | publish/ 195 | 196 | # Publish Web Output 197 | *.[Pp]ublish.xml 198 | *.azurePubxml 199 | # Note: Comment the next line if you want to checkin your web deploy settings, 200 | # but database connection strings (with potential passwords) will be unencrypted 201 | *.pubxml 202 | *.publishproj 203 | 204 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 205 | # checkin your Azure Web App publish settings, but sensitive information contained 206 | # in these scripts will be unencrypted 207 | PublishScripts/ 208 | 209 | # NuGet Packages 210 | *.nupkg 211 | # NuGet Symbol Packages 212 | *.snupkg 213 | # The packages folder can be ignored because of Package Restore 214 | **/[Pp]ackages/* 215 | # except build/, which is used as an MSBuild target. 216 | !**/[Pp]ackages/build/ 217 | # Uncomment if necessary however generally it will be regenerated when needed 218 | #!**/[Pp]ackages/repositories.config 219 | # NuGet v3's project.json files produces more ignorable files 220 | *.nuget.props 221 | *.nuget.targets 222 | 223 | # Microsoft Azure Build Output 224 | csx/ 225 | *.build.csdef 226 | 227 | # Microsoft Azure Emulator 228 | ecf/ 229 | rcf/ 230 | 231 | # Windows Store app package directories and files 232 | AppPackages/ 233 | BundleArtifacts/ 234 | Package.StoreAssociation.xml 235 | _pkginfo.txt 236 | *.appx 237 | *.appxbundle 238 | *.appxupload 239 | 240 | # Visual Studio cache files 241 | # files ending in .cache can be ignored 242 | *.[Cc]ache 243 | # but keep track of directories ending in .cache 244 | !?*.[Cc]ache/ 245 | 246 | # Others 247 | ClientBin/ 248 | ~$* 249 | *~ 250 | *.dbmdl 251 | *.dbproj.schemaview 252 | *.jfm 253 | *.pfx 254 | *.publishsettings 255 | orleans.codegen.cs 256 | 257 | # Including strong name files can present a security risk 258 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 259 | #*.snk 260 | 261 | # Since there are multiple workflows, uncomment next line to ignore bower_components 262 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 263 | #bower_components/ 264 | 265 | # RIA/Silverlight projects 266 | Generated_Code/ 267 | 268 | # Backup & report files from converting an old project file 269 | # to a newer Visual Studio version. Backup files are not needed, 270 | # because we have git ;-) 271 | _UpgradeReport_Files/ 272 | Backup*/ 273 | UpgradeLog*.XML 274 | UpgradeLog*.htm 275 | ServiceFabricBackup/ 276 | *.rptproj.bak 277 | 278 | # SQL Server files 279 | *.mdf 280 | *.ldf 281 | *.ndf 282 | 283 | # Business Intelligence projects 284 | *.rdl.data 285 | *.bim.layout 286 | *.bim_*.settings 287 | *.rptproj.rsuser 288 | *- [Bb]ackup.rdl 289 | *- [Bb]ackup ([0-9]).rdl 290 | *- [Bb]ackup ([0-9][0-9]).rdl 291 | 292 | # Microsoft Fakes 293 | FakesAssemblies/ 294 | 295 | # GhostDoc plugin setting file 296 | *.GhostDoc.xml 297 | 298 | # Node.js Tools for Visual Studio 299 | .ntvs_analysis.dat 300 | node_modules/ 301 | 302 | # Visual Studio 6 build log 303 | *.plg 304 | 305 | # Visual Studio 6 workspace options file 306 | *.opt 307 | 308 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 309 | *.vbw 310 | 311 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 312 | *.vbp 313 | 314 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 315 | *.dsw 316 | *.dsp 317 | 318 | # Visual Studio 6 technical files 319 | *.ncb 320 | *.aps 321 | 322 | # Visual Studio LightSwitch build output 323 | **/*.HTMLClient/GeneratedArtifacts 324 | **/*.DesktopClient/GeneratedArtifacts 325 | **/*.DesktopClient/ModelManifest.xml 326 | **/*.Server/GeneratedArtifacts 327 | **/*.Server/ModelManifest.xml 328 | _Pvt_Extensions 329 | 330 | # Paket dependency manager 331 | .paket/paket.exe 332 | paket-files/ 333 | 334 | # FAKE - F# Make 335 | .fake/ 336 | 337 | # CodeRush personal settings 338 | .cr/personal 339 | 340 | # Python Tools for Visual Studio (PTVS) 341 | __pycache__/ 342 | *.pyc 343 | 344 | # Cake - Uncomment if you are using it 345 | # tools/** 346 | # !tools/packages.config 347 | 348 | # Tabs Studio 349 | *.tss 350 | 351 | # Telerik's JustMock configuration file 352 | *.jmconfig 353 | 354 | # BizTalk build output 355 | *.btp.cs 356 | *.btm.cs 357 | *.odx.cs 358 | *.xsd.cs 359 | 360 | # OpenCover UI analysis results 361 | OpenCover/ 362 | 363 | # Azure Stream Analytics local run output 364 | ASALocalRun/ 365 | 366 | # MSBuild Binary and Structured Log 367 | *.binlog 368 | 369 | # NVidia Nsight GPU debugger configuration file 370 | *.nvuser 371 | 372 | # MFractors (Xamarin productivity tool) working folder 373 | .mfractor/ 374 | 375 | # Local History for Visual Studio 376 | .localhistory/ 377 | 378 | # Visual Studio History (VSHistory) files 379 | .vshistory/ 380 | 381 | # BeatPulse healthcheck temp database 382 | healthchecksdb 383 | 384 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 385 | MigrationBackup/ 386 | 387 | # Ionide (cross platform F# VS Code tools) working folder 388 | .ionide/ 389 | 390 | # Fody - auto-generated XML schema 391 | FodyWeavers.xsd 392 | 393 | # VS Code files for those working on multiple tools 394 | .vscode/ 395 | !.vscode/settings.json 396 | !.vscode/tasks.json 397 | !.vscode/launch.json 398 | !.vscode/extensions.json 399 | *.code-workspace 400 | 401 | # Local History for Visual Studio Code 402 | .history/ 403 | 404 | # Windows Installer files from build outputs 405 | *.cab 406 | *.msi 407 | *.msix 408 | *.msm 409 | *.msp 410 | 411 | # JetBrains Rider 412 | *.sln.iml 413 | 414 | *.sln 415 | *.vcxproj 416 | *.vcxproj.filters 417 | build/ 418 | out/ 419 | 420 | include/version.h 421 | -------------------------------------------------------------------------------- /src/config.cpp: -------------------------------------------------------------------------------- 1 | #include "config.h" 2 | 3 | Config &Config::getInstance() 4 | { 5 | static Config instance; 6 | return instance; 7 | } 8 | 9 | std::filesystem::path Config::getConfigDirectory() 10 | { 11 | std::filesystem::path configDir; 12 | 13 | #ifdef _WIN32 14 | // Windows: %APPDATA%/Presence For Plex 15 | char *appData = nullptr; 16 | size_t appDataSize = 0; 17 | _dupenv_s(&appData, &appDataSize, "APPDATA"); 18 | if (appData) 19 | { 20 | configDir = std::filesystem::path(appData) / "Presence For Plex"; 21 | free(appData); 22 | } 23 | #else 24 | // Unix/Linux/macOS: $XDG_CONFIG_DIR/presence-for-plex or ~/.config/presence-for-plex 25 | char *xdgConfig = getenv("XDG_CONFIG_DIR"); 26 | char *home = getenv("HOME"); 27 | 28 | if (xdgConfig) 29 | { 30 | configDir = std::filesystem::path(xdgConfig) / "presence-for-plex"; 31 | } 32 | else if (home) 33 | { 34 | configDir = std::filesystem::path(home) / ".config" / "presence-for-plex"; 35 | } 36 | #endif 37 | 38 | // Create directory if it doesn't exist 39 | if (!std::filesystem::exists(configDir)) 40 | { 41 | std::filesystem::create_directories(configDir); 42 | } 43 | 44 | return configDir; 45 | } 46 | 47 | Config::Config() 48 | { 49 | configPath = getConfigDirectory() / "config.yaml"; 50 | loadConfig(); 51 | } 52 | 53 | Config::~Config() 54 | { 55 | saveConfig(); 56 | } 57 | 58 | bool Config::loadConfig() 59 | { 60 | if (!std::filesystem::exists(configPath)) 61 | { 62 | LOG_INFO("Config", "Config file does not exist, creating default"); 63 | return saveConfig(); 64 | } 65 | 66 | try 67 | { 68 | YAML::Node loadedConfig = YAML::LoadFile(configPath.string()); 69 | 70 | // Thread-safe update of configuration 71 | { 72 | std::unique_lock lock(mutex); 73 | loadFromYaml(loadedConfig); 74 | } 75 | 76 | LOG_INFO("Config", "Config loaded successfully"); 77 | LOG_DEBUG("Config", "Found " + std::to_string(plexServers.size()) + " Plex servers in config"); 78 | return true; 79 | } 80 | catch (const std::exception &e) 81 | { 82 | LOG_ERROR("Config", "Error loading config: " + std::string(e.what())); 83 | return false; 84 | } 85 | } 86 | 87 | bool Config::saveConfig() 88 | { 89 | try 90 | { 91 | // Create the config directory if it doesn't exist 92 | std::filesystem::path configDir = configPath.parent_path(); 93 | if (!std::filesystem::exists(configDir)) 94 | { 95 | std::filesystem::create_directories(configDir); 96 | } 97 | 98 | // Build YAML data with thread safety 99 | YAML::Node configToSave; 100 | { 101 | std::shared_lock lock(mutex); 102 | configToSave = saveToYaml(); 103 | } 104 | 105 | // Write to file 106 | std::ofstream ofs(configPath); 107 | if (!ofs) 108 | { 109 | LOG_ERROR("Config", "Failed to open config file for writing"); 110 | return false; 111 | } 112 | 113 | ofs << configToSave; 114 | ofs.close(); 115 | 116 | LOG_INFO("Config", "Config saved successfully"); 117 | return true; 118 | } 119 | catch (const std::exception &e) 120 | { 121 | LOG_ERROR("Config", "Error saving config: " + std::string(e.what())); 122 | return false; 123 | } 124 | } 125 | 126 | void Config::loadFromYaml(const YAML::Node &config) 127 | { 128 | // General settings 129 | logLevel = config["log_level"] ? config["log_level"].as() : 1; 130 | 131 | // Plex auth 132 | if (config["plex"]) 133 | { 134 | const auto &plex = config["plex"]; 135 | plexAuthToken = plex["auth_token"] ? plex["auth_token"].as() : ""; 136 | plexClientIdentifier = plex["client_identifier"] ? plex["client_identifier"].as() : ""; 137 | plexUsername = plex["username"] ? plex["username"].as() : ""; 138 | } 139 | 140 | // Plex servers 141 | plexServers.clear(); 142 | if (config["plex_servers"] && config["plex_servers"].IsSequence()) 143 | { 144 | for (const auto &server : config["plex_servers"]) 145 | { 146 | std::string name = server["name"] ? server["name"].as() : ""; 147 | std::string clientId = server["client_identifier"] ? server["client_identifier"].as() : ""; 148 | std::string localUri = server["local_uri"] ? server["local_uri"].as() : ""; 149 | std::string publicUri = server["public_uri"] ? server["public_uri"].as() : ""; 150 | std::string accessToken = server["access_token"] ? server["access_token"].as() : ""; 151 | bool owned = server["owned"] ? server["owned"].as() : false; 152 | 153 | auto serverPtr = std::make_shared(); 154 | serverPtr->name = name; 155 | serverPtr->clientIdentifier = clientId; 156 | serverPtr->localUri = localUri; 157 | serverPtr->publicUri = publicUri; 158 | serverPtr->accessToken = accessToken; 159 | serverPtr->owned = owned; 160 | 161 | plexServers[clientId] = serverPtr; 162 | } 163 | } 164 | 165 | // Discord settings 166 | if (config["discord"]) 167 | { 168 | const auto &discord = config["discord"]; 169 | discordClientId = discord["client_id"] ? discord["client_id"].as() : discordClientId.load(); 170 | } 171 | 172 | // TMDB API key 173 | if (config["tmdb_access_token"]) 174 | { 175 | tmdbAccessToken = config["tmdb_access_token"].as(); 176 | } 177 | } 178 | 179 | // Version information 180 | std::string Config::getVersionString() const 181 | { 182 | return VERSION_STRING; 183 | } 184 | 185 | int Config::getVersionMajor() const 186 | { 187 | return VERSION_MAJOR; 188 | } 189 | 190 | int Config::getVersionMinor() const 191 | { 192 | return VERSION_MINOR; 193 | } 194 | 195 | int Config::getVersionPatch() const 196 | { 197 | return VERSION_PATCH; 198 | } 199 | 200 | YAML::Node Config::saveToYaml() const 201 | { 202 | YAML::Node config; 203 | 204 | // General settings 205 | config["log_level"] = logLevel.load(); 206 | 207 | // Plex auth 208 | YAML::Node plex; 209 | plex["auth_token"] = plexAuthToken; 210 | plex["client_identifier"] = plexClientIdentifier; 211 | plex["username"] = plexUsername; 212 | config["plex"] = plex; 213 | 214 | // Plex servers 215 | YAML::Node servers; 216 | for (const auto &[id, server] : plexServers) 217 | { 218 | YAML::Node serverNode; 219 | serverNode["name"] = server->name; 220 | serverNode["client_identifier"] = server->clientIdentifier; 221 | serverNode["local_uri"] = server->localUri; 222 | serverNode["public_uri"] = server->publicUri; 223 | serverNode["access_token"] = server->accessToken; 224 | serverNode["owned"] = server->owned; 225 | servers.push_back(serverNode); 226 | } 227 | config["plex_servers"] = servers; 228 | 229 | // Discord settings 230 | YAML::Node discord; 231 | discord["client_id"] = discordClientId.load(); 232 | config["discord"] = discord; 233 | 234 | // Version information 235 | YAML::Node version; 236 | version["major"] = VERSION_MAJOR; 237 | version["minor"] = VERSION_MINOR; 238 | version["patch"] = VERSION_PATCH; 239 | version["string"] = VERSION_STRING; 240 | config["version"] = version; 241 | 242 | // TMDB API key 243 | config["tmdb_access_token"] = tmdbAccessToken; 244 | 245 | return config; 246 | } 247 | 248 | // General settings 249 | int Config::getLogLevel() const 250 | { 251 | return logLevel.load(); 252 | } 253 | 254 | void Config::setLogLevel(int level) 255 | { 256 | logLevel.store(level); 257 | } 258 | 259 | // Plex settings 260 | std::string Config::getPlexAuthToken() const 261 | { 262 | std::shared_lock lock(mutex); 263 | return plexAuthToken; 264 | } 265 | 266 | void Config::setPlexAuthToken(const std::string &token) 267 | { 268 | std::unique_lock lock(mutex); 269 | plexAuthToken = token; 270 | } 271 | 272 | std::string Config::getPlexClientIdentifier() const 273 | { 274 | std::shared_lock lock(mutex); 275 | return plexClientIdentifier; 276 | } 277 | 278 | void Config::setPlexClientIdentifier(const std::string &id) 279 | { 280 | std::unique_lock lock(mutex); 281 | plexClientIdentifier = id; 282 | } 283 | 284 | std::string Config::getPlexUsername() const 285 | { 286 | std::shared_lock lock(mutex); 287 | return plexUsername; 288 | } 289 | 290 | void Config::setPlexUsername(const std::string &username) 291 | { 292 | std::unique_lock lock(mutex); 293 | plexUsername = username; 294 | } 295 | 296 | const std::map> &Config::getPlexServers() const 297 | { 298 | std::shared_lock lock(mutex); 299 | return plexServers; 300 | } 301 | 302 | void Config::addPlexServer(const std::string &name, const std::string &clientId, 303 | const std::string &localUri, const std::string &publicUri, 304 | const std::string &accessToken, bool owned) 305 | { 306 | std::unique_lock lock(mutex); 307 | 308 | // Check if this server already exists 309 | auto it = plexServers.find(clientId); 310 | if (it != plexServers.end()) 311 | { 312 | // Update existing server 313 | it->second->name = name; 314 | it->second->localUri = localUri; 315 | it->second->publicUri = publicUri; 316 | it->second->accessToken = accessToken; 317 | it->second->owned = owned; 318 | return; 319 | } 320 | 321 | // Add new server 322 | auto server = std::make_shared(); 323 | server->name = name; 324 | server->clientIdentifier = clientId; 325 | server->localUri = localUri; 326 | server->publicUri = publicUri; 327 | server->accessToken = accessToken; 328 | server->owned = owned; 329 | plexServers[clientId] = server; 330 | } 331 | 332 | void Config::clearPlexServers() 333 | { 334 | std::unique_lock lock(mutex); 335 | plexServers.clear(); 336 | } 337 | 338 | // Discord settings 339 | uint64_t Config::getDiscordClientId() const 340 | { 341 | return discordClientId.load(); 342 | } 343 | 344 | void Config::setDiscordClientId(uint64_t id) 345 | { 346 | discordClientId.store(id); 347 | } 348 | 349 | std::string Config::getTMDBAccessToken() const 350 | { 351 | std::shared_lock lock(mutex); 352 | return tmdbAccessToken; 353 | } 354 | 355 | void Config::setTMDBAccessToken(const std::string &token) 356 | { 357 | std::unique_lock lock(mutex); 358 | tmdbAccessToken = token; 359 | } 360 | -------------------------------------------------------------------------------- /src/http_client.cpp: -------------------------------------------------------------------------------- 1 | #include "http_client.h" 2 | #include "thread_utils.h" 3 | 4 | HttpClient::HttpClient() 5 | { 6 | curl_global_init(CURL_GLOBAL_ALL); 7 | m_curl = curl_easy_init(); 8 | LOG_DEBUG("HttpClient", "HttpClient initialized"); 9 | } 10 | 11 | HttpClient::~HttpClient() 12 | { 13 | if (m_sseRunning) 14 | { 15 | stopSSE(); 16 | } 17 | 18 | if (m_curl) 19 | { 20 | curl_easy_cleanup(m_curl); 21 | m_curl = nullptr; 22 | } 23 | 24 | curl_global_cleanup(); 25 | LOG_DEBUG("HttpClient", "HttpClient object destroyed"); 26 | } 27 | 28 | size_t HttpClient::writeCallback(char *ptr, size_t size, size_t nmemb, void *userdata) 29 | { 30 | std::string *response = static_cast(userdata); 31 | size_t totalSize = size * nmemb; 32 | response->append(ptr, totalSize); 33 | return totalSize; 34 | } 35 | 36 | struct curl_slist *HttpClient::createHeaderList(const std::map &headers) 37 | { 38 | struct curl_slist *curl_headers = NULL; 39 | for (const auto &[key, value] : headers) 40 | { 41 | std::string header = key + ": " + value; 42 | curl_headers = curl_slist_append(curl_headers, header.c_str()); 43 | } 44 | LOG_DEBUG_STREAM("HttpClient", "Created header list with " << headers.size() << " headers"); 45 | return curl_headers; 46 | } 47 | 48 | bool HttpClient::setupCommonOptions(const std::string &url, const std::map &headers) 49 | { 50 | if (!m_curl) 51 | { 52 | LOG_ERROR("HttpClient", "CURL not initialized"); 53 | return false; 54 | } 55 | 56 | curl_easy_reset(m_curl); 57 | curl_easy_setopt(m_curl, CURLOPT_URL, url.c_str()); 58 | curl_easy_setopt(m_curl, CURLOPT_TIMEOUT, 10L); 59 | 60 | LOG_DEBUG_STREAM("HttpClient", "Set up request to URL: " << url); 61 | return true; 62 | } 63 | 64 | bool HttpClient::checkResponse(CURLcode res) 65 | { 66 | if (res != CURLE_OK) 67 | { 68 | LOG_ERROR("HttpClient", "Request failed: " + std::string(curl_easy_strerror(res))); 69 | return false; 70 | } 71 | 72 | long response_code; 73 | curl_easy_getinfo(m_curl, CURLINFO_RESPONSE_CODE, &response_code); 74 | 75 | if (response_code < 200 || response_code >= 300) 76 | { 77 | LOG_ERROR("HttpClient", "Request failed with HTTP status code: " + std::to_string(response_code)); 78 | return false; 79 | } 80 | 81 | LOG_DEBUG_STREAM("HttpClient", "Request successful with status code: " << response_code); 82 | return true; 83 | } 84 | 85 | bool HttpClient::get(const std::string &url, const std::map &headers, std::string &response) 86 | { 87 | LOG_INFO_STREAM("HttpClient", "Sending GET request to: " << url); 88 | 89 | if (!setupCommonOptions(url, headers)) 90 | { 91 | return false; 92 | } 93 | 94 | curl_easy_setopt(m_curl, CURLOPT_WRITEFUNCTION, writeCallback); 95 | curl_easy_setopt(m_curl, CURLOPT_WRITEDATA, &response); 96 | 97 | struct curl_slist *curl_headers = createHeaderList(headers); 98 | curl_easy_setopt(m_curl, CURLOPT_HTTPHEADER, curl_headers); 99 | 100 | LOG_DEBUG("HttpClient", "Executing GET request"); 101 | CURLcode res = curl_easy_perform(m_curl); 102 | curl_slist_free_all(curl_headers); 103 | 104 | bool success = checkResponse(res); 105 | if (success) 106 | { 107 | LOG_DEBUG_STREAM("HttpClient", "GET request succeeded with response size: " << response.size() << " bytes"); 108 | } 109 | return success; 110 | } 111 | 112 | bool HttpClient::post(const std::string &url, const std::map &headers, 113 | const std::string &body, std::string &response) 114 | { 115 | LOG_INFO_STREAM("HttpClient", "Sending POST request to: " << url); 116 | LOG_DEBUG_STREAM("HttpClient", "POST body size: " << body.size() << " bytes"); 117 | 118 | if (!setupCommonOptions(url, headers)) 119 | { 120 | return false; 121 | } 122 | 123 | curl_easy_setopt(m_curl, CURLOPT_POST, 1L); 124 | curl_easy_setopt(m_curl, CURLOPT_POSTFIELDS, body.c_str()); 125 | curl_easy_setopt(m_curl, CURLOPT_WRITEFUNCTION, writeCallback); 126 | curl_easy_setopt(m_curl, CURLOPT_WRITEDATA, &response); 127 | 128 | struct curl_slist *curl_headers = createHeaderList(headers); 129 | curl_easy_setopt(m_curl, CURLOPT_HTTPHEADER, curl_headers); 130 | 131 | LOG_DEBUG("HttpClient", "Executing POST request"); 132 | CURLcode res = curl_easy_perform(m_curl); 133 | curl_slist_free_all(curl_headers); 134 | 135 | bool success = checkResponse(res); 136 | if (success) 137 | { 138 | LOG_DEBUG_STREAM("HttpClient", "POST request succeeded with response size: " << response.size() << " bytes"); 139 | } 140 | return success; 141 | } 142 | 143 | size_t HttpClient::sseCallback(char *ptr, size_t size, size_t nmemb, void *userdata) 144 | { 145 | HttpClient *client = static_cast(userdata); 146 | size_t total_size = size * nmemb; 147 | 148 | client->m_sseBuffer.append(ptr, total_size); 149 | LOG_DEBUG_STREAM("HttpClient", "SSE received " << total_size << " bytes"); 150 | 151 | // Process events in buffer 152 | size_t pos; 153 | while ((pos = client->m_sseBuffer.find("\n\n")) != std::string::npos) 154 | { 155 | std::string event = client->m_sseBuffer.substr(0, pos); 156 | client->m_sseBuffer.erase(0, pos + 2); // +2 for \n\n 157 | 158 | size_t data_pos = event.find("data: "); 159 | if (data_pos != std::string::npos) 160 | { 161 | std::string data = event.substr(data_pos + 6); 162 | LOG_DEBUG_STREAM("HttpClient", "SSE event received, data size: " << data.size() << " bytes"); 163 | if (client->m_eventCallback) 164 | { 165 | LOG_DEBUG("HttpClient", "Calling SSE event callback"); 166 | client->m_eventCallback(data); 167 | } 168 | } 169 | } 170 | 171 | return total_size; 172 | } 173 | 174 | int HttpClient::sseCallbackProgress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, 175 | curl_off_t ultotal, curl_off_t ulnow) 176 | { 177 | HttpClient *httpClient = static_cast(clientp); 178 | if (httpClient->m_stopFlag) 179 | { 180 | LOG_DEBUG("HttpClient", "SSE connection termination requested"); 181 | return 1; // Abort transfer 182 | } 183 | return 0; // Continue transfer 184 | } 185 | 186 | bool HttpClient::stopSSE() 187 | { 188 | m_stopFlag = true; 189 | LOG_INFO("HttpClient", "Requesting SSE connection termination"); 190 | 191 | if (m_sseThread.joinable()) 192 | { 193 | std::unique_lock lock(m_sseMutex); 194 | if (m_sseRunning) 195 | { 196 | LOG_INFO("HttpClient", "Waiting for SSE thread to stop"); 197 | 198 | bool threadStopped = m_sseCondVar.wait_for(lock, std::chrono::seconds(5), 199 | [this] { return !m_sseRunning; }); 200 | 201 | if (!threadStopped) { 202 | LOG_WARNING("HttpClient", "SSE thread did not respond to stop request in time"); 203 | } 204 | } 205 | 206 | lock.unlock(); 207 | ThreadUtils::joinWithTimeout(m_sseThread, std::chrono::seconds(2), "SSE thread"); 208 | } 209 | 210 | LOG_INFO("HttpClient", "SSE thread stopped successfully"); 211 | return true; 212 | } 213 | 214 | bool HttpClient::startSSE(const std::string &url, const std::map &headers, 215 | EventCallback callback) 216 | { 217 | LOG_INFO_STREAM("HttpClient", "Starting SSE connection to: " << url); 218 | 219 | { 220 | std::lock_guard lock(m_sseMutex); 221 | m_stopFlag = false; 222 | m_sseRunning = true; 223 | m_eventCallback = callback; 224 | m_sseBuffer.clear(); 225 | } 226 | 227 | m_sseThread = std::thread([this, url, headers]() 228 | { 229 | LOG_INFO("HttpClient", "SSE thread starting"); 230 | 231 | try { 232 | CURL* sse_curl = curl_easy_init(); 233 | if (!sse_curl) { 234 | LOG_ERROR("HttpClient", "Failed to initialize CURL for SSE connection"); 235 | std::lock_guard lock(m_sseMutex); 236 | m_sseRunning = false; 237 | m_sseCondVar.notify_all(); 238 | return; 239 | } 240 | LOG_DEBUG("HttpClient", "CURL initialized for SSE connection"); 241 | 242 | int retryCount = 0; 243 | while (!m_stopFlag) { 244 | // Setup SSE connection 245 | curl_easy_reset(sse_curl); 246 | curl_easy_setopt(sse_curl, CURLOPT_URL, url.c_str()); 247 | curl_easy_setopt(sse_curl, CURLOPT_WRITEFUNCTION, sseCallback); 248 | curl_easy_setopt(sse_curl, CURLOPT_WRITEDATA, this); 249 | curl_easy_setopt(sse_curl, CURLOPT_TCP_NODELAY, 1L); 250 | 251 | // Setup progress monitoring for cancelation 252 | curl_easy_setopt(sse_curl, CURLOPT_XFERINFOFUNCTION, sseCallbackProgress); 253 | curl_easy_setopt(sse_curl, CURLOPT_XFERINFODATA, this); 254 | curl_easy_setopt(sse_curl, CURLOPT_NOPROGRESS, 0L); 255 | 256 | // Setup headers 257 | struct curl_slist *curl_headers = createHeaderList(headers); 258 | curl_headers = curl_slist_append(curl_headers, "Accept: text/event-stream"); 259 | curl_easy_setopt(sse_curl, CURLOPT_HTTPHEADER, curl_headers); 260 | 261 | if (m_stopFlag) { 262 | LOG_INFO("HttpClient", "SSE connection setup aborted due to stop request"); 263 | curl_slist_free_all(curl_headers); 264 | break; 265 | } 266 | 267 | // Perform request 268 | LOG_INFO_STREAM("HttpClient", "Establishing SSE connection, attempt #" << (retryCount + 1)); 269 | CURLcode res = curl_easy_perform(sse_curl); 270 | 271 | if (res == CURLE_ABORTED_BY_CALLBACK) { 272 | LOG_INFO("HttpClient", "SSE connection aborted by callback"); 273 | } else if (res != CURLE_OK) { 274 | retryCount++; 275 | LOG_WARNING_STREAM("HttpClient", "SSE connection error: " << curl_easy_strerror(res) 276 | << ", retry count: " << retryCount); 277 | if (!m_stopFlag) { 278 | int retryDelay = (std::min)(5 * retryCount, 60); // Exponential backoff with max 60 seconds 279 | LOG_DEBUG_STREAM("HttpClient", "Retrying SSE connection in " << retryDelay << " seconds"); 280 | std::this_thread::sleep_for(std::chrono::seconds(retryDelay)); 281 | } 282 | } else { 283 | // Connection ended normally, reset retry count 284 | LOG_INFO("HttpClient", "SSE connection ended normally"); 285 | retryCount = 0; 286 | } 287 | 288 | curl_slist_free_all(curl_headers); 289 | 290 | if (m_stopFlag) { 291 | LOG_INFO("HttpClient", "Exiting SSE connection loop due to stop request"); 292 | break; 293 | } 294 | } 295 | 296 | if (sse_curl) { 297 | curl_easy_cleanup(sse_curl); 298 | LOG_DEBUG("HttpClient", "Cleaned up CURL handle for SSE"); 299 | } 300 | } 301 | catch (const std::exception& e) { 302 | LOG_ERROR("HttpClient", "Exception in SSE thread: " + std::string(e.what())); 303 | } 304 | catch (...) { 305 | LOG_ERROR("HttpClient", "Unknown exception in SSE thread"); 306 | } 307 | 308 | // Mark as not running and notify waiting threads 309 | { 310 | std::lock_guard lock(m_sseMutex); 311 | m_sseRunning = false; 312 | m_sseCondVar.notify_all(); 313 | } 314 | 315 | LOG_INFO("HttpClient", "SSE thread exiting"); }); 316 | 317 | LOG_DEBUG("HttpClient", "SSE thread started successfully"); 318 | return true; 319 | } -------------------------------------------------------------------------------- /src/application.cpp: -------------------------------------------------------------------------------- 1 | #include "application.h" 2 | #include "version.h" 3 | 4 | Application::Application() 5 | { 6 | setupLogging(); 7 | } 8 | 9 | void Application::setupLogging() 10 | { 11 | // Set up logging 12 | Logger::getInstance().setLogLevel(static_cast(Config::getInstance().getLogLevel())); 13 | Logger::getInstance().initFileLogging(Config::getConfigDirectory() / "log.txt", true); 14 | 15 | #ifndef NDEBUG 16 | Logger::getInstance().setLogLevel(LogLevel::Debug); 17 | #endif 18 | LOG_INFO("Application", "Presence For Plex starting up"); 19 | } 20 | 21 | void Application::setupDiscordCallbacks() 22 | { 23 | m_discord->setConnectedCallback([this]() 24 | { 25 | #ifdef _WIN32 26 | // Check if this is first launch by looking for auth token 27 | bool isFirstLaunch = Config::getInstance().getPlexAuthToken().empty(); 28 | if (isFirstLaunch) { 29 | m_trayIcon->setConnectionStatus("Status: Setup Required"); 30 | } else { 31 | m_trayIcon->setConnectionStatus("Status: Connecting to Plex..."); 32 | } 33 | #endif 34 | m_plex->init(); 35 | std::unique_lock lock(m_discordConnectMutex); 36 | m_discordConnectCv.notify_all(); 37 | }); 38 | 39 | m_discord->setDisconnectedCallback([this]() 40 | { 41 | m_plex->stop(); 42 | #ifdef _WIN32 43 | m_trayIcon->setConnectionStatus("Status: Waiting for Discord..."); 44 | #endif 45 | }); 46 | } 47 | 48 | bool Application::initialize() 49 | { 50 | try 51 | { 52 | m_plex = std::make_unique(); 53 | m_discord = std::make_unique(); 54 | #ifdef _WIN32 55 | m_trayIcon = std::make_unique("Presence For Plex"); 56 | #endif 57 | 58 | #ifdef _WIN32 59 | m_trayIcon->setExitCallback([this]() 60 | { 61 | LOG_INFO("Application", "Exit triggered from tray icon"); 62 | stop(); }); 63 | m_trayIcon->setUpdateCheckCallback([this]() 64 | { checkForUpdates(); }); 65 | m_trayIcon->show(); 66 | #endif 67 | 68 | setupDiscordCallbacks(); 69 | 70 | m_discord->start(); 71 | m_initialized = true; 72 | return true; 73 | } 74 | catch (const std::exception &e) 75 | { 76 | LOG_ERROR("Application", "Initialization failed: " + std::string(e.what())); 77 | return false; 78 | } 79 | } 80 | 81 | void Application::checkForUpdates() 82 | { 83 | LOG_INFO("Application", "Checking for updates..."); 84 | 85 | // Get current version from version.h 86 | std::string currentVersion = VERSION_STRING; 87 | LOG_DEBUG("Application", "Current version: " + currentVersion); 88 | 89 | try 90 | { 91 | // Fetch the latest release information from GitHub 92 | std::string apiUrl = "https://api.github.com/repos/abarnes6/presence-for-plex/releases/latest"; 93 | 94 | // Setup headers required by GitHub API 95 | std::map headers = { 96 | {"User-Agent", "Presence-For-Plex-Update-Checker"}, 97 | {"Accept", "application/json"}}; 98 | 99 | // Response from GitHub API 100 | std::string response; 101 | 102 | // Create HTTP client instance 103 | HttpClient httpClient; 104 | 105 | if (httpClient.get(apiUrl, headers, response)) 106 | { 107 | // Parse the JSON response 108 | try 109 | { 110 | auto releaseInfo = json::parse(response); 111 | 112 | // Extract the tag name (version) from the release 113 | std::string latestVersion = releaseInfo["tag_name"]; 114 | // Remove 'v' prefix if present 115 | if (!latestVersion.empty() && latestVersion[0] == 'v') 116 | { 117 | latestVersion = latestVersion.substr(1); 118 | } 119 | 120 | LOG_INFO("Application", "Latest version: " + latestVersion); 121 | 122 | // Compare versions 123 | bool updateAvailable = (latestVersion != currentVersion); 124 | 125 | std::string message; 126 | std::string downloadUrl = ""; 127 | 128 | if (updateAvailable) 129 | { 130 | message = "An update is available!\n"; 131 | message += "Latest version: " + latestVersion + " (current: " + currentVersion + ")\n\n"; 132 | 133 | // Add download URL or update instructions 134 | downloadUrl = releaseInfo["html_url"]; 135 | message += "Click to open the download page."; 136 | 137 | LOG_INFO("Application", "Update available: " + latestVersion); 138 | } 139 | else 140 | { 141 | message = "You are running the latest version.\n\n"; 142 | message += "Current version: " + currentVersion; 143 | 144 | LOG_INFO("Application", "No updates available"); 145 | } 146 | 147 | #ifdef _WIN32 148 | // Show the result as a notification 149 | if (updateAvailable) 150 | { 151 | m_trayIcon->showUpdateNotification("Presence For Plex Update", message, downloadUrl); 152 | } 153 | else 154 | { 155 | m_trayIcon->showNotification("Presence For Plex Update", message); 156 | } 157 | #endif 158 | } 159 | catch (const json::exception &e) 160 | { 161 | std::string errorMsg = "Failed to parse GitHub response: " + std::string(e.what()); 162 | LOG_ERROR("Application", errorMsg); 163 | 164 | #ifdef _WIN32 165 | m_trayIcon->showNotification("Update Check Failed", errorMsg, true); 166 | #endif 167 | } 168 | } 169 | else 170 | { 171 | std::string errorMsg = "Failed to check for updates: Could not connect to GitHub."; 172 | LOG_ERROR("Application", errorMsg); 173 | 174 | #ifdef _WIN32 175 | m_trayIcon->showNotification("Update Check Failed", errorMsg, true); 176 | #endif 177 | } 178 | } 179 | catch (const std::exception &e) 180 | { 181 | std::string errorMsg = "Failed to check for updates: " + std::string(e.what()); 182 | LOG_ERROR("Application", errorMsg); 183 | 184 | #ifdef _WIN32 185 | m_trayIcon->showNotification("Update Check Failed", errorMsg, true); 186 | #endif 187 | } 188 | } 189 | 190 | void Application::updateTrayStatus(const MediaInfo &info) 191 | { 192 | #ifdef _WIN32 193 | if (info.state == PlaybackState::Stopped) 194 | { 195 | m_trayIcon->setConnectionStatus("Status: No active sessions"); 196 | } 197 | else if (info.state == PlaybackState::Playing) 198 | { 199 | m_trayIcon->setConnectionStatus("Status: Playing"); 200 | } 201 | else if (info.state == PlaybackState::Paused) 202 | { 203 | m_trayIcon->setConnectionStatus("Status: Paused"); 204 | } 205 | else if (info.state == PlaybackState::Buffering) 206 | { 207 | m_trayIcon->setConnectionStatus("Status: Buffering..."); 208 | } 209 | else if (info.state == PlaybackState::BadToken) 210 | { 211 | m_trayIcon->setConnectionStatus("Status: Invalid Plex token"); 212 | } 213 | else 214 | { 215 | m_trayIcon->setConnectionStatus("Status: Connecting to Plex..."); 216 | } 217 | #endif 218 | } 219 | 220 | void Application::processPlaybackInfo(const MediaInfo &info) 221 | { 222 | if (info.state != PlaybackState::BadToken && info.state != PlaybackState::NotInitialized) 223 | { 224 | if (info.state != m_lastState || (info.state == PlaybackState::Playing && abs(info.startTime - m_lastStartTime) > 5)) 225 | { 226 | LOG_DEBUG("Application", "Playback state changed, updating Discord presence to " + std::to_string(static_cast(info.state))); 227 | m_discord->updatePresence(info); 228 | } 229 | m_lastStartTime = info.startTime; 230 | m_lastState = info.state; 231 | } 232 | else if (info.state == PlaybackState::NotInitialized) 233 | { 234 | LOG_INFO("Application", "Plex class not initialized, skipping update"); 235 | m_lastState = PlaybackState::NotInitialized; 236 | } 237 | else 238 | { 239 | LOG_ERROR("Application", "Invalid Plex token, stopping Discord presence updates"); 240 | m_discord->clearPresence(); 241 | m_lastState = PlaybackState::BadToken; 242 | } 243 | } 244 | 245 | void Application::run() 246 | { 247 | m_running = true; 248 | LOG_DEBUG("Application", "Entering main loop"); 249 | 250 | while (m_running) 251 | { 252 | try 253 | { 254 | if (!m_discord->isConnected()) 255 | { 256 | std::unique_lock lock(m_discordConnectMutex); 257 | // Reduce wait time to be more responsive to shutdown 258 | m_discordConnectCv.wait_for(lock, 259 | std::chrono::milliseconds(500), 260 | [this]() 261 | { return m_discord->isConnected() || !m_running; }); 262 | 263 | if (!m_running || !m_discord->isConnected()) 264 | { 265 | continue; 266 | } 267 | } 268 | 269 | MediaInfo info = m_plex->getCurrentPlayback(); 270 | 271 | updateTrayStatus(info); 272 | processPlaybackInfo(info); 273 | } 274 | catch (const std::exception &e) 275 | { 276 | LOG_ERROR("Application", "Error in main loop: " + std::string(e.what())); 277 | } 278 | 279 | std::this_thread::sleep_for(std::chrono::seconds(1)); 280 | } 281 | 282 | performCleanup(); 283 | } 284 | 285 | void Application::performCleanup() 286 | { 287 | LOG_INFO("Application", "Stopping application"); 288 | m_running = false; 289 | 290 | // Launch cleanup operations in parallel 291 | std::vector> cleanupTasks; 292 | 293 | #ifdef _WIN32 294 | if (m_trayIcon) 295 | { 296 | LOG_INFO("Application", "Destroying tray icon"); 297 | try 298 | { 299 | m_trayIcon->hide(); 300 | } 301 | catch (const std::exception &e) 302 | { 303 | LOG_ERROR("Application", "Error hiding tray icon: " + std::string(e.what())); 304 | } 305 | } 306 | #endif 307 | 308 | if (m_plex) 309 | { 310 | LOG_INFO("Application", "Cleaning up Plex connections"); 311 | cleanupTasks.push_back(std::async(std::launch::async, [this]() 312 | { 313 | try { 314 | m_plex->stop(); 315 | } catch (const std::exception& e) { 316 | LOG_ERROR("Application", "Exception during Plex cleanup: " + std::string(e.what())); 317 | } catch (...) { 318 | LOG_ERROR("Application", "Unknown exception during Plex cleanup"); 319 | } })); 320 | } 321 | 322 | if (m_discord) 323 | { 324 | LOG_INFO("Application", "Stopping Discord connection"); 325 | cleanupTasks.push_back(std::async(std::launch::async, [this]() 326 | { 327 | try { 328 | m_discord->stop(); 329 | } catch (const std::exception& e) { 330 | LOG_ERROR("Application", "Exception during Discord cleanup: " + std::string(e.what())); 331 | } catch (...) { 332 | LOG_ERROR("Application", "Unknown exception during Discord cleanup"); 333 | } })); 334 | } 335 | 336 | // Wait for all cleanup tasks with timeout 337 | for (auto &task : cleanupTasks) 338 | { 339 | if (task.wait_for(std::chrono::seconds(5)) == std::future_status::timeout) 340 | { 341 | LOG_WARNING("Application", "A cleanup task did not complete within the timeout"); 342 | } 343 | } 344 | 345 | LOG_INFO("Application", "Application stopped"); 346 | } 347 | 348 | void Application::stop() 349 | { 350 | LOG_INFO("Application", "Stop requested"); 351 | 352 | if (!m_initialized) 353 | { 354 | performCleanup(); 355 | return; 356 | } 357 | 358 | // Set running to false and wake up any waiting threads 359 | m_running = false; 360 | 361 | // Wake up thread waiting for Discord connection if we're in that state 362 | std::unique_lock lock(m_discordConnectMutex); 363 | m_discordConnectCv.notify_all(); 364 | } 365 | -------------------------------------------------------------------------------- /src/trayicon.cpp: -------------------------------------------------------------------------------- 1 | #include "trayicon.h" 2 | 3 | #ifdef _WIN32 4 | // Static instance pointer for Windows callback 5 | TrayIcon *TrayIcon::s_instance = nullptr; 6 | 7 | TrayIcon::TrayIcon(const std::string &appName) 8 | : m_appName(appName), m_running(false), m_hWnd(NULL), m_hMenu(NULL), 9 | m_connectionStatus("Status: Initializing..."), m_iconShown(false) 10 | { 11 | // Set static instance for callback 12 | s_instance = this; 13 | 14 | // Start UI thread 15 | m_running = true; 16 | m_uiThread = std::thread(&TrayIcon::uiThreadFunction, this); 17 | 18 | // Wait for window to be created before returning 19 | for (int i = 0; i < 50 && m_hWnd == NULL; i++) 20 | { 21 | std::this_thread::sleep_for(std::chrono::milliseconds(10)); 22 | } 23 | 24 | if (m_hWnd == NULL) 25 | { 26 | LOG_ERROR("TrayIcon", "Failed to create window in time"); 27 | } 28 | } 29 | 30 | TrayIcon::~TrayIcon() 31 | { 32 | LOG_INFO("TrayIcon", "Destroying tray icon"); 33 | 34 | // Make sure we hide the icon first 35 | if (m_iconShown) 36 | { 37 | hide(); 38 | } 39 | 40 | // Clean up resources 41 | m_running = false; 42 | 43 | // If there's a window, send a message to destroy it 44 | if (m_hWnd) 45 | { 46 | PostMessage(m_hWnd, WM_CLOSE, 0, 0); 47 | } 48 | 49 | // Wait for UI thread to finish 50 | if (m_uiThread.joinable()) 51 | { 52 | m_uiThread.join(); 53 | } 54 | 55 | s_instance = nullptr; 56 | } 57 | 58 | void TrayIcon::show() 59 | { 60 | if (!m_hWnd) 61 | { 62 | LOG_ERROR("TrayIcon", "Cannot show tray icon: window handle is NULL"); 63 | return; 64 | } 65 | 66 | if (m_nid.cbSize == 0) 67 | { 68 | LOG_ERROR("TrayIcon", "Cannot show tray icon: notification data not initialized"); 69 | return; 70 | } 71 | 72 | if (m_iconShown) 73 | { 74 | LOG_DEBUG("TrayIcon", "Tray icon already shown, skipping"); 75 | return; 76 | } 77 | 78 | LOG_INFO("TrayIcon", "Adding tray icon"); 79 | 80 | if (!Shell_NotifyIconW(NIM_ADD, &m_nid)) 81 | { 82 | DWORD error = GetLastError(); 83 | LOG_ERROR_STREAM("TrayIcon", "Failed to show tray icon, error code: " << error); 84 | } 85 | else 86 | { 87 | LOG_INFO("TrayIcon", "Tray icon shown successfully"); 88 | m_iconShown = true; 89 | } 90 | } 91 | 92 | void TrayIcon::hide() 93 | { 94 | if (!m_iconShown) 95 | { 96 | LOG_DEBUG("TrayIcon", "Tray icon not showing, nothing to hide"); 97 | return; 98 | } 99 | 100 | if (!m_hWnd) 101 | { 102 | LOG_ERROR("TrayIcon", "Cannot hide tray icon: window handle is NULL"); 103 | m_iconShown = false; 104 | return; 105 | } 106 | 107 | if (m_nid.cbSize == 0) 108 | { 109 | LOG_ERROR("TrayIcon", "Cannot hide tray icon: notification data not initialized"); 110 | m_iconShown = false; 111 | return; 112 | } 113 | 114 | LOG_INFO("TrayIcon", "Removing tray icon"); 115 | 116 | if (!Shell_NotifyIconW(NIM_DELETE, &m_nid)) 117 | { 118 | DWORD error = GetLastError(); 119 | LOG_ERROR_STREAM("TrayIcon", "Failed to hide tray icon, error code: " << error); 120 | } 121 | else 122 | { 123 | LOG_INFO("TrayIcon", "Tray icon hidden successfully"); 124 | } 125 | 126 | m_iconShown = false; 127 | } 128 | 129 | void TrayIcon::setExitCallback(std::function callback) 130 | { 131 | m_exitCallback = callback; 132 | } 133 | 134 | void TrayIcon::setUpdateCheckCallback(std::function callback) 135 | { 136 | m_updateCheckCallback = callback; 137 | } 138 | 139 | void TrayIcon::executeExitCallback() 140 | { 141 | if (m_exitCallback) 142 | { 143 | // Create a local copy of the callback to avoid potential use-after-free 144 | auto exitCallback = m_exitCallback; 145 | 146 | // Use ThreadUtils to execute the callback with a timeout 147 | ThreadUtils::executeWithTimeout( 148 | [exitCallback]() 149 | { 150 | if (exitCallback) 151 | { 152 | exitCallback(); 153 | } 154 | }, 155 | std::chrono::seconds(5), 156 | "Exit callback"); 157 | } 158 | } 159 | 160 | void TrayIcon::executeUpdateCheckCallback() 161 | { 162 | if (m_updateCheckCallback) 163 | { 164 | // Create a local copy of the callback to avoid potential use-after-free 165 | auto updateCheckCallback = m_updateCheckCallback; 166 | 167 | // Use ThreadUtils to execute the callback with a timeout 168 | ThreadUtils::executeWithTimeout( 169 | [updateCheckCallback]() 170 | { 171 | if (updateCheckCallback) 172 | { 173 | updateCheckCallback(); 174 | } 175 | }, 176 | std::chrono::seconds(5), 177 | "Update check callback"); 178 | } 179 | } 180 | 181 | void TrayIcon::setConnectionStatus(const std::string &status) 182 | { 183 | if (status == m_connectionStatus) 184 | return; 185 | LOG_DEBUG_STREAM("TrayIcon", "Setting connection status: " << status); 186 | m_connectionStatus = status; 187 | updateMenu(); 188 | } 189 | 190 | void TrayIcon::updateMenu() 191 | { 192 | if (!m_hMenu) 193 | return; 194 | 195 | // Delete the existing status item if it exists 196 | RemoveMenu(m_hMenu, ID_TRAY_STATUS, MF_BYCOMMAND); 197 | 198 | // Convert status to wide string 199 | std::wstring wStatus; 200 | int length = MultiByteToWideChar(CP_UTF8, 0, m_connectionStatus.c_str(), -1, NULL, 0); 201 | if (length > 0) 202 | { 203 | wStatus.resize(length); 204 | MultiByteToWideChar(CP_UTF8, 0, m_connectionStatus.c_str(), -1, &wStatus[0], length); 205 | } 206 | else 207 | { 208 | wStatus = L"Status: Unknown"; 209 | } 210 | 211 | // Insert the status at the top 212 | InsertMenuW(m_hMenu, 0, MF_BYPOSITION | MF_STRING | MF_DISABLED | MF_GRAYED, ID_TRAY_STATUS, wStatus.c_str()); 213 | } 214 | 215 | void TrayIcon::showNotification(const std::string &title, const std::string &message, bool isError) 216 | { 217 | if (!m_hWnd || !m_iconShown) 218 | { 219 | LOG_ERROR("TrayIcon", "Cannot show notification: window handle is NULL or icon not shown"); 220 | return; 221 | } 222 | 223 | // Convert title and message to wide strings 224 | std::wstring wTitle, wMessage; 225 | int titleLength = MultiByteToWideChar(CP_UTF8, 0, title.c_str(), -1, NULL, 0); 226 | int messageLength = MultiByteToWideChar(CP_UTF8, 0, message.c_str(), -1, NULL, 0); 227 | 228 | if (titleLength > 0 && messageLength > 0) 229 | { 230 | wTitle.resize(titleLength); 231 | wMessage.resize(messageLength); 232 | 233 | MultiByteToWideChar(CP_UTF8, 0, title.c_str(), -1, &wTitle[0], titleLength); 234 | MultiByteToWideChar(CP_UTF8, 0, message.c_str(), -1, &wMessage[0], messageLength); 235 | } 236 | else 237 | { 238 | LOG_ERROR("TrayIcon", "Failed to convert notification text to wide string"); 239 | return; 240 | } 241 | 242 | // Show the notification in a non-blocking way 243 | NOTIFYICONDATAW nid = {0}; 244 | nid.cbSize = sizeof(NOTIFYICONDATAW); 245 | nid.hWnd = m_hWnd; 246 | nid.uID = m_nid.uID; 247 | nid.uFlags = NIF_INFO; 248 | nid.dwInfoFlags = isError ? NIIF_ERROR : NIIF_INFO; 249 | 250 | wcsncpy_s(nid.szInfoTitle, _countof(nid.szInfoTitle), wTitle.c_str(), _TRUNCATE); 251 | wcsncpy_s(nid.szInfo, _countof(nid.szInfo), wMessage.c_str(), _TRUNCATE); 252 | 253 | if (!Shell_NotifyIconW(NIM_MODIFY, &nid)) 254 | { 255 | DWORD error = GetLastError(); 256 | LOG_ERROR_STREAM("TrayIcon", "Failed to show notification, error code: " << error); 257 | 258 | // Fall back to a threaded MessageBox if balloon notification fails 259 | std::thread([wTitle, wMessage, isError]() 260 | { 261 | UINT type = MB_OK | (isError ? MB_ICONERROR : MB_ICONINFORMATION); 262 | MessageBoxW(NULL, wMessage.c_str(), wTitle.c_str(), type); }) 263 | .detach(); 264 | } 265 | else 266 | { 267 | LOG_INFO("TrayIcon", "Notification shown successfully"); 268 | } 269 | } 270 | 271 | void TrayIcon::showUpdateNotification(const std::string &title, const std::string &message, const std::string &downloadUrl) 272 | { 273 | // Store download URL for later use if notification is clicked 274 | m_downloadUrl = downloadUrl; 275 | LOG_DEBUG_STREAM("TrayIcon", "Storing download URL for notification: " << downloadUrl); 276 | 277 | // Use the regular notification system 278 | showNotification(title, message, false); 279 | } 280 | 281 | void TrayIcon::openDownloadUrl() 282 | { 283 | if (m_downloadUrl.empty()) 284 | { 285 | LOG_DEBUG("TrayIcon", "No download URL available to open"); 286 | return; 287 | } 288 | 289 | LOG_INFO_STREAM("TrayIcon", "Opening download URL: " << m_downloadUrl); 290 | 291 | // Convert URL to wide string 292 | std::wstring wUrl; 293 | int urlLength = MultiByteToWideChar(CP_UTF8, 0, m_downloadUrl.c_str(), -1, NULL, 0); 294 | if (urlLength > 0) 295 | { 296 | wUrl.resize(urlLength); 297 | MultiByteToWideChar(CP_UTF8, 0, m_downloadUrl.c_str(), -1, &wUrl[0], urlLength); 298 | 299 | // Open URL in default browser 300 | INT_PTR result = (INT_PTR)ShellExecuteW(NULL, L"open", wUrl.c_str(), NULL, NULL, SW_SHOWNORMAL); 301 | if (result <= 32) // ShellExecute returns a value <= 32 for errors 302 | { 303 | LOG_ERROR_STREAM("TrayIcon", "Failed to open URL, error code: " << result); 304 | } 305 | else 306 | { 307 | LOG_INFO("TrayIcon", "URL opened successfully"); 308 | } 309 | } 310 | else 311 | { 312 | LOG_ERROR("TrayIcon", "Failed to convert URL to wide string"); 313 | } 314 | } 315 | 316 | // Static window procedure 317 | LRESULT CALLBACK TrayIcon::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) 318 | { 319 | // Access the instance 320 | TrayIcon *instance = s_instance; 321 | if (!instance) 322 | { 323 | return DefWindowProc(hwnd, message, wParam, lParam); 324 | } 325 | 326 | switch (message) 327 | { 328 | case WM_CREATE: 329 | // Create the icon menu 330 | instance->m_hMenu = CreatePopupMenu(); 331 | // Status will be added dynamically 332 | AppendMenuW(instance->m_hMenu, MF_SEPARATOR, 0, NULL); // Add separator 333 | AppendMenuW(instance->m_hMenu, MF_STRING, ID_TRAY_CHECK_UPDATES, L"Check for Updates"); 334 | AppendMenuW(instance->m_hMenu, MF_SEPARATOR, 0, NULL); // Add separator 335 | AppendMenuW(instance->m_hMenu, MF_STRING, ID_TRAY_EXIT, L"Exit"); 336 | 337 | // Initialize with default status 338 | if (instance->m_connectionStatus.empty()) 339 | { 340 | instance->m_connectionStatus = "Status: Initializing..."; 341 | } 342 | instance->updateMenu(); 343 | break; 344 | 345 | case WM_COMMAND: 346 | switch (LOWORD(wParam)) 347 | { 348 | case ID_TRAY_EXIT: 349 | LOG_INFO("TrayIcon", "Exit selected from menu via WM_COMMAND"); 350 | instance->executeExitCallback(); 351 | break; 352 | case ID_TRAY_CHECK_UPDATES: 353 | LOG_INFO("TrayIcon", "Check for updates selected from menu via WM_COMMAND"); 354 | instance->executeUpdateCheckCallback(); 355 | break; 356 | } 357 | break; 358 | 359 | case WM_TRAYICON: 360 | if (LOWORD(lParam) == WM_RBUTTONUP || LOWORD(lParam) == WM_LBUTTONUP) 361 | { 362 | LOG_DEBUG_STREAM("TrayIcon", "Tray icon clicked: " << LOWORD(lParam)); 363 | 364 | // Update the menu before showing it 365 | instance->updateMenu(); 366 | 367 | POINT pt; 368 | GetCursorPos(&pt); 369 | SetForegroundWindow(hwnd); 370 | UINT clicked = TrackPopupMenu( 371 | instance->m_hMenu, TPM_RETURNCMD | TPM_NONOTIFY, 372 | pt.x, pt.y, 0, hwnd, NULL); 373 | 374 | switch (clicked) 375 | { 376 | case ID_TRAY_EXIT: 377 | LOG_INFO("TrayIcon", "Exit selected from tray menu"); 378 | instance->executeExitCallback(); 379 | break; 380 | case ID_TRAY_CHECK_UPDATES: 381 | LOG_INFO("TrayIcon", "Check for updates selected from tray menu"); 382 | instance->executeUpdateCheckCallback(); 383 | break; 384 | } 385 | } 386 | else if (LOWORD(lParam) == NIN_BALLOONUSERCLICK) 387 | { 388 | LOG_INFO("TrayIcon", "Notification balloon clicked"); 389 | instance->openDownloadUrl(); 390 | } 391 | break; 392 | 393 | case WM_CLOSE: 394 | case WM_DESTROY: 395 | LOG_INFO("TrayIcon", "Window destroyed"); 396 | instance->m_running = false; 397 | PostQuitMessage(0); 398 | break; 399 | 400 | default: 401 | return DefWindowProc(hwnd, message, wParam, lParam); 402 | } 403 | return 0; 404 | } 405 | 406 | void TrayIcon::uiThreadFunction() 407 | { 408 | // Register window class 409 | const wchar_t *className = L"PresenceForPlexTray"; 410 | 411 | WNDCLASSEXW wc = {0}; 412 | wc.cbSize = sizeof(WNDCLASSEXW); 413 | wc.lpfnWndProc = WndProc; 414 | wc.hInstance = GetModuleHandle(NULL); 415 | wc.lpszClassName = className; 416 | 417 | // Load icon with error checking - try multiple methods 418 | HICON hIcon = NULL; 419 | 420 | // First try loading by resource ID 421 | hIcon = LoadIconW(GetModuleHandle(NULL), MAKEINTRESOURCEW(IDI_APPICON)); 422 | 423 | // If that fails, try loading by name 424 | if (!hIcon) 425 | { 426 | LOG_INFO("TrayIcon", "Failed to load icon by ID, trying by name"); 427 | hIcon = LoadIconW(GetModuleHandle(NULL), L"IDI_APPICON"); 428 | } 429 | 430 | // If still no icon, try the default system icon 431 | if (!hIcon) 432 | { 433 | DWORD error = GetLastError(); 434 | LOG_ERROR_STREAM("TrayIcon", "Failed to load application icon, error code: " << error); 435 | // Try to load a default system icon instead 436 | hIcon = LoadIconW(NULL, MAKEINTRESOURCEW(IDI_APPLICATION)); 437 | LOG_INFO("TrayIcon", "Using default system icon instead"); 438 | } 439 | else 440 | { 441 | LOG_INFO("TrayIcon", "Application icon loaded successfully"); 442 | } 443 | 444 | wc.hIcon = hIcon; 445 | wc.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(IDC_ARROW)); 446 | 447 | if (!RegisterClassExW(&wc)) 448 | { 449 | DWORD error = GetLastError(); 450 | LOG_ERROR_STREAM("TrayIcon", "Failed to register window class, error code: " << error); 451 | return; 452 | } 453 | 454 | // Convert app name to wide string 455 | std::wstring wAppName; 456 | int length = MultiByteToWideChar(CP_UTF8, 0, m_appName.c_str(), -1, NULL, 0); 457 | if (length > 0) 458 | { 459 | wAppName.resize(length); 460 | MultiByteToWideChar(CP_UTF8, 0, m_appName.c_str(), -1, &wAppName[0], length); 461 | } 462 | else 463 | { 464 | wAppName = L"Presence For Plex"; 465 | } 466 | 467 | // Create the hidden window 468 | m_hWnd = CreateWindowExW( 469 | 0, 470 | className, wAppName.c_str(), 471 | WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 472 | 10, 10, NULL, NULL, GetModuleHandle(NULL), NULL); 473 | 474 | if (!m_hWnd) 475 | { 476 | DWORD error = GetLastError(); 477 | LOG_ERROR_STREAM("TrayIcon", "Failed to create window, error code: " << error); 478 | return; 479 | } 480 | 481 | // Keep window hidden 482 | ShowWindow(m_hWnd, SW_HIDE); 483 | UpdateWindow(m_hWnd); 484 | 485 | // Initialize tray icon 486 | ZeroMemory(&m_nid, sizeof(m_nid)); 487 | m_nid.cbSize = sizeof(NOTIFYICONDATAW); 488 | m_nid.hWnd = m_hWnd; 489 | m_nid.uID = ID_TRAY_APP_ICON; 490 | m_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; 491 | m_nid.uCallbackMessage = WM_TRAYICON; 492 | 493 | // Use the same icon for the tray as we used for the window 494 | m_nid.hIcon = hIcon; 495 | 496 | // If we still don't have an icon, try one more time specifically for the tray 497 | if (!m_nid.hIcon) 498 | { 499 | LOG_INFO("TrayIcon", "Trying to load tray icon separately"); 500 | m_nid.hIcon = LoadIconW(GetModuleHandle(NULL), MAKEINTRESOURCEW(IDI_APPICON)); 501 | 502 | if (!m_nid.hIcon) 503 | { 504 | DWORD error = GetLastError(); 505 | LOG_ERROR_STREAM("TrayIcon", "Failed to load tray icon, error code: " << error); 506 | // Try to load a default system icon instead 507 | m_nid.hIcon = LoadIconW(NULL, MAKEINTRESOURCEW(IDI_APPLICATION)); 508 | LOG_INFO("TrayIcon", "Using default system icon for tray"); 509 | } 510 | else 511 | { 512 | LOG_INFO("TrayIcon", "Tray icon loaded successfully"); 513 | } 514 | } 515 | 516 | // Set initial tooltip 517 | wcscpy_s(m_nid.szTip, _countof(m_nid.szTip), L"Presence For Plex"); 518 | 519 | LOG_INFO("TrayIcon", "Tray icon initialized, ready to be shown"); 520 | 521 | // Message loop 522 | MSG msg; 523 | while (m_running && GetMessage(&msg, NULL, 0, 0)) 524 | { 525 | TranslateMessage(&msg); 526 | DispatchMessage(&msg); 527 | } 528 | 529 | LOG_INFO("TrayIcon", "UI thread exiting"); 530 | } 531 | 532 | #endif -------------------------------------------------------------------------------- /src/discord_ipc.cpp: -------------------------------------------------------------------------------- 1 | #include "discord_ipc.h" 2 | 3 | // Platform-specific localtime function 4 | #ifdef _WIN32 5 | inline void safe_localtime(std::tm *tm_ptr, const std::time_t *time_ptr) 6 | { 7 | localtime_s(tm_ptr, time_ptr); 8 | } 9 | #else 10 | inline void safe_localtime(std::tm *tm_ptr, const std::time_t *time_ptr) 11 | { 12 | *tm_ptr = *localtime(time_ptr); 13 | } 14 | #endif 15 | 16 | using json = nlohmann::json; 17 | 18 | DiscordIPC::DiscordIPC() : connected(false) 19 | { 20 | #ifdef _WIN32 21 | pipe_handle = INVALID_HANDLE_VALUE; 22 | #else 23 | pipe_fd = -1; 24 | #endif 25 | } 26 | 27 | DiscordIPC::~DiscordIPC() 28 | { 29 | if (connected) 30 | { 31 | closePipe(); 32 | } 33 | } 34 | 35 | #if defined(_WIN32) 36 | bool DiscordIPC::openPipe() 37 | { 38 | // Windows implementation using named pipes 39 | LOG_INFO("DiscordIPC", "Attempting to connect to Discord via Windows named pipes"); 40 | for (int i = 0; i < 10; i++) 41 | { 42 | std::string pipeName = "\\\\.\\pipe\\discord-ipc-" + std::to_string(i); 43 | LOG_DEBUG("DiscordIPC", "Trying pipe: " + pipeName); 44 | 45 | pipe_handle = CreateFile( 46 | pipeName.c_str(), 47 | GENERIC_READ | GENERIC_WRITE, 48 | 0, 49 | NULL, 50 | OPEN_EXISTING, 51 | 0, 52 | NULL); 53 | 54 | if (pipe_handle != INVALID_HANDLE_VALUE) 55 | { 56 | // Try setting pipe to message mode, but don't fail if this doesn't work 57 | // Some Discord versions may not support message mode 58 | DWORD mode = PIPE_READMODE_MESSAGE; 59 | if (!SetNamedPipeHandleState(pipe_handle, &mode, NULL, NULL)) 60 | { 61 | DWORD error = GetLastError(); 62 | LOG_DEBUG("DiscordIPC", "Warning: Failed to set pipe mode. Using default mode. Error: " + std::to_string(error)); 63 | } 64 | 65 | LOG_INFO("DiscordIPC", "Successfully connected to Discord pipe: " + pipeName); 66 | connected = true; 67 | return true; 68 | } 69 | 70 | // Log the specific error for debugging 71 | DWORD error = GetLastError(); 72 | LOG_DEBUG("DiscordIPC", "Failed to connect to " + pipeName + ": error code " + std::to_string(error)); 73 | } 74 | LOG_INFO("DiscordIPC", "Could not connect to any Discord pipe. Is Discord running?"); 75 | return false; 76 | } 77 | 78 | #elif defined(__APPLE__) 79 | bool DiscordIPC::openPipe() 80 | { 81 | const char *temp = getenv("TMPDIR"); 82 | LOG_INFO("DiscordIPC", "Attempting to connect to Discord via Unix sockets on macOS"); 83 | for (int i = 0; i < 10; i++) 84 | { 85 | std::string socket_path; 86 | if (temp) 87 | { 88 | socket_path = std::string(temp) + "discord-ipc-" + std::to_string(i); 89 | } 90 | else 91 | { 92 | LOG_WARNING("DiscordIPC", "Could not determine temporary directory, skipping socket " + std::to_string(i)); 93 | continue; 94 | } 95 | 96 | LOG_DEBUG("DiscordIPC", "Trying socket: " + socket_path); 97 | 98 | pipe_fd = socket(AF_UNIX, SOCK_STREAM, 0); 99 | if (pipe_fd == -1) 100 | { 101 | LOG_DEBUG("DiscordIPC", "Failed to create socket: " + std::string(strerror(errno))); 102 | continue; 103 | } 104 | 105 | struct sockaddr_un addr; 106 | memset(&addr, 0, sizeof(addr)); 107 | addr.sun_family = AF_UNIX; 108 | 109 | // Make sure we don't overflow the sun_path buffer 110 | if (socket_path.length() >= sizeof(addr.sun_path)) 111 | { 112 | LOG_WARNING("DiscordIPC", "Socket path too long: " + socket_path); 113 | close(pipe_fd); 114 | pipe_fd = -1; 115 | continue; 116 | } 117 | 118 | strncpy(addr.sun_path, socket_path.c_str(), sizeof(addr.sun_path) - 1); 119 | 120 | if (::connect(pipe_fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) 121 | { 122 | LOG_INFO("DiscordIPC", "Successfully connected to Discord socket: " + socket_path); 123 | 124 | connected = true; 125 | return true; 126 | } 127 | 128 | LOG_DEBUG("DiscordIPC", "Failed to connect to socket: " + socket_path + ": " + std::string(strerror(errno))); 129 | close(pipe_fd); 130 | pipe_fd = -1; 131 | } 132 | 133 | LOG_INFO("DiscordIPC", "Could not connect to any Discord socket. Is Discord running?"); 134 | return false; 135 | } 136 | #elif defined(__linux__) || defined(__unix__) 137 | bool DiscordIPC::openPipe() 138 | { 139 | // Unix implementation using sockets 140 | LOG_INFO("DiscordIPC", "Attempting to connect to Discord via Unix sockets"); 141 | 142 | // First try conventional socket paths 143 | for (int i = 0; i < 10; i++) 144 | { 145 | std::string socket_path; 146 | // Check environment variables first (XDG standard) 147 | const char *xdgRuntime = getenv("XDG_RUNTIME_DIR"); 148 | const char *home = getenv("HOME"); 149 | 150 | if (xdgRuntime) 151 | { 152 | socket_path = std::string(xdgRuntime) + "/discord-ipc-" + std::to_string(i); 153 | } 154 | else if (home) 155 | { 156 | socket_path = std::string(home) + "/.discord-ipc-" + std::to_string(i); 157 | } 158 | else 159 | { 160 | LOG_WARNING("DiscordIPC", "Could not determine user home directory, skipping socket " + std::to_string(i)); 161 | continue; 162 | } 163 | 164 | LOG_DEBUG("DiscordIPC", "Trying socket: " + socket_path); 165 | 166 | pipe_fd = socket(AF_UNIX, SOCK_STREAM, 0); 167 | if (pipe_fd == -1) 168 | { 169 | LOG_DEBUG("DiscordIPC", "Failed to create socket: " + std::string(strerror(errno))); 170 | continue; 171 | } 172 | 173 | struct sockaddr_un addr; 174 | memset(&addr, 0, sizeof(addr)); 175 | addr.sun_family = AF_UNIX; 176 | 177 | // Make sure we don't overflow the sun_path buffer 178 | if (socket_path.length() >= sizeof(addr.sun_path)) 179 | { 180 | LOG_WARNING("DiscordIPC", "Socket path too long: " + socket_path); 181 | close(pipe_fd); 182 | pipe_fd = -1; 183 | continue; 184 | } 185 | 186 | strncpy(addr.sun_path, socket_path.c_str(), sizeof(addr.sun_path) - 1); 187 | 188 | if (::connect(pipe_fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) 189 | { 190 | LOG_INFO("DiscordIPC", "Successfully connected to Discord socket: " + socket_path); 191 | 192 | connected = true; 193 | return true; 194 | } 195 | 196 | LOG_DEBUG("DiscordIPC", "Failed to connect to socket: " + socket_path + ": " + std::string(strerror(errno))); 197 | close(pipe_fd); 198 | pipe_fd = -1; 199 | } 200 | 201 | // Try Linux-specific paths 202 | // Try snap-specific Discord socket paths 203 | std::string snap_path = "/run/user/" + std::to_string(getuid()) + "/snap.discord/discord-ipc-0"; 204 | LOG_DEBUG("DiscordIPC", "Trying Snap socket: " + snap_path); 205 | 206 | pipe_fd = socket(AF_UNIX, SOCK_STREAM, 0); 207 | if (pipe_fd != -1) 208 | { 209 | struct sockaddr_un addr; 210 | memset(&addr, 0, sizeof(addr)); 211 | addr.sun_family = AF_UNIX; 212 | strncpy(addr.sun_path, snap_path.c_str(), sizeof(addr.sun_path) - 1); 213 | 214 | if (::connect(pipe_fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) 215 | { 216 | LOG_INFO("DiscordIPC", "Successfully connected to Discord Snap socket: " + snap_path); 217 | connected = true; 218 | return true; 219 | } 220 | 221 | LOG_DEBUG("DiscordIPC", "Failed to connect to Snap socket: " + snap_path + ": " + std::string(strerror(errno))); 222 | close(pipe_fd); 223 | pipe_fd = -1; 224 | } 225 | 226 | // Try flatpak-specific Discord socket paths 227 | std::string flatpak_path = "/run/user/" + std::to_string(getuid()) + "/app/com.discordapp.Discord/discord-ipc-0"; 228 | LOG_DEBUG("DiscordIPC", "Trying Flatpak socket: " + flatpak_path); 229 | 230 | pipe_fd = socket(AF_UNIX, SOCK_STREAM, 0); 231 | if (pipe_fd != -1) 232 | { 233 | struct sockaddr_un addr; 234 | memset(&addr, 0, sizeof(addr)); 235 | addr.sun_family = AF_UNIX; 236 | strncpy(addr.sun_path, flatpak_path.c_str(), sizeof(addr.sun_path) - 1); 237 | 238 | if (::connect(pipe_fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) 239 | { 240 | LOG_INFO("DiscordIPC", "Successfully connected to Discord Flatpak socket: " + flatpak_path); 241 | connected = true; 242 | return true; 243 | } 244 | 245 | LOG_DEBUG("DiscordIPC", "Failed to connect to Flatpak socket: " + flatpak_path + ": " + std::string(strerror(errno))); 246 | close(pipe_fd); 247 | pipe_fd = -1; 248 | } 249 | 250 | LOG_INFO("DiscordIPC", "Could not connect to any Discord socket. Is Discord running?"); 251 | return false; 252 | } 253 | #endif 254 | 255 | void DiscordIPC::closePipe() 256 | { 257 | connected = false; 258 | LOG_INFO("DiscordIPC", "Disconnecting from Discord..."); 259 | #ifdef _WIN32 260 | if (pipe_handle != INVALID_HANDLE_VALUE) 261 | { 262 | LOG_DEBUG("DiscordIPC", "Closing pipe handle"); 263 | CloseHandle(pipe_handle); 264 | pipe_handle = INVALID_HANDLE_VALUE; 265 | } 266 | #else 267 | if (pipe_fd != -1) 268 | { 269 | LOG_DEBUG("DiscordIPC", "Closing socket"); 270 | close(pipe_fd); 271 | pipe_fd = -1; 272 | } 273 | #endif 274 | LOG_INFO("DiscordIPC", "Disconnected from Discord"); 275 | } 276 | 277 | bool DiscordIPC::isConnected() const 278 | { 279 | return connected; 280 | } 281 | 282 | bool DiscordIPC::writeFrame(int opcode, const std::string &payload) 283 | { 284 | if (!connected) 285 | { 286 | LOG_DEBUG("DiscordIPC", "Can't write frame: not connected"); 287 | return false; 288 | } 289 | 290 | LOG_DEBUG("DiscordIPC", "Writing frame - Opcode: " + std::to_string(opcode) + ", Data length: " + std::to_string(payload.size())); 291 | LOG_DEBUG("DiscordIPC", "Writing frame data: " + payload); 292 | 293 | // Create a properly formatted Discord IPC message 294 | uint32_t len = static_cast(payload.size()); 295 | std::vector buf(8 + len); // Header (8 bytes) + payload 296 | auto *p = reinterpret_cast(buf.data()); 297 | p[0] = htole32(opcode); // First 4 bytes: opcode with proper endianness 298 | p[1] = htole32(len); // Next 4 bytes: payload length with proper endianness 299 | memcpy(buf.data() + 8, payload.data(), len); 300 | 301 | #ifdef _WIN32 302 | DWORD written; 303 | if (!WriteFile(pipe_handle, buf.data(), 8 + len, &written, nullptr) || 304 | written != 8 + len) 305 | { 306 | DWORD error = GetLastError(); 307 | LOG_ERROR("DiscordIPC", "Failed to write frame to pipe. Error code: " + std::to_string(error) + ", Bytes written: " + std::to_string(written)); 308 | connected = false; 309 | return false; 310 | } 311 | LOG_DEBUG("DiscordIPC", "Successfully wrote " + std::to_string(written) + " bytes to pipe"); 312 | FlushFileBuffers(pipe_handle); 313 | #else 314 | ssize_t n = ::write(pipe_fd, buf.data(), 8 + len); 315 | if (n != 8 + len) 316 | { 317 | LOG_ERROR("DiscordIPC", "Failed to write frame to socket. Expected: " + std::to_string(8 + len) + ", Actual: " + std::to_string(n)); 318 | if (n < 0) 319 | { 320 | LOG_ERROR("DiscordIPC", "Write error: " + std::string(strerror(errno))); 321 | } 322 | connected = false; 323 | return false; 324 | } 325 | LOG_DEBUG("DiscordIPC", "Successfully wrote " + std::to_string(n) + " bytes to socket"); 326 | #endif 327 | return true; 328 | } 329 | 330 | bool DiscordIPC::readFrame(int &opcode, std::string &data) 331 | { 332 | if (!connected) 333 | { 334 | LOG_DEBUG("DiscordIPC", "Can't read frame: not connected"); 335 | return false; 336 | } 337 | 338 | LOG_DEBUG("DiscordIPC", "Attempting to read frame from Discord"); 339 | opcode = -1; 340 | 341 | // First read the 8-byte header (opcode + length) 342 | char header[8]; 343 | int header_bytes_read = 0; 344 | 345 | #ifdef _WIN32 346 | LOG_DEBUG("DiscordIPC", "Reading header (8 bytes)..."); 347 | while (header_bytes_read < 8) 348 | { 349 | DWORD bytes_read; 350 | if (!ReadFile(pipe_handle, header + header_bytes_read, 8 - header_bytes_read, &bytes_read, NULL)) 351 | { 352 | DWORD error = GetLastError(); 353 | LOG_ERROR("DiscordIPC", "Failed to read header: error code " + std::to_string(error)); 354 | connected = false; 355 | return false; 356 | } 357 | 358 | if (bytes_read == 0) 359 | { 360 | LOG_ERROR("DiscordIPC", "Read zero bytes from pipe - connection closed"); 361 | connected = false; 362 | return false; 363 | } 364 | 365 | header_bytes_read += bytes_read; 366 | LOG_DEBUG("DiscordIPC", "Read " + std::to_string(bytes_read) + " header bytes, total: " + std::to_string(header_bytes_read) + "/8"); 367 | } 368 | #else 369 | LOG_DEBUG("DiscordIPC", "Reading header (8 bytes)..."); 370 | while (header_bytes_read < 8) 371 | { 372 | ssize_t bytes_read = read(pipe_fd, header + header_bytes_read, 8 - header_bytes_read); 373 | if (bytes_read <= 0) 374 | { 375 | if (bytes_read < 0) 376 | { 377 | LOG_ERROR("DiscordIPC", "Error reading from socket: " + std::string(strerror(errno))); 378 | } 379 | else 380 | { 381 | LOG_ERROR("DiscordIPC", "Socket closed during header read"); 382 | } 383 | connected = false; 384 | return false; 385 | } 386 | header_bytes_read += bytes_read; 387 | LOG_DEBUG("DiscordIPC", "Read " + std::to_string(bytes_read) + " header bytes, total: " + std::to_string(header_bytes_read) + "/8"); 388 | } 389 | #endif 390 | 391 | // Parse the header with proper endianness handling 392 | uint32_t raw0, raw1; 393 | memcpy(&raw0, header, 4); 394 | memcpy(&raw1, header + 4, 4); 395 | opcode = le32toh(raw0); 396 | uint32_t length = le32toh(raw1); 397 | 398 | LOG_DEBUG("DiscordIPC", "Frame header parsed - Opcode: " + std::to_string(opcode) + ", Expected data length: " + std::to_string(length)); 399 | 400 | if (length == 0) 401 | { 402 | LOG_DEBUG("DiscordIPC", "Frame has zero length, no data to read"); 403 | data.clear(); 404 | return true; 405 | } 406 | 407 | // Now read the actual payload data 408 | data.resize(length); 409 | uint32_t data_bytes_read = 0; 410 | 411 | #ifdef _WIN32 412 | LOG_DEBUG("DiscordIPC", "Reading payload (" + std::to_string(length) + " bytes)..."); 413 | while (data_bytes_read < length) 414 | { 415 | DWORD bytes_read; 416 | if (!ReadFile(pipe_handle, &data[data_bytes_read], length - data_bytes_read, &bytes_read, NULL)) 417 | { 418 | DWORD error = GetLastError(); 419 | LOG_ERROR("DiscordIPC", "Failed to read data: error code " + std::to_string(error)); 420 | connected = false; 421 | return false; 422 | } 423 | 424 | if (bytes_read == 0) 425 | { 426 | LOG_ERROR("DiscordIPC", "Read zero bytes from pipe during payload read - connection closed"); 427 | connected = false; 428 | return false; 429 | } 430 | 431 | data_bytes_read += bytes_read; 432 | LOG_DEBUG("DiscordIPC", "Read " + std::to_string(bytes_read) + " data bytes, total: " + std::to_string(data_bytes_read) + "/" + std::to_string(length)); 433 | } 434 | #else 435 | LOG_DEBUG("DiscordIPC", "Reading payload (" + std::to_string(length) + " bytes)..."); 436 | while (data_bytes_read < length) 437 | { 438 | ssize_t bytes_read = read(pipe_fd, &data[data_bytes_read], length - data_bytes_read); 439 | if (bytes_read <= 0) 440 | { 441 | if (bytes_read < 0) 442 | { 443 | LOG_ERROR("DiscordIPC", "Error reading from socket: " + std::string(strerror(errno))); 444 | } 445 | else 446 | { 447 | LOG_ERROR("DiscordIPC", "Socket closed during payload read"); 448 | } 449 | connected = false; 450 | return false; 451 | } 452 | data_bytes_read += bytes_read; 453 | LOG_DEBUG("DiscordIPC", "Read " + std::to_string(bytes_read) + " data bytes, total: " + std::to_string(data_bytes_read) + "/" + std::to_string(length)); 454 | } 455 | #endif 456 | LOG_DEBUG("DiscordIPC", "Reading frame data: " + data); 457 | return true; 458 | } 459 | 460 | bool DiscordIPC::sendHandshake(uint64_t clientId) 461 | { 462 | if (!connected) 463 | { 464 | LOG_DEBUG("DiscordIPC", "Can't send handshake: not connected"); 465 | return false; 466 | } 467 | 468 | LOG_INFO("DiscordIPC", "Sending handshake with client ID: " + std::to_string(clientId)); 469 | 470 | // Create handshake payload 471 | json payload = { 472 | {"client_id", std::to_string(clientId)}, 473 | {"v", 1}}; 474 | 475 | std::string handshake_str = payload.dump(); 476 | LOG_DEBUG("DiscordIPC", "Handshake payload: " + handshake_str); 477 | 478 | return writeFrame(OP_HANDSHAKE, handshake_str); 479 | } 480 | 481 | bool DiscordIPC::sendPing() 482 | { 483 | if (!connected) 484 | { 485 | LOG_DEBUG("DiscordIPC", "Can't send ping: not connected"); 486 | return false; 487 | } 488 | 489 | LOG_DEBUG("DiscordIPC", "Sending ping"); 490 | static const json ping = json::object(); // empty payload 491 | std::string ping_str = ping.dump(); 492 | 493 | return writeFrame(OP_PING, ping_str); 494 | } 495 | -------------------------------------------------------------------------------- /src/discord.cpp: -------------------------------------------------------------------------------- 1 | #include "discord.h" 2 | 3 | constexpr int MAX_PAUSED_DURATION = 9999; 4 | 5 | // Rate limit constants chosen based on how Music Presence does it - kind of... 6 | // I can't find anything online about Discord's rate limits, but there is definitely something 7 | constexpr int MAX_FRAMES_PER_WINDOW = 5; 8 | constexpr int RATE_LIMIT_WINDOW_SECONDS = 15; 9 | constexpr int MIN_FRAME_INTERVAL_SECONDS = 1; 10 | constexpr int MAX_FRAMES_SHORT_WINDOW = 3; 11 | constexpr int RATE_LIMIT_SHORT_WINDOW = 5; 12 | 13 | using json = nlohmann::json; 14 | 15 | Discord::Discord() : running(false), 16 | needs_reconnect(false), 17 | reconnect_attempts(0), 18 | is_playing(false), 19 | nonce_counter(0), 20 | onConnected(nullptr), 21 | onDisconnected(nullptr), 22 | has_queued_frame(false), 23 | last_frame_write_time(0) 24 | { 25 | } 26 | 27 | Discord::~Discord() 28 | { 29 | if (running) 30 | stop(); 31 | LOG_INFO("Discord", "Discord object destroyed"); 32 | } 33 | 34 | void Discord::connectionThread() 35 | { 36 | LOG_INFO("Discord", "Connection thread started"); 37 | while (running) 38 | { 39 | // Handle connection logic 40 | if (!ipc.isConnected()) 41 | { 42 | LOG_DEBUG("Discord", "Not connected, attempting connection"); 43 | 44 | // Calculate exponential backoff delay 45 | if (reconnect_attempts > 0) 46 | { 47 | int delay = std::min(5 * reconnect_attempts, 60); 48 | LOG_INFO("Discord", "Reconnection attempt " + std::to_string(reconnect_attempts) + 49 | ", waiting " + std::to_string(delay) + " seconds"); 50 | 51 | for (int i = 0; i < delay * 2 && running; ++i) 52 | { 53 | std::this_thread::sleep_for(std::chrono::milliseconds(500)); 54 | if (i % 10 == 0) 55 | { 56 | processQueuedFrame(); // Process frames more frequently during wait 57 | } 58 | } 59 | if (!running) 60 | { 61 | break; 62 | } 63 | } 64 | 65 | reconnect_attempts++; 66 | 67 | // Attempt to connect 68 | if (!attemptConnection()) 69 | { 70 | LOG_INFO("Discord", "Failed to connect to Discord IPC, will retry later"); 71 | continue; 72 | } 73 | 74 | reconnect_attempts = 0; 75 | LOG_INFO("Discord", "Successfully connected to Discord"); 76 | 77 | // Call connected callback if set 78 | if (onConnected) 79 | { 80 | onConnected(); 81 | } 82 | } 83 | else 84 | { 85 | LOG_DEBUG("Discord", "Checking Discord connection health"); 86 | 87 | if (!isStillAlive()) 88 | { 89 | LOG_INFO("Discord", "Connection to Discord lost, will reconnect"); 90 | if (ipc.isConnected()) 91 | { 92 | ipc.closePipe(); 93 | } 94 | needs_reconnect = true; 95 | 96 | // Call disconnected callback if set 97 | if (onDisconnected) 98 | { 99 | onDisconnected(); 100 | } 101 | continue; 102 | } 103 | else 104 | { 105 | needs_reconnect = false; 106 | } 107 | 108 | // Connection health check sleep 109 | for (int i = 0; i < 600 && running && !needs_reconnect; ++i) // 600 * 100ms = 60s 110 | { 111 | std::this_thread::sleep_for(std::chrono::milliseconds(100)); 112 | if (i % 10 == 0) 113 | { 114 | processQueuedFrame(); 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | bool Discord::attemptConnection() 122 | { 123 | if (!ipc.openPipe()) 124 | { 125 | return false; 126 | } 127 | 128 | LOG_DEBUG("Discord", "Connection established, sending handshake"); 129 | LOG_DEBUG("Discord", "Using client ID: " + std::to_string(Config::getInstance().getDiscordClientId())); 130 | 131 | if (!ipc.sendHandshake(Config::getInstance().getDiscordClientId())) 132 | { 133 | LOG_ERROR("Discord", "Handshake write failed"); 134 | ipc.closePipe(); 135 | return false; 136 | } 137 | 138 | int opcode; 139 | std::string response; 140 | LOG_DEBUG("Discord", "Waiting for handshake response"); 141 | if (!ipc.readFrame(opcode, response) || opcode != OP_FRAME) 142 | { 143 | LOG_ERROR("Discord", "Failed to read handshake response. Opcode: " + std::to_string(opcode)); 144 | if (!response.empty()) 145 | { 146 | LOG_DEBUG("Discord", "Response content: " + response); 147 | } 148 | ipc.closePipe(); 149 | return false; 150 | } 151 | 152 | LOG_DEBUG("Discord", "Handshake response received"); 153 | 154 | try 155 | { 156 | LOG_DEBUG("Discord", "Parsing response: " + response); 157 | json ready = json::parse(response); 158 | 159 | if (!ready.contains("evt")) 160 | { 161 | LOG_ERROR("Discord", "Discord response missing 'evt' field"); 162 | LOG_DEBUG("Discord", "Complete response: " + response); 163 | ipc.closePipe(); 164 | return false; 165 | } 166 | 167 | if (ready["evt"] != "READY") 168 | { 169 | LOG_ERROR("Discord", "Discord did not respond with READY event: " + ready["evt"].dump()); 170 | ipc.closePipe(); 171 | return false; 172 | } 173 | LOG_DEBUG("Discord", "Handshake READY event confirmed"); 174 | return true; 175 | } 176 | catch (const json::parse_error &e) 177 | { 178 | LOG_ERROR_STREAM("Discord", "JSON parse error in READY response: " << e.what() << " at position " << e.byte); 179 | LOG_DEBUG_STREAM("Discord", "Response that caused the error: " << response); 180 | } 181 | catch (const json::type_error &e) 182 | { 183 | LOG_ERROR_STREAM("Discord", "JSON type error in READY response: " << e.what()); 184 | LOG_DEBUG_STREAM("Discord", "Response that caused the error: " << response); 185 | } 186 | catch (const std::exception &e) 187 | { 188 | LOG_ERROR("Discord", "Failed to parse READY response: " + std::string(e.what())); 189 | LOG_DEBUG("Discord", "Response that caused the error: " + response); 190 | } 191 | 192 | ipc.closePipe(); 193 | return false; 194 | } 195 | 196 | void Discord::updatePresence(const MediaInfo &info) 197 | { 198 | LOG_DEBUG_STREAM("Discord", "updatePresence called for title: " << info.title); 199 | 200 | if (!ipc.isConnected()) 201 | { 202 | LOG_WARNING("Discord", "Can't update presence: not connected to Discord"); 203 | return; 204 | } 205 | 206 | std::lock_guard lock(mutex); 207 | 208 | if (info.state == PlaybackState::Playing || 209 | info.state == PlaybackState::Paused || 210 | info.state == PlaybackState::Buffering) 211 | { 212 | std::string stateStr = (info.state == PlaybackState::Playing) ? "playing" : (info.state == PlaybackState::Paused) ? "paused" 213 | : "buffering"; 214 | LOG_DEBUG_STREAM("Discord", "Media is " << stateStr << ", updating presence"); 215 | 216 | is_playing = true; 217 | 218 | std::string nonce = generateNonce(); 219 | std::string presence = createPresence(info, nonce); 220 | 221 | LOG_INFO_STREAM("Discord", "Queuing presence update: " << info.title << " - " << info.username 222 | << (info.state == PlaybackState::Paused ? " (Paused)" : "") 223 | << (info.state == PlaybackState::Buffering ? " (Buffering)" : "")); 224 | 225 | // Queue the presence update 226 | queuePresenceMessage(presence); 227 | 228 | // Attempt to send it immediately 229 | processQueuedFrame(); 230 | } 231 | else 232 | { 233 | // Clear presence if not playing anymore 234 | if (is_playing) 235 | { 236 | LOG_INFO("Discord", "Media stopped, clearing presence"); 237 | clearPresence(); 238 | } 239 | } 240 | } 241 | 242 | bool Discord::isConnected() const 243 | { 244 | return ipc.isConnected(); 245 | } 246 | 247 | std::string Discord::generateNonce() 248 | { 249 | return std::to_string(++nonce_counter); 250 | } 251 | 252 | std::string Discord::createPresence(const MediaInfo &info, const std::string &nonce) 253 | { 254 | json activity = createActivity(info); 255 | 256 | #ifdef _WIN32 257 | auto process_id = static_cast(GetCurrentProcessId()); 258 | #else 259 | auto process_id = static_cast(getpid()); 260 | #endif 261 | 262 | json args = { 263 | {"pid", process_id}, 264 | {"activity", activity}}; 265 | 266 | json presence = {{"cmd", "SET_ACTIVITY"}, {"args", args}, {"nonce", nonce}}; 267 | 268 | return presence.dump(); 269 | } 270 | 271 | std::string Discord::createPresenceMetadata(const MediaInfo &info, const std::string &nonce) 272 | { 273 | json activity = createActivity(info); 274 | 275 | json presence = { 276 | {"cmd", "SET_ACTIVITY"}, 277 | {"data", activity}, 278 | {"evt", nullptr}, 279 | {"nonce", nonce}}; 280 | 281 | return presence.dump(); 282 | } 283 | 284 | json Discord::createActivity(const MediaInfo &info) 285 | { 286 | std::string state; 287 | std::string details; 288 | json assets = {}; 289 | int activityType = 3; // Default: Watching 290 | 291 | // Default large image 292 | assets["large_image"] = "plex_logo"; 293 | 294 | if (!info.artPath.empty()) 295 | { 296 | assets["large_image"] = info.artPath; 297 | } 298 | 299 | if (info.type == MediaType::TVShow) 300 | { 301 | activityType = 3; // Watching 302 | std::stringstream ss; 303 | ss << "S" << info.season; 304 | ss << " • "; 305 | ss << "E" << info.episode; 306 | 307 | details = info.grandparentTitle; // Show Title 308 | state = ss.str() + " - " + info.title; // SXX • EXX - Episode Title 309 | assets["large_text"] = info.grandparentTitle; // Show Title on hover 310 | } 311 | else if (info.type == MediaType::Movie) 312 | { 313 | activityType = 3; // Watching 314 | details = info.title + " (" + std::to_string(info.year) + ")"; 315 | if (!info.genres.empty()) 316 | { 317 | state = std::accumulate(std::next(info.genres.begin()), info.genres.end(), 318 | info.genres[0], 319 | [](std::string a, const std::string &b) 320 | { 321 | return a + ", " + b; 322 | }); 323 | } 324 | else 325 | { 326 | state = "Watching Movie"; // Fallback state 327 | } 328 | assets["large_text"] = info.title; // Movie title on hover 329 | } 330 | else if (info.type == MediaType::Music) 331 | { 332 | activityType = 2; // Listening 333 | details = info.title; // Track Title 334 | state = info.artist + " - " + info.album; // Artist - Album 335 | } 336 | else // Unknown or other types 337 | { 338 | activityType = 0; // Playing (generic) 339 | details = info.title; 340 | state = "Playing media"; 341 | assets["large_text"] = info.title; 342 | } 343 | 344 | if (info.state == PlaybackState::Buffering) 345 | { 346 | state = "🔄 Buffering..."; 347 | // Keep existing details 348 | } 349 | else if (info.state == PlaybackState::Paused) 350 | { 351 | assets["small_image"] = "paused"; 352 | assets["small_text"] = "Paused"; 353 | // Keep existing details and state 354 | } 355 | else if (info.state == PlaybackState::Stopped) 356 | { 357 | // This case might not be reached if filtering happens earlier, but good practice 358 | state = "Stopped"; 359 | // Keep existing details 360 | } 361 | 362 | if (details.empty()) 363 | { 364 | details = "Watching something..."; // Fallback if details somehow ends up empty 365 | } 366 | if (state.empty()) 367 | { 368 | state = "Idle"; // Fallback if state somehow ends up empty 369 | } 370 | 371 | // Calculate timestamps for progress bar 372 | auto now = std::chrono::system_clock::now(); 373 | int64_t current_time = std::chrono::duration_cast(now.time_since_epoch()).count(); 374 | int64_t start_timestamp = 0; 375 | int64_t end_timestamp = 0; 376 | 377 | if (info.state == PlaybackState::Playing) 378 | { 379 | start_timestamp = current_time - static_cast(info.progress); 380 | end_timestamp = current_time + static_cast(info.duration - info.progress); 381 | } 382 | else if (info.state == PlaybackState::Paused || info.state == PlaybackState::Buffering) 383 | { 384 | auto max_duration = std::chrono::duration_cast(std::chrono::hours(MAX_PAUSED_DURATION)) 385 | .count(); 386 | start_timestamp = current_time + max_duration; 387 | end_timestamp = start_timestamp + static_cast(info.duration); 388 | } 389 | 390 | json timestamps = { 391 | {"start", start_timestamp}, 392 | {"end", end_timestamp}}; 393 | 394 | json buttons = {}; 395 | 396 | // Add relevant buttons based on available IDs 397 | if (!info.malId.empty()) 398 | { 399 | buttons.push_back({{"label", "View on MyAnimeList"}, 400 | {"url", "https://myanimelist.net/anime/" + info.malId}}); 401 | } 402 | else if (!info.imdbId.empty()) 403 | { 404 | buttons.push_back({{"label", "View on IMDb"}, 405 | {"url", "https://www.imdb.com/title/" + info.imdbId}}); 406 | } 407 | // Potentially add buttons for music later (e.g., MusicBrainz, Spotify link if available) 408 | 409 | json ret = { 410 | {"type", activityType}, 411 | {"state", state}, 412 | {"details", details}, 413 | {"status_display_type", 2}, 414 | {"assets", assets}, 415 | {"instance", true}}; 416 | 417 | if (!timestamps.empty()) 418 | { 419 | ret["timestamps"] = timestamps; 420 | } 421 | 422 | if (!buttons.empty()) 423 | { 424 | ret["buttons"] = buttons; 425 | } 426 | 427 | return ret; 428 | } 429 | 430 | void Discord::sendPresenceMessage(const std::string &message) 431 | { 432 | if (!ipc.writeFrame(OP_FRAME, message)) 433 | { 434 | LOG_WARNING("Discord", "Failed to send presence update"); 435 | needs_reconnect = true; 436 | // Call disconnected callback if set 437 | if (onDisconnected) 438 | { 439 | onDisconnected(); 440 | } 441 | return; 442 | } 443 | 444 | int opcode; 445 | std::string response; 446 | if (ipc.readFrame(opcode, response)) 447 | { 448 | try 449 | { 450 | json response_json = json::parse(response); 451 | if (response_json.contains("evt") && response_json["evt"] == "ERROR") 452 | { 453 | LOG_WARNING_STREAM("Discord", "Discord rejected presence update: " << response); 454 | } 455 | } 456 | catch (const std::exception &e) 457 | { 458 | LOG_WARNING_STREAM("Discord", "Failed to parse response: " << e.what()); 459 | } 460 | } 461 | else 462 | { 463 | LOG_WARNING("Discord", "Failed to read Discord response"); 464 | } 465 | } 466 | 467 | void Discord::queuePresenceMessage(const std::string &message) 468 | { 469 | std::lock_guard lock(frame_queue_mutex); 470 | queued_frame = message; 471 | has_queued_frame = true; 472 | LOG_DEBUG("Discord", "Frame queued for sending"); 473 | } 474 | 475 | void Discord::processQueuedFrame() 476 | { 477 | std::string frame_to_send; 478 | 479 | { 480 | std::lock_guard lock(frame_queue_mutex); 481 | if (!has_queued_frame) 482 | { 483 | return; 484 | } 485 | 486 | auto now = std::chrono::steady_clock::now(); 487 | auto now_seconds = std::chrono::duration_cast(now.time_since_epoch()).count(); 488 | 489 | // Check rate limits 490 | if (!canSendFrame(now_seconds)) 491 | { 492 | return; 493 | } 494 | 495 | frame_to_send = queued_frame; 496 | has_queued_frame = false; 497 | 498 | // Record this frame write time 499 | frame_write_times.push_back(now_seconds); 500 | last_frame_write_time = now_seconds; 501 | } 502 | 503 | LOG_DEBUG("Discord", "Processing queued frame"); 504 | sendPresenceMessage(frame_to_send); 505 | } 506 | 507 | bool Discord::canSendFrame(int64_t current_time) 508 | { 509 | // Enforce minimum interval between frames 510 | if (current_time - last_frame_write_time < MIN_FRAME_INTERVAL_SECONDS) 511 | { 512 | LOG_DEBUG("Discord", "Rate limit: Too soon since last frame"); 513 | return false; 514 | } 515 | 516 | while (!frame_write_times.empty() && 517 | frame_write_times.front() < current_time - RATE_LIMIT_WINDOW_SECONDS) 518 | { 519 | frame_write_times.pop_front(); 520 | } 521 | 522 | // Check if we've reached the maximum frames per long window (15 seconds) 523 | if (frame_write_times.size() >= MAX_FRAMES_PER_WINDOW - 1) 524 | { 525 | LOG_DEBUG("Discord", "Rate limit: Maximum frames per 15-second window reached"); 526 | return false; 527 | } 528 | 529 | int frames_in_short_window = 0; 530 | for (const auto ×tamp : frame_write_times) 531 | { 532 | if (timestamp >= current_time - RATE_LIMIT_SHORT_WINDOW) 533 | { 534 | frames_in_short_window++; 535 | } 536 | } 537 | 538 | if (frames_in_short_window >= MAX_FRAMES_SHORT_WINDOW - 1) 539 | { 540 | LOG_DEBUG("Discord", "Rate limit: Maximum frames per 5-second window reached"); 541 | return false; 542 | } 543 | 544 | return true; 545 | } 546 | 547 | void Discord::clearPresence() 548 | { 549 | LOG_DEBUG("Discord", "clearPresence called"); 550 | if (!ipc.isConnected()) 551 | { 552 | LOG_WARNING("Discord", "Can't clear presence: not connected to Discord"); 553 | return; 554 | } 555 | 556 | is_playing = false; 557 | 558 | #ifdef _WIN32 559 | auto process_id = static_cast(GetCurrentProcessId()); 560 | #else 561 | auto process_id = static_cast(getpid()); 562 | #endif 563 | // Create empty presence payload to clear current presence 564 | json presence = { 565 | {"cmd", "SET_ACTIVITY"}, 566 | {"args", {{"pid", process_id}, {"activity", nullptr}}}, 567 | {"nonce", generateNonce()}}; 568 | 569 | std::string presence_str = presence.dump(); 570 | 571 | // Queue the clear presence message instead of sending immediately 572 | queuePresenceMessage(presence_str); 573 | } 574 | 575 | bool Discord::isStillAlive() 576 | { 577 | 578 | // Get current time 579 | auto now = std::chrono::duration_cast( 580 | std::chrono::steady_clock::now().time_since_epoch()) 581 | .count(); 582 | 583 | // Skip ping if there was a recent write 584 | if (now - last_frame_write_time < 60) 585 | { 586 | LOG_DEBUG("Discord", "Skipping ping due to recent write activity"); 587 | return true; 588 | } 589 | 590 | if (!ipc.sendPing()) 591 | { 592 | LOG_WARNING("Discord", "Failed to send ping"); 593 | return false; 594 | } 595 | 596 | // Read and process PONG response 597 | int opcode; 598 | std::string response; 599 | if (ipc.readFrame(opcode, response)) 600 | { 601 | if (opcode != OP_PONG) 602 | { 603 | LOG_WARNING_STREAM("Discord", "Unexpected response to PING. Opcode: " << opcode); 604 | return false; 605 | } 606 | } 607 | else 608 | { 609 | LOG_WARNING("Discord", "Failed to read PONG response"); 610 | return false; 611 | } 612 | 613 | return true; 614 | } 615 | 616 | void Discord::start() 617 | { 618 | LOG_INFO("Discord", "Starting Discord Rich Presence"); 619 | running = true; 620 | conn_thread = std::thread(&Discord::connectionThread, this); 621 | } 622 | 623 | void Discord::stop() 624 | { 625 | LOG_INFO("Discord", "Stopping Discord Rich Presence"); 626 | running = false; 627 | 628 | if (conn_thread.joinable()) 629 | { 630 | ThreadUtils::joinWithTimeout(conn_thread, std::chrono::seconds(3), "Discord connection thread"); 631 | } 632 | 633 | if (ipc.isConnected()) 634 | { 635 | ipc.closePipe(); 636 | } 637 | } 638 | 639 | void Discord::setConnectedCallback(ConnectionCallback callback) 640 | { 641 | onConnected = callback; 642 | } 643 | 644 | void Discord::setDisconnectedCallback(ConnectionCallback callback) 645 | { 646 | onDisconnected = callback; 647 | } 648 | -------------------------------------------------------------------------------- /src/plex.cpp: -------------------------------------------------------------------------------- 1 | #include "plex.h" 2 | 3 | // API endpoints and constants 4 | namespace 5 | { 6 | constexpr const char *PLEX_TV_API_URL = "https://plex.tv/api/v2"; 7 | constexpr const char *PLEX_PIN_URL = "https://plex.tv/api/v2/pins"; 8 | constexpr const char *PLEX_AUTH_URL = "https://app.plex.tv/auth#"; 9 | constexpr const char *PLEX_USER_URL = "https://plex.tv/api/v2/user"; 10 | constexpr const char *PLEX_RESOURCES_URL = "https://plex.tv/api/v2/resources?includeHttps=1"; 11 | constexpr const char *JIKAN_API_URL = "https://api.jikan.moe/v4/anime"; 12 | constexpr const char *TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; 13 | constexpr const char *SSE_NOTIFICATIONS_ENDPOINT = "/:/eventsource/notifications?filters=playing"; 14 | constexpr const char *SESSION_ENDPOINT = "/status/sessions"; 15 | 16 | // Cache timeouts (in seconds) 17 | constexpr const int TMDB_CACHE_TIMEOUT = 86400; // 24 hours 18 | constexpr const int MAL_CACHE_TIMEOUT = 86400; // 24 hours 19 | constexpr const int MEDIA_CACHE_TIMEOUT = 3600; // 1 hour 20 | constexpr const int SESSION_CACHE_TIMEOUT = 300; // 5 minutes 21 | } 22 | 23 | // Cache structures 24 | struct TimedCacheEntry 25 | { 26 | time_t timestamp; 27 | bool valid() const { return (std::time(nullptr) - timestamp) < MEDIA_CACHE_TIMEOUT; } 28 | }; 29 | 30 | struct TMDBCacheEntry : TimedCacheEntry 31 | { 32 | std::string artPath; 33 | bool valid() const { return (std::time(nullptr) - timestamp) < TMDB_CACHE_TIMEOUT; } 34 | }; 35 | 36 | struct MALCacheEntry : TimedCacheEntry 37 | { 38 | std::string malId; 39 | bool valid() const { return (std::time(nullptr) - timestamp) < MAL_CACHE_TIMEOUT; } 40 | }; 41 | 42 | struct MediaCacheEntry : TimedCacheEntry 43 | { 44 | MediaInfo info; 45 | }; 46 | 47 | struct SessionUserCacheEntry : TimedCacheEntry 48 | { 49 | std::string username; 50 | bool valid() const { return (std::time(nullptr) - timestamp) < SESSION_CACHE_TIMEOUT; } 51 | }; 52 | 53 | struct ServerUriCacheEntry : TimedCacheEntry 54 | { 55 | std::string uri; 56 | bool valid() const { return (std::time(nullptr) - timestamp) < SESSION_CACHE_TIMEOUT; } // Reuse session timeout 57 | }; 58 | 59 | Plex::Plex() : m_initialized(false), m_shuttingDown(false) 60 | { 61 | LOG_INFO("Plex", "Plex object created"); 62 | } 63 | 64 | Plex::~Plex() 65 | { 66 | LOG_INFO("Plex", "Plex object destroyed"); 67 | } 68 | 69 | bool Plex::init() 70 | { 71 | LOG_INFO("Plex", "Initializing Plex connection"); 72 | if (m_initialized) 73 | { 74 | LOG_WARNING("Plex", "Plex already initialized, skipping init"); 75 | return true; 76 | } 77 | m_initialized = false; 78 | m_shuttingDown = false; 79 | 80 | // Check if we have a Plex auth token 81 | auto &config = Config::getInstance(); 82 | std::string authToken = config.getPlexAuthToken(); 83 | 84 | if (authToken.empty()) 85 | { 86 | // No auth token, need to acquire one 87 | if (!acquireAuthToken()) 88 | { 89 | LOG_ERROR("Plex", "Failed to acquire Plex auth token"); 90 | return false; 91 | } 92 | authToken = config.getPlexAuthToken(); 93 | } 94 | 95 | LOG_INFO("Plex", "Using Plex auth token: " + authToken.substr(0, 5) + "..."); 96 | 97 | // Fetch available servers 98 | auto servers = config.getPlexServers(); 99 | if (config.getPlexServers().size() == 0) 100 | { 101 | LOG_INFO("Plex", "No Plex servers found, fetching from Plex.tv"); 102 | if (!fetchServers()) 103 | { 104 | LOG_ERROR("Plex", "Failed to fetch Plex servers"); 105 | return false; 106 | } 107 | } 108 | 109 | // Set up SSE connections to each server 110 | setupServerConnections(); 111 | 112 | m_initialized = true; 113 | return true; 114 | } 115 | 116 | // Helper function for URL encoding 117 | std::string Plex::urlEncode(const std::string &value) 118 | { 119 | std::ostringstream escaped; 120 | escaped.fill('0'); 121 | escaped << std::hex; 122 | 123 | for (char c : value) 124 | { 125 | if (isalnum(static_cast(c)) || c == '-' || c == '_' || c == '.' || c == '~') 126 | { 127 | escaped << c; 128 | } 129 | else 130 | { 131 | escaped << '%' << std::uppercase << std::setw(2) << int(static_cast(c)); 132 | escaped << std::nouppercase; 133 | } 134 | } 135 | 136 | return escaped.str(); 137 | } 138 | 139 | // Standard headers helper method 140 | std::map Plex::getStandardHeaders(const std::string &token) 141 | { 142 | std::map headers = { 143 | {"X-Plex-Client-Identifier", getClientIdentifier()}, 144 | {"X-Plex-Product", "Presence For Plex"}, 145 | {"X-Plex-Version", Config::getInstance().getVersionString()}, 146 | {"X-Plex-Device", "PC"}, 147 | #if defined(_WIN32) 148 | {"X-Plex-Platform", "Windows"}, 149 | #elif defined(__APPLE__) 150 | {"X-Plex-Platform", "macOS"}, 151 | #else 152 | {"X-Plex-Platform", "Linux"}, 153 | #endif 154 | {"Accept", "application/json"}}; 155 | 156 | // Add token if provided 157 | if (!token.empty()) 158 | { 159 | headers["X-Plex-Token"] = token; 160 | } 161 | 162 | return headers; 163 | } 164 | 165 | bool Plex::acquireAuthToken() 166 | { 167 | LOG_INFO("Plex", "Acquiring Plex auth token"); 168 | 169 | #ifdef _WIN32 170 | // Inform the user about the authentication process through the tray icon 171 | // This will be handled by the Application class's initialization 172 | #endif 173 | 174 | std::string clientId = getClientIdentifier(); 175 | HttpClient client; 176 | std::map headers = getStandardHeaders(); 177 | 178 | // Request a PIN from Plex 179 | std::string pinId, pin; 180 | if (!requestPlexPin(pinId, pin, client, headers)) 181 | { 182 | return false; 183 | } 184 | 185 | // Open browser for authentication 186 | openAuthorizationUrl(pin, clientId); 187 | 188 | // Poll for authorization 189 | return pollForPinAuthorization(pinId, pin, clientId, client, headers); 190 | } 191 | 192 | bool Plex::requestPlexPin(std::string &pinId, std::string &pin, HttpClient &client, 193 | const std::map &headers) 194 | { 195 | std::string response; 196 | std::string data = "strong=true"; 197 | 198 | if (!client.post(PLEX_PIN_URL, headers, data, response)) 199 | { 200 | LOG_ERROR("Plex", "Failed to request PIN from Plex"); 201 | return false; 202 | } 203 | 204 | LOG_DEBUG("Plex", "PIN response: " + response); 205 | 206 | // Parse the PIN response 207 | try 208 | { 209 | auto json = nlohmann::json::parse(response); 210 | pin = json["code"].get(); 211 | pinId = std::to_string(json["id"].get()); 212 | 213 | LOG_INFO("Plex", "Got PIN: " + pin + " (ID: " + pinId + ")"); 214 | return true; 215 | } 216 | catch (const std::exception &e) 217 | { 218 | LOG_ERROR("Plex", "Error parsing PIN response: " + std::string(e.what())); 219 | return false; 220 | } 221 | } 222 | 223 | void Plex::openAuthorizationUrl(const std::string &pin, const std::string &clientId) 224 | { 225 | // Construct the authorization URL 226 | std::string authUrl = std::string(PLEX_AUTH_URL) + 227 | "?clientID=" + clientId + 228 | "&code=" + pin + 229 | "&context%5Bdevice%5D%5Bproduct%5D=Presence%20For%20Plex"; 230 | 231 | LOG_INFO("Plex", "Opening browser for authentication: " + authUrl); 232 | 233 | #ifdef _WIN32 234 | // Show a message box to instruct the user before opening the browser 235 | MessageBoxA(NULL, 236 | "A browser window will open for Plex authentication.\n\n" 237 | "Please log in to your Plex account and authorize Presence For Plex.\n\n" 238 | "The application will continue setup after successful authentication.", 239 | "Plex Authentication Required", 240 | MB_ICONINFORMATION | MB_OK); 241 | 242 | ShellExecuteA(NULL, "open", authUrl.c_str(), NULL, NULL, SW_SHOWNORMAL); 243 | #else 244 | // For non-Windows platforms 245 | std::string cmd = "xdg-open \"" + authUrl + "\""; 246 | system(cmd.c_str()); 247 | #endif 248 | } 249 | 250 | bool Plex::pollForPinAuthorization(const std::string &pinId, const std::string &pin, 251 | const std::string &clientId, HttpClient &client, 252 | const std::map &headers) 253 | { 254 | const int maxAttempts = 30; // Try for about 5 minutes 255 | const int pollInterval = 10; // seconds 256 | const int sleepChunks = 10; // Break sleep into smaller chunks 257 | 258 | LOG_INFO("Plex", "Waiting for user to authorize PIN..."); 259 | 260 | for (int attempt = 0; attempt < maxAttempts; ++attempt) 261 | { 262 | // Check if application is shutting down 263 | if (m_shuttingDown) 264 | { 265 | LOG_INFO("Plex", "Application is shutting down, aborting PIN authorization"); 266 | return false; 267 | } 268 | 269 | // Wait before polling, but check for shutdown more frequently 270 | for (int i = 0; i < sleepChunks && !m_shuttingDown; ++i) 271 | { 272 | std::this_thread::sleep_for(std::chrono::seconds(pollInterval / sleepChunks)); 273 | } 274 | 275 | if (m_shuttingDown) 276 | { 277 | LOG_INFO("Plex", "Application is shutting down, aborting PIN authorization"); 278 | return false; 279 | } 280 | 281 | // Check PIN status 282 | std::string statusUrl = std::string(PLEX_PIN_URL) + "/" + pinId; 283 | std::string statusResponse; 284 | 285 | if (!client.get(statusUrl, headers, statusResponse)) 286 | { 287 | LOG_ERROR("Plex", "Failed to check PIN status"); 288 | continue; 289 | } 290 | 291 | try 292 | { 293 | auto statusJson = nlohmann::json::parse(statusResponse); 294 | bool authDone = statusJson["authToken"].is_string() && 295 | !statusJson["authToken"].get().empty(); 296 | 297 | if (authDone) 298 | { 299 | std::string authToken = statusJson["authToken"].get(); 300 | LOG_INFO("Plex", "Successfully authenticated with Plex!"); 301 | 302 | // Save the auth token 303 | Config::getInstance().setPlexAuthToken(authToken); 304 | 305 | // Fetch and save the username 306 | fetchAndSaveUsername(authToken, clientId); 307 | 308 | Config::getInstance().saveConfig(); 309 | 310 | return true; 311 | } 312 | else 313 | { 314 | LOG_DEBUG("Plex", "PIN not yet authorized, waiting... (" + 315 | std::to_string(attempt + 1) + "/" + 316 | std::to_string(maxAttempts) + ")"); 317 | } 318 | } 319 | catch (const std::exception &e) 320 | { 321 | LOG_ERROR("Plex", "Error parsing PIN status: " + std::string(e.what())); 322 | } 323 | } 324 | 325 | #ifdef _WIN32 326 | MessageBoxA(NULL, 327 | "Plex authentication timed out. Please try again.", 328 | "Plex Authentication Timeout", 329 | MB_ICONERROR | MB_OK); 330 | #endif 331 | 332 | LOG_ERROR("Plex", "Timed out waiting for PIN authorization"); 333 | return false; 334 | } 335 | 336 | bool Plex::fetchAndSaveUsername(const std::string &authToken, const std::string &clientId) 337 | { 338 | LOG_INFO("Plex", "Fetching Plex username"); 339 | 340 | // Create HTTP client 341 | HttpClient client; 342 | std::map headers = getStandardHeaders(authToken); 343 | 344 | // Make the request to fetch account information 345 | std::string response; 346 | 347 | if (!client.get(PLEX_USER_URL, headers, response)) 348 | { 349 | LOG_ERROR("Plex", "Failed to fetch user information"); 350 | return false; 351 | } 352 | 353 | try 354 | { 355 | auto json = nlohmann::json::parse(response); 356 | 357 | if (json.contains("username")) 358 | { 359 | std::string username = json["username"].get(); 360 | LOG_INFO("Plex", "Username: " + username); 361 | 362 | // Save the username 363 | Config::getInstance().setPlexUsername(username); 364 | return true; 365 | } 366 | else if (json.contains("title")) 367 | { 368 | // Some accounts may have title instead of username 369 | std::string username = json["title"].get(); 370 | LOG_INFO("Plex", "Username (from title): " + username); 371 | 372 | // Save the username 373 | Config::getInstance().setPlexUsername(username); 374 | return true; 375 | } 376 | 377 | LOG_ERROR("Plex", "Username not found in response"); 378 | } 379 | catch (const std::exception &e) 380 | { 381 | LOG_ERROR("Plex", "Error parsing user response: " + std::string(e.what())); 382 | } 383 | 384 | return false; 385 | } 386 | 387 | std::string Plex::getClientIdentifier() 388 | { 389 | auto &config = Config::getInstance(); 390 | std::string clientId = config.getPlexClientIdentifier(); 391 | 392 | if (clientId.empty()) 393 | { 394 | clientId = uuid::generate_uuid_v4(); 395 | config.setPlexClientIdentifier(clientId); 396 | config.saveConfig(); 397 | } 398 | 399 | return clientId; 400 | } 401 | 402 | bool Plex::fetchServers() 403 | { 404 | LOG_INFO("Plex", "Fetching Plex servers"); 405 | 406 | auto &config = Config::getInstance(); 407 | std::string authToken = config.getPlexAuthToken(); 408 | std::string clientId = config.getPlexClientIdentifier(); 409 | 410 | if (authToken.empty() || clientId.empty()) 411 | { 412 | LOG_ERROR("Plex", "Missing auth token or client identifier"); 413 | return false; 414 | } 415 | 416 | // Create HTTP client and headers 417 | HttpClient client; 418 | std::map headers = getStandardHeaders(authToken); 419 | 420 | // Make the request to Plex.tv 421 | std::string response; 422 | 423 | if (!client.get(PLEX_RESOURCES_URL, headers, response)) 424 | { 425 | LOG_ERROR("Plex", "Failed to fetch servers from Plex.tv"); 426 | return false; 427 | } 428 | 429 | LOG_DEBUG("Plex", "Received server response: " + response); 430 | 431 | // Parse the JSON response 432 | return parseServerJson(response); 433 | } 434 | 435 | bool Plex::parseServerJson(const std::string &jsonStr) 436 | { 437 | LOG_INFO("Plex", "Parsing server JSON"); 438 | 439 | try 440 | { 441 | auto json = nlohmann::json::parse(jsonStr); 442 | 443 | auto &config = Config::getInstance(); 444 | 445 | // Clear existing servers in config 446 | config.clearPlexServers(); 447 | 448 | // Process each resource (server) 449 | for (const auto &resource : json) 450 | { 451 | // Check if this is a Plex Media Server 452 | std::string provides = resource.value("provides", ""); 453 | if (provides != "server") 454 | { 455 | continue; 456 | } 457 | 458 | std::shared_ptr server = std::make_shared(); 459 | server->name = resource.value("name", "Unknown"); 460 | server->clientIdentifier = resource.value("clientIdentifier", ""); 461 | server->accessToken = resource.value("accessToken", ""); 462 | server->running = false; 463 | server->owned = resource.value("owned", false); 464 | 465 | LOG_INFO("Plex", "Found server: " + server->name + 466 | " (" + server->clientIdentifier + ")" + 467 | (server->owned ? " [owned]" : " [shared]")); 468 | 469 | // Process connections (we want both local and remote) 470 | if (resource.contains("connections") && resource["connections"].is_array()) 471 | { 472 | for (const auto &connection : resource["connections"]) 473 | { 474 | std::string uri = connection.value("uri", ""); 475 | bool isLocal = connection.value("local", false); 476 | 477 | if (isLocal) 478 | { 479 | server->localUri = uri; 480 | LOG_INFO("Plex", " Local URI: " + uri); 481 | } 482 | else 483 | { 484 | server->publicUri = uri; 485 | LOG_INFO("Plex", " Public URI: " + uri); 486 | } 487 | } 488 | } 489 | 490 | // Add server to our map and config 491 | if (!server->localUri.empty() || !server->publicUri.empty()) 492 | { 493 | // Save to config with ownership status 494 | config.addPlexServer(server->name, server->clientIdentifier, 495 | server->localUri, server->publicUri, 496 | server->accessToken, server->owned); 497 | } 498 | } 499 | 500 | config.saveConfig(); 501 | 502 | LOG_INFO("Plex", "Found " + std::to_string(config.getPlexServers().size()) + " Plex servers"); 503 | return !config.getPlexServers().empty(); 504 | } 505 | catch (const std::exception &e) 506 | { 507 | LOG_ERROR("Plex", "Failed to parse server JSON: " + std::string(e.what())); 508 | return false; 509 | } 510 | } 511 | 512 | void Plex::setupServerConnections() 513 | { 514 | LOG_INFO("Plex", "Setting up server connections"); 515 | 516 | for (auto &[id, server] : Config::getInstance().getPlexServers()) 517 | { 518 | setupServerSSEConnection(server); 519 | } 520 | } 521 | 522 | std::string Plex::getPreferredServerUri(const std::shared_ptr &server) 523 | { 524 | // Check if we have a cached URI that's still valid 525 | std::string serverId = server->clientIdentifier; 526 | { 527 | std::lock_guard cacheLock(m_cacheMutex); 528 | auto cacheIt = m_serverUriCache.find(serverId); 529 | if (cacheIt != m_serverUriCache.end() && cacheIt->second.valid()) 530 | { 531 | LOG_DEBUG("Plex", "Using cached URI for server " + server->name + ": " + cacheIt->second.uri); 532 | return cacheIt->second.uri; 533 | } 534 | } 535 | 536 | // No valid cache entry, determine the best URI to use 537 | std::string serverUri; 538 | 539 | // Only test local URI if it exists 540 | if (!server->localUri.empty()) 541 | { 542 | LOG_DEBUG("Plex", "Testing local URI accessibility: " + server->localUri); 543 | HttpClient testClient; 544 | std::map headers = getStandardHeaders(server->accessToken); 545 | std::string response; 546 | 547 | // Try a basic request to check connectivity 548 | if (testClient.get(server->localUri, headers, response)) 549 | { 550 | LOG_INFO("Plex", "Local URI is accessible, using it: " + server->localUri); 551 | serverUri = server->localUri; 552 | } 553 | else 554 | { 555 | LOG_INFO("Plex", "Local URI not accessible, falling back to public URI"); 556 | serverUri = server->publicUri; 557 | } 558 | } 559 | else 560 | { 561 | serverUri = server->publicUri; 562 | } 563 | 564 | // Cache the result 565 | { 566 | std::lock_guard cacheLock(m_cacheMutex); 567 | ServerUriCacheEntry entry; 568 | entry.timestamp = std::time(nullptr); 569 | entry.uri = serverUri; 570 | m_serverUriCache[serverId] = entry; 571 | } 572 | 573 | return serverUri; 574 | } 575 | 576 | void Plex::setupServerSSEConnection(const std::shared_ptr &server) 577 | { 578 | // Create a new HTTP client for this server 579 | server->httpClient = std::make_unique(); 580 | server->running = true; 581 | 582 | // Get the preferred URI 583 | std::string serverUri = getPreferredServerUri(server); 584 | 585 | if (serverUri.empty()) 586 | { 587 | LOG_WARNING("Plex", "No URI available for server: " + server->name); 588 | return; 589 | } 590 | 591 | LOG_INFO("Plex", "Setting up SSE connection to server: " + server->name + " using " + 592 | (serverUri == server->localUri ? "local" : "public") + " URI"); 593 | 594 | // Set up headers 595 | std::map headers = getStandardHeaders(server->accessToken); 596 | 597 | // Set up SSE endpoint URL 598 | std::string sseUrl = serverUri + SSE_NOTIFICATIONS_ENDPOINT; 599 | 600 | // Set up callback for SSE events 601 | auto callback = [this, id = server->clientIdentifier](const std::string &event) 602 | { 603 | this->handleSSEEvent(id, event); 604 | }; 605 | 606 | // Start SSE connection 607 | if (!server->httpClient->startSSE(sseUrl, headers, callback)) 608 | { 609 | LOG_ERROR("Plex", "Failed to set up SSE connection for server: " + server->name); 610 | } 611 | } 612 | 613 | void Plex::handleSSEEvent(const std::string &serverId, const std::string &event) 614 | { 615 | try 616 | { 617 | auto json = nlohmann::json::parse(event); 618 | 619 | LOG_DEBUG("Plex", "Received event from server " + serverId + ": " + event); 620 | 621 | // Check for PlaySessionStateNotification 622 | if (json.contains("PlaySessionStateNotification")) 623 | { 624 | processPlaySessionStateNotification(serverId, json["PlaySessionStateNotification"]); 625 | } 626 | } 627 | catch (const std::exception &e) 628 | { 629 | LOG_ERROR("Plex", "Error parsing SSE event: " + std::string(e.what()) + 630 | ", Event: " + event.substr(0, 100) + (event.length() > 100 ? "..." : "")); 631 | } 632 | } 633 | 634 | void Plex::processPlaySessionStateNotification(const std::string &serverId, const nlohmann::json ¬ification) 635 | { 636 | LOG_DEBUG("Plex", "Processing PlaySessionStateNotification: " + notification.dump()); 637 | 638 | // Find the server 639 | auto server_itr = Config::getInstance().getPlexServers().find(serverId); 640 | if (server_itr == Config::getInstance().getPlexServers().end()) 641 | { 642 | LOG_ERROR("Plex", "Unknown server ID: " + serverId); 643 | return; 644 | } 645 | auto server = server_itr->second; 646 | 647 | // Extract essential session information 648 | std::string sessionKey = notification.value("sessionKey", ""); 649 | std::string state = notification.value("state", ""); 650 | std::string mediaKey = notification.value("key", ""); 651 | int64_t viewOffset = notification.value("viewOffset", 0); 652 | 653 | LOG_DEBUG("Plex", "Playback state update received: " + state + " sessionKey: " + sessionKey); 654 | 655 | std::lock_guard lock(m_sessionMutex); 656 | 657 | if (state == "playing" || state == "paused" || state == "buffering") 658 | { 659 | updateSessionInfo(serverId, sessionKey, state, mediaKey, viewOffset, server); 660 | } 661 | else if (state == "stopped") 662 | { 663 | // Remove the session if it exists 664 | if (m_activeSessions.find(sessionKey) != m_activeSessions.end()) 665 | { 666 | LOG_INFO("Plex", "Removing stopped session: " + sessionKey); 667 | m_activeSessions.erase(sessionKey); 668 | } 669 | } 670 | } 671 | 672 | void Plex::updateSessionInfo(const std::string &serverId, const std::string &sessionKey, 673 | const std::string &state, const std::string &mediaKey, 674 | int64_t viewOffset, const std::shared_ptr &server) 675 | { 676 | // Get the preferred URI 677 | std::string serverUri = getPreferredServerUri(server); 678 | 679 | // For owned servers, we need to check if this session belongs to the current user 680 | if (server->owned) 681 | { 682 | // Create cache key for session user info 683 | std::string sessionUserCacheKey = serverUri + sessionKey; 684 | bool needUserFetch = true; 685 | std::string username; 686 | 687 | { 688 | std::lock_guard cacheLock(m_cacheMutex); 689 | auto cacheIt = m_sessionUserCache.find(sessionUserCacheKey); 690 | if (cacheIt != m_sessionUserCache.end() && cacheIt->second.valid()) 691 | { 692 | username = cacheIt->second.username; 693 | needUserFetch = false; 694 | LOG_DEBUG("Plex", "Using cached user info for session: " + sessionKey); 695 | } 696 | } 697 | 698 | if (needUserFetch) 699 | { 700 | username = fetchSessionUsername(serverUri, server->accessToken, sessionKey); 701 | 702 | if (!username.empty()) 703 | { 704 | std::lock_guard cacheLock(m_cacheMutex); 705 | SessionUserCacheEntry entry; 706 | entry.timestamp = std::time(nullptr); 707 | entry.username = username; 708 | m_sessionUserCache[sessionUserCacheKey] = entry; 709 | } 710 | else 711 | { 712 | LOG_DEBUG("Plex", "No username found for session: " + sessionKey + 713 | ", retrying after a short delay"); 714 | std::this_thread::sleep_for(std::chrono::seconds(1)); 715 | username = fetchSessionUsername(serverUri, server->accessToken, sessionKey); 716 | if (!username.empty()) 717 | { 718 | std::lock_guard cacheLock(m_cacheMutex); 719 | SessionUserCacheEntry entry; 720 | entry.timestamp = std::time(nullptr); 721 | entry.username = username; 722 | m_sessionUserCache[sessionUserCacheKey] = entry; 723 | } 724 | else 725 | { 726 | LOG_WARNING("Plex", "Did not find a username tied to session, not updating: " + sessionKey); 727 | return; 728 | } 729 | } 730 | } 731 | 732 | // Skip sessions that don't belong to the current user 733 | if (username != Config::getInstance().getPlexUsername()) 734 | { 735 | LOG_DEBUG("Plex", "Ignoring session for different user: " + username); 736 | return; 737 | } 738 | } 739 | 740 | // Create a cache key for media info 741 | std::string mediaInfoCacheKey = serverUri + mediaKey; 742 | 743 | // Check if we have cached media info that's still valid 744 | MediaInfo info; 745 | bool needMediaFetch = true; 746 | 747 | { 748 | std::lock_guard cacheLock(m_cacheMutex); 749 | auto cacheIt = m_mediaInfoCache.find(mediaInfoCacheKey); 750 | if (cacheIt != m_mediaInfoCache.end() && cacheIt->second.valid()) 751 | { 752 | info = cacheIt->second.info; 753 | needMediaFetch = false; 754 | LOG_DEBUG("Plex", "Using cached media info for key: " + mediaKey); 755 | } 756 | } 757 | 758 | // Fetch media details if needed 759 | if (needMediaFetch) 760 | { 761 | info = fetchMediaDetails(serverUri, server->accessToken, mediaKey); 762 | 763 | // Cache the result 764 | std::lock_guard cacheLock(m_cacheMutex); 765 | MediaCacheEntry entry; 766 | entry.timestamp = std::time(nullptr); 767 | entry.info = info; 768 | m_mediaInfoCache[mediaInfoCacheKey] = entry; 769 | } 770 | 771 | // Update playback state 772 | updatePlaybackState(info, state, viewOffset); 773 | 774 | // Update session key and server ID 775 | info.sessionKey = sessionKey; 776 | info.serverId = serverId; 777 | 778 | // Store the updated info 779 | m_activeSessions[sessionKey] = info; 780 | 781 | LOG_INFO("Plex", "Updated session " + sessionKey + ": " + info.title + 782 | " (" + std::to_string(info.progress) + "/" + std::to_string(info.duration) + "s)"); 783 | } 784 | 785 | void Plex::updatePlaybackState(MediaInfo &info, const std::string &state, int64_t viewOffset) 786 | { 787 | if (state == "playing") 788 | { 789 | info.state = PlaybackState::Playing; 790 | } 791 | else if (state == "paused") 792 | { 793 | info.state = PlaybackState::Paused; 794 | } 795 | else if (state == "buffering") 796 | { 797 | info.state = PlaybackState::Buffering; 798 | } 799 | 800 | info.progress = viewOffset / 1000.0; // Convert from milliseconds to seconds 801 | info.startTime = std::time(nullptr) - static_cast(info.progress); 802 | } 803 | 804 | std::string Plex::fetchSessionUsername(const std::string &serverUri, const std::string &accessToken, 805 | const std::string &sessionKey) 806 | { 807 | LOG_DEBUG("Plex", "Fetching username for session: " + sessionKey); 808 | 809 | HttpClient client; 810 | std::map headers = getStandardHeaders(accessToken); 811 | 812 | std::string url = serverUri + SESSION_ENDPOINT; 813 | std::string response; 814 | 815 | if (!client.get(url, headers, response)) 816 | { 817 | LOG_ERROR("Plex", "Failed to fetch session information"); 818 | return ""; 819 | } 820 | 821 | try 822 | { 823 | auto json = nlohmann::json::parse(response); 824 | 825 | if (!json.contains("MediaContainer")) 826 | { 827 | LOG_ERROR("Plex", "Invalid session response format" + response); 828 | return ""; 829 | } 830 | if (json["MediaContainer"].contains("size") && json["MediaContainer"]["size"].get() == 0) 831 | { 832 | LOG_DEBUG("Plex", "No active sessions found"); 833 | return ""; 834 | } 835 | 836 | // Find the matching session by sessionKey 837 | for (const auto &session : json["MediaContainer"]["Metadata"]) 838 | { 839 | if (session.contains("sessionKey") && session["sessionKey"].get() == sessionKey) 840 | { 841 | // Extract user info 842 | if (session.contains("User") && session["User"].contains("title")) 843 | { 844 | std::string username = session["User"]["title"].get(); 845 | LOG_INFO("Plex", "Found user for session " + sessionKey + ": " + username); 846 | return username; 847 | } 848 | break; 849 | } 850 | } 851 | } 852 | catch (const std::exception &e) 853 | { 854 | LOG_ERROR("Plex", "Error parsing session data: " + std::string(e.what())); 855 | } 856 | 857 | return ""; 858 | } 859 | 860 | MediaInfo Plex::fetchMediaDetails(const std::string &serverUri, const std::string &accessToken, 861 | const std::string &mediaKey) 862 | { 863 | LOG_DEBUG("Plex", "Fetching media details for key: " + mediaKey); 864 | 865 | MediaInfo info; 866 | info.state = PlaybackState::Stopped; 867 | 868 | HttpClient client; 869 | std::map headers = getStandardHeaders(accessToken); 870 | 871 | std::string url = serverUri + mediaKey; 872 | std::string response; 873 | 874 | if (!client.get(url, headers, response)) 875 | { 876 | LOG_ERROR("Plex", "Failed to fetch media details"); 877 | return info; 878 | } 879 | 880 | try 881 | { 882 | auto json = nlohmann::json::parse(response); 883 | 884 | if (!json.contains("MediaContainer") || !json["MediaContainer"].contains("Metadata") || 885 | json["MediaContainer"]["Metadata"].empty()) 886 | { 887 | LOG_ERROR("Plex", "Invalid media details response"); 888 | return info; 889 | } 890 | 891 | auto metadata = json["MediaContainer"]["Metadata"][0]; 892 | 893 | // Extract common info first 894 | extractBasicMediaInfo(metadata, info); 895 | 896 | // Handle media type-specific details 897 | std::string type = metadata.value("type", "unknown"); 898 | if (type == "movie") 899 | { 900 | extractMovieSpecificInfo(metadata, info); 901 | } 902 | else if (type == "episode") 903 | { 904 | extractTVShowSpecificInfo(metadata, info); 905 | // Fetch grandparent metadata for GUIDs and Genres if needed 906 | fetchGrandparentMetadata(serverUri, accessToken, info); 907 | } 908 | else if (type == "track") 909 | { 910 | extractMusicSpecificInfo(metadata, info, serverUri, accessToken); 911 | } 912 | else 913 | { 914 | info.type = MediaType::Unknown; 915 | } 916 | 917 | LOG_INFO("Plex", "Media details: " + info.title + " (" + type + ")"); 918 | } 919 | catch (const std::exception &e) 920 | { 921 | LOG_ERROR("Plex", "Error parsing media details: " + std::string(e.what())); 922 | } 923 | 924 | return info; 925 | } 926 | 927 | void Plex::extractBasicMediaInfo(const nlohmann::json &metadata, MediaInfo &info) 928 | { 929 | // Set basic info common to all media types 930 | info.title = metadata.value("title", "Unknown"); 931 | info.originalTitle = metadata.value("originalTitle", info.title); 932 | info.duration = metadata.value("duration", 0) / 1000.0; // Convert from milliseconds to seconds 933 | info.summary = metadata.value("summary", "No summary available"); 934 | info.year = metadata.value("year", 0); 935 | // Extract potential album/parent title 936 | info.album = metadata.value("parentTitle", ""); // Often the album for music 937 | info.artist = metadata.value("grandparentTitle", ""); // Often the artist for music 938 | } 939 | 940 | void Plex::extractMovieSpecificInfo(const nlohmann::json &metadata, MediaInfo &info) 941 | { 942 | info.type = MediaType::Movie; 943 | parseGuid(metadata, info); 944 | parseGenres(metadata, info); 945 | } 946 | 947 | void Plex::extractTVShowSpecificInfo(const nlohmann::json &metadata, MediaInfo &info) 948 | { 949 | info.type = MediaType::TVShow; 950 | info.grandparentTitle = metadata.value("grandparentTitle", "Unknown"); 951 | info.season = metadata.value("parentIndex", 0); 952 | info.episode = metadata.value("index", 0); 953 | 954 | if (metadata.contains("grandparentKey")) 955 | { 956 | info.grandparentKey = metadata.value("grandparentKey", ""); 957 | } 958 | } 959 | 960 | void Plex::extractMusicSpecificInfo(const nlohmann::json &metadata, MediaInfo &info, 961 | const std::string &serverUri, const std::string &accessToken) 962 | { 963 | info.type = MediaType::Music; 964 | 965 | parseGenres(metadata, info); 966 | } 967 | 968 | void Plex::fetchGrandparentMetadata(const std::string &serverUrl, const std::string &accessToken, 969 | MediaInfo &info) 970 | { 971 | 972 | if (info.grandparentKey.empty()) 973 | { 974 | LOG_ERROR("Plex", "No grandparent key available for TV show metadata"); 975 | return; 976 | } 977 | 978 | LOG_DEBUG("Plex", "Fetching TV show metadata for key: " + info.grandparentKey); 979 | 980 | // Create HTTP client 981 | HttpClient client; 982 | 983 | // Set up headers 984 | std::map headers = getStandardHeaders(accessToken); 985 | 986 | // Make the request 987 | std::string url = serverUrl + info.grandparentKey; 988 | std::string response; 989 | 990 | if (!client.get(url, headers, response)) 991 | { 992 | LOG_ERROR("Plex", "Failed to fetch TV show metadata"); 993 | return; 994 | } 995 | 996 | try 997 | { 998 | auto json = nlohmann::json::parse(response); 999 | 1000 | if (!json.contains("MediaContainer") || !json["MediaContainer"].contains("Metadata") || 1001 | json["MediaContainer"]["Metadata"].empty()) 1002 | { 1003 | LOG_ERROR("Plex", "Invalid TV show metadata response"); 1004 | return; 1005 | } 1006 | 1007 | auto metadata = json["MediaContainer"]["Metadata"][0]; 1008 | 1009 | parseGuid(metadata, info); 1010 | 1011 | // Parse genres 1012 | parseGenres(metadata, info); 1013 | } 1014 | catch (const std::exception &e) 1015 | { 1016 | LOG_ERROR("Plex", "Error parsing TV show metadata: " + std::string(e.what())); 1017 | } 1018 | } 1019 | 1020 | void Plex::parseGuid(const nlohmann::json &metadata, MediaInfo &info) 1021 | { 1022 | if (metadata.contains("Guid") && metadata["Guid"].is_array()) 1023 | { 1024 | for (const auto &guid : metadata["Guid"]) 1025 | { 1026 | std::string id = guid.value("id", ""); 1027 | if (id.find("imdb://") == 0) 1028 | { 1029 | info.imdbId = id.substr(7); 1030 | LOG_INFO("Plex", "Found IMDb ID: " + info.imdbId); 1031 | } 1032 | else if (id.find("tmdb://") == 0) 1033 | { 1034 | info.tmdbId = id.substr(7); 1035 | 1036 | // Check TMDB artwork cache 1037 | bool needArtworkFetch = true; 1038 | { 1039 | std::lock_guard cacheLock(m_cacheMutex); 1040 | auto cacheIt = m_tmdbArtworkCache.find(info.tmdbId); 1041 | if (cacheIt != m_tmdbArtworkCache.end() && cacheIt->second.valid()) 1042 | { 1043 | info.artPath = cacheIt->second.artPath; 1044 | needArtworkFetch = false; 1045 | LOG_DEBUG("Plex", "Using cached TMDB artwork for ID: " + info.tmdbId); 1046 | } 1047 | } 1048 | 1049 | if (needArtworkFetch) 1050 | { 1051 | fetchTMDBArtwork(info.tmdbId, info); 1052 | 1053 | // Cache the result if we found artwork 1054 | if (!info.artPath.empty()) 1055 | { 1056 | std::lock_guard cacheLock(m_cacheMutex); 1057 | TMDBCacheEntry entry; 1058 | entry.timestamp = std::time(nullptr); 1059 | entry.artPath = info.artPath; 1060 | m_tmdbArtworkCache[info.tmdbId] = entry; 1061 | } 1062 | } 1063 | 1064 | LOG_INFO("Plex", "Found TMDB ID: " + info.tmdbId); 1065 | } 1066 | } 1067 | } 1068 | } 1069 | 1070 | void Plex::parseGenres(const nlohmann::json &metadata, MediaInfo &info) 1071 | { 1072 | if (metadata.contains("Genre") && metadata["Genre"].is_array()) 1073 | { 1074 | for (const auto &genre : metadata["Genre"]) 1075 | { 1076 | info.genres.push_back(genre.value("tag", "")); 1077 | } 1078 | } 1079 | 1080 | if (isAnimeContent(metadata)) 1081 | { 1082 | fetchAnimeMetadata(metadata, info); 1083 | } 1084 | } 1085 | 1086 | bool Plex::isAnimeContent(const nlohmann::json &metadata) 1087 | { 1088 | if (metadata.contains("Genre") && metadata["Genre"].is_array()) 1089 | { 1090 | for (const auto &genre : metadata["Genre"]) 1091 | { 1092 | if (genre.value("tag", "") == "Anime") 1093 | { 1094 | LOG_INFO("Plex", "Detected Anime genre tag"); 1095 | return true; 1096 | } 1097 | } 1098 | } 1099 | return false; 1100 | } 1101 | 1102 | void Plex::fetchAnimeMetadata(const nlohmann::json &metadata, MediaInfo &info) 1103 | { 1104 | LOG_INFO("Plex", "Anime detected, searching MyAnimeList via Jikan API"); 1105 | 1106 | // Create cache key (use title as key) 1107 | std::string cacheKey = metadata.value("title", "Unknown") + "_" + 1108 | std::to_string(metadata.value("year", 0)); 1109 | 1110 | // Check if we have cached MAL info 1111 | bool needMALFetch = true; 1112 | { 1113 | std::lock_guard cacheLock(m_cacheMutex); 1114 | auto cacheIt = m_malIdCache.find(cacheKey); 1115 | if (cacheIt != m_malIdCache.end() && cacheIt->second.valid()) 1116 | { 1117 | info.malId = cacheIt->second.malId; 1118 | needMALFetch = false; 1119 | LOG_DEBUG("Plex", "Using cached MAL ID for: " + cacheKey); 1120 | } 1121 | } 1122 | 1123 | if (needMALFetch) 1124 | { 1125 | HttpClient jikanClient; 1126 | std::string encodedTitle = cacheKey; 1127 | std::string::size_type pos = 0; 1128 | 1129 | encodedTitle = urlEncode(encodedTitle); 1130 | 1131 | std::string jikanUrl = std::string(JIKAN_API_URL) + "?q=" + encodedTitle; 1132 | 1133 | std::string jikanResponse; 1134 | if (jikanClient.get(jikanUrl, {}, jikanResponse)) 1135 | { 1136 | try 1137 | { 1138 | auto jikanJson = nlohmann::json::parse(jikanResponse); 1139 | if (jikanJson.contains("data") && !jikanJson["data"].empty()) 1140 | { 1141 | auto firstResult = jikanJson["data"][0]; 1142 | if (firstResult.contains("mal_id")) 1143 | { 1144 | info.malId = std::to_string(firstResult["mal_id"].get()); 1145 | LOG_INFO("Plex", "Found MyAnimeList ID: " + info.malId); 1146 | 1147 | // Cache the result 1148 | std::lock_guard cacheLock(m_cacheMutex); 1149 | MALCacheEntry entry; 1150 | entry.timestamp = std::time(nullptr); 1151 | entry.malId = info.malId; 1152 | m_malIdCache[cacheKey] = entry; 1153 | } 1154 | } 1155 | } 1156 | catch (const std::exception &e) 1157 | { 1158 | LOG_ERROR("Plex", "Error parsing Jikan API response: " + std::string(e.what())); 1159 | } 1160 | } 1161 | else 1162 | { 1163 | LOG_ERROR("Plex", "Failed to fetch data from Jikan API"); 1164 | } 1165 | } 1166 | } 1167 | 1168 | void Plex::fetchTMDBArtwork(const std::string &tmdbId, MediaInfo &info) 1169 | { 1170 | LOG_DEBUG("Plex", "Fetching TMDB artwork for ID: " + tmdbId); 1171 | 1172 | // TMDB API requires an access token - get it from config 1173 | std::string accessToken = Config::getInstance().getTMDBAccessToken(); 1174 | 1175 | if (accessToken.empty()) 1176 | { 1177 | LOG_INFO("Plex", "No TMDB access token available"); 1178 | return; 1179 | } 1180 | 1181 | // Create HTTP client 1182 | HttpClient client; 1183 | std::string url; 1184 | 1185 | // Construct proper endpoint URL based on media type 1186 | if (info.type == MediaType::Movie) 1187 | { 1188 | url = "https://api.themoviedb.org/3/movie/" + tmdbId + "/images"; 1189 | } 1190 | else 1191 | { 1192 | url = "https://api.themoviedb.org/3/tv/" + tmdbId + "/images"; 1193 | } 1194 | 1195 | // Set up headers with Bearer token for v4 authentication 1196 | std::map headers = { 1197 | {"Authorization", "Bearer " + accessToken}, 1198 | {"Content-Type", "application/json;charset=utf-8"}}; 1199 | 1200 | // Make the request 1201 | std::string response; 1202 | if (!client.get(url, headers, response)) 1203 | { 1204 | LOG_ERROR("Plex", "Failed to fetch TMDB images"); 1205 | return; 1206 | } 1207 | 1208 | try 1209 | { 1210 | auto json = nlohmann::json::parse(response); 1211 | 1212 | // First try to get a poster 1213 | if (json.contains("posters") && !json["posters"].empty()) 1214 | { 1215 | std::string posterPath = json["posters"][0]["file_path"]; 1216 | info.artPath = std::string(TMDB_IMAGE_BASE_URL) + posterPath; 1217 | LOG_INFO("Plex", "Found TMDB poster: " + info.artPath); 1218 | } 1219 | // Fallback to backdrops 1220 | else if (json.contains("backdrops") && !json["backdrops"].empty()) 1221 | { 1222 | std::string backdropPath = json["backdrops"][0]["file_path"]; 1223 | info.artPath = std::string(TMDB_IMAGE_BASE_URL) + backdropPath; 1224 | LOG_INFO("Plex", "Found TMDB backdrop: " + info.artPath); 1225 | } 1226 | } 1227 | catch (const std::exception &e) 1228 | { 1229 | LOG_ERROR("Plex", "Error parsing TMDB response: " + std::string(e.what())); 1230 | } 1231 | } 1232 | 1233 | MediaInfo Plex::getCurrentPlayback() 1234 | { 1235 | if (!m_initialized) 1236 | { 1237 | LOG_WARNING("Plex", "Plex not initialized"); 1238 | MediaInfo info; 1239 | info.state = PlaybackState::NotInitialized; 1240 | return info; 1241 | } 1242 | 1243 | std::lock_guard lock(m_sessionMutex); 1244 | 1245 | // If no active sessions, return stopped state 1246 | if (m_activeSessions.empty()) 1247 | { 1248 | LOG_DEBUG("Plex", "No active sessions"); 1249 | MediaInfo info; 1250 | info.state = PlaybackState::Stopped; 1251 | return info; 1252 | } 1253 | 1254 | // Find the oldest playing/paused/buffering session 1255 | MediaInfo newest; 1256 | time_t newestTime = (std::numeric_limits::min)(); 1257 | 1258 | for (const auto &[key, info] : m_activeSessions) 1259 | { 1260 | if (info.state == PlaybackState::Playing || 1261 | info.state == PlaybackState::Paused || 1262 | info.state == PlaybackState::Buffering) 1263 | { 1264 | 1265 | if (info.startTime > newestTime) 1266 | { 1267 | newest = info; 1268 | newestTime = info.startTime; 1269 | } 1270 | } 1271 | } 1272 | 1273 | if (newestTime == (std::numeric_limits::min)()) 1274 | { 1275 | // No playing/paused/buffering sessions 1276 | LOG_DEBUG("Plex", "No active playing sessions"); 1277 | MediaInfo info; 1278 | info.state = PlaybackState::Stopped; 1279 | return info; 1280 | } 1281 | 1282 | LOG_DEBUG("Plex", "Returning playback info for: " + newest.title + " (" + std::to_string(static_cast(newest.state)) + ")"); 1283 | return newest; 1284 | } 1285 | 1286 | void Plex::stop() 1287 | { 1288 | LOG_INFO("Plex", "Stopping all Plex connections"); 1289 | 1290 | m_shuttingDown = true; 1291 | 1292 | // Stop all SSE connections with a very short timeout since we're shutting down 1293 | for (auto &[id, server] : Config::getInstance().getPlexServers()) 1294 | { 1295 | if (server->httpClient) 1296 | { 1297 | LOG_INFO("Plex", "Stopping SSE connection for server: " + server->name); 1298 | server->running = false; 1299 | 1300 | // Explicitly reset the client to ensure destruction 1301 | server->httpClient.reset(); 1302 | } 1303 | } 1304 | 1305 | // Clear any cached data 1306 | std::lock_guard cacheLock(m_cacheMutex); 1307 | m_tmdbArtworkCache.clear(); 1308 | m_malIdCache.clear(); 1309 | m_mediaInfoCache.clear(); 1310 | m_sessionUserCache.clear(); 1311 | m_serverUriCache.clear(); 1312 | 1313 | m_initialized = false; 1314 | LOG_INFO("Plex", "All Plex connections stopped"); 1315 | } 1316 | --------------------------------------------------------------------------------