""" Date: 16th/July/2026 Created by: Jap-Slappa NFO Metadata Updater+PSG v3.0 By Gemini """ import PySimpleGUI as sg import traceback # import logging import sys import time from pathlib import Path def basic_custom_error(): """Print Minimal Custom info about basic Python stack trace --- Code Example: >>> except: my_custom_error() --- NOTE: Module Name or Function name where the error occurred... | Are NOT required, as ARGs - Nor Provided at print-out! """ print() print(' ============= An exception occurred =============') print(' =================================================== ') traceback.print_exc(limit=1, file=sys.stdout) print(' =================================================== ') print() time.sleep(2.0) #? === End Of Function == # Paths to my main Anime TV Shows Libraries/Folders main_folder_paths_list = [ r'G:\Kodi Builds - 2024\_Kodi 21 Builds_NEW_2025\02. TMM - Anime STRM Files\COMPLTED - TMM Anime STRMs\01. Anime TV Shows\01. Anime-TV 2026\__Currently-Watching--Amine-2025+', r'G:\Kodi Builds - 2024\_Kodi 21 Builds_NEW_2025\02. TMM - Anime STRM Files\COMPLTED - TMM Anime STRMs\01. Anime TV Shows\01. Anime-TV 2026\__Discovered-NEW-Anime_TV--2026', r'G:\Kodi Builds - 2024\_Kodi 21 Builds_NEW_2025\02. TMM - Anime STRM Files\COMPLTED - TMM Anime STRMs\01. Anime TV Shows\01. Anime-TV 2026\__Old_Fav_Anime', ] Main_Anime_Lib_Folder = r'G:\Kodi Builds - 2024\_Kodi 21 Builds_NEW_2025\02. TMM - Anime STRM Files\COMPLTED - TMM Anime STRMs\01. Anime TV Shows\01. Anime-TV 2026' # My_default_folder_path = r'\\192.168.4.212\web\00.My_Watclists_4_Kodi' def open_folder_dialog(My_default_folder_path): try: select_folder = sg.filedialog.askdirectory(initialdir=My_default_folder_path) if select_folder: return select_folder else: return None except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Function == def Custom_Popup_Get_Folder(My_default_folder_path:Path): """Creates the initial window layout to select a folder and scan.""" try: Anime_Folder_Name, Show_Name, Season_Folder = path_splitter(My_default_folder_path) layout = [ [ sg.Text("Custom Popup Layout + Get Folder", font=("Helvetica", 14, "bold") ) ], #todo: === Update Anime Folder Selection === [ sg.Text(f'Anime Folder Name = {Anime_Folder_Name}', font=("Helvetica", 12, "bold"), key='-ANIME_FOLDER_NAME-', ) ], [ sg.Text(f'Show + Season = {Show_Name}: {Season_Folder}', font=("Helvetica", 12, "bold"), key='-SHOW_AND_SEASON-', ) ], #todo: === Update Anime Folder Selection === [sg.Text("Select the directory containing your .nfo files:")], [ sg.Multiline(str(My_default_folder_path), size=(100, 3), key='-UPDATE_FOLDER_PATH-' ), # (width, height) sg.Button("Browse Folder", key="-BROWSE_FOLDER-", ), # I can't get this (sg.filedialog.askdirectory) working here yet! # sg.filedialog.askdirectory(initialdir=str(My_default_folder_path)), ], [sg.HSeparator(pad=(0, 15))], [ sg.Button("Exit", key="-EXIT-"), sg.Push(), sg.Button("Search Folder", key="-SEARCH_FOLDER-"), ] ] return layout except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Function == def path_splitter(the_path:str): # Explicitly make a PathLib Object PathLib_Path = Path(the_path) Anime_Folder_Name = PathLib_Path.parts[-3] Show_Name = PathLib_Path.parts[-2] Season_Folder = PathLib_Path.name return Anime_Folder_Name, Show_Name, Season_Folder #? === End Of Function == def PSG_get_nfo_folder_path(My_default_folder_path): try: # Open the folder scanning configuration window main_window = sg.Window("Custom Popup Window", Custom_Popup_Get_Folder(My_default_folder_path), finalize=True, ) while True: event, values = main_window.read() if event in (sg.WIN_CLOSED, "-EXIT-"): break elif event == "-BROWSE_FOLDER-": print('Event (Button Pressed) = BROWSE_FOLDER') selected_folder_2 = open_folder_dialog(app.Main_folders_dir) Anime_Folder_Name, Show_Name, Season_Folder = path_splitter(selected_folder_2) main_window['-ANIME_FOLDER_NAME-'].update(f'Anime Folder Name = {Anime_Folder_Name}') main_window['-SHOW_AND_SEASON-'].update(f'Show + Season = {Show_Name} >> {Season_Folder}') main_window['-UPDATE_FOLDER_PATH-'].update(selected_folder_2) print('selected_folder_2 = ', selected_folder_2) print() elif event == "-SEARCH_FOLDER-": selected_folder = values.get("-UPDATE_FOLDER_PATH-", False) # This Works print('Event = SEARCH_FOLDER Button Was Pressed!!') print('selected_folder = ', selected_folder) print() try: if selected_folder: PathLib_selected_folder_Path = Path(selected_folder) TV_Show_Folder_Name = PathLib_selected_folder_Path.parts[-2] print('') print('==== PSG_get_nfo_folder_path() ====') print('Your Selected TV SHow Folder To... "SCAN"') print('---') print('PathLib_selected_folder_Path [Name]: ', f'{TV_Show_Folder_Name}: {PathLib_selected_folder_Path.name}') print('PathLib_selected_folder_Path [FULL]: \n >>', PathLib_selected_folder_Path) print('') return PathLib_selected_folder_Path elif selected_folder is None: return except Exception as Error: basic_custom_error() print(f"PSG_get_nfo_folder_path() \n(Error: {Error})") main_window.close() except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Function == ############################################################## #------------------------------------------------------------ #todo: NFO Updater (Class) #------------------------------------------------------------ ############################################################## class NfoUpdaterApp: def __init__(self): # Set a clean, modern dark theme sg.theme("DarkBlue") self.target_dir = '' # Default fallback path self.nfo_files:list[Path] = [] # Stores Path objects #? Main_folders_dir: Path is created in... "Select_Main_Folder_Window()" Function self.Main_folders_dir:Path = '' # Folder with my TV Show Folder self.Main_folders_list:list[Path] = main_folder_paths_list # Stores the Main Folders Path objects self.Main_folders_window = None self.main_window = None self.list_window = None def create_initial_layout(self): """Creates the initial window layout to select a folder and scan.""" Anime_Libray_Name = self.target_dir.parts[-3] Anime_Show_Name = self.target_dir.parts[-2] Anime_Season_Folder = self.target_dir.name layout = [ [ sg.Text("Kodi NFO Watched Status Updater", font=("Helvetica", 14, "bold") ) ], [ sg.Text(f'Anime_Libray_Name = {Anime_Libray_Name}', font=("Helvetica", 12, "bold") ) ], [ sg.Text(f'Target = {Anime_Show_Name}: {Anime_Season_Folder}', font=("Helvetica", 12, "bold") ) ], [sg.Text("Select the directory containing your media and .nfo files:")], [ sg.Input(str(self.target_dir), key="-FOLDER-", size=(100, 4)), # (width, height) sg.FolderBrowse(initial_folder=str(self.target_dir)) ], [sg.HSeparator(pad=(0, 15))], [ sg.Button("Exit", key="-EXIT-"), sg.Push(), sg.Button("Scan Directory", key="-SCAN-", button_color=("white", "#2A8C82"), bind_return_key=True), ] ] return layout #? === End Of Method/Function == def create_MAIN_Folder_list_layout(self): """Dynamically generates a scrollable list of found Folders with checkboxes on the right.""" folder_rows_List:list[str] = [] try: # LOOP: To Build individual rows for each Main folder path, in my List for idx, folder_path in enumerate(self.Main_folders_list, start=1): idx_num = str(idx).zfill(2) #todo: Explicitly make PathLib Obj pathlib_folder_path = Path(folder_path) #^ Individual row construction + Add to the Rows list folder_rows_List.append( [ # Display the foldername (relative to parent to keep it short and clean) sg.Text(f'{idx_num}-{pathlib_folder_path.name}', font=("Helvetica", 11, "bold"), size=(40, 2), tooltip=str(pathlib_folder_path)), sg.Push(), # Pushes the checkbox all the way to the right # Key is indexed so we can match it back to the Path list later sg.Checkbox("", default=False, font=("Helvetica", 20), size=(40, 2), expand_x=True, expand_y=True, enable_events=True, key=f"-CHECK_MAIN{idx}-", ) ] ) # Pack the rows into a scrollable column scrollable_column_2 = sg.Column( #^ Input the completed rows list here folder_rows_List, # scrollable=True, vertical_scroll_only=True, # size=(550, 300), size=(600, 250), # (width, height) key="-SCROLLABLE_COL_MAIN-", expand_x=True, expand_y=True ) #todo: TV Show folder Layout layout_msg_01 = (f"Found {len(self.Main_folders_list)} " f"TV Show folders in: {Path(self.Main_folders_dir).name}") except Exception as Error: basic_custom_error() print(f"(Error: {Error})") layout = [ [sg.Text("Select TV Show folder to Update", font=("Helvetica", 14, "bold"))], [ sg.Text(layout_msg_01, text_color="#A4E3DB", font=("Helvetica", 11, "bold"), ) ], [sg.HSeparator(pad=(0, 10))], #^ Input the scrollable_column_2, with the completed rows list here [scrollable_column_2], [sg.HSeparator(pad=(0, 15))], [ sg.Button("Close", key="-MAIN_CLOSE-"), sg.Push(), sg.Button("Select Folder", key="-MAIN_FOLDER_SELECT-", button_color=("white", "#2A8C82")), ] ] return layout #? === End Of Method/Function == def create_list_layout(self): """Dynamically generates a scrollable list of found .nfo files with checkboxes on the right.""" def Check_If_Watched_In_NFO(found_nfo_file): pathlib_found_nfo = Path(found_nfo_file) content = pathlib_found_nfo.read_text(encoding='utf-8', errors='ignore') # Swap if "false" in content: return 'NOT', True elif "true" in content: return '', False #? === End Of Method/Function == file_rows:list[str] = [] try: # Build individual rows for each NFO file found for idx, file_path in enumerate(self.nfo_files): watched_status_txt, bool_status = Check_If_Watched_In_NFO(file_path) idx_num = str(idx).zfill(2) file_rows.append([ # Display the filename (relative to parent to keep it short and clean) sg.Text(f'{idx_num}-{file_path.name}', size=(50, 1), tooltip=str(file_path)), sg.Push(), # Pushes the checkbox all the way to the right # Key is indexed so we can match it back to the Path list later sg.Checkbox("", default=bool_status, key=f"-CHECK_{idx}-"), sg.Text( f'Status: {watched_status_txt}', font=("Helvetica", 10, "bold"), ) ]) # Pack the rows into a scrollable column scrollable_column = sg.Column( file_rows, scrollable=True, vertical_scroll_only=True, # size=(550, 300), size=(800, 450), # (width, height) key="-SCROLLABLE_COL-", expand_x=True, expand_y=True ) _, Show_Name, Season_Folder = path_splitter(self.target_dir) layout = [ [sg.Text("Select NFO Files to Update", font=("Helvetica", 14, "bold"))], [ sg.Text((f"Found {len(self.nfo_files)} .nfo files " f"in >> {Show_Name} / {Season_Folder}"), text_color="#A4E3DB" ) ], [sg.HSeparator(pad=(0, 10))], [scrollable_column], [sg.HSeparator(pad=(0, 15))], [ # Bottom Row of Buttons sg.Button("Select All", key="-SELECT_ALL-"), sg.Button("Select None", key="-SELECT_NONE-"), sg.Push(), sg.Button("Update NFO", key="-UPDATE-", button_color=("white", "#2A8C82")), sg.Button("Close", key="-CLOSE-") ] ] except Exception as Error: basic_custom_error() print(f"(Error: {Error})") return layout #? === End Of Method/Function == def scan_directory(self, path_str): """Scans the selected path using Pathlib for .nfo files.""" try: self.target_dir = Path(path_str) if not self.target_dir.exists() or not self.target_dir.is_dir(): sg.popup_error("Error", "Selected directory does not exist or is invalid.", keep_on_top=True) return False # self.nfo_files = list(self.target_dir.rglob("*.nfo")) # OG Gemini Code # Scan the Selected Folder ONLY self.nfo_files = list(self.target_dir.glob("*.nfo")) # Me if not self.nfo_files: sg.popup_annoying("No Files Found", "No .nfo files were found in the selected folder tree.", keep_on_top=True) return False except Exception as Error: basic_custom_error() print(f"(Error: {Error})") return True #? === End Of Method/Function == def process_nfo_files(self, values): """Performs the watched-status updates on selected files.""" changed_files = [] unchanged_files = [] try: for idx, file_path in enumerate(self.nfo_files): # Check if this specific file's checkbox was ticked is_selected = values.get(f"-CHECK_{idx}-", False) if not is_selected: unchanged_files.append(f"{file_path.name} (Skipped)") continue try: watch_false = "false" watch_true = "true" playcount_zero = "0" playcount_one = "1" # Read content using UTF-8 safely content = file_path.read_text(encoding='utf-8', errors='ignore') modified = False # Swap if "false" in content: if watch_false == "false": content = content.replace("false", "true") modified = True elif watch_true == "true": modified = False continue # Swap if "0" in content: if playcount_zero == "0": content = content.replace("0", "1") modified = True elif playcount_one == "1": modified = False continue if modified: file_path.write_text(content, encoding='utf-8') changed_files.append(file_path.name) else: unchanged_files.append(file_path.name) except Exception as e: unchanged_files.append(f"{file_path.name} (Error: {e})") # Generate a detailed summary window self.show_summary_popup(changed_files, unchanged_files) except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Method/Function == def show_summary_popup(self, changed, unchanged): """Creates a readable, scrollable text summary popup of the operation.""" try: summary_text = ( f"=== UPDATE SUMMARY ===\n" f"Total Files Found: {len(self.nfo_files)}\n" f"Files Updated: {len(changed)}\n" f"Files Unchanged / Skipped: {len(unchanged)}\n\n" ) if changed: # OG Code # summary_text += "UPDATED FILES:\n" + "\n".join([f" [✓] {f}" for f in changed]) + "\n\n" # New Code (Coz this... [✓], caused an error. I changed to.. [+]) summary_text += "UPDATED FILES:\n" + "\n".join([f" [+] {f}" for f in changed]) + "\n\n" else: summary_text += "UPDATED FILES:\n None\n\n" if unchanged: summary_text += "UNCHANGED / SKIPPED FILES:\n" + "\n".join([f" [-] {f}" for f in unchanged]) + "\n" sg.popup_scrolled( summary_text, title="Results", # size=(80, 20), # (width, height) size=(80, 30), # (width, height) font=("Consolas", 10), non_blocking=False ) print('') print(f'{summary_text}') print('') except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Method/Function == ############################################################### #todo: This Works! def get_main_folder_path(self, values): """Performs the watched-status updates on selected files.""" changed_files = [] unchanged_files = [] try: for idx, file_path in enumerate(self.Main_folders_list, start=1): # Check if this specific file's checkbox was ticked is_selected = values.get(f"-CHECK_MAIN{idx}-", False) if is_selected: selected_Anime_Lib_Folder = Path(file_path) print(f'') print(f'Your Selected Anime Lib Folder Is...') print('.....Name: ', f'{selected_Anime_Lib_Folder.parts[-2]}: {selected_Anime_Lib_Folder.name}') print('Full Path: \n >>', selected_Anime_Lib_Folder) print(f'') return selected_Anime_Lib_Folder elif not is_selected: # unchanged_files.append(f"{file_path.name} (Skipped)") continue except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Method/Function == #todo: This Works! def Select_Main_Folder_Window(self): try: # Open the folder scanning configuration window self.Main_folders_window = sg.Window("Select Your Main Anime Library Folder", self.create_MAIN_Folder_list_layout(), finalize=True, ) while True: foldr_event, foldr_values = self.Main_folders_window.read() if foldr_event in (sg.WIN_CLOSED, "-MAIN_CLOSE-"): break elif foldr_event == "-MAIN_FOLDER_SELECT-": # Process only what's checked app.Main_folders_dir = Path(self.get_main_folder_path(foldr_values)) break # Close selection list after running updates self.Main_folders_window.close() except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Method/Function == ############################################################### def run(self): # Open the folder scanning configuration window self.main_window = sg.Window("Kodi NFO Updater", self.create_initial_layout(), finalize=True, ) try: while True: event, values = self.main_window.read() if event in (sg.WIN_CLOSED, "-EXIT-"): break if event == "-SCAN-": folder_path = values["-FOLDER-"] if self.scan_directory(folder_path): # Hide main window and open the selection list window self.main_window.hide() self.list_window = sg.Window("NFO Selector", self.create_list_layout(), modal=True, resizable=True, finalize=True, ) # Sub-loop for managing the list window selection while True: list_event, list_values = self.list_window.read() if list_event in (sg.WIN_CLOSED, "-CLOSE-"): break elif list_event == "-SELECT_ALL-": for idx in range(len(self.nfo_files)): self.list_window[f"-CHECK_{idx}-"].update(True) elif list_event == "-SELECT_NONE-": for idx in range(len(self.nfo_files)): self.list_window[f"-CHECK_{idx}-"].update(False) elif list_event == "-UPDATE-": # Process only what's checked self.process_nfo_files(list_values) break # Close selection list after running updates self.list_window.close() self.list_window = None self.main_window.un_hide() # Restore the main setup window self.main_window.close() except Exception as Error: basic_custom_error() print(f"(Error: {Error})") #? === End Of Method/Function == #todo ============ End Of The Whole Class ============ if __name__ == "__main__": # Create an app instance app = NfoUpdaterApp() # THIS # Runs the Main function in my Class/app instance app.Select_Main_Folder_Window() # THIS # Main_folders_dir / Path(my_selected_path) my_selected_path = PSG_get_nfo_folder_path(app.Main_folders_dir) # THIS # my_selected_path = Custom_Popup_Get_Folder(app.Main_folders_dir) app.target_dir = Path(my_selected_path) # THIS # Runs the Main function in my Class/app instance app.run()