├── 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 | 
18 |
19 | v2.1.0
20 | 
21 |
22 | v2.0.0 (Uses customtkinter instead of PySimpleGUI UI)
23 | 
24 |
25 | v1.2
26 | 
27 |
28 | v1.1
29 | 
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 |
--------------------------------------------------------------------------------