├── version.py ├── .gitignore ├── install_requirements.bat ├── README.md ├── RatingsToPlexRatingsGUI.py └── RatingsToPlexRatingsController.py /version.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.2.0" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .python-version 3 | 4 | __pycache__/ 5 | 6 | *.log 7 | -------------------------------------------------------------------------------- /install_requirements.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Installing required packages... 3 | pip install customtkinter 4 | pip install plexapi 5 | echo Installation complete. 6 | pause -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | - [IMDb Ratings To Plex Ratings](#imdb-ratings-to-plex-ratings) 3 | - [How it works](#how-it-works) 4 | - [Command for creating an exe out of the python file](#command-for-creating-an-exe-out-of-the-python-file) 5 | - [Exporting Your IMDb Ratings](#exporting-your-imdb-ratings) 6 | - [Exporting Your Letterboxd Ratings](#exporting-your-letterboxd-ratings) 7 | - [Requirements](#requirements) 8 | 9 | # **IMDb & Letterboxd Ratings To Plex Ratings** 10 | 11 | Ratings-To-Plex is a desktop application that allows you to easily sync and transfer your IMDb and Letterboxd ratings to your Plex Media Server. This tool automates the process of updating movie ratings in your Plex libraries, providing a seamless way to ensure that your Plex media collection reflects your ratings from IMDb and Letterboxd. 12 | 13 |
14 | Click to view screenshots of the program
15 | 16 | v2.2.0
17 | ![image](https://github.com/user-attachments/assets/baa0ee3f-345b-4c26-bd9b-7c7fb89ded95) 18 | 19 | v2.1.0
20 | ![image](https://github.com/user-attachments/assets/4299c0f8-d424-4cce-9673-94a5fa85faaa) 21 | 22 | v2.0.0 (Uses customtkinter instead of PySimpleGUI UI)
23 | ![image](https://github.com/primetime43/Ratings-To-Plex-Ratings/assets/12754111/3ae89679-1e61-4cf1-9b33-1eba558162e4) 24 | 25 | v1.2
26 | ![image](https://github.com/primetime43/Ratings-To-Plex-Ratings/assets/12754111/b74b5ecf-84a3-4a7d-96be-3fd8e6ff66b5) 27 | 28 | v1.1
29 | ![image](https://github.com/primetime43/Ratings-To-Plex-Ratings/assets/12754111/453b78ab-2b90-4368-a796-feb97d8548be) 30 |
31 | 32 | ## **How it works** 33 | 34 | This script uses a simple GUI to authenticate with your Plex account, select a server, import a CSV file with your IMDb/Letterboxd ratings, and update the ratings of your Plex movie library accordingly. 35 | 36 | Here's a brief rundown of the steps: 37 | 38 | 1. **Log into Plex**: The application uses Plex's OAuth mechanism to authenticate your account. After clicking the "Login to Plex" button, it opens a web browser where you can authorize the app. Once authorized, the app obtains a token to interact with your Plex account. 39 | 40 | 2. **Select a server**: The application fetches all the servers associated with your Plex account that you own. You can then select the server whose movie ratings you want to update. 41 | 42 | 3. **Select a library**: Select the library to retrieve and update the ratings for this library. 43 | 44 | 4. **Select a CSV file**: Choose a CSV exported from IMDb (Your Ratings export) or Letterboxd (Data export → ratings.csv). The application parses it and stages rating updates. 45 | 46 | 5. **Choose media types (IMDb only)**: Toggle which IMDb "Title Type" entries to process: Movie, TV Series, TV Mini Series, TV Movie. (Letterboxd export is movies only.) 47 | 6. **Optional – Mark as watched**: If enabled, any item whose rating is set/updated will be marked watched. (Use cautiously—partial watches will become fully watched.) 48 | 7. **Optional – Force overwrite ratings**: If enabled, the tool will always reapply the rating even if Plex already shows the same value (bypasses the unchanged skip logic; useful if you cleared a rating in Plex and Plex still returns a stale value through the API). 49 | 8. **Optional – Update items outside selected library**: When enabled, the tool will search *all* of your owned movie/show libraries for matches instead of limiting to the single selected library. Use this if you maintain multiple libraries (e.g. "4K Movies" + "HD Movies") and want ratings written wherever the item exists. (The dropdown library is still required for UI flow, but matching spans every movie/show library.) 50 | 9. **Optional – Dry run (preview only)**: If enabled, the tool will NOT write anything to Plex. Instead it will simulate the run and log messages like `"[DRY RUN] Would update ..."` so you can verify counts and a sample before committing. Failure/unmatched CSV export is also skipped in dry-run. 51 | 10. **Click "Update Plex Ratings"**: Starts the background (or simulated) update process. Progress and decisions (updated / skipped / failures) stream into the log panel. 52 | 53 | ### Rating scale handling 54 | 55 | - Plex stores user ratings on a 1–10 scale. 56 | - IMDb ratings are already 1–10, so they are applied directly with no conversion. 57 | - Letterboxd ratings are 0.5–5; the tool multiplies by 2 to map them onto Plex's 1–10 scale (e.g. 4.0 → 8, 3.5 → 7). 58 | - Unchanged ratings are skipped to avoid unnecessary Plex API writes (unless *Force overwrite ratings* is enabled). 59 | 60 | ### Star ↔ 1–10 Mapping 61 | | Plex UI Stars | Stored Value | 62 | |---------------|--------------| 63 | | 0.5 | 1 | 64 | | 1.0 | 2 | 65 | | 1.5 | 3 | 66 | | 2.0 | 4 | 67 | | 2.5 | 5 | 68 | | 3.0 | 6 | 69 | | 3.5 | 7 | 70 | | 4.0 | 8 | 71 | | 4.5 | 9 | 72 | | 5.0 | 10 | 73 | 74 | ### Dual-Form Logging 75 | When a rating is updated the log shows both numeric (1–10) and star forms (e.g. `Updated Plex rating for "Inception (2010)" to 8 (4.0★)`). This is informational only; no rounding is applied—IMDb ratings are written exactly as provided. 76 | 77 | The application logs all the operations it performs, which includes connecting to the server, finding the movies, and updating the ratings. If an error occurs during the login or updating process, the application will display an error message. 78 | 79 | ### Dry Run Mode Details 80 | When the "Dry run" checkbox is selected: 81 | - No ratings are written and nothing is marked watched. 82 | - All other matching / filtering logic still runs so counts are accurate. 83 | - A subset of prospective changes (capped to avoid spamming) is logged with a `[DRY RUN]` prefix. 84 | - Failures/unmatched export CSV is suppressed (so you don’t clutter your folder with test files). 85 | - Final summary line shows `DRY RUN:` instead of `Successfully updated`. 86 | 87 | Use a dry run first after large CSV exports or when tuning media type filters to ensure the updates match expectations. 88 | 89 | ## **Command for creating an exe out of the python file** 90 | ``` 91 | pyinstaller --onefile --noconsole RatingsToPlexRatingsGUI.py 92 | ``` 93 | 94 | ## **Exporting Your IMDb Ratings:** 95 | 1. Go to IMDb and sign into your account. 96 | 2. Once you're signed in, click on your username in the top right corner and select "Your Ratings" from the dropdown menu. 97 | 3. In the "Your Ratings" page, you will find an "Export" button, usually located on the right side of the page. Click on it. 98 | 4. A CSV file will then be downloaded to your device, containing all your IMDb ratings. 99 | 100 | ## **Exporting Your Letterboxd Ratings:** 101 | 1. Go [here to letterboxd](https://letterboxd.com/settings/data/) and export your data. 102 | 2. Once exported, use the ratings.csv file in that zip file in the program to update the ratings. 103 | 104 | ## **Requirements:** 105 | - Python 3.10+ 106 | - Packages: `customtkinter`, `plexapi` 107 | 108 | Quick install (Windows batch provided): 109 | ``` 110 | install_requirements.bat 111 | ``` 112 | or manually: 113 | ``` 114 | pip install customtkinter plexapi 115 | ``` 116 | -------------------------------------------------------------------------------- /RatingsToPlexRatingsGUI.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | import threading 3 | import tkinter as tk 4 | from tkinter import StringVar, filedialog, scrolledtext, messagebox 5 | from typing import Optional 6 | from RatingsToPlexRatingsController import RatingsToPlexRatingsController 7 | from version import __version__ 8 | 9 | 10 | class IMDbRatingsToPlexRatingsApp(ctk.CTk): 11 | def __init__(self): 12 | super().__init__() 13 | ctk.set_appearance_mode("dark") 14 | ctk.set_default_color_theme("blue") 15 | self.title(f"IMDb Ratings To Plex Ratings v{__version__}") 16 | self.geometry("1000x640") 17 | self.minsize(900, 600) 18 | self.resizable(True, True) 19 | 20 | self.controller = RatingsToPlexRatingsController(log_callback=self.log_message) 21 | 22 | self.selected_file_path: Optional[str] = None 23 | self.server_var = tk.StringVar(value="Select a server") 24 | self.server_var.trace_add("write", self.on_server_selection_change) 25 | self.library_var = tk.StringVar(value="Select a library") 26 | self.library_var.trace_add("write", self.on_library_selection_change) 27 | self.radio_value = StringVar(value="IMDb") 28 | self.movie_var = tk.BooleanVar(value=True) 29 | self.tv_series_var = tk.BooleanVar(value=True) 30 | self.tv_mini_series_var = tk.BooleanVar(value=True) 31 | self.tv_movie_var = tk.BooleanVar(value=True) 32 | self.mark_watched_var = tk.BooleanVar(value=False) 33 | self.mark_watched_var.trace_add("write", self.on_mark_watched_change) 34 | self.force_overwrite_var = tk.BooleanVar(value=False) 35 | self.dry_run_var = tk.BooleanVar(value=False) 36 | self.all_libraries_var = tk.BooleanVar(value=False) 37 | self.all_libraries_var.trace_add("write", self.on_all_libraries_change) 38 | self.file_label_var = tk.StringVar(value="No file selected") 39 | self.status_var = tk.StringVar(value="Ready") 40 | self.theme_var = tk.StringVar(value="Dark") 41 | self.theme_var.trace_add("write", self.on_theme_change) 42 | self._update_running = False 43 | 44 | self.setup_ui() 45 | 46 | def setup_ui(self): 47 | self.grid_rowconfigure(0, weight=1) 48 | self.grid_rowconfigure(1, weight=0) 49 | self.grid_columnconfigure(0, weight=0, minsize=380) 50 | self.grid_columnconfigure(1, weight=1) 51 | 52 | self.left_panel = ctk.CTkFrame(self, corner_radius=8) 53 | self.left_panel.grid(row=0, column=0, sticky="nsew", padx=(10, 6), pady=10) 54 | self.left_panel.grid_rowconfigure(0, weight=1) 55 | self.left_panel.grid_rowconfigure(1, weight=0) 56 | self.left_panel.grid_columnconfigure(0, weight=1) 57 | 58 | self.tabview = ctk.CTkTabview(self.left_panel, corner_radius=8) 59 | self.tabview.grid(row=0, column=0, sticky="nsew", padx=8, pady=8) 60 | tab_general = self.tabview.add("General") 61 | tab_login = self.tabview.add("Login") 62 | tab_source = self.tabview.add("Source") 63 | tab_filters = self.tabview.add("Filters") 64 | tab_options = self.tabview.add("Options") 65 | 66 | # General Tab 67 | tab_general.grid_columnconfigure(0, weight=1) 68 | self.header_label = ctk.CTkLabel(tab_general, text="IMDb → Plex Ratings", font=("Segoe UI", 16, "bold")) 69 | self.header_label.grid(row=0, column=0, sticky="w", padx=8, pady=(8, 2)) 70 | self.ver = ctk.CTkLabel(tab_general, text=f"v{__version__}", font=("Segoe UI", 12)) 71 | self.ver.grid(row=0, column=0, sticky="e", padx=8, pady=(8, 2)) 72 | self.theme_row = ctk.CTkFrame(tab_general) 73 | self.theme_row.grid(row=1, column=0, sticky="ew", padx=8, pady=(4, 8)) 74 | self.theme_row.grid_columnconfigure(1, weight=1) 75 | ctk.CTkLabel(self.theme_row, text="Theme:").grid(row=0, column=0, padx=(0, 6)) 76 | self.theme_menu = ctk.CTkOptionMenu(self.theme_row, values=["Dark", "Light", "System"], variable=self.theme_var, width=120) 77 | self.theme_menu.grid(row=0, column=1, sticky="ew") 78 | self.src_type_frame = ctk.CTkFrame(tab_general) 79 | self.src_type_frame.grid(row=2, column=0, sticky="ew", padx=8, pady=(0, 8)) 80 | ctk.CTkLabel(self.src_type_frame, text="Source Type", font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", padx=8, pady=(6, 2)) 81 | 82 | 83 | self.imdb_radio = ctk.CTkRadioButton(self.src_type_frame, text="IMDb", variable=self.radio_value, value="IMDb", command=self.update_header_label) 84 | self.imdb_radio.grid(row=1, column=0, padx=8, pady=2, sticky="w") 85 | self.letterboxd_radio = ctk.CTkRadioButton(self.src_type_frame, text="Letterboxd", variable=self.radio_value, value="Letterboxd", command=self.update_header_label) 86 | self.letterboxd_radio.grid(row=1, column=1, padx=8, pady=2, sticky="w") 87 | 88 | # Login Tab 89 | tab_login.grid_columnconfigure(0, weight=1) 90 | ctk.CTkLabel(tab_login, text="Plex Login", font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w", padx=8, pady=(8, 4)) 91 | self.login_button = ctk.CTkButton(tab_login, text="Login to Plex", command=self.login_to_plex) 92 | self.login_button.grid(row=1, column=0, padx=8, pady=(0, 8), sticky="ew") 93 | self.server_menu = ctk.CTkOptionMenu(tab_login, variable=self.server_var, values=[""], width=200) 94 | self.server_menu.grid(row=2, column=0, padx=8, pady=4, sticky="ew") 95 | self.library_menu = ctk.CTkOptionMenu(tab_login, variable=self.library_var, values=[""], width=200) 96 | self.library_menu.grid(row=3, column=0, padx=8, pady=(0, 4), sticky="ew") 97 | self.all_libraries_checkbox = ctk.CTkCheckBox(tab_login, text="Search ALL libraries (no library selection required)", variable=self.all_libraries_var) 98 | self.all_libraries_checkbox.grid(row=4, column=0, padx=8, pady=(0, 8), sticky="w") 99 | 100 | # Source Tab 101 | tab_source.grid_columnconfigure(0, weight=1) 102 | ctk.CTkLabel(tab_source, text="Source CSV", font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w", padx=8, pady=(8, 4)) 103 | self.select_file_button = ctk.CTkButton(tab_source, text="Select CSV File", command=self.select_file) 104 | self.select_file_button.grid(row=1, column=0, padx=8, pady=4, sticky="ew") 105 | self.file_label = ctk.CTkLabel(tab_source, textvariable=self.file_label_var, anchor="w", wraplength=300) 106 | self.file_label.grid(row=2, column=0, padx=8, pady=(0, 8), sticky="ew") 107 | 108 | # Filters Tab 109 | tab_filters.grid_columnconfigure(0, weight=1) 110 | ctk.CTkLabel(tab_filters, text="Media Filters", font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w", padx=8, pady=(8, 4)) 111 | filters_inner = ctk.CTkFrame(tab_filters) 112 | filters_inner.grid(row=1, column=0, sticky="ew", padx=8, pady=4) 113 | filters_inner.grid_columnconfigure((0, 1), weight=1) 114 | self.movie_checkbox = ctk.CTkCheckBox(filters_inner, text="Movie", variable=self.movie_var) 115 | self.movie_checkbox.grid(row=0, column=0, padx=6, pady=2, sticky="w") 116 | self.tv_series_checkbox = ctk.CTkCheckBox(filters_inner, text="TV Series", variable=self.tv_series_var) 117 | self.tv_series_checkbox.grid(row=0, column=1, padx=6, pady=2, sticky="w") 118 | self.tv_mini_series_checkbox = ctk.CTkCheckBox(filters_inner, text="TV Mini Series", variable=self.tv_mini_series_var) 119 | self.tv_mini_series_checkbox.grid(row=1, column=0, padx=6, pady=2, sticky="w") 120 | self.tv_movie_checkbox = ctk.CTkCheckBox(filters_inner, text="TV Movie", variable=self.tv_movie_var) 121 | self.tv_movie_checkbox.grid(row=1, column=1, padx=6, pady=2, sticky="w") 122 | 123 | # Options Tab 124 | tab_options.grid_columnconfigure(0, weight=1) 125 | ctk.CTkLabel(tab_options, text="Options", font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w", padx=8, pady=(8, 4)) 126 | self.watched_checkbox = ctk.CTkCheckBox(tab_options, text="Mark watched if rating imported", variable=self.mark_watched_var) 127 | self.watched_checkbox.grid(row=1, column=0, padx=8, pady=2, sticky="w") 128 | self.force_overwrite_checkbox = ctk.CTkCheckBox(tab_options, text="Force reapply ratings (ignore unchanged)", variable=self.force_overwrite_var) 129 | self.force_overwrite_checkbox.grid(row=2, column=0, padx=8, pady=2, sticky="w") 130 | self.dry_run_checkbox = ctk.CTkCheckBox(tab_options, text="Dry run (preview only)", variable=self.dry_run_var) 131 | self.dry_run_checkbox.grid(row=3, column=0, padx=8, pady=(2, 8), sticky="w") 132 | 133 | def update_header_label(self): 134 | source = self.radio_value.get() 135 | if source == "IMDb": 136 | self.header_label.configure(text="IMDb → Plex Ratings") 137 | elif source == "Letterboxd": 138 | self.header_label.configure(text="Letterboxd → Plex Ratings") 139 | else: 140 | self.header_label.configure(text="→ Plex Ratings") 141 | 142 | # Action Bar 143 | self.action_frame = ctk.CTkFrame(self.left_panel) 144 | self.action_frame.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8)) 145 | self.action_frame.grid_columnconfigure(0, weight=1) 146 | self.startUpdate_button = ctk.CTkButton(self.action_frame, text="Update Plex Ratings", command=self.update_ratings) 147 | self.startUpdate_button.grid(row=0, column=0, padx=8, pady=(8, 4), sticky="ew") 148 | self.progress_bar = ctk.CTkProgressBar(self.action_frame, mode="indeterminate") 149 | self.progress_bar.grid(row=1, column=0, padx=8, pady=(0, 8), sticky="ew") 150 | self.progress_bar.set(0) 151 | 152 | # Log Panel 153 | self.log_frame = ctk.CTkFrame(self, corner_radius=8) 154 | self.log_frame.grid(row=0, column=1, sticky="nsew", padx=(6, 10), pady=10) 155 | self.log_frame.grid_rowconfigure(1, weight=1) 156 | self.log_frame.grid_columnconfigure(0, weight=1) 157 | log_header = ctk.CTkLabel(self.log_frame, text="Activity Log", font=("Segoe UI", 14, "bold")) 158 | log_header.grid(row=0, column=0, sticky="nw", padx=12, pady=(12, 4)) 159 | self.log_textbox = scrolledtext.ScrolledText( 160 | self.log_frame, 161 | wrap=tk.WORD, 162 | height=10, 163 | state="disabled", 164 | font=("Consolas", 10), 165 | bg="#1e1e1e", 166 | fg="white", 167 | insertbackground="white", 168 | borderwidth=0, 169 | ) 170 | self.log_textbox.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="nsew") 171 | 172 | # Status Bar 173 | self.status_frame = ctk.CTkFrame(self) 174 | self.status_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=10, pady=(0, 10)) 175 | self.status_frame.grid_columnconfigure(0, weight=1) 176 | self.status_label = ctk.CTkLabel(self.status_frame, textvariable=self.status_var, anchor="w") 177 | self.status_label.grid(row=0, column=0, padx=10, pady=4, sticky="ew") 178 | 179 | self.bind("", self._on_root_resize) 180 | 181 | # Event & Helper Methods 182 | def on_theme_change(self, *args): 183 | mode = self.theme_var.get().lower() 184 | ctk.set_appearance_mode("system" if mode == "system" else mode) 185 | 186 | def set_status(self, text: str): 187 | self.status_var.set(text) 188 | 189 | def on_mark_watched_change(self, *args): 190 | if self.mark_watched_var.get(): 191 | messagebox.showwarning( 192 | "WARNING - Mark as Watched Enabled", 193 | "When enabled, any title that has its rating imported will be marked as watched. Use with caution." 194 | ) 195 | 196 | def select_file(self): 197 | self.selected_file_path = filedialog.askopenfilename(filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]) 198 | if self.selected_file_path: 199 | self.log_message(f"Selected file: {self.selected_file_path}") 200 | display_name = self.selected_file_path.replace("\\", "/").split("/")[-1] 201 | self.file_label_var.set(display_name) 202 | self.set_status("CSV loaded. Ready to update.") 203 | else: 204 | self.log_message("No file selected.") 205 | self.file_label_var.set("No file selected") 206 | self.set_status("No file selected") 207 | 208 | def on_server_selection_change(self, *args): 209 | selected_server = self.server_var.get() 210 | if selected_server == "Select a server": 211 | return 212 | self.log_message(f"Server selected: {selected_server} (loading libraries...)") 213 | self.library_menu.configure(values=["Loading..."]) 214 | self.set_status(f"Fetching libraries for {selected_server}...") 215 | self.controller.get_libraries_async(selected_server, self._on_libraries_loaded) 216 | 217 | def _on_libraries_loaded(self, libraries): 218 | def _update(): 219 | self.update_libraries_dropdown(libraries) 220 | self.after(0, _update) 221 | 222 | def on_library_selection_change(self, *args): 223 | if self.all_libraries_var.get(): 224 | return 225 | selected_library = self.library_var.get() 226 | if selected_library and selected_library != "Select a library": 227 | self.set_status(f"Library '{selected_library}' selected.") 228 | 229 | def update_libraries_dropdown(self, libraries): 230 | if libraries: 231 | self.library_menu.configure(values=libraries) 232 | self.set_status("Libraries loaded. Choose a library or enable all-libraries mode.") 233 | else: 234 | self.set_status("No libraries found.") 235 | 236 | def update_ratings(self): 237 | if self._update_running: 238 | return 239 | self._update_running = True 240 | self._set_ui_state("disabled") 241 | self.set_status("Updating Plex ratings...") 242 | self.progress_bar.start() 243 | threading.Thread(target=self._update_ratings_thread, daemon=True).start() 244 | 245 | def _update_ratings_thread(self): 246 | selected_library = self.library_var.get() 247 | filepath = self.selected_file_path 248 | if not filepath: 249 | self.log_message("Please select a file first.") 250 | self.after(0, self._on_update_complete, False, "Missing file.") 251 | return 252 | if not self.all_libraries_var.get() and selected_library == "Select a library": 253 | self.log_message("Please select a library or enable 'Search ALL libraries'.") 254 | self.after(0, self._on_update_complete, False, "Missing library.") 255 | return 256 | self.log_message(f"Starting update from {self.radio_value.get()}...") 257 | values = { 258 | "-IMDB-": self.radio_value.get() == "IMDb", 259 | "-LETTERBOXD-": self.radio_value.get() == "Letterboxd", 260 | "-MOVIE-": self.movie_var.get(), 261 | "-TVSERIES-": self.tv_series_var.get(), 262 | "-TVMINISERIES-": self.tv_mini_series_var.get(), 263 | "-TVMOVIE-": self.tv_movie_var.get(), 264 | "-WATCHED-": self.mark_watched_var.get(), 265 | "-FORCEOVERWRITE-": self.force_overwrite_var.get(), 266 | "-DRYRUN-": self.dry_run_var.get(), 267 | "-ALLLIBS-": self.all_libraries_var.get(), 268 | } 269 | success = self.controller.update_ratings(filepath, selected_library, values) 270 | self.after(0, self._on_update_complete, success, None) 271 | 272 | def _on_update_complete(self, success: bool, error_msg: Optional[str]): 273 | self.progress_bar.stop() 274 | self._set_ui_state("normal") 275 | self._update_running = False 276 | self.set_status("Update complete." if success else (error_msg or "Update failed.")) 277 | 278 | def log_message(self, message: str): 279 | self.log_textbox.configure(state="normal") 280 | self.log_textbox.insert(tk.END, message + "\n") 281 | self.log_textbox.configure(state="disabled") 282 | self.log_textbox.see(tk.END) 283 | 284 | def _set_ui_state(self, state: str): 285 | widgets = [ 286 | self.startUpdate_button, 287 | self.select_file_button, 288 | self.server_menu, 289 | self.library_menu, 290 | self.imdb_radio, 291 | self.letterboxd_radio, 292 | self.movie_checkbox, 293 | self.tv_series_checkbox, 294 | self.tv_mini_series_checkbox, 295 | self.tv_movie_checkbox, 296 | self.login_button, 297 | self.watched_checkbox, 298 | self.force_overwrite_checkbox, 299 | self.dry_run_checkbox, 300 | self.all_libraries_checkbox, 301 | ] 302 | for w in widgets: 303 | w.configure(state=state) 304 | if state == "normal" and self.all_libraries_var.get(): 305 | self.library_menu.configure(state="disabled") 306 | 307 | def on_all_libraries_change(self, *args): 308 | if self.all_libraries_var.get(): 309 | self.library_menu.configure(state="disabled") 310 | self.set_status("All libraries mode enabled.") 311 | else: 312 | self.library_menu.configure(state="normal") 313 | self.set_status("Select a library or enable all-libraries mode.") 314 | 315 | def login_to_plex(self): 316 | threading.Thread(target=self._login_to_plex_thread, daemon=True).start() 317 | 318 | def _login_to_plex_thread(self): 319 | self.controller.login_and_fetch_servers(self.update_servers_ui) 320 | 321 | def update_servers_ui(self, servers, success): 322 | if success and servers: 323 | self.server_menu.configure(values=servers) 324 | self.set_status("Servers loaded. Select a server.") 325 | elif success: 326 | self.set_status("No servers found.") 327 | else: 328 | self.set_status("Login failed. Retry.") 329 | 330 | def _on_root_resize(self, event): 331 | try: 332 | panel_w = self.left_panel.winfo_width() 333 | if panel_w > 150: 334 | self.file_label.configure(wraplength=panel_w - 70) 335 | except Exception: 336 | pass 337 | 338 | 339 | if __name__ == "__main__": 340 | app = IMDbRatingsToPlexRatingsApp() 341 | app.mainloop() 342 | -------------------------------------------------------------------------------- /RatingsToPlexRatingsController.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | import logging 4 | import threading 5 | import time 6 | import webbrowser 7 | from typing import Callable, List, Optional, Dict, Tuple 8 | from pathlib import Path 9 | from concurrent.futures import ThreadPoolExecutor 10 | from collections import deque 11 | from plexapi.myplex import MyPlexPinLogin, MyPlexAccount 12 | 13 | # Performance & parallelism constants 14 | IMDB_LAZY_LOOKUP_THRESHOLD = 300 # If number of IMDb rows to process <= this, do per-guid lookup instead of full library scan 15 | PARALLEL_MIN_ITEMS = 600 # Activate parallel rating updates if >= this many IMDb rows (and not lazy) 16 | PARALLEL_WORKERS = 6 # Thread pool size for parallel updates 17 | MAX_WRITES_PER_SECOND = 0 # 0 => unlimited (disable limiter); tune if server errors appear 18 | 19 | 20 | class _RateLimiter: 21 | """Simple moving-window rate limiter (thread-safe). 22 | 23 | Ensures no more than max_per_second operations occur in any rolling 1s window. 24 | Blocks (sleeping in small increments) until a slot is available. 25 | """ 26 | 27 | def __init__(self, max_per_second: int): 28 | self.max_per_second = max_per_second 29 | self._timestamps = deque() 30 | self._lock = threading.Lock() 31 | 32 | def acquire(self): # pragma: no cover (timing based) 33 | if self.max_per_second <= 0: 34 | return # unlimited 35 | while True: 36 | with self._lock: 37 | now = time.perf_counter() 38 | while self._timestamps and now - self._timestamps[0] > 1.0: 39 | self._timestamps.popleft() 40 | if len(self._timestamps) < self.max_per_second: 41 | self._timestamps.append(now) 42 | return 43 | time.sleep(0.01) 44 | 45 | # Configure logging 46 | logging.basicConfig( 47 | filename="RatingsToPlex.log", 48 | level=logging.DEBUG, 49 | format="%(asctime)s [%(levelname)s] %(message)s", 50 | datefmt="%Y-%m-%d %H:%M:%S", 51 | encoding='utf-8' 52 | ) 53 | logger = logging.getLogger(__name__) 54 | 55 | 56 | class PlexConnection: 57 | """Wraps a Plex account/resources with lightweight caching for faster UI interactions.""" 58 | 59 | def __init__(self, account, server, resources): 60 | self.account = account 61 | self.server = server 62 | self.resources = resources 63 | self._server_cache = {} # server_name -> connected PlexServer 64 | self._libraries_cache = {} # server_name -> list[str] 65 | self._lock = threading.Lock() 66 | logger.debug("PlexConnection initialized with account: %s, server: %s", account, server) 67 | 68 | def get_servers(self) -> List[str]: 69 | return [resource.name for resource in self.resources] 70 | 71 | def switch_to_server(self, server_name: str) -> bool: 72 | # Reuse cached connection if available 73 | with self._lock: 74 | if server_name in self._server_cache: 75 | self.server = self._server_cache[server_name] 76 | logger.debug("Using cached server connection for: %s", server_name) 77 | return True 78 | try: 79 | resource = next((res for res in self.resources if res.name == server_name), None) 80 | if resource: 81 | connected = self.account.resource(resource.name).connect(timeout=8) # type: ignore[arg-type] 82 | with self._lock: 83 | self._server_cache[server_name] = connected 84 | self.server = connected 85 | logger.info("Connected to server: %s", server_name) 86 | return True 87 | except Exception as e: 88 | logger.error("Error switching server: %s", e) 89 | return False 90 | 91 | def get_libraries(self) -> List[str]: 92 | if not self.server: 93 | logger.warning("Server is not connected. Cannot fetch libraries.") 94 | return [] 95 | server_name = getattr(self.server, 'friendlyName', None) or getattr(self.server, 'name', None) 96 | if server_name and server_name in self._libraries_cache: 97 | logger.debug("Libraries cache hit for server: %s", server_name) 98 | return self._libraries_cache[server_name] 99 | try: 100 | libs = [section.title for section in self.server.library.sections()] 101 | if server_name: 102 | self._libraries_cache[server_name] = libs 103 | logger.debug("Fetched %d libraries for server %s", len(libs), server_name) 104 | return libs 105 | except Exception as e: 106 | logger.error("Failed to fetch libraries from server: %s", e) 107 | return [] 108 | 109 | def prefetch_all_libraries_async(self, log_fn: Optional[Callable[[str], None]] = None): 110 | """Background warm-up of server connections and library lists for all servers.""" 111 | 112 | def _worker(): 113 | for res in self.resources: 114 | name = res.name 115 | if name in self._libraries_cache: 116 | continue 117 | try: 118 | start = time.perf_counter() 119 | if name not in self._server_cache: 120 | connected = self.account.resource(res.name).connect(timeout=8) # type: ignore[arg-type] 121 | with self._lock: 122 | self._server_cache[name] = connected 123 | server_obj = self._server_cache[name] 124 | libs = [s.title for s in server_obj.library.sections()] 125 | self._libraries_cache[name] = libs 126 | duration = time.perf_counter() - start 127 | if log_fn: 128 | log_fn(f"Prefetched libraries for '{name}' ({len(libs)} libraries) in {duration:.2f}s") 129 | except Exception as e: # pragma: no cover (best-effort prefetch) 130 | if log_fn: 131 | log_fn(f"Prefetch failed for '{name}': {e}") 132 | threading.Thread(target=_worker, daemon=True).start() 133 | 134 | 135 | class RatingsToPlexRatingsController: 136 | def __init__(self, server=None, log_callback=None): 137 | self.plex_connection = None 138 | self.log_callback = log_callback 139 | logger.debug("RatingsToPlexRatingsController initialized") 140 | 141 | def log_message(self, message, log_filename): 142 | now = datetime.datetime.now() 143 | timestamp = now.strftime("%Y-%m-%d %H:%M:%S") 144 | full_message = f"{timestamp} - {message}\n" 145 | logger.info(message) 146 | if self.log_callback: 147 | self.log_callback(full_message) 148 | # Ensure UTF-8 so special characters in logs do not raise Windows charmap errors 149 | try: 150 | with open(log_filename, 'a', encoding='utf-8') as log_file: 151 | log_file.write(full_message) 152 | except UnicodeEncodeError: 153 | # Fallback: strip/replace problematic chars and retry to avoid aborting the entire run 154 | safe_message = full_message.encode('ascii', 'replace').decode('ascii') 155 | try: 156 | with open(log_filename, 'a', encoding='utf-8', errors='ignore') as log_file: 157 | log_file.write(safe_message) 158 | except Exception as inner_e: # pragma: no cover 159 | logger.error("Secondary log write failure (sanitized) for %s: %s", log_filename, inner_e) 160 | except Exception as e: # pragma: no cover 161 | logger.error("Log write failure for %s: %s", log_filename, e) 162 | 163 | def login_and_fetch_servers(self, update_ui_callback): 164 | logger.info("Initiating Plex login and fetching servers") 165 | headers = {'X-Plex-Client-Identifier': 'unique_client_identifier'} 166 | pinlogin = MyPlexPinLogin(headers=headers, oauth=True) 167 | oauth_url = pinlogin.oauthUrl() 168 | webbrowser.open(oauth_url) 169 | pinlogin.run(timeout=120) 170 | pinlogin.waitForLogin() 171 | if pinlogin.token: 172 | logger.info("Plex login successful") 173 | plex_account = MyPlexAccount(token=pinlogin.token) 174 | resources = [r for r in plex_account.resources() if r.owned and r.connections and r.provides == 'server'] 175 | servers = [r.name for r in resources] 176 | if servers: 177 | logger.info("Fetched servers: %s", servers) 178 | self.plex_connection = PlexConnection(plex_account, None, resources) 179 | # No persistent seeding; rely on live prefetch 180 | self.plex_connection.prefetch_all_libraries_async(log_fn=lambda m: logger.debug(m)) 181 | update_ui_callback(servers=servers, success=True) 182 | else: 183 | logger.warning("No servers found after login") 184 | update_ui_callback(servers=None, success=False) 185 | else: 186 | logger.error("Plex login failed or timed out") 187 | update_ui_callback(servers=None, success=False) 188 | 189 | def get_servers(self): 190 | if self.plex_connection: 191 | return self.plex_connection.get_servers() 192 | logger.warning("No Plex connection found. Cannot get servers.") 193 | return [] 194 | 195 | def get_libraries(self, server_name): 196 | if self.plex_connection.switch_to_server(server_name): 197 | return self.plex_connection.get_libraries() 198 | logger.error("Failed to switch to server: %s", server_name) 199 | return [] 200 | 201 | def get_libraries_async(self, server_name: str, callback: Callable[[List[str]], None]): 202 | def _worker(): 203 | libs = self.get_libraries(server_name) 204 | try: 205 | callback(libs) 206 | except Exception as e: # pragma: no cover 207 | logger.error("Library callback error: %s", e) 208 | threading.Thread(target=_worker, daemon=True).start() 209 | 210 | # Persistent cache methods removed 211 | 212 | def update_ratings(self, filepath, selected_library, values): 213 | now = datetime.datetime.now() 214 | log_filename = f"RatingsUpdateLog_{now.strftime('%Y%m%d_%H%M%S')}.log" 215 | logger.info("Starting update_ratings with file: %s and library: %s", filepath, selected_library) 216 | if not self.plex_connection or not self.plex_connection.server: 217 | logger.error("Not connected to a Plex server") 218 | self.log_message('Error: Not connected to a Plex server', log_filename) 219 | return False 220 | all_libs_mode = values.get('-ALLLIBS-', False) 221 | library_section = None 222 | if all_libs_mode: 223 | try: 224 | # Collect all movie/show libraries (filter to those providing rating capable media) 225 | sections = [s for s in self.plex_connection.server.library.sections() if getattr(s, 'type', '') in ('movie', 'show')] 226 | if not sections: 227 | self.log_message('Error: No movie/show libraries found for cross-library update.', log_filename) 228 | return False 229 | library_section = sections[0] # Use first for fetchItem purposes; searches will specify section 230 | self.log_message(f"Cross-library mode enabled: {len(sections)} libraries will be searched.", log_filename) 231 | except Exception as e: 232 | self.log_message(f'Error enumerating libraries: {e}', log_filename) 233 | return False 234 | else: 235 | library_section = self.plex_connection.server.library.section(selected_library) 236 | if not library_section: 237 | logger.error("Library section %s not found", selected_library) 238 | self.log_message(f'Error: Library section {selected_library} not found', log_filename) 239 | return False 240 | dry_run = values.get('-DRYRUN-', False) 241 | if dry_run: 242 | self.log_message('DRY RUN ENABLED: No changes will be written to Plex.', log_filename) 243 | try: 244 | with open(filepath, 'r', encoding='utf-8') as file: 245 | csv_reader = csv.DictReader(file) 246 | if values['-IMDB-']: 247 | return self.update_ratings_from_imdb(csv_reader, library_section, values, log_filename, filepath, dry_run=dry_run) 248 | elif values['-LETTERBOXD-']: 249 | return self.update_ratings_from_letterboxd(csv_reader, library_section, values, log_filename, filepath, dry_run=dry_run) 250 | except FileNotFoundError: 251 | logger.error("CSV file not found: %s", filepath) 252 | self.log_message('Error: File not found', log_filename) 253 | return False 254 | except Exception as e: 255 | logger.error("Error processing CSV: %s", e) 256 | self.log_message(f'Error processing CSV: {e}', log_filename) 257 | return False 258 | 259 | def update_ratings_from_imdb(self, csv_reader, library_section, values, log_filename, source_filepath, dry_run: bool = False): 260 | selected_media_types = self._get_selected_media_types(values) 261 | logger.info("Updating IMDb ratings (lazy threshold=%d)", IMDB_LAZY_LOOKUP_THRESHOLD) 262 | self.log_message("Updating IMDb ratings", log_filename) 263 | 264 | rows = [r for r in csv_reader if r.get('Title Type') in selected_media_types] 265 | total_movies = len(rows) 266 | total_updated_movies = 0 267 | failures: List[Dict[str, str]] = [] 268 | missing_id = 0 269 | invalid_rating = 0 270 | not_found = 0 271 | type_mismatch = 0 272 | rate_failed = 0 273 | unchanged_skipped = 0 274 | 275 | def imdb_type_to_plex_types(imdb_type): 276 | mapping = { 277 | 'Movie': {'movie'}, 278 | 'TV Movie': {'movie'}, 279 | 'Short': {'movie'}, 280 | 'TV Series': {'show'}, 281 | 'TV Mini Series': {'show'}, 282 | 'TV Episode': {'episode'}, 283 | } 284 | return mapping.get(imdb_type, set()) 285 | 286 | use_lazy = total_movies <= IMDB_LAZY_LOOKUP_THRESHOLD 287 | logger.debug("IMDb rows=%d using %s strategy", total_movies, 'lazy lookup' if use_lazy else 'bulk scan') 288 | 289 | guidLookup = {} 290 | if not use_lazy: 291 | start = time.perf_counter() 292 | # If cross-library mode, aggregate all sections 293 | if values.get('-ALLLIBS-', False) and self.plex_connection and self.plex_connection.server: 294 | try: 295 | sections = [s for s in self.plex_connection.server.library.sections() if getattr(s, 'type', '') in ('movie', 'show')] 296 | except Exception as e: # pragma: no cover 297 | sections = [library_section] 298 | logger.error('Failed listing sections for cross-library mode: %s', e) 299 | else: 300 | sections = [library_section] 301 | count_sections = len(sections) 302 | total_items_scanned = 0 303 | for sec in sections: 304 | try: 305 | for item in sec.all(): 306 | total_items_scanned += 1 307 | if getattr(item, 'guid', None): 308 | guidLookup[item.guid] = item 309 | for guid in getattr(item, 'guids', []) or []: 310 | guidLookup[guid.id] = item 311 | except Exception as e: # pragma: no cover 312 | logger.error('Failed scanning section %s: %s', getattr(sec, 'title', '?'), e) 313 | duration = time.perf_counter() - start 314 | logger.debug("Built full GUID index (%d entries from %d sections; scanned %d items) in %.2fs", len(guidLookup), count_sections, total_items_scanned, duration) 315 | 316 | preview_samples = [] # collect up to N previews (sequential lazy path) 317 | PREVIEW_LIMIT = 15 318 | 319 | # Decide if we will use parallel processing (only for non-lazy, non-dry-run large batches) 320 | use_parallel = (not dry_run and not use_lazy and total_movies >= PARALLEL_MIN_ITEMS) 321 | if use_parallel: 322 | self.log_message(f"Parallel IMDb update enabled: {total_movies} items, {PARALLEL_WORKERS} workers", log_filename) 323 | 324 | if use_parallel: 325 | rate_limiter = _RateLimiter(MAX_WRITES_PER_SECOND) 326 | force_overwrite = values.get('-FORCEOVERWRITE-', False) 327 | mark_watched = values.get('-WATCHED-', False) 328 | lock = threading.Lock() 329 | 330 | def worker(movie_row) -> Tuple[Dict[str, int], Optional[Dict[str, str]]]: 331 | local_counts = { 332 | 'updated': 0, 333 | 'missing_id': 0, 334 | 'invalid_rating': 0, 335 | 'not_found': 0, 336 | 'type_mismatch': 0, 337 | 'rate_failed': 0, 338 | 'unchanged_skipped': 0 339 | } 340 | failure_entry = None 341 | imdb_id = movie_row.get('Const') 342 | if not imdb_id: 343 | local_counts['missing_id'] += 1 344 | failure_entry = { 345 | 'Title': movie_row.get('Title', ''), 346 | 'Year': movie_row.get('Year', ''), 347 | 'IMDbID': '', 348 | 'Reason': 'Missing IMDb ID (Const)', 349 | 'YourRating': movie_row.get('Your Rating', ''), 350 | 'TitleType': movie_row.get('Title Type', '') 351 | } 352 | return local_counts, failure_entry 353 | rating_raw = movie_row.get('Your Rating', '') 354 | try: 355 | your_rating = float((rating_raw or '').strip()) 356 | except (ValueError, AttributeError): 357 | local_counts['invalid_rating'] += 1 358 | failure_entry = { 359 | 'Title': movie_row.get('Title', ''), 360 | 'Year': movie_row.get('Year', ''), 361 | 'IMDbID': imdb_id, 362 | 'Reason': 'Invalid rating value', 363 | 'YourRating': rating_raw, 364 | 'TitleType': movie_row.get('Title Type', '') 365 | } 366 | return local_counts, failure_entry 367 | plex_rating = your_rating 368 | found = guidLookup.get(f'imdb://{imdb_id}') 369 | if not found: 370 | local_counts['not_found'] += 1 371 | failure_entry = { 372 | 'Title': movie_row.get('Title', ''), 373 | 'Year': movie_row.get('Year', ''), 374 | 'IMDbID': imdb_id, 375 | 'Reason': 'Not found in Plex by GUID', 376 | 'YourRating': rating_raw, 377 | 'TitleType': movie_row.get('Title Type', '') 378 | } 379 | return local_counts, failure_entry 380 | expected_types = imdb_type_to_plex_types(movie_row['Title Type']) 381 | item_type = getattr(found, 'type', None) 382 | if expected_types and item_type not in expected_types: 383 | local_counts['type_mismatch'] += 1 384 | failure_entry = { 385 | 'Title': movie_row.get('Title', ''), 386 | 'Year': movie_row.get('Year', ''), 387 | 'IMDbID': imdb_id, 388 | 'Reason': f'Type mismatch (Plex={item_type})', 389 | 'YourRating': rating_raw, 390 | 'TitleType': movie_row.get('Title Type', '') 391 | } 392 | return local_counts, failure_entry 393 | # Fetch fresh for current userRating 394 | if getattr(found, 'ratingKey', None): 395 | try: 396 | fresh = library_section.fetchItem(found.ratingKey) 397 | if fresh: 398 | found = fresh 399 | except Exception: 400 | pass 401 | existing_rating = getattr(found, 'userRating', None) 402 | if not force_overwrite and existing_rating is not None: 403 | try: 404 | existing_rating_float = float(existing_rating) 405 | except Exception: 406 | existing_rating_float = existing_rating 407 | if isinstance(existing_rating_float, (int, float)) and abs(existing_rating_float - plex_rating) < 0.01: 408 | local_counts['unchanged_skipped'] += 1 409 | return local_counts, None 410 | try: 411 | rate_limiter.acquire() 412 | found.rate(rating=plex_rating) 413 | msg = f'Updated Plex rating for "{found.title} ({found.year})" to {plex_rating}' 414 | self.log_message(msg, log_filename) 415 | if mark_watched: 416 | rate_limiter.acquire() 417 | try: 418 | found.markWatched() 419 | self.log_message(f'Marked "{found.title} ({found.year})" as watched', log_filename) 420 | except Exception as e: 421 | self.log_message(f'Error marking as watched for {found.title}: {e}', log_filename) 422 | local_counts['updated'] += 1 423 | except Exception as e: 424 | local_counts['rate_failed'] += 1 425 | failure_entry = { 426 | 'Title': getattr(found, 'title', ''), 427 | 'Year': getattr(found, 'year', ''), 428 | 'IMDbID': imdb_id, 429 | 'Reason': f'Rate failed: {e}', 430 | 'YourRating': rating_raw, 431 | 'TitleType': movie_row.get('Title Type', '') 432 | } 433 | return local_counts, failure_entry 434 | 435 | aggregated = { 436 | 'updated': 0, 437 | 'missing_id': 0, 438 | 'invalid_rating': 0, 439 | 'not_found': 0, 440 | 'type_mismatch': 0, 441 | 'rate_failed': 0, 442 | 'unchanged_skipped': 0 443 | } 444 | with ThreadPoolExecutor(max_workers=PARALLEL_WORKERS) as executor: 445 | for counts, failure in executor.map(worker, rows): 446 | for k, v in counts.items(): 447 | aggregated[k] += v 448 | if failure: 449 | failures.append(failure) 450 | total_updated_movies = aggregated['updated'] 451 | missing_id = aggregated['missing_id'] 452 | invalid_rating = aggregated['invalid_rating'] 453 | not_found = aggregated['not_found'] 454 | type_mismatch = aggregated['type_mismatch'] 455 | rate_failed = aggregated['rate_failed'] 456 | unchanged_skipped = aggregated['unchanged_skipped'] 457 | else: 458 | # Existing sequential path (includes dry-run & lazy path) 459 | for movie in rows: 460 | imdb_id = movie.get('Const') 461 | if not imdb_id: 462 | missing_id += 1 463 | failures.append({ 464 | 'Title': movie.get('Title', ''), 465 | 'Year': movie.get('Year', ''), 466 | 'IMDbID': '', 467 | 'Reason': 'Missing IMDb ID (Const)', 468 | 'YourRating': movie.get('Your Rating', ''), 469 | 'TitleType': movie.get('Title Type', '') 470 | }) 471 | continue 472 | rating_raw = movie.get('Your Rating', '') 473 | try: 474 | your_rating = float((rating_raw or '').strip()) 475 | except (ValueError, AttributeError): 476 | invalid_rating += 1 477 | failures.append({ 478 | 'Title': movie.get('Title', ''), 479 | 'Year': movie.get('Year', ''), 480 | 'IMDbID': imdb_id, 481 | 'Reason': 'Invalid rating value', 482 | 'YourRating': rating_raw, 483 | 'TitleType': movie.get('Title Type', '') 484 | }) 485 | continue 486 | plex_rating = your_rating 487 | found_movie = None 488 | if use_lazy: 489 | if values.get('-ALLLIBS-', False) and self.plex_connection and self.plex_connection.server: 490 | try: 491 | sections = [s for s in self.plex_connection.server.library.sections() if getattr(s, 'type', '') in ('movie', 'show')] 492 | except Exception as e: # pragma: no cover 493 | sections = [library_section] 494 | logger.debug('Section listing failed (lazy cross-lib): %s', e) 495 | else: 496 | sections = [library_section] 497 | for sec in sections: 498 | try: 499 | results = sec.search(guid=f'imdb://{imdb_id}') 500 | if results: 501 | found_movie = results[0] 502 | break 503 | except Exception as e: # pragma: no cover 504 | logger.debug("Lazy search error for %s in %s: %s", imdb_id, getattr(sec, 'title', '?'), e) 505 | else: 506 | found_movie = guidLookup.get(f'imdb://{imdb_id}') 507 | if not found_movie: 508 | not_found += 1 509 | failures.append({ 510 | 'Title': movie.get('Title', ''), 511 | 'Year': movie.get('Year', ''), 512 | 'IMDbID': imdb_id, 513 | 'Reason': 'Not found in Plex by GUID', 514 | 'YourRating': rating_raw, 515 | 'TitleType': movie.get('Title Type', '') 516 | }) 517 | continue 518 | expected_types = imdb_type_to_plex_types(movie['Title Type']) 519 | item_type = getattr(found_movie, 'type', None) 520 | if expected_types and item_type not in expected_types: 521 | skip_msg = (f'Skipped "{found_movie.title} ({getattr(found_movie, "year", "?")})" - ' 522 | f'type mismatch (CSV: {movie["Title Type"]}, Plex: {item_type})') 523 | logger.debug(skip_msg) 524 | self.log_message(skip_msg, log_filename) 525 | type_mismatch += 1 526 | failures.append({ 527 | 'Title': movie.get('Title', ''), 528 | 'Year': movie.get('Year', ''), 529 | 'IMDbID': imdb_id, 530 | 'Reason': f'Type mismatch (Plex={item_type})', 531 | 'YourRating': rating_raw, 532 | 'TitleType': movie.get('Title Type', '') 533 | }) 534 | continue 535 | force_overwrite = values.get('-FORCEOVERWRITE-', False) 536 | if getattr(found_movie, 'ratingKey', None): 537 | try: 538 | fresh = library_section.fetchItem(found_movie.ratingKey) 539 | if fresh: 540 | found_movie = fresh 541 | except Exception as e: # pragma: no cover 542 | logger.debug('fetchItem failed for %s: %s', imdb_id, e) 543 | existing_rating = getattr(found_movie, 'userRating', None) 544 | if not force_overwrite and existing_rating is not None: 545 | try: 546 | existing_rating_float = float(existing_rating) 547 | except Exception: 548 | existing_rating_float = existing_rating 549 | logger.debug('Existing rating (fresh) for %s (%s): %s incoming: %s', found_movie.title, imdb_id, existing_rating_float, plex_rating) 550 | if isinstance(existing_rating_float, (int, float)) and abs(existing_rating_float - plex_rating) < 0.01: 551 | unchanged_skipped += 1 552 | debug_msg = (f'Skipping unchanged rating for "{found_movie.title} ({getattr(found_movie, "year", "?")})" ' 553 | f'existing={existing_rating_float} incoming={plex_rating}') 554 | logger.debug(debug_msg) 555 | self.log_message(debug_msg, log_filename) 556 | continue 557 | try: 558 | if dry_run: 559 | star_form = plex_rating / 2.0 560 | preview_entry = f'[DRY RUN] Would update "{found_movie.title} ({found_movie.year})" to {plex_rating}' 561 | if values.get("-WATCHED-", False): 562 | preview_entry += " and mark watched" 563 | preview_samples.append(preview_entry) 564 | self.log_message(preview_entry, log_filename) 565 | total_updated_movies += 1 566 | else: 567 | found_movie.rate(rating=plex_rating) 568 | star_form = plex_rating / 2.0 569 | message = f'Updated Plex rating for "{found_movie.title} ({found_movie.year})" to {plex_rating}' 570 | logger.info(message) 571 | self.log_message(message, log_filename) 572 | if values.get("-WATCHED-", False): 573 | try: 574 | found_movie.markWatched() 575 | watched_msg = f'Marked "{found_movie.title} ({found_movie.year})" as watched' 576 | logger.info(watched_msg) 577 | self.log_message(watched_msg, log_filename) 578 | except Exception as e: 579 | error_msg = f"Error marking as watched for {found_movie.title}: {e}" 580 | logger.error(error_msg) 581 | self.log_message(error_msg, log_filename) 582 | total_updated_movies += 1 583 | except Exception as e: 584 | rate_failed += 1 585 | failures.append({ 586 | 'Title': getattr(found_movie, 'title', ''), 587 | 'Year': getattr(found_movie, 'year', ''), 588 | 'IMDbID': imdb_id, 589 | 'Reason': f'Rate failed: {e}', 590 | 'YourRating': rating_raw, 591 | 'TitleType': movie.get('Title Type', '') 592 | }) 593 | if dry_run and len(preview_samples) >= PREVIEW_LIMIT: 594 | pass 595 | 596 | if dry_run: 597 | message = f"DRY RUN: {total_updated_movies} of {total_movies} items would be updated (IMDb)" 598 | else: 599 | message = f"Successfully updated {total_updated_movies} out of {total_movies} (IMDb)" 600 | logger.info(message) 601 | self.log_message(message, log_filename) 602 | breakdown = [ 603 | "Breakdown:", 604 | f" Skipped unchanged: {unchanged_skipped}", 605 | f" Missing IMDb ID: {missing_id}", 606 | f" Invalid rating value: {invalid_rating}", 607 | f" Not found in Plex: {not_found}", 608 | f" Type mismatch: {type_mismatch}", 609 | f" Rate failed errors: {rate_failed}", 610 | f" Exported failures: {len(failures)}" 611 | ] 612 | for line in breakdown: 613 | self.log_message(line, log_filename) 614 | if not dry_run: 615 | self._export_failures_if_any(failures, source_filepath, 'imdb', log_filename) 616 | else: 617 | self.log_message('Dry run mode: No failure CSV exported.', log_filename) 618 | return True 619 | 620 | def update_ratings_from_letterboxd(self, csv_reader, library_section, values, log_filename, source_filepath, dry_run: bool = False): 621 | total_movies = 0 622 | total_updated_movies = 0 623 | failures: List[Dict[str, str]] = [] 624 | missing_field = 0 625 | invalid_rating = 0 626 | not_found = 0 627 | rate_failed = 0 628 | unchanged_skipped = 0 629 | logger.info("Updating Letterboxd ratings") 630 | library_movies = {} 631 | if values.get('-ALLLIBS-', False) and self.plex_connection and self.plex_connection.server: 632 | try: 633 | sections = [s for s in self.plex_connection.server.library.sections() if getattr(s, 'type', '') == 'movie'] 634 | except Exception as e: # pragma: no cover 635 | sections = [library_section] 636 | logger.error('Failed listing sections for Letterboxd cross-library: %s', e) 637 | else: 638 | sections = [library_section] 639 | for sec in sections: 640 | try: 641 | for item in sec.all(): 642 | if getattr(item, 'type', None) != 'movie': 643 | continue 644 | key = (item.title.lower().strip(), str(item.year)) 645 | library_movies.setdefault(key, item) 646 | except Exception as e: # pragma: no cover 647 | logger.error('Failed scanning section %s for Letterboxd: %s', getattr(sec, 'title', '?'), e) 648 | for movie in csv_reader: 649 | try: 650 | name = (movie.get('Name') or '').strip() 651 | year = (movie.get('Year') or '').strip() 652 | rating_str = (movie.get('Rating') or '').strip() 653 | if not name or not year or not rating_str: 654 | missing_field += 1 655 | failures.append({ 656 | 'Title': name, 657 | 'Year': year, 658 | 'Reason': 'Missing required field (Name/Year/Rating)', 659 | 'YourRating': rating_str 660 | }) 661 | continue 662 | try: 663 | your_rating = float(rating_str) * 2 664 | except ValueError: 665 | invalid_rating += 1 666 | failures.append({ 667 | 'Title': name, 668 | 'Year': year, 669 | 'Reason': 'Invalid rating value', 670 | 'YourRating': rating_str 671 | }) 672 | continue 673 | plex_rating = your_rating 674 | search_key = (name.lower(), year) 675 | found_movie = library_movies.get(search_key) 676 | if not found_movie: 677 | not_found += 1 678 | failures.append({ 679 | 'Title': name, 680 | 'Year': year, 681 | 'Reason': 'Not found in Plex (title/year match failed)', 682 | 'YourRating': rating_str 683 | }) 684 | else: 685 | force_overwrite = values.get('-FORCEOVERWRITE-', False) 686 | if getattr(found_movie, 'ratingKey', None): 687 | try: 688 | fresh = library_section.fetchItem(found_movie.ratingKey) 689 | if fresh: 690 | found_movie = fresh 691 | except Exception as e: # pragma: no cover 692 | logger.debug('fetchItem failed for ratingKey %s: %s', getattr(found_movie, 'ratingKey', '?'), e) 693 | existing_rating = getattr(found_movie, 'userRating', None) 694 | if not force_overwrite and existing_rating is not None: 695 | try: 696 | existing_rating_float = float(existing_rating) 697 | except Exception: 698 | existing_rating_float = existing_rating 699 | logger.debug('Existing rating (fresh) for %s: %s incoming: %s', found_movie.title, existing_rating_float, plex_rating) 700 | if isinstance(existing_rating_float, (int, float)) and abs(existing_rating_float - plex_rating) < 0.01: 701 | unchanged_skipped += 1 702 | total_movies += 1 703 | debug_msg = (f'Skipping unchanged rating for "{found_movie.title} ({getattr(found_movie, "year", "?")})" ' 704 | f'existing={existing_rating_float} incoming={plex_rating}') 705 | logger.debug(debug_msg) 706 | self.log_message(debug_msg, log_filename) 707 | continue 708 | try: 709 | if dry_run: 710 | star_form = plex_rating / 2.0 711 | preview_entry = f'[DRY RUN] Would update "{found_movie.title} ({found_movie.year})" to {plex_rating}' 712 | if values.get("-WATCHED-", False): 713 | preview_entry += " and mark watched" 714 | self.log_message(preview_entry, log_filename) 715 | total_updated_movies += 1 716 | else: 717 | found_movie.rate(rating=plex_rating) 718 | star_form = plex_rating / 2.0 719 | message = f'Updated Plex rating for "{found_movie.title} ({found_movie.year})" to {plex_rating}' 720 | logger.info(message) 721 | self.log_message(message, log_filename) 722 | if values.get("-WATCHED-", False): 723 | try: 724 | found_movie.markWatched() 725 | watched_msg = f'Marked "{found_movie.title} ({found_movie.year})" as watched' 726 | logger.info(watched_msg) 727 | self.log_message(watched_msg, log_filename) 728 | except Exception as e: 729 | error_msg = f"Error marking as watched for {found_movie.title}: {e}" 730 | logger.error(error_msg) 731 | self.log_message(error_msg, log_filename) 732 | total_updated_movies += 1 733 | except Exception as e: 734 | rate_failed += 1 735 | failures.append({ 736 | 'Title': name, 737 | 'Year': year, 738 | 'Reason': f'Rate failed: {e}', 739 | 'YourRating': rating_str 740 | }) 741 | except Exception as e: # pragma: no cover 742 | logger.error('Error processing row: %s', e) 743 | total_movies += 1 744 | if dry_run: 745 | message = f"DRY RUN: {total_updated_movies} of {total_movies} items would be updated (Letterboxd)" 746 | else: 747 | message = f"Successfully updated {total_updated_movies} out of {total_movies} (Letterboxd)" 748 | logger.info(message) 749 | self.log_message(message, log_filename) 750 | breakdown = [ 751 | "Breakdown:", 752 | f" Skipped unchanged: {unchanged_skipped}", 753 | f" Missing required fields: {missing_field}", 754 | f" Invalid rating value: {invalid_rating}", 755 | f" Not found in Plex: {not_found}", 756 | f" Rate failed errors: {rate_failed}", 757 | f" Exported failures: {len(failures)}" 758 | ] 759 | for line in breakdown: 760 | self.log_message(line, log_filename) 761 | if not dry_run: 762 | self._export_failures_if_any(failures, source_filepath, 'letterboxd', log_filename) 763 | else: 764 | self.log_message('Dry run mode: No failure CSV exported.', log_filename) 765 | return True 766 | 767 | def _get_selected_media_types(self, values): 768 | selected_media_types = [] 769 | if values['-MOVIE-']: 770 | selected_media_types.append('Movie') 771 | if values['-TVSERIES-']: 772 | selected_media_types.append('TV Series') 773 | if values['-TVMINISERIES-']: 774 | selected_media_types.append('TV Mini Series') 775 | if values['-TVMOVIE-']: 776 | selected_media_types.append('TV Movie') 777 | if values.get('-SHORT-', False): 778 | selected_media_types.append('Short') 779 | if values.get('-TVEPISODE-', False): 780 | selected_media_types.append('TV Episode') 781 | logger.debug("Selected media types: %s", selected_media_types) 782 | return selected_media_types 783 | 784 | # --------------------- Failure Export Helper --------------------- # 785 | def _export_failures_if_any(self, failures: List[Dict[str, str]], source_filepath: str, source_name: str, log_filename: str): 786 | if not failures: 787 | self.log_message("No failed or unmatched items to export.", log_filename) 788 | return 789 | try: 790 | ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') 791 | base = Path(source_filepath).stem 792 | out_path = Path.cwd() / f"Unmatched_{source_name}_{base}_{ts}.csv" 793 | # Determine headers union for robustness 794 | headers = set() 795 | for f in failures: 796 | headers.update(f.keys()) 797 | headers = list(headers) 798 | with open(out_path, 'w', newline='', encoding='utf-8') as f: 799 | writer = csv.DictWriter(f, fieldnames=headers) 800 | writer.writeheader() 801 | writer.writerows(failures) 802 | self.log_message(f"Exported {len(failures)} unmatched/failed items to {out_path}", log_filename) 803 | except Exception as e: 804 | self.log_message(f"Failed to export unmatched items CSV: {e}", log_filename) 805 | 806 | --------------------------------------------------------------------------------