├── .gitignore ├── README.md ├── _PlexPosterSetHelper.spec ├── assets ├── bulk_import.png ├── cli_overview.png ├── gui_overview.png └── url_scrape.png ├── bulk_import.txt ├── dist └── Plex Poster Set Helper (standalone).zip ├── example_config.json ├── icons └── Plex.ico ├── plex_poster_set_helper.py ├── requirements.txt └── test_module.py /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | __pycache__ 3 | tempCodeRunnerFile.py 4 | build/ 5 | !dist/*.zip -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # plex-poster-set-helper 3 | 4 | plex-poster-set-helper is a tool to help upload sets of posters from ThePosterDB or MediUX to your Plex server in seconds! 5 | 6 | ## Installation 7 | 8 | 1. [Install Python](https://www.python.org/downloads/) (if not installed already) 9 | 10 | 2. Extract all files into a folder 11 | 12 | 3. Open a terminal in the folder 13 | 14 | 4. Install the required dependencies using 15 | 16 | ```bash 17 | pip install -r requirements.txt 18 | ``` 19 | 20 | 5. Rename example_config.json to config.json, and populate with the proper information: 21 | - **"base_url"** 22 | - The IP and port of your Plex server. e.g. "http://12.345.67.890:32400/". 23 | - **"token"** 24 | - Your Plex token (can be found [here](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)). 25 | - **"tv_library"** 26 | - The name of your TV Shows library (e.g., "TV Shows"). Multiple libraries are also supported (see the **Multiple Libraries** section below). 27 | - **"movie_library"** 28 | - The name of your Movies library (e.g., "Movies"). Multiple libraries are also supported (see the **Multiple Libraries** section below). 29 | - **"mediux_filters"** 30 | - Specify which media types to upload by including these flags: 31 | - show_cover 32 | - background 33 | - season_cover 34 | - title_card 35 | 36 | ## Usage 37 | 38 | Run python plex_poster_set_helper.py in a terminal, using one of the following options: 39 | 40 | ### Command Line Arguments 41 | 42 | The script supports various command-line arguments for flexible use. 43 | 44 | 1. **Launch the GUI** 45 | Use the gui argument to open the graphical user interface: 46 | 47 | ```bash 48 | python plex_poster_set_helper.py gui 49 | ``` 50 | 51 | 2. **Single Link Import** 52 | Provide a link directly to set posters from a single MediUX or ThePosterDB set: 53 | 54 | ```bash 55 | python plex_poster_set_helper.py https://mediux.pro/sets/9242 56 | ``` 57 | 58 | 3. **Bulk Import** 59 | Import multiple links from a .txt file using the bulk argument: 60 | 61 | ```bash 62 | python plex_poster_set_helper.py bulk bulk_import.txt 63 | ``` 64 | 65 | - The .txt file should contain one URL per line. Lines starting with # or // will be ignored as comments. 66 | 67 | - **If no text file parameter is provided, it will use the default value from config.json for bulk_txt.** 68 | 69 | 70 | ## Supported Features 71 | 72 | ### Interactive CLI Mode 73 | 74 | ![GUI Overview](https://raw.githubusercontent.com/tonywied17/plex-poster-set-helper/refs/heads/main/assets/cli_overview.png) 75 | 76 | If no command-line arguments are provided, the script will enter an interactive CLI mode, where you can select from menu options to perform various tasks: 77 | 78 | - **Option 1:** Enter a ThePosterDB set URL, MediUX set URL, or ThePosterDB user URL to set posters for individual items or entire user collections. 79 | - **Option 2:** Run a bulk import by specifying the path to a `.txt` file containing multiple URLs (or simply press `Enter` to use the default bulk file defined in `config.json`). 80 | - **Option 3:** Launch the GUI for a graphical interface. 81 | - **Option 4:** Stop the program and exit. 82 | 83 | When using bulk import, if no file path is specified, the script will default to the file provided in the `config.json` under the `bulk_txt` key. Each URL in the `.txt` file should be on a separate line, and any lines starting with `#` or `//` will be ignored as comments. 84 | 85 | ### GUI Mode 86 | 87 | ![GUI Overview](https://raw.githubusercontent.com/tonywied17/plex-poster-set-helper/refs/heads/main/assets/gui_overview.png) 88 | ![Bulk Import](https://raw.githubusercontent.com/tonywied17/plex-poster-set-helper/refs/heads/main/assets/bulk_import.png) 89 | ![URL Scrape](https://raw.githubusercontent.com/tonywied17/plex-poster-set-helper/refs/heads/main/assets/url_scrape.png) 90 | 91 | 92 | The GUI provides a more user-friendly interface for managing poster uploads. Users can run the script with python plex_poster_set_helper.py gui to launch the CustomTkinter-based interface, where they can: 93 | - Easily enter single or bulk URLs. 94 | - View progress, status updates, and more in an intuitive layout. 95 | 96 | ### Multiple Libraries 97 | 98 | To target multiple Plex libraries, modify config.json as follows: 99 | 100 | ```json 101 | "tv_library": ["TV Shows", "Kids TV Shows"], 102 | "movie_library": ["Movies", "Kids Movies"] 103 | ``` 104 | 105 | Using these options, the tool will apply posters to the same media in all specified libraries. 106 | 107 | ### Bulk Import 108 | 109 | 1. Use the bulk argument to import your default `bulk_text` file specified in `config.json`. 110 | 2. Or, specify the path to a .txt file containing URLs as a second argument. Each URL will be processed to set posters for the corresponding media. 111 | 112 | ### Filters 113 | 114 | The mediux_filters option in config.json allows you to control which media types get posters: 115 | - show_cover: Upload covers for TV shows. 116 | - background: Upload background images. 117 | - season_cover: Set posters for each season. 118 | - title_card: Add title cards. 119 | 120 | ## Executable Build 121 | 122 | In the `dist/` directory, you'll find the compiled executable for Windows: `Plex Poster Set Helper.zip`. This executable allows you to run the tool without needing to have Python installed. 123 | 124 | To rebuild the executable: 125 | 126 | *Note: Prior to building, set the `interactive_cli` boolean to False on line `20` to ensure the executable launches in GUI Mode by default* 127 | 128 | 1. Install PyInstaller if you don't have it already: 129 | ```bash 130 | pip install pyinstaller 131 | ``` 132 | 133 | 2. Use the provided spec file (`_PlexPosterSetHelper.spec`) to build the executable: 134 | 135 | ```bash 136 | pyinstaller _PlexPosterSetHelper.spec 137 | ``` 138 | 139 | This will create the executable along with the necessary files. 140 | -------------------------------------------------------------------------------- /_PlexPosterSetHelper.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['plex_poster_set_helper.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[('icons/Plex.ico', 'icons')], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | pyz = PYZ(a.pure) 18 | 19 | exe = EXE( 20 | pyz, 21 | a.scripts, 22 | a.binaries, 23 | a.datas, 24 | [], 25 | name='Plex Poster Set Helper', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | upx_exclude=[], 31 | runtime_tmpdir=None, 32 | console=False, 33 | disable_windowed_traceback=False, 34 | argv_emulation=False, 35 | target_arch=None, 36 | codesign_identity=None, 37 | entitlements_file=None, 38 | icon=['icons\\Plex.ico'], 39 | ) 40 | -------------------------------------------------------------------------------- /assets/bulk_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbrown430/plex-poster-set-helper/a7382134eeb6421b14c052950695bbefc6282913/assets/bulk_import.png -------------------------------------------------------------------------------- /assets/cli_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbrown430/plex-poster-set-helper/a7382134eeb6421b14c052950695bbefc6282913/assets/cli_overview.png -------------------------------------------------------------------------------- /assets/gui_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbrown430/plex-poster-set-helper/a7382134eeb6421b14c052950695bbefc6282913/assets/gui_overview.png -------------------------------------------------------------------------------- /assets/url_scrape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbrown430/plex-poster-set-helper/a7382134eeb6421b14c052950695bbefc6282913/assets/url_scrape.png -------------------------------------------------------------------------------- /bulk_import.txt: -------------------------------------------------------------------------------- 1 | # bulk_import.txt file with just URLs on separate lines. 2 | # You CAN comment with "#" and "//" if you desire! 3 | 4 | # --- Movies --------------------------------------------- 5 | 6 | // The Dark Knight Collection 7 | https://theposterdb.com/set/13035 8 | 9 | // Pirates of the Caribbean: 10 | https://mediux.pro/sets/12816 11 | 12 | // The Terminator 13 | https://mediux.pro/sets/10551 14 | 15 | 16 | # --- TV Shows ----------------------------------------- 17 | 18 | // Futurama 19 | https://mediux.pro/sets/21200 20 | https://mediux.pro/sets/1993 21 | 22 | // Bob's Burgers 23 | https://mediux.pro/sets/208 24 | https://mediux.pro/sets/27633 25 | 26 | // Archer 27 | https://mediux.pro/sets/10988 28 | https://mediux.pro/sets/4394 -------------------------------------------------------------------------------- /dist/Plex Poster Set Helper (standalone).zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbrown430/plex-poster-set-helper/a7382134eeb6421b14c052950695bbefc6282913/dist/Plex Poster Set Helper (standalone).zip -------------------------------------------------------------------------------- /example_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "base_url": "", 3 | "token": "", 4 | "bulk_txt": "bulk_import.txt", 5 | "tv_library": [ 6 | "TV Shows", 7 | "Anime" 8 | ], 9 | "movie_library": [ 10 | "Movies" 11 | ], 12 | "mediux_filters": [ 13 | "title_card", 14 | "background", 15 | "season_cover", 16 | "show_cover" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /icons/Plex.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbrown430/plex-poster-set-helper/a7382134eeb6421b14c052950695bbefc6282913/icons/Plex.ico -------------------------------------------------------------------------------- /plex_poster_set_helper.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import math 3 | import os 4 | import sys 5 | import json 6 | from bs4 import BeautifulSoup 7 | from plexapi.server import PlexServer 8 | import plexapi.exceptions 9 | import time 10 | import re 11 | import customtkinter as ctk 12 | import tkinter as tk 13 | import threading 14 | import xml.etree.ElementTree 15 | import atexit 16 | from PIL import Image 17 | 18 | 19 | #! Interactive CLI mode flag 20 | interactive_cli = True # Set to False when building the executable with PyInstaller for it launches the GUI by default 21 | 22 | 23 | #@ ---------------------- CORE FUNCTIONS ---------------------- 24 | 25 | def plex_setup(gui_mode=False): 26 | global plex 27 | plex = None 28 | 29 | # Check if config.json exists 30 | if os.path.exists("config.json"): 31 | try: 32 | config = json.load(open("config.json")) 33 | base_url = config.get("base_url", "") 34 | token = config.get("token", "") 35 | tv_library = config.get("tv_library", []) 36 | movie_library = config.get("movie_library", []) 37 | except Exception as e: 38 | if gui_mode: 39 | app.after(300, update_error, f"Error with config.json: {str(e)}") 40 | else: 41 | sys.exit("Error with config.json file. Please consult the readme.md.") 42 | return None, None 43 | else: 44 | # No config file, skip setting up Plex for now 45 | base_url, token, tv_library, movie_library = "", "", [], [] 46 | 47 | # Validate the fields 48 | if not base_url or not token: 49 | if gui_mode: 50 | app.after(100, update_error, "Invalid Plex token or base URL. Please provide valid values in config.json or via the GUI.") 51 | else: 52 | print('Invalid Plex token or base URL. Please provide valid values in config.json or via the GUI.') 53 | return None, None 54 | 55 | try: 56 | plex = PlexServer(base_url, token) # Initialize the Plex server connection 57 | except requests.exceptions.RequestException as e: 58 | # Handle network-related errors (e.g., unable to reach the server) 59 | if gui_mode: 60 | app.after(100, update_error, f"Unable to connect to Plex server: {str(e)}") 61 | else: 62 | sys.exit('Unable to connect to Plex server. Please check the "base_url" in config.json or provide one.') 63 | return None, None 64 | except plexapi.exceptions.Unauthorized as e: 65 | # Handle authentication-related errors (e.g., invalid token) 66 | if gui_mode: 67 | app.after(100, update_error, f"Invalid Plex token: {str(e)}") 68 | else: 69 | sys.exit('Invalid Plex token. Please check the "token" in config.json or provide one.') 70 | return None, None 71 | except xml.etree.ElementTree.ParseError as e: 72 | # Handle XML parsing errors (e.g., invalid XML response from Plex) 73 | if gui_mode: 74 | app.after(100, update_error, f"Received invalid XML from Plex server: {str(e)}") 75 | else: 76 | print("Received invalid XML from Plex server. Check server connection.") 77 | return None, None 78 | except Exception as e: 79 | # Handle any other unexpected errors 80 | if gui_mode: 81 | app.after(100, update_error, f"Unexpected error: {str(e)}") 82 | else: 83 | sys.exit(f"Unexpected error: {str(e)}") 84 | return None, None 85 | 86 | # Continue with the setup (assuming plex server is successfully initialized) 87 | if isinstance(tv_library, str): 88 | tv_library = [tv_library] 89 | elif not isinstance(tv_library, list): 90 | if gui_mode: 91 | app.after(100, update_error, "tv_library must be either a string or a list") 92 | sys.exit("tv_library must be either a string or a list") 93 | 94 | tv = [] 95 | for tv_lib in tv_library: 96 | try: 97 | plex_tv = plex.library.section(tv_lib) 98 | tv.append(plex_tv) 99 | except plexapi.exceptions.NotFound as e: 100 | if gui_mode: 101 | app.after(100, update_error, f'TV library named "{tv_lib}" not found: {str(e)}') 102 | else: 103 | sys.exit(f'TV library named "{tv_lib}" not found. Please check the "tv_library" in config.json or provide one.') 104 | 105 | if isinstance(movie_library, str): 106 | movie_library = [movie_library] 107 | elif not isinstance(movie_library, list): 108 | if gui_mode: 109 | app.after(100, update_error, "movie_library must be either a string or a list") 110 | sys.exit("movie_library must be either a string or a list") 111 | 112 | movies = [] 113 | for movie_lib in movie_library: 114 | try: 115 | plex_movie = plex.library.section(movie_lib) 116 | movies.append(plex_movie) 117 | except plexapi.exceptions.NotFound as e: 118 | if gui_mode: 119 | app.after(100, update_error, f'Movie library named "{movie_lib}" not found: {str(e)}') 120 | else: 121 | sys.exit(f'Movie library named "{movie_lib}" not found. Please check the "movie_library" in config.json or provide one.') 122 | 123 | return tv, movies 124 | 125 | 126 | 127 | def cook_soup(url): 128 | headers = { 129 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', 130 | 'Sec-Ch-Ua-Mobile': '?0', 131 | 'Sec-Ch-Ua-Platform': 'Windows' 132 | } 133 | 134 | response = requests.get(url, headers=headers) 135 | 136 | if response.status_code == 200 or (response.status_code == 500 and "mediux.pro" in url): 137 | soup = BeautifulSoup(response.text, 'html.parser') 138 | return soup 139 | else: 140 | sys.exit(f"Failed to retrieve the page. Status code: {response.status_code}") 141 | 142 | 143 | def title_cleaner(string): 144 | if " (" in string: 145 | title = string.split(" (")[0] 146 | elif " -" in string: 147 | title = string.split(" -")[0] 148 | else: 149 | title = string 150 | 151 | title = title.strip() 152 | 153 | return title 154 | 155 | 156 | def parse_string_to_dict(input_string): 157 | # Remove unnecessary replacements 158 | input_string = input_string.replace('\\\\\\\"', "") 159 | input_string = input_string.replace("\\","") 160 | input_string = input_string.replace("u0026", "&") 161 | 162 | # Find JSON data in the input string 163 | json_start_index = input_string.find('{') 164 | json_end_index = input_string.rfind('}') 165 | json_data = input_string[json_start_index:json_end_index+1] 166 | 167 | # Parse JSON data into a dictionary 168 | parsed_dict = json.loads(json_data) 169 | return parsed_dict 170 | 171 | 172 | def find_in_library(library, poster): 173 | items = [] 174 | for lib in library: 175 | try: 176 | if poster["year"] is not None: 177 | library_item = lib.get(poster["title"], year=poster["year"]) 178 | else: 179 | library_item = lib.get(poster["title"]) 180 | 181 | if library_item: 182 | items.append(library_item) 183 | except: 184 | pass 185 | 186 | if items: 187 | return items 188 | 189 | print(f"{poster['title']} not found, skipping.") 190 | return None 191 | 192 | 193 | def find_collection(library, poster): 194 | collections = [] 195 | for lib in library: 196 | try: 197 | movie_collections = lib.collections() 198 | for plex_collection in movie_collections: 199 | if plex_collection.title == poster["title"]: 200 | collections.append(plex_collection) 201 | except: 202 | pass 203 | 204 | if collections: 205 | return collections 206 | 207 | #print(f"{poster['title']} collection not found, skipping.") 208 | return None 209 | 210 | 211 | def upload_tv_poster(poster, tv): 212 | tv_show_items = find_in_library(tv, poster) 213 | if tv_show_items: 214 | for tv_show in tv_show_items: 215 | try: 216 | if poster["season"] == "Cover": 217 | upload_target = tv_show 218 | print(f"Uploaded cover art for {poster['title']} - {poster['season']} in {tv_show.librarySectionTitle} library.") 219 | elif poster["season"] == 0: 220 | upload_target = tv_show.season("Specials") 221 | print(f"Uploaded art for {poster['title']} - Specials in {tv_show.librarySectionTitle} library.") 222 | elif poster["season"] == "Backdrop": 223 | upload_target = tv_show 224 | print(f"Uploaded background art for {poster['title']} in {tv_show.librarySectionTitle} library.") 225 | elif poster["season"] >= 1: 226 | if poster["episode"] == "Cover": 227 | upload_target = tv_show.season(poster["season"]) 228 | print(f"Uploaded art for {poster['title']} - Season {poster['season']} in {tv_show.librarySectionTitle} library.") 229 | elif poster["episode"] is None: 230 | upload_target = tv_show.season(poster["season"]) 231 | print(f"Uploaded art for {poster['title']} - Season {poster['season']} in {tv_show.librarySectionTitle} library.") 232 | elif poster["episode"] is not None: 233 | try: 234 | upload_target = tv_show.season(poster["season"]).episode(poster["episode"]) 235 | print(f"Uploaded art for {poster['title']} - Season {poster['season']} Episode {poster['episode']} in {tv_show.librarySectionTitle} library..") 236 | except: 237 | print(f"{poster['title']} - {poster['season']} Episode {poster['episode']} not found in {tv_show.librarySectionTitle} library, skipping.") 238 | if poster["season"] == "Backdrop": 239 | try: 240 | upload_target.uploadArt(url=poster['url']) 241 | except: 242 | print("Unable to upload last poster.") 243 | else: 244 | try: 245 | upload_target.uploadPoster(url=poster['url']) 246 | except: 247 | print("Unable to upload last poster.") 248 | if poster["source"] == "posterdb": 249 | time.sleep(6) # too many requests prevention 250 | except: 251 | print(f"{poster['title']} - Season {poster['season']} not found in {tv_show.librarySectionTitle} library, skipping.") 252 | else: 253 | print(f"{poster['title']} not found in any library.") 254 | 255 | 256 | def upload_movie_poster(poster, movies): 257 | movie_items = find_in_library(movies, poster) 258 | if movie_items: 259 | for movie_item in movie_items: 260 | try: 261 | movie_item.uploadPoster(poster["url"]) 262 | print(f'Uploaded art for {poster["title"]} in {movie_item.librarySectionTitle} library.') 263 | if poster["source"] == "posterdb": 264 | time.sleep(6) # too many requests prevention 265 | except: 266 | print(f'Unable to upload art for {poster["title"]} in {movie_item.librarySectionTitle} library.') 267 | else: 268 | print(f'{poster["title"]} not found in any library.') 269 | 270 | 271 | def upload_collection_poster(poster, movies): 272 | collection_items = find_collection(movies, poster) 273 | if collection_items: 274 | for collection in collection_items: 275 | try: 276 | collection.uploadPoster(poster["url"]) 277 | print(f'Uploaded art for {poster["title"]} in {collection.librarySectionTitle} library.') 278 | if poster["source"] == "posterdb": 279 | time.sleep(6) # too many requests prevention 280 | except: 281 | print(f'Unable to upload art for {poster["title"]} in {collection.librarySectionTitle} library.') 282 | else: 283 | print(f'{poster["title"]} collection not found in any library.') 284 | 285 | 286 | def set_posters(url, tv, movies): 287 | movieposters, showposters, collectionposters = scrape(url) 288 | 289 | for poster in collectionposters: 290 | upload_collection_poster(poster, movies) 291 | 292 | for poster in movieposters: 293 | upload_movie_poster(poster, movies) 294 | 295 | for poster in showposters: 296 | upload_tv_poster(poster, tv) 297 | 298 | def scrape_posterdb_set_link(soup): 299 | try: 300 | view_all_div = soup.find('a', class_='rounded view_all')['href'] 301 | except: 302 | return None 303 | return view_all_div 304 | 305 | 306 | def scrape_posterdb_set_link(soup): 307 | try: 308 | view_all_div = soup.find("a", class_="rounded view_all")["href"] 309 | except: 310 | return None 311 | return view_all_div 312 | 313 | def scrape_posterd_user_info(soup): 314 | try: 315 | span_tag = soup.find('span', class_='numCount') 316 | number_str = span_tag['data-count'] 317 | 318 | upload_count = int(number_str) 319 | pages = math.ceil(upload_count/24) 320 | return pages 321 | except: 322 | return None 323 | 324 | def scrape_posterdb(soup): 325 | movieposters = [] 326 | showposters = [] 327 | collectionposters = [] 328 | 329 | # find the poster grid 330 | poster_div = soup.find('div', class_='row d-flex flex-wrap m-0 w-100 mx-n1 mt-n1') 331 | 332 | # find all poster divs 333 | posters = poster_div.find_all('div', class_='col-6 col-lg-2 p-1') 334 | 335 | # loop through the poster divs 336 | for poster in posters: 337 | # get if poster is for a show or movie 338 | media_type = poster.find('a', class_="text-white", attrs={'data-toggle': 'tooltip', 'data-placement': 'top'})['title'] 339 | # get high resolution poster image 340 | overlay_div = poster.find('div', class_='overlay') 341 | poster_id = overlay_div.get('data-poster-id') 342 | poster_url = "https://theposterdb.com/api/assets/" + poster_id 343 | # get metadata 344 | title_p = poster.find('p', class_='p-0 mb-1 text-break').string 345 | 346 | if media_type == "Show": 347 | title = title_p.split(" (")[0] 348 | try: 349 | year = int(title_p.split(" (")[1].split(")")[0]) 350 | except: 351 | year = None 352 | 353 | if " - " in title_p: 354 | split_season = title_p.split(" - ")[-1] 355 | if split_season == "Specials": 356 | season = 0 357 | elif "Season" in split_season: 358 | season = int(split_season.split(" ")[1]) 359 | else: 360 | season = "Cover" 361 | 362 | showposter = {} 363 | showposter["title"] = title 364 | showposter["url"] = poster_url 365 | showposter["season"] = season 366 | showposter["episode"] = None 367 | showposter["year"] = year 368 | showposter["source"] = "posterdb" 369 | showposters.append(showposter) 370 | 371 | elif media_type == "Movie": 372 | title_split = title_p.split(" (") 373 | if len(title_split[1]) != 5: 374 | title = title_split[0] + " (" + title_split[1] 375 | else: 376 | title = title_split[0] 377 | year = title_split[-1].split(")")[0] 378 | 379 | movieposter = {} 380 | movieposter["title"] = title 381 | movieposter["url"] = poster_url 382 | movieposter["year"] = int(year) 383 | movieposter["source"] = "posterdb" 384 | movieposters.append(movieposter) 385 | 386 | elif media_type == "Collection": 387 | collectionposter = {} 388 | collectionposter["title"] = title_p 389 | collectionposter["url"] = poster_url 390 | collectionposter["source"] = "posterdb" 391 | collectionposters.append(collectionposter) 392 | 393 | return movieposters, showposters, collectionposters 394 | 395 | def get_mediux_filters(): 396 | config = json.load(open("config.json")) 397 | return config.get("mediux_filters", None) 398 | 399 | 400 | def check_mediux_filter(mediux_filters, filter): 401 | return filter in mediux_filters if mediux_filters else True 402 | 403 | def scrape_mediux(soup): 404 | base_url = "https://mediux.pro/_next/image?url=https%3A%2F%2Fapi.mediux.pro%2Fassets%2F" 405 | quality_suffix = "&w=3840&q=80" 406 | scripts = soup.find_all('script') 407 | media_type = None 408 | showposters = [] 409 | movieposters = [] 410 | collectionposters = [] 411 | mediux_filters = get_mediux_filters() 412 | year = 0 # Default year value 413 | title = "Untitled" # Default title value 414 | 415 | for script in scripts: 416 | if 'files' in script.text: 417 | if 'set' in script.text: 418 | if 'Set Link\\' not in script.text: 419 | data_dict = parse_string_to_dict(script.text) 420 | poster_data = data_dict["set"]["files"] 421 | 422 | for data in poster_data: 423 | if data["show_id"] is not None or data["show_id_backdrop"] is not None or data["episode_id"] is not None or data["season_id"] is not None or data["show_id"] is not None: 424 | media_type = "Show" 425 | else: 426 | media_type = "Movie" 427 | 428 | for data in poster_data: 429 | if media_type == "Show": 430 | 431 | episodes = data_dict["set"]["show"]["seasons"] 432 | show_name = data_dict["set"]["show"]["name"] 433 | try: 434 | year = int(data_dict["set"]["show"]["first_air_date"][:4]) 435 | except: 436 | year = None 437 | 438 | if data["fileType"] == "title_card": 439 | episode_id = data["episode_id"]["id"] 440 | season = data["episode_id"]["season_id"]["season_number"] 441 | title = data["title"] 442 | try: 443 | episode = int(title.rsplit(" E",1)[1]) 444 | except: 445 | print(f"Error getting episode number for {title}.") 446 | file_type = "title_card" 447 | 448 | elif data["fileType"] == "backdrop": 449 | season = "Backdrop" 450 | episode = None 451 | file_type = "background" 452 | elif data["season_id"] is not None: 453 | season_id = data["season_id"]["id"] 454 | season_data = [episode for episode in episodes if episode["id"] == season_id][0] 455 | episode = "Cover" 456 | season = season_data["season_number"] 457 | file_type = "season_cover" 458 | elif data["show_id"] is not None: 459 | season = "Cover" 460 | episode = None 461 | file_type = "show_cover" 462 | 463 | elif media_type == "Movie": 464 | 465 | if data["movie_id"]: 466 | if data_dict["set"]["movie"]: 467 | title = data_dict["set"]["movie"]["title"] 468 | year = int(data_dict["set"]["movie"]["release_date"][:4]) 469 | elif data_dict["set"]["collection"]: 470 | movie_id = data["movie_id"]["id"] 471 | movies = data_dict["set"]["collection"]["movies"] 472 | movie_data = [movie for movie in movies if movie["id"] == movie_id][0] 473 | title = movie_data["title"] 474 | year = int(movie_data["release_date"][:4]) 475 | elif data["collection_id"]: 476 | title = data_dict["set"]["collection"]["collection_name"] 477 | 478 | image_stub = data["id"] 479 | poster_url = f"{base_url}{image_stub}{quality_suffix}" 480 | 481 | if media_type == "Show": 482 | showposter = {} 483 | showposter["title"] = show_name 484 | showposter["season"] = season 485 | showposter["episode"] = episode 486 | showposter["url"] = poster_url 487 | showposter["source"] = "mediux" 488 | showposter["year"] = year 489 | 490 | if check_mediux_filter(mediux_filters=mediux_filters, filter=file_type): 491 | showposters.append(showposter) 492 | else: 493 | print(f"{show_name} - skipping. '{file_type}' is not in 'mediux_filters'") 494 | 495 | elif media_type == "Movie": 496 | if "Collection" in title: 497 | collectionposter = {} 498 | collectionposter["title"] = title 499 | collectionposter["url"] = poster_url 500 | collectionposter["source"] = "mediux" 501 | collectionposters.append(collectionposter) 502 | 503 | else: 504 | movieposter = {} 505 | movieposter["title"] = title 506 | movieposter["year"] = int(year) 507 | movieposter["url"] = poster_url 508 | movieposter["source"] = "mediux" 509 | movieposters.append(movieposter) 510 | 511 | return movieposters, showposters, collectionposters 512 | 513 | 514 | def scrape(url): 515 | if ("theposterdb.com" in url): 516 | if("/set/" in url or "/user/" in url): 517 | soup = cook_soup(url) 518 | return scrape_posterdb(soup) 519 | elif("/poster/" in url): 520 | soup = cook_soup(url) 521 | set_url = scrape_posterdb_set_link(soup) 522 | if set_url is not None: 523 | set_soup = cook_soup(set_url) 524 | return scrape_posterdb(set_soup) 525 | else: 526 | sys.exit("Poster set not found. Check the link you are inputting.") 527 | #menu_selection = input("You've provided the link to a single poster, rather than a set. \n \t 1. Upload entire set\n \t 2. Upload single poster \nType your selection: ") 528 | elif ("mediux.pro" in url) and ("sets" in url): 529 | soup = cook_soup(url) 530 | return scrape_mediux(soup) 531 | elif (".html" in url): 532 | with open(url, 'r', encoding='utf-8') as file: 533 | html_content = file.read() 534 | soup = BeautifulSoup(html_content, 'html.parser') 535 | return scrape_posterdb(soup) 536 | else: 537 | sys.exit("Poster set not found. Check the link you are inputting.") 538 | 539 | 540 | def scrape_entire_user(url): 541 | '''Scrape all pages of a user's uploads.''' 542 | soup = cook_soup(url) 543 | pages = scrape_posterd_user_info(soup) 544 | 545 | if not pages: 546 | print(f"Could not determine the number of pages for {url}") 547 | return 548 | 549 | if "?" in url: 550 | cleaned_url = url.split("?")[0] 551 | url = cleaned_url 552 | 553 | for page in range(pages): 554 | print(f"Scraping page {page + 1}.") 555 | page_url = f"{url}?section=uploads&page={page + 1}" 556 | set_posters(page_url, tv, movies) 557 | 558 | 559 | def is_not_comment(url): 560 | '''Check if the URL is not a comment or empty line.''' 561 | regex = r"^(?!\/\/|#|^$)" 562 | pattern = re.compile(regex) 563 | return True if re.match(pattern, url) else False 564 | 565 | 566 | def parse_urls(bulk_import_list): 567 | '''Parse the URLs from a list and scrape them.''' 568 | valid_urls = [] 569 | for line in bulk_import_list: 570 | url = line.strip() 571 | if url and not url.startswith(("#", "//")): 572 | valid_urls.append(url) 573 | 574 | for url in valid_urls: 575 | if "/user/" in url: 576 | print(f"Scraping user data from: {url}") 577 | scrape_entire_user(url) 578 | else: 579 | print(f"Returning non-user URL: {url}") 580 | # If it's not a /user/ URL, return it as before 581 | return valid_urls 582 | 583 | return valid_urls 584 | 585 | 586 | def parse_cli_urls(file_path, tv, movies): 587 | '''Parse the URLs from a file and scrape them.''' 588 | try: 589 | with open(file_path, 'r', encoding='utf-8') as file: 590 | urls = file.readlines() 591 | for url in urls: 592 | url = url.strip() 593 | if is_not_comment(url): 594 | if "/user/" in url: 595 | scrape_entire_user(url) 596 | else: 597 | set_posters(url, tv, movies) 598 | except FileNotFoundError: 599 | print("File not found. Please enter a valid file path.") 600 | 601 | 602 | def cleanup(): 603 | '''Function to handle cleanup tasks on exit.''' 604 | if plex: 605 | print("Closing Plex server connection...") 606 | print("Exiting application. Cleanup complete.") 607 | 608 | atexit.register(cleanup) 609 | 610 | #@ ---------------------- GUI FUNCTIONS ---------------------- 611 | 612 | 613 | 614 | # * UI helper functions --- 615 | 616 | def get_exe_dir(): 617 | """Get the directory of the executable or script file.""" 618 | if getattr(sys, 'frozen', False): 619 | return os.path.dirname(sys.executable) # Path to executable 620 | else: 621 | return os.path.dirname(__file__) # Path to script file 622 | 623 | def resource_path(relative_path): 624 | """Get the absolute path to resource, works for dev and for PyInstaller bundle.""" 625 | try: 626 | # PyInstaller creates a temp folder for the bundled app, MEIPASS is the path to that folder 627 | base_path = sys._MEIPASS 628 | except Exception: 629 | # If running in a normal Python environment, use the current working directory 630 | base_path = os.path.abspath(".") 631 | 632 | return os.path.join(base_path, relative_path) 633 | 634 | def get_full_path(relative_path): 635 | '''Helper function to get the absolute path based on the script's location.''' 636 | print("relative_path", relative_path) 637 | script_dir = os.path.dirname(os.path.abspath(__file__)) 638 | return os.path.join(script_dir, relative_path) 639 | 640 | def update_status(message, color="white"): 641 | '''Update the status label with a message and color.''' 642 | app.after(0, lambda: status_label.configure(text=message, text_color=color)) 643 | 644 | def update_error(message): 645 | '''Update the error label with a message, with a small delay.''' 646 | # app.after(500, lambda: status_label.configure(text=message, text_color="red")) 647 | status_label.configure(text=message, text_color="red") 648 | 649 | def clear_url(): 650 | '''Clear the URL entry field.''' 651 | url_entry.delete(0, ctk.END) 652 | status_label.configure(text="URL cleared.", text_color="orange") 653 | 654 | def set_default_tab(tabview): 655 | '''Set the default tab to the Settings tab.''' 656 | plex_base_url = base_url_entry.get() 657 | plex_token = token_entry.get() 658 | 659 | if plex_base_url and plex_token: 660 | tabview.set("Bulk Import") 661 | else: 662 | tabview.set("Settings") 663 | 664 | def bind_context_menu(widget): 665 | '''Bind the right-click context menu to the widget.''' 666 | widget.bind("", clear_placeholder_on_right_click) 667 | widget.bind("", clear_placeholder_on_right_click) 668 | 669 | def clear_placeholder_on_right_click(event): 670 | """Clears placeholder text and sets focus before showing the context menu.""" 671 | widget = event.widget 672 | if isinstance(widget, ctk.CTkEntry) and widget.get() == "": 673 | widget.delete(0, tk.END) 674 | widget.focus() 675 | show_global_context_menu(event) 676 | 677 | def show_global_context_menu(event): 678 | '''Show the global context menu at the cursor position.''' 679 | widget = event.widget 680 | global_context_menu.entryconfigure("Cut", command=lambda: widget.event_generate("<>")) 681 | global_context_menu.entryconfigure("Copy", command=lambda: widget.event_generate("<>")) 682 | global_context_menu.entryconfigure("Paste", command=lambda: widget.event_generate("<>")) 683 | global_context_menu.tk_popup(event.x_root, event.y_root) 684 | 685 | 686 | # * Configuration file I/O functions --- 687 | 688 | def load_config(config_path="config.json"): 689 | '''Load the configuration from the JSON file. If it doesn't exist, create it with default values.''' 690 | default_config = { 691 | "base_url": "", 692 | "token": "", 693 | "bulk_txt": "bulk_import.txt", 694 | "tv_library": ["TV Shows", "Anime"], 695 | "movie_library": ["Movies"], 696 | "mediux_filters": ["title_card", "background", "season_cover", "show_cover"] 697 | } 698 | 699 | # Create the config.json file if it doesn't exist 700 | if not os.path.isfile(config_path): 701 | try: 702 | with open(config_path, "w") as config_file: 703 | json.dump(default_config, config_file, indent=4) 704 | print(f"Config file '{config_path}' created with default settings.") 705 | except Exception as e: 706 | update_error(f"Error creating config: {str(e)}") 707 | return {} 708 | 709 | # Load the configuration from the config.json file 710 | try: 711 | with open(config_path, "r") as config_file: 712 | config = json.load(config_file) 713 | 714 | base_url = config.get("base_url", "") 715 | token = config.get("token", "") 716 | tv_library = config.get("tv_library", []) 717 | movie_library = config.get("movie_library", []) 718 | mediux_filters = config.get("mediux_filters", []) 719 | bulk_txt = config.get("bulk_txt", "bulk_import.txt") 720 | 721 | return { 722 | "base_url": base_url, 723 | "token": token, 724 | "tv_library": tv_library, 725 | "movie_library": movie_library, 726 | "mediux_filters": mediux_filters, 727 | "bulk_txt": bulk_txt 728 | } 729 | except Exception as e: 730 | update_error(f"Error loading config: {str(e)}") 731 | return {} 732 | 733 | def save_config(): 734 | '''Save the configuration from the UI fields to the file and update the in-memory config.''' 735 | 736 | new_config = { 737 | "base_url": base_url_entry.get().strip(), 738 | "token": token_entry.get().strip(), 739 | "tv_library": [item.strip() for item in tv_library_text.get().strip().split(",")], 740 | "movie_library": [item.strip() for item in movie_library_text.get().strip().split(",")], 741 | "mediux_filters": mediux_filters_text.get().strip().split(", "), 742 | "bulk_txt": bulk_txt_entry.get().strip() 743 | } 744 | 745 | try: 746 | with open("config.json", "w") as f: 747 | json.dump(new_config, f, indent=4) 748 | 749 | # Update the in-memory config dictionary 750 | global config 751 | config = new_config 752 | 753 | load_and_update_ui() 754 | 755 | update_status("Configuration saved successfully!", color="#E5A00D") 756 | except Exception as e: 757 | update_status(f"Error saving config: {str(e)}", color="red") 758 | 759 | def load_and_update_ui(): 760 | '''Load the configuration and update the UI fields.''' 761 | config = load_config() 762 | 763 | if base_url_entry is not None: 764 | base_url_entry.delete(0, ctk.END) 765 | base_url_entry.insert(0, config.get("base_url", "")) 766 | 767 | if token_entry is not None: 768 | token_entry.delete(0, ctk.END) 769 | token_entry.insert(0, config.get("token", "")) 770 | 771 | if bulk_txt_entry is not None: 772 | bulk_txt_entry.delete(0, ctk.END) 773 | bulk_txt_entry.insert(0, config.get("bulk_txt", "bulk_import.txt")) 774 | 775 | if tv_library_text is not None: 776 | tv_library_text.delete(0, ctk.END) 777 | tv_library_text.insert(0, ", ".join(config.get("tv_library", []))) 778 | 779 | if movie_library_text is not None: 780 | movie_library_text.delete(0, ctk.END) 781 | movie_library_text.insert(0, ", ".join(config.get("movie_library", []))) 782 | 783 | if mediux_filters_text is not None: 784 | mediux_filters_text.delete(0, ctk.END) 785 | mediux_filters_text.insert(0, ", ".join(config.get("mediux_filters", []))) 786 | 787 | load_bulk_import_file() 788 | 789 | 790 | 791 | # * Threaded functions for scraping and setting posters --- 792 | 793 | def run_url_scrape_thread(): 794 | '''Run the URL scrape in a separate thread.''' 795 | global scrape_button, clear_button, bulk_import_button 796 | url = url_entry.get() 797 | 798 | if not url: 799 | update_status("Please enter a valid URL.", color="red") 800 | return 801 | 802 | scrape_button.configure(state="disabled") 803 | clear_button.configure(state="disabled") 804 | bulk_import_button.configure(state="disabled") 805 | 806 | threading.Thread(target=process_scrape_url, args=(url,)).start() 807 | 808 | def run_bulk_import_scrape_thread(): 809 | '''Run the bulk import scrape in a separate thread.''' 810 | global bulk_import_button 811 | bulk_import_list = bulk_import_text.get(1.0, ctk.END).strip().split("\n") 812 | valid_urls = parse_urls(bulk_import_list) 813 | 814 | if not valid_urls: 815 | app.after(0, lambda: update_status("No bulk import entries found.", color="red")) 816 | return 817 | 818 | scrape_button.configure(state="disabled") 819 | clear_button.configure(state="disabled") 820 | bulk_import_button.configure(state="disabled") 821 | 822 | threading.Thread(target=process_bulk_import, args=(valid_urls,)).start() 823 | 824 | 825 | 826 | # * Processing functions for scraping and setting posters --- 827 | 828 | def process_scrape_url(url): 829 | '''Process the URL scrape.''' 830 | try: 831 | # Perform plex setup 832 | tv, movies = plex_setup(gui_mode=True) 833 | 834 | # Check if plex setup returned valid values 835 | if tv is None or movies is None: 836 | update_status("Plex setup incomplete. Please configure your settings.", color="red") 837 | return 838 | 839 | soup = cook_soup(url) 840 | update_status(f"Scraping: {url}", color="#E5A00D") 841 | 842 | # Proceed with setting posters 843 | set_posters(url, tv, movies) 844 | update_status(f"Posters successfully set for: {url}", color="#E5A00D") 845 | 846 | except Exception as e: 847 | update_status(f"Error: {e}", color="red") 848 | 849 | finally: 850 | app.after(0, lambda: [ 851 | scrape_button.configure(state="normal"), 852 | clear_button.configure(state="normal"), 853 | bulk_import_button.configure(state="normal"), 854 | ]) 855 | 856 | def process_bulk_import(valid_urls): 857 | '''Process the bulk import scrape.''' 858 | try: 859 | tv, movies = plex_setup(gui_mode=True) 860 | 861 | # Check if plex setup returned valid values 862 | if tv is None or movies is None: 863 | update_status("Plex setup incomplete. Please configure your settings.", color="red") 864 | return 865 | 866 | for i, url in enumerate(valid_urls): 867 | status_text = f"Processing item {i+1} of {len(valid_urls)}: {url}" 868 | update_status(status_text, color="#E5A00D") 869 | set_posters(url, tv, movies) 870 | update_status(f"Completed: {url}", color="#E5A00D") 871 | 872 | update_status("Bulk import scraping completed.", color="#E5A00D") 873 | except Exception as e: 874 | update_status(f"Error during bulk import: {e}", color="red") 875 | finally: 876 | app.after(0, lambda: [ 877 | scrape_button.configure(state="normal"), 878 | clear_button.configure(state="normal"), 879 | bulk_import_button.configure(state="normal"), 880 | ]) 881 | 882 | 883 | 884 | # * Bulk import file I/O functions --- 885 | 886 | def load_bulk_import_file(): 887 | '''Load the bulk import file into the text area.''' 888 | try: 889 | # Get the current bulk_txt value from the config 890 | bulk_txt_path = config.get("bulk_txt", "bulk_import.txt") 891 | 892 | # Use get_exe_dir() to determine the correct path for both frozen and non-frozen cases 893 | bulk_txt_path = os.path.join(get_exe_dir(), bulk_txt_path) 894 | 895 | if not os.path.exists(bulk_txt_path): 896 | print(f"File does not exist: {bulk_txt_path}") 897 | bulk_import_text.delete(1.0, ctk.END) 898 | bulk_import_text.insert(ctk.END, "Bulk import file path is not set or file does not exist.") 899 | status_label.configure(text="Bulk import file path not set or file not found.", text_color="red") 900 | return 901 | 902 | with open(bulk_txt_path, "r", encoding="utf-8") as file: 903 | content = file.read() 904 | 905 | bulk_import_text.delete(1.0, ctk.END) 906 | bulk_import_text.insert(ctk.END, content) 907 | 908 | except FileNotFoundError: 909 | bulk_import_text.delete(1.0, ctk.END) 910 | bulk_import_text.insert(ctk.END, "File not found or empty.") 911 | except Exception as e: 912 | bulk_import_text.delete(1.0, ctk.END) 913 | bulk_import_text.insert(ctk.END, f"Error loading file: {str(e)}") 914 | 915 | 916 | def save_bulk_import_file(): 917 | '''Save the bulk import text area content to a file relative to the executable location.''' 918 | try: 919 | exe_path = get_exe_dir() 920 | bulk_txt_path = os.path.join(exe_path, config.get("bulk_txt", "bulk_import.txt")) 921 | 922 | os.makedirs(os.path.dirname(bulk_txt_path), exist_ok=True) 923 | 924 | with open(bulk_txt_path, "w", encoding="utf-8") as file: 925 | file.write(bulk_import_text.get(1.0, ctk.END).strip()) 926 | 927 | status_label.configure(text="Bulk import file saved!", text_color="#E5A00D") 928 | except Exception as e: 929 | status_label.configure( 930 | text=f"Error saving bulk import file: {str(e)}", text_color="red" 931 | ) 932 | 933 | 934 | 935 | # * Button Creation --- 936 | 937 | def create_button(container, text, command, color=None, primary=False, height=35): 938 | """Create a custom button with hover effects for a CustomTkinter GUI.""" 939 | 940 | button_height = height 941 | button_fg = "#2A2B2B" if color else "#1C1E1E" 942 | button_border = "#484848" 943 | button_text_color = "#CECECE" if color else "#696969" 944 | plex_orange = "#E5A00D" 945 | 946 | 947 | if primary: 948 | button_fg = plex_orange 949 | button_text_color, button_border = "#1C1E1E", "#1C1E1E" 950 | 951 | button = ctk.CTkButton( 952 | container, 953 | text=text, 954 | command=command, 955 | border_width=1, 956 | text_color=button_text_color, 957 | fg_color=button_fg, 958 | border_color=button_border, 959 | hover_color="#333333", 960 | width=80, 961 | height=button_height, 962 | font=("Roboto", 13, "bold"), 963 | ) 964 | 965 | def on_enter(event): 966 | """Change button appearance when mouse enters.""" 967 | if color: 968 | button.configure(fg_color="#2A2B2B", text_color=lighten_color(color, 0.3), border_color=lighten_color(color, 0.5)) 969 | else: 970 | button.configure(fg_color="#1C1E1E", text_color=plex_orange, border_color=plex_orange) 971 | 972 | def on_leave(event): 973 | """Reset button appearance when mouse leaves.""" 974 | if color: 975 | button.configure(fg_color="#2A2B2B", text_color="#CECECE", border_color=button_border) 976 | else: 977 | if primary: 978 | button.configure(fg_color=plex_orange, text_color="#1C1E1E", border_color="#1C1E1E") 979 | else: 980 | button.configure(fg_color="#1C1E1E", text_color="#696969", border_color=button_border) 981 | 982 | def lighten_color(color, amount=0.5): 983 | """Lighten a color by blending it with white.""" 984 | hex_to_rgb = lambda c: tuple(int(c[i:i+2], 16) for i in (1, 3, 5)) 985 | r, g, b = hex_to_rgb(color) 986 | 987 | r = int(r + (255 - r) * amount) 988 | g = int(g + (255 - g) * amount) 989 | b = int(b + (255 - b) * amount) 990 | 991 | return f"#{r:02x}{g:02x}{b:02x}" 992 | 993 | button.bind("", on_enter) 994 | button.bind("", on_leave) 995 | 996 | return button 997 | 998 | 999 | 1000 | # * Main UI Creation function --- 1001 | 1002 | def create_ui(): 1003 | '''Create the main UI window.''' 1004 | global app, global_context_menu, scrape_button, clear_button, mediux_filters_text, bulk_import_text, base_url_entry, token_entry, status_label, url_entry, app, bulk_import_button, tv_library_text, movie_library_text, bulk_txt_entry 1005 | 1006 | app = ctk.CTk() 1007 | ctk.set_appearance_mode("dark") 1008 | 1009 | app.title("Plex Poster Upload Helper") 1010 | app.geometry("850x600") 1011 | app.iconbitmap(resource_path("icons/Plex.ico")) 1012 | app.configure(fg_color="#2A2B2B") 1013 | 1014 | global_context_menu = tk.Menu(app, tearoff=0) 1015 | global_context_menu.add_command(label="Cut") 1016 | global_context_menu.add_command(label="Copy") 1017 | global_context_menu.add_command(label="Paste") 1018 | 1019 | def open_url(url): 1020 | '''Open a URL in the default web browser.''' 1021 | import webbrowser 1022 | webbrowser.open(url) 1023 | 1024 | 1025 | # ! Create a frame for the link bar -- 1026 | link_bar = ctk.CTkFrame(app, fg_color="transparent") 1027 | link_bar.pack(fill="x", pady=5, padx=10) 1028 | 1029 | # ? Link to Plex Media Server from the base URL 1030 | base_url = config.get("base_url", None) 1031 | target_url = base_url if base_url else "https://www.plex.tv" 1032 | 1033 | plex_icon = ctk.CTkImage(light_image=Image.open(resource_path("icons/Plex.ico")), size=(24, 24)) 1034 | plex_icon_image = Image.open(resource_path("icons/Plex.ico")) 1035 | 1036 | icon_label = ctk.CTkLabel(link_bar, image=plex_icon, text="", anchor="w") 1037 | icon_label.pack(side="left", padx=0, pady=0) 1038 | url_text = base_url if base_url else "Plex Media Server" 1039 | url_label = ctk.CTkLabel(link_bar, text=url_text, anchor="w", font=("Roboto", 14, "bold"), text_color="#CECECE") 1040 | url_label.pack(side="left", padx=(5, 10)) 1041 | 1042 | def on_hover_enter(event): 1043 | app.config(cursor="hand2") 1044 | rotated_image = plex_icon_image.rotate(15, expand=True) 1045 | rotated_ctk_icon = ctk.CTkImage(light_image=rotated_image, size=(24, 24)) 1046 | icon_label.configure(image=rotated_ctk_icon) 1047 | 1048 | def on_hover_leave(event): 1049 | app.config(cursor="") 1050 | icon_label.configure(image=plex_icon) 1051 | 1052 | def on_click(event): 1053 | open_url(target_url) 1054 | 1055 | for widget in (icon_label, url_label): 1056 | widget.bind("", on_hover_enter) 1057 | widget.bind("", on_hover_leave) 1058 | widget.bind("", on_click) 1059 | 1060 | # ? Links to Mediux and ThePosterDB 1061 | mediux_button = create_button( 1062 | link_bar, 1063 | text="MediUX.pro", 1064 | command=lambda: open_url("https://mediux.pro"), 1065 | color="#945af2", 1066 | height=30 1067 | ) 1068 | mediux_button.pack(side="right", padx=5) 1069 | 1070 | posterdb_button = create_button( 1071 | link_bar, 1072 | text="ThePosterDB", 1073 | command=lambda: open_url("https://theposterdb.com"), 1074 | color="#FA6940", 1075 | height=30 1076 | ) 1077 | posterdb_button.pack(side="right", padx=5) 1078 | 1079 | 1080 | #! Create Tabview -- 1081 | tabview = ctk.CTkTabview(app) 1082 | tabview.pack(fill="both", expand=True, padx=10, pady=0) 1083 | 1084 | tabview.configure( 1085 | fg_color="#2A2B2B", 1086 | segmented_button_fg_color="#1C1E1E", 1087 | segmented_button_selected_color="#2A2B2B", 1088 | segmented_button_selected_hover_color="#2A2B2B", 1089 | segmented_button_unselected_color="#1C1E1E", 1090 | segmented_button_unselected_hover_color="#1C1E1E", 1091 | text_color="#CECECE", 1092 | text_color_disabled="#777777", 1093 | border_color="#484848", 1094 | border_width=1, 1095 | ) 1096 | 1097 | #! Form row label hover 1098 | LABEL_HOVER = "#878787" 1099 | def on_hover_in(label): 1100 | label.configure(text_color=LABEL_HOVER) 1101 | 1102 | def on_hover_out(label): 1103 | label.configure(text_color="#696969") 1104 | 1105 | #! Settings Tab -- 1106 | settings_tab = tabview.add("Settings") 1107 | settings_tab.grid_columnconfigure(0, weight=0) 1108 | settings_tab.grid_columnconfigure(1, weight=1) 1109 | 1110 | # Plex Base URL 1111 | base_url_label = ctk.CTkLabel(settings_tab, text="Plex Base URL", text_color="#696969", font=("Roboto", 15)) 1112 | base_url_label.grid(row=0, column=0, pady=5, padx=10, sticky="w") 1113 | base_url_entry = ctk.CTkEntry(settings_tab, placeholder_text="Enter Plex Base URL", fg_color="#1C1E1E", text_color="#A1A1A1", border_width=0, height=40) 1114 | base_url_entry.grid(row=0, column=1, pady=5, padx=10, sticky="ew") 1115 | base_url_entry.bind("", lambda event: on_hover_in(base_url_label)) 1116 | base_url_entry.bind("", lambda event: on_hover_out(base_url_label)) 1117 | bind_context_menu(base_url_entry) 1118 | 1119 | # Plex Token 1120 | token_label = ctk.CTkLabel(settings_tab, text="Plex Token", text_color="#696969", font=("Roboto", 15)) 1121 | token_label.grid(row=1, column=0, pady=5, padx=10, sticky="w") 1122 | token_entry = ctk.CTkEntry(settings_tab, placeholder_text="Enter Plex Token", fg_color="#1C1E1E", text_color="#A1A1A1", border_width=0, height=40) 1123 | token_entry.grid(row=1, column=1, pady=5, padx=10, sticky="ew") 1124 | token_entry.bind("", lambda event: on_hover_in(token_label)) 1125 | token_entry.bind("", lambda event: on_hover_out(token_label)) 1126 | bind_context_menu(token_entry) 1127 | 1128 | # Bulk Import File 1129 | bulk_txt_label = ctk.CTkLabel(settings_tab, text="Bulk Import File", text_color="#696969", font=("Roboto", 15)) 1130 | bulk_txt_label.grid(row=2, column=0, pady=5, padx=10, sticky="w") 1131 | bulk_txt_entry = ctk.CTkEntry(settings_tab, placeholder_text="Enter bulk import file path", fg_color="#1C1E1E", text_color="#A1A1A1", border_width=0, height=40) 1132 | bulk_txt_entry.grid(row=2, column=1, pady=5, padx=10, sticky="ew") 1133 | bulk_txt_entry.bind("", lambda event: on_hover_in(bulk_txt_label)) 1134 | bulk_txt_entry.bind("", lambda event: on_hover_out(bulk_txt_label)) 1135 | bind_context_menu(bulk_txt_entry) 1136 | 1137 | # TV Library Names 1138 | tv_library_label = ctk.CTkLabel(settings_tab, text="TV Library Names", text_color="#696969", font=("Roboto", 15)) 1139 | tv_library_label.grid(row=3, column=0, pady=5, padx=10, sticky="w") 1140 | tv_library_text = ctk.CTkEntry(settings_tab, fg_color="#1C1E1E", text_color="#A1A1A1", border_width=0, height=40) 1141 | tv_library_text.grid(row=3, column=1, pady=5, padx=10, sticky="ew") 1142 | tv_library_text.bind("", lambda event: on_hover_in(tv_library_label)) 1143 | tv_library_text.bind("", lambda event: on_hover_out(tv_library_label)) 1144 | bind_context_menu(tv_library_text) 1145 | 1146 | # Movie Library Names 1147 | movie_library_label = ctk.CTkLabel(settings_tab, text="Movie Library Names", text_color="#696969", font=("Roboto", 15)) 1148 | movie_library_label.grid(row=4, column=0, pady=5, padx=10, sticky="w") 1149 | movie_library_text = ctk.CTkEntry(settings_tab, fg_color="#1C1E1E", text_color="#A1A1A1", border_width=0, height=40) 1150 | movie_library_text.grid(row=4, column=1, pady=5, padx=10, sticky="ew") 1151 | movie_library_text.bind("", lambda event: on_hover_in(movie_library_label)) 1152 | movie_library_text.bind("", lambda event: on_hover_out(movie_library_label)) 1153 | bind_context_menu(movie_library_text) 1154 | 1155 | # Mediux Filters 1156 | mediux_filters_label = ctk.CTkLabel(settings_tab, text="Mediux Filters", text_color="#696969", font=("Roboto", 15)) 1157 | mediux_filters_label.grid(row=5, column=0, pady=5, padx=10, sticky="w") 1158 | mediux_filters_text = ctk.CTkEntry(settings_tab, fg_color="#1C1E1E", text_color="#A1A1A1", border_width=0, height=40) 1159 | mediux_filters_text.grid(row=5, column=1, pady=5, padx=10, sticky="ew") 1160 | mediux_filters_text.bind("", lambda event: on_hover_in(mediux_filters_label)) 1161 | mediux_filters_text.bind("", lambda event: on_hover_out(mediux_filters_label)) 1162 | bind_context_menu(mediux_filters_text) 1163 | 1164 | settings_tab.grid_rowconfigure(0, weight=0) 1165 | settings_tab.grid_rowconfigure(1, weight=0) 1166 | settings_tab.grid_rowconfigure(2, weight=0) 1167 | settings_tab.grid_rowconfigure(3, weight=0) 1168 | settings_tab.grid_rowconfigure(4, weight=0) 1169 | settings_tab.grid_rowconfigure(5, weight=0) 1170 | settings_tab.grid_rowconfigure(6, weight=1) 1171 | 1172 | # ? Load and Save Buttons (Anchored to the bottom) 1173 | load_button = create_button(settings_tab, text="Reload", command=load_and_update_ui) 1174 | load_button.grid(row=7, column=0, pady=5, padx=5, ipadx=30, sticky="ew") 1175 | save_button = create_button(settings_tab, text="Save", command=save_config, primary=True) 1176 | save_button.grid(row=7, column=1, pady=5, padx=5, sticky="ew") 1177 | 1178 | settings_tab.grid_rowconfigure(7, weight=0, minsize=40) 1179 | 1180 | 1181 | #! Bulk Import Tab -- 1182 | bulk_import_tab = tabview.add("Bulk Import") 1183 | 1184 | bulk_import_tab.grid_columnconfigure(0, weight=0) 1185 | bulk_import_tab.grid_columnconfigure(1, weight=3) 1186 | bulk_import_tab.grid_columnconfigure(2, weight=0) 1187 | 1188 | # bulk_import_label = ctk.CTkLabel(bulk_import_tab, text=f"Bulk Import Text", text_color="#CECECE") 1189 | # bulk_import_label.grid(row=0, column=0, pady=5, padx=10, sticky="w") 1190 | bulk_import_text = ctk.CTkTextbox( 1191 | bulk_import_tab, 1192 | height=15, 1193 | wrap="none", 1194 | state="normal", 1195 | fg_color="#1C1E1E", 1196 | text_color="#A1A1A1", 1197 | font=("Courier", 14) 1198 | ) 1199 | bulk_import_text.grid(row=1, column=0, padx=10, pady=5, sticky="nsew", columnspan=2) 1200 | bind_context_menu(bulk_import_text) 1201 | 1202 | bulk_import_tab.grid_rowconfigure(0, weight=0) 1203 | bulk_import_tab.grid_rowconfigure(1, weight=1) 1204 | bulk_import_tab.grid_rowconfigure(2, weight=0) 1205 | 1206 | # Button row: Load, Save, Run buttons 1207 | load_bulk_button = create_button(bulk_import_tab, text="Reload", command=load_bulk_import_file) 1208 | load_bulk_button.grid(row=2, column=0, pady=5, padx=5, ipadx=30, sticky="ew") 1209 | 1210 | save_bulk_button = create_button(bulk_import_tab, text="Save", command=save_bulk_import_file) 1211 | save_bulk_button.grid(row=2, column=1, pady=5, padx=5, sticky="ew", columnspan=2) 1212 | 1213 | bulk_import_button = create_button(bulk_import_tab, text="Run Bulk Import", command=run_bulk_import_scrape_thread, primary=True) 1214 | bulk_import_button.grid(row=3, column=0, pady=5, padx=5, sticky="ew", columnspan=3) 1215 | 1216 | 1217 | #! Poster Scrape Tab -- 1218 | poster_scrape_tab = tabview.add("Poster Scrape") 1219 | 1220 | poster_scrape_tab.grid_columnconfigure(0, weight=0) 1221 | poster_scrape_tab.grid_columnconfigure(1, weight=1) 1222 | poster_scrape_tab.grid_columnconfigure(2, weight=0) 1223 | 1224 | poster_scrape_tab.grid_rowconfigure(0, weight=0) 1225 | poster_scrape_tab.grid_rowconfigure(1, weight=0) 1226 | poster_scrape_tab.grid_rowconfigure(2, weight=1) 1227 | poster_scrape_tab.grid_rowconfigure(3, weight=0) 1228 | 1229 | url_label = ctk.CTkLabel(poster_scrape_tab, text="Enter a ThePosterDB set URL, MediUX set URL, or ThePosterDB user URL", text_color="#696969", font=("Roboto", 15)) 1230 | url_label.grid(row=0, column=0, columnspan=2, pady=5, padx=5, sticky="w") 1231 | 1232 | url_entry = ctk.CTkEntry(poster_scrape_tab, placeholder_text="e.g., https://mediux.pro/sets/6527", fg_color="#1C1E1E", text_color="#A1A1A1", border_width=0, height=40) 1233 | url_entry.grid(row=1, column=0, columnspan=2, pady=5, padx=5, sticky="ew") 1234 | url_entry.bind("", lambda event: on_hover_in(url_label)) 1235 | url_entry.bind("", lambda event: on_hover_out(url_label)) 1236 | bind_context_menu(url_entry) 1237 | 1238 | clear_button = create_button(poster_scrape_tab, text="Clear", command=clear_url) 1239 | clear_button.grid(row=3, column=0, pady=5, padx=5, ipadx=30, sticky="ew") 1240 | 1241 | scrape_button = create_button(poster_scrape_tab, text="Run URL Scrape", command=run_url_scrape_thread, primary=True) 1242 | scrape_button.grid(row=3, column=1, pady=5, padx=5, sticky="ew", columnspan=2) 1243 | 1244 | poster_scrape_tab.grid_rowconfigure(2, weight=1) 1245 | 1246 | 1247 | #! Status and Error Labels -- 1248 | status_label = ctk.CTkLabel(app, text="", text_color="#E5A00D") 1249 | status_label.pack(side="bottom", fill="x", pady=(5)) 1250 | 1251 | 1252 | #! Load configuration and bulk import data at start, set default tab 1253 | load_and_update_ui() 1254 | load_bulk_import_file() 1255 | 1256 | set_default_tab(tabview) # default tab will be 'Settings' if base_url and token are not set, otherwise 'Bulk Import' 1257 | 1258 | app.mainloop() 1259 | 1260 | 1261 | # * CLI-based user input loop (fallback if no arguments were provided) --- 1262 | def interactive_cli_loop(tv, movies, bulk_txt): 1263 | while True: 1264 | print("\n--- Poster Scraper Interactive CLI ---") 1265 | print("1. Enter a ThePosterDB set URL, MediUX set URL, or ThePosterDB user URL") 1266 | print("2. Run Bulk Import from a file") 1267 | print("3. Launch GUI") 1268 | print("4. Stop") 1269 | 1270 | choice = input("Select an option (1-4): ") 1271 | 1272 | if choice == '1': 1273 | url = input("Enter the URL: ") 1274 | if check_libraries(tv, movies): 1275 | if "/user/" in url.lower(): 1276 | scrape_entire_user(url) 1277 | else: 1278 | set_posters(url, tv, movies) 1279 | 1280 | elif choice == '2': 1281 | file_path = input(f"Enter the path to the bulk import .txt file, or press [Enter] to use '{bulk_txt}': ") 1282 | file_path = file_path.strip() or bulk_txt 1283 | if check_libraries(tv, movies): 1284 | parse_cli_urls(file_path, tv, movies) 1285 | 1286 | elif choice == '3': 1287 | print("Launching GUI...") 1288 | tv, movies = plex_setup(gui_mode=True) 1289 | create_ui() 1290 | break # Exit CLI loop to launch GUI 1291 | 1292 | elif choice == '4': 1293 | print("Stopping...") 1294 | break 1295 | 1296 | else: 1297 | print("Invalid choice. Please select an option between 1 and 4.") 1298 | 1299 | 1300 | def check_libraries(tv, movies): 1301 | if not tv: 1302 | print("No TV libraries initialized. Verify the 'tv_library' in config.json.") 1303 | if not movies: 1304 | print("No Movies libraries initialized. Verify the 'movie_library' in config.json.") 1305 | return bool(tv) and bool(movies) 1306 | 1307 | 1308 | # * Main Initialization --- 1309 | if __name__ == "__main__": 1310 | config = load_config() 1311 | bulk_txt = config.get("bulk_txt", "bulk_import.txt") 1312 | 1313 | # Check for CLI arguments regardless of interactive_cli flag 1314 | if len(sys.argv) > 1: 1315 | command = sys.argv[1].lower() 1316 | 1317 | # Handle command-line arguments 1318 | if command == 'gui': 1319 | create_ui() 1320 | tv, movies = plex_setup(gui_mode=True) 1321 | 1322 | elif command == 'bulk': 1323 | tv, movies = plex_setup(gui_mode=False) 1324 | if len(sys.argv) > 2: 1325 | file_path = sys.argv[2] 1326 | parse_cli_urls(file_path, tv, movies) 1327 | else: 1328 | print(f"Using bulk import file: {bulk_txt}") 1329 | parse_cli_urls(bulk_txt, tv, movies) 1330 | 1331 | elif "/user/" in command: 1332 | scrape_entire_user(command) 1333 | else: 1334 | tv, movies = plex_setup(gui_mode=False) 1335 | set_posters(command, tv, movies) 1336 | 1337 | else: 1338 | # If no CLI arguments, proceed with UI creation (if not in interactive CLI mode) 1339 | if not interactive_cli: 1340 | create_ui() 1341 | tv, movies = plex_setup(gui_mode=True) 1342 | else: 1343 | sys.stdout.reconfigure(encoding='utf-8') 1344 | gui_flag = (len(sys.argv) > 1 and sys.argv[1].lower() == 'gui') 1345 | 1346 | # Perform CLI plex_setup if GUI flag is not present 1347 | if not gui_flag: 1348 | tv, movies = plex_setup(gui_mode=False) 1349 | 1350 | # Handle interactive CLI 1351 | interactive_cli_loop(tv, movies, bulk_txt) 1352 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.12.2 2 | certifi==2023.11.17 3 | charset-normalizer==3.3.2 4 | idna==3.6 5 | PlexAPI==4.15.6 6 | requests==2.31.0 7 | soupsieve==2.5 8 | urllib3==2.1.0 9 | customtkinter==5.2.2 10 | Pillow==10.4.0 -------------------------------------------------------------------------------- /test_module.py: -------------------------------------------------------------------------------- 1 | import plex_poster_set_helper 2 | import pytest 3 | 4 | def test_scrapeposterdb_set_tv_series(): 5 | soup = plex_poster_set_helper.cook_soup("https://theposterdb.com/set/8846") 6 | movieposters, showposters, collectionposters = plex_poster_set_helper.scrape_posterdb(soup) 7 | assert len(movieposters) == 0 8 | assert len(collectionposters) == 0 9 | assert len(showposters) == 10 10 | for showposter in showposters: 11 | assert showposter["title"] == "Brooklyn Nine-Nine" 12 | assert showposter["year"] == 2013 13 | assert showposter["episode"] == None 14 | assert showposter["season"] == "Cover" or (showposter["season"] >= 0 and showposter["season"] <= 8) 15 | assert showposter["source"] == "posterdb" 16 | 17 | def test_scrapeposterdb_set_movie_collection(): 18 | soup = plex_poster_set_helper.cook_soup("https://theposterdb.com/set/13035") 19 | movieposters, showposters, collectionposters = plex_poster_set_helper.scrape_posterdb(soup) 20 | assert len(movieposters) == 3 21 | assert len(collectionposters) == 1 22 | assert len(showposters) == 0 23 | for collectionposter in collectionposters: 24 | assert collectionposter["title"] == "The Dark Knight Collection" 25 | assert collectionposter["source"] == "posterdb" 26 | 27 | def test_scrape_mediux_set_tv_series(): 28 | soup = plex_poster_set_helper.cook_soup("https://mediux.pro/sets/9242") 29 | movieposters, showposters, collectionposters = plex_poster_set_helper.scrape_mediux(soup) 30 | assert len(movieposters) == 0 31 | assert len(collectionposters) == 0 32 | assert len(showposters) == 11 33 | backdrop_count = 0 34 | episode_count = 0 35 | cover_count = 0 36 | for showposter in showposters: 37 | assert showposter["title"] == "Mr. & Mrs. Smith" 38 | assert showposter["year"] == 2024 39 | assert showposter["source"] == "mediux" 40 | if (isinstance(showposter["episode"], int)): 41 | episode_count+=1 42 | elif showposter["episode"] == "Cover": 43 | cover_count+=1 44 | elif showposter["season"] == "Cover": 45 | cover_count+=1 46 | elif showposter["season"] == "Backdrop": 47 | backdrop_count+=1 48 | assert backdrop_count == 1 49 | assert episode_count == 8 50 | assert cover_count == 2 51 | 52 | def test_scrape_mediux_set_tv_series_long(): 53 | soup = plex_poster_set_helper.cook_soup("https://mediux.pro/sets/13427") 54 | movieposters, showposters, collectionposters = plex_poster_set_helper.scrape_mediux(soup) 55 | assert len(movieposters) == 0 56 | assert len(collectionposters) == 0 57 | assert len(showposters) == 264 58 | backdrop_count = 0 59 | episode_count = 0 60 | cover_count = 0 61 | for showposter in showposters: 62 | assert showposter["title"] == "Modern Family" 63 | assert showposter["year"] == 2009 64 | assert showposter["source"] == "mediux" 65 | if (isinstance(showposter["episode"], int)): 66 | episode_count+=1 67 | elif showposter["episode"] == "Cover": 68 | cover_count+=1 69 | elif showposter["season"] == "Cover": 70 | cover_count+=1 71 | elif showposter["season"] == "Backdrop": 72 | backdrop_count+=1 73 | assert backdrop_count == 1 74 | assert episode_count == 250 75 | assert cover_count == 13 76 | 77 | def test_scrape_mediux_boxset(): 78 | soup = plex_poster_set_helper.cook_soup("https://mediux.pro/sets/9406") 79 | movieposters, showposters, collectionposters = plex_poster_set_helper.scrape_mediux(soup) 80 | assert len(movieposters) == 0 81 | assert len(collectionposters) == 0 82 | assert len(showposters) == 247 83 | backdrop_count = 0 84 | episode_count = 0 85 | cover_count = 0 86 | for showposter in showposters: 87 | assert showposter["title"] == "Doctor Who" 88 | assert showposter["year"] == 2005 89 | assert showposter["source"] == "mediux" 90 | if (isinstance(showposter["episode"], int)): 91 | episode_count+=1 92 | elif showposter["episode"] == "Cover": 93 | cover_count+=1 94 | elif showposter["season"] == "Cover": 95 | cover_count+=1 96 | elif showposter["season"] == "Backdrop": 97 | backdrop_count+=1 98 | assert backdrop_count == 0 99 | assert episode_count == 232 100 | assert cover_count == 15 101 | 102 | 103 | test_scrape_mediux_set_tv_series() --------------------------------------------------------------------------------