-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgui.py
More file actions
845 lines (738 loc) · 61.5 KB
/
gui.py
File metadata and controls
845 lines (738 loc) · 61.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
# -*- coding: utf-8 -*-
# gui.py
"""
SortMeDown Media Sorter - GUI (gui.py) for bang bang
================================
v6.7.1
- BUG FIX: JSONDecodeError crashes on malformed API responses (OMDb, TMDB, TVDB, AniList)
- BUG FIX: AniList title field crash when API returns null
- BUG FIX: TMDB year fallback produced invalid "{}" string
- BUG FIX: Python 3.8 compatibility for path comparison
- BUG FIX: Missing shutil import caused rename feature to crash
- BUG FIX: Duplicate MediaClassifier class definition in api.py
- BUG FIX: Duplicate _perform_safe_shutdown method
- BUG FIX: Lock file could be orphaned on crash
- FEATURE: Collapsing repeated "No new files found" log lines (x1 x2 x3...)
- FEATURE: Source folder button to open source directory from Actions tab
- FEATURE: Minimize behavior setting (Tray only / Tray & Taskbar)
- IMPROVED: API session properly closed on exit
v6.6.7
- BUG FIX: reorganize was missing functions left before refactor
v6.6.6
- ENHANCED: prioritize file name over dir
v6.6.5
- BUG FIX: review/api anime
v6.6.4
- Uuse bangbang refactored
v6.6.3
- BUG FIX: Tray sync
- BUG FIX: Startup crash
- BUG FIX: limit to single instance
v6.6.1
- Release with new bangbang
v6.6.0
- Release
- FEATURE: Lightweight, cross-platform notifications and startup logic.
- FIXED: Crashing bug in startup logic due to incorrect pyshortcuts call.
- FIXED: Tab order and restored original About tab.
v6.5.0
- FEATURE: New start up logic.
v6.4.0
- FEATURE: TVDB API Integration
- FEATURE: Improved smart API provider logic
v6.2.7.0
- ENHANCED: Refactored Reorganize with 2 windows
- FEATURE: Quick Clean up no API
v6.2.6.0
- FEATURE: Portable & Installer ready
v6.2.5.1
- FIXED: Tray menu did not feat. new tab.
v6.2.5.0
- FEATURE: Implemented pagination in the Reorganize tab for large libraries.
- BUG FIX: A bug where the about tab could cause a crash.
v6.2
- FEATURE: new Reorganize tab
v6.1.0.1
- Release
"""
import customtkinter as ctk
from tkinter import filedialog, messagebox
import logging
import threading
from pathlib import Path
from PIL import Image, ImageDraw
import pystray
import sys
import tkinter
import os
import webbrowser
from typing import List, Dict
import math
import subprocess
import shutil
import bangbang_engine as backend
#import bangbang as backend
APP_NAME = "SortMeDown"
def get_config_path() -> Path:
try:
if hasattr(sys, '_MEIPASS'): portable_path = Path(sys.executable).parent / "config.json"
else: portable_path = Path(__file__).parent / "config.json"
if portable_path.exists():
logging.info(f"Running in PORTABLE mode. Using config at: {portable_path}")
return portable_path
except Exception: pass
if sys.platform == "win32": app_data_dir = Path(os.getenv("APPDATA")) / APP_NAME
elif sys.platform == "darwin": app_data_dir = Path.home() / "Library" / "Application Support" / APP_NAME
else: app_data_dir = Path.home() / ".config" / APP_NAME
app_data_dir.mkdir(parents=True, exist_ok=True)
config_path = app_data_dir / "config.json"
logging.info(f"Using standard config location: {config_path}")
return config_path
CONFIG_FILE = get_config_path()
def get_version_info():
doc = __doc__ or ""
lines = doc.strip().split('\n')
version = "v?.?.?"; history_content = []
for line in lines:
stripped = line.strip()
if stripped.startswith('v'): version = stripped.split()[0]; break
changelog_started = False
for line in lines:
if line.strip().startswith('v'): changelog_started = True
if changelog_started: history_content.append(line)
history = "\n".join(history_content) if history_content else "Version history not found."
return version, history
def resource_path(relative_path):
try: base_path = Path(sys._MEIPASS)
except Exception: base_path = Path(__file__).parent.absolute()
return base_path / relative_path
class GuiLoggingHandler(logging.Handler):
REPEAT_MARKER = "No new files found"
def __init__(self, text_widget):
super().__init__(); self.text_widget = text_widget
self.text_widget.tag_config("INFO", foreground="white"); self.text_widget.tag_config("DRYRUN", foreground="#00FFFF")
self.text_widget.tag_config("WARNING", foreground="orange"); self.text_widget.tag_config("ERROR", foreground="#FF5555")
self.text_widget.tag_config("SUCCESS", foreground="#00FF7F"); self.text_widget.tag_config("FRENCH", foreground="#6495ED")
self._repeat_count = 0
def emit(self, record):
msg = self.format(record); tag = "INFO"
if "🔵⚪🔴" in msg: tag = "FRENCH"
elif "DRY RUN:" in msg or "Dry Run is ENABLED" in msg: tag = "DRYRUN"
elif "✅" in msg or "Settings saved" in msg: tag = "SUCCESS"
elif record.levelname == "WARNING": tag = "WARNING"
elif record.levelname in ["ERROR", "CRITICAL"]: tag = "ERROR"
is_repeat = self.REPEAT_MARKER in msg
if is_repeat:
self._repeat_count += 1
else:
self._repeat_count = 0
count = self._repeat_count
def insert_text():
if not self.text_widget.winfo_exists(): return
self.text_widget.configure(state="normal")
if count > 1:
self.text_widget.delete("end-2l", "end-1l")
display = f"{msg} x{count}\n"
else:
display = msg + '\n'
self.text_widget.insert(ctk.END, display, tag)
self.text_widget.see(ctk.END)
self.text_widget.configure(state="disabled")
if hasattr(self.text_widget, 'after'):
try: self.text_widget.after(0, insert_text)
except Exception: pass
class App(ctk.CTk):
def __init__(self, start_hidden: bool = False):
super().__init__()
self.version, self.version_history = get_version_info()
self.title(f"SortMeDown Media Sorter {self.version}"); self.geometry("900x900"); ctk.set_appearance_mode("Dark")
self.after(200, self._set_window_icon)
self.config = backend.Config.load(CONFIG_FILE)
self.api_client = backend.APIClient(self.config)
self.sorter_thread = None; self.sorter_instance = None; self.tray_icon = None; self.tray_thread = None; self.tab_view = None
self.is_quitting = False; self.path_entries = {}; self.mismatch_buttons = {}; self.default_button_color = None; self.default_hover_color = None
self.is_watching = False; self.log_is_visible = True; self.selected_mismatched_file = None
self.reorganize_all_files = []; self.reorganize_selection_state = {}; self.reorganize_current_page = 0
self.reorganize_items_per_page = 200; self.rename_preview_cache: Dict[Path, Path] = {}
self.api_provider_var = ctk.StringVar(value={"omdb": "OMDb", "tmdb": "TMDB", "tvdb": "TVDB"}.get(self.config.API_PROVIDER, "OMDb"))
self.enabled_vars = { 'MOVIES_ENABLED': ctk.BooleanVar(value=self.config.MOVIES_ENABLED), 'TV_SHOWS_ENABLED': ctk.BooleanVar(value=self.config.TV_SHOWS_ENABLED), 'ANIME_MOVIES_ENABLED': ctk.BooleanVar(value=self.config.ANIME_MOVIES_ENABLED), 'ANIME_SERIES_ENABLED': ctk.BooleanVar(value=self.config.ANIME_SERIES_ENABLED) }
self.dry_run_var = ctk.BooleanVar(value=False)
self.fallback_var = ctk.StringVar(value=self.config.FALLBACK_SHOW_DESTINATION)
self.notify_on_mismatch_var = ctk.BooleanVar(value=self.config.NOTIFY_ON_MISMATCH)
self.minimize_mode_var = ctk.StringVar(value="Tray only" if self.config.MINIMIZE_TO_TRAY_ONLY else "Tray & Taskbar")
self.start_with_windows_var = ctk.BooleanVar()
self.grid_columnconfigure(0, weight=1); self.grid_rowconfigure(0, weight=0); self.grid_rowconfigure(1, weight=1); self.grid_rowconfigure(2, weight=0)
self.controls_frame = ctk.CTkFrame(self); self.controls_frame.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
self.create_controls()
self.log_textbox = ctk.CTkTextbox(self, state="disabled", font=("Courier New", 12)); self.log_textbox.grid(row=1, column=0, padx=10, pady=(0,5), sticky="nsew")
self.progress_frame = ctk.CTkFrame(self, fg_color="transparent"); self.progress_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 10)); self.progress_frame.grid_columnconfigure(0, weight=1)
self.progress_label = ctk.CTkLabel(self.progress_frame, text=""); self.progress_label.grid(row=0, column=0, sticky="w", padx=5)
self.progress_bar = ctk.CTkProgressBar(self.progress_frame); self.progress_bar.set(0); self.progress_bar.grid(row=1, column=0, sticky="ew", padx=5)
self.version_label = ctk.CTkLabel(self.progress_frame, text=self.version, text_color="gray50"); self.version_label.grid(row=0, column=1, rowspan=2, padx=(10, 5), sticky="e")
self.progress_frame.grid_remove()
self.setup_logging(); self.protocol("WM_DELETE_WINDOW", self.quit_app); self.bind("<Unmap>", self.on_minimize); self.setup_tray_icon(); self.update_fallback_ui_state()
self._check_startup_status()
self.after(500, self.check_api_keys_on_startup)
if start_hidden: self.withdraw()
def check_api_keys_on_startup(self):
if not (self.config.OMDB_API_KEY and self.config.OMDB_API_KEY != "yourkey") and \
not (self.config.TMDB_API_KEY and self.config.TMDB_API_KEY != "yourkey") and \
not (self.config.TVDB_API_KEY and self.config.TVDB_API_KEY != "yourkey"):
logging.warning("⚠️ No API Key Found! Please add at least one key in the 'Settings' tab.")
def _set_window_icon(self):
try:
if sys.platform == "win32": self.iconbitmap(str(resource_path("icon.ico")))
else: self.iconphoto(True, tkinter.PhotoImage(file=str(resource_path("icon.png"))))
except Exception as e: logging.warning(f"Could not set window icon: {e}")
def setup_logging(self):
log_handler = GuiLoggingHandler(self.log_textbox)
log_handler.setFormatter(logging.Formatter("%(asctime)s - %(message)s", "%H:%M:%S"))
logging.basicConfig(level=logging.INFO, handlers=[log_handler], force=True)
def create_controls(self):
self.tab_view = ctk.CTkTabview(self.controls_frame); self.tab_view.pack(expand=True, fill="both", padx=5, pady=5)
self.create_actions_tab(self.tab_view.add("Actions"))
self.create_reorganize_tab(self.tab_view.add("Reorganize"))
self.create_mismatch_tab(self.tab_view.add("Review"))
self.create_settings_tab(self.tab_view.add("Settings"))
self.create_about_tab(self.tab_view.add("About"))
self.tab_view.configure(command=self.on_tab_selected)
self.tab_view.set("Actions")
def on_tab_selected(self):
tab_name = self.tab_view.get()
if tab_name == "Review": self.scan_mismatched_files()
is_about_tab = tab_name == "About"
self.grid_rowconfigure(0, weight=1 if is_about_tab else 0)
self.grid_rowconfigure(1, weight=0 if is_about_tab else 1)
if is_about_tab: self.log_textbox.grid_remove()
elif self.log_is_visible: self.log_textbox.grid()
def create_actions_tab(self, parent):
parent.grid_columnconfigure(0, weight=1)
bf = ctk.CTkFrame(parent, fg_color="transparent"); bf.grid(row=0, column=0, sticky="ew"); bf.grid_columnconfigure((0, 2), weight=1); bf.grid_columnconfigure(1, weight=0)
self.sort_now_button = ctk.CTkButton(bf, text="Single Shot Sort", command=self.start_sort_now); self.sort_now_button.grid(row=0, column=0, padx=(0, 5), pady=10, sticky="ew")
self.default_button_color = self.sort_now_button.cget("fg_color"); self.default_hover_color = self.sort_now_button.cget("hover_color")
self.stop_button = ctk.CTkButton(bf, text="", width=60, command=self.stop_running_task, fg_color="gray25", border_width=0, state="disabled"); self.stop_button.grid(row=0, column=1, padx=5, pady=10)
self.watch_button = ctk.CTkButton(bf, text="Launch Watchdog", command=self.toggle_watch_mode); self.watch_button.grid(row=0, column=2, padx=(5, 0), pady=10, sticky="ew")
of = ctk.CTkFrame(parent, fg_color="transparent"); of.grid(row=2, column=0, columnspan=3, sticky="ew"); of.grid_columnconfigure((0,1), weight=1)
self.dry_run_checkbox = ctk.CTkCheckBox(of, text="Dry Run", variable=self.dry_run_var); self.dry_run_checkbox.grid(row=0, column=0, padx=5, pady=5, sticky="w")
wif = ctk.CTkFrame(of, fg_color="transparent"); wif.grid(row=0, column=1, padx=5, pady=5, sticky="e")
ctk.CTkLabel(wif, text="Check every").pack(side="left", padx=(0,5)); self.watch_interval_entry = ctk.CTkEntry(wif, width=40); self.watch_interval_entry.pack(side="left"); self.watch_interval_entry.insert(0, str(self.config.WATCH_INTERVAL // 60)); ctk.CTkLabel(wif, text="minutes").pack(side="left", padx=(5,0))
btn_row = ctk.CTkFrame(of, fg_color="transparent"); btn_row.grid(row=1, column=1, sticky="e", padx=5, pady=5)
self.toggle_log_button = ctk.CTkButton(btn_row, text="Hide Log", width=100, command=self.toggle_log_visibility); self.toggle_log_button.pack(side="left", padx=(0, 5))
ctk.CTkButton(btn_row, text="Source", width=80, command=self.open_temp_folder).pack(side="left")
ctk.CTkFrame(parent, height=2, fg_color="gray25").grid(row=3, column=0, pady=(10, 5), sticky="ew")
tf = ctk.CTkFrame(parent, fg_color="transparent"); tf.grid(row=4, column=0, sticky="ew", pady=(0, 5)); tf.grid_columnconfigure((0, 1, 2, 3), weight=1)
self.toggles_map = {}; am = {'Movies': 'MOVIES_ENABLED', 'TV Shows': 'TV_SHOWS_ENABLED', 'Anime Movies': 'ANIME_MOVIES_ENABLED', 'Anime': 'ANIME_SERIES_ENABLED'}
for i, (label, key) in enumerate(am.items()): cb = ctk.CTkCheckBox(tf, text=label, variable=self.enabled_vars[key], command=self.on_media_type_toggled); cb.grid(row=0, column=i, padx=5, pady=5); self.toggles_map[key] = cb
ff = ctk.CTkFrame(parent, fg_color="transparent"); ff.grid(row=5, column=0, pady=5, sticky="ew"); ctk.CTkLabel(ff, text="For mismatched shows, default to:").pack(side="left", padx=(5,10))
self.ignore_radio = ctk.CTkRadioButton(ff, text="Do Nothing", variable=self.fallback_var, value="ignore"); self.ignore_radio.pack(side="left", padx=5)
self.mismatch_radio = ctk.CTkRadioButton(ff, text="Mismatched Folder", variable=self.fallback_var, value="mismatched"); self.mismatch_radio.pack(side="left", padx=5)
self.tv_radio = ctk.CTkRadioButton(ff, text="TV Shows Folder", variable=self.fallback_var, value="tv"); self.tv_radio.pack(side="left", padx=5)
self.anime_radio = ctk.CTkRadioButton(ff, text="Anime Folder", variable=self.fallback_var, value="anime"); self.anime_radio.pack(side="left", padx=5)
self.update_fallback_ui_state()
def create_reorganize_tab(self, parent):
parent.grid_columnconfigure(0, weight=1); parent.grid_rowconfigure(2, weight=1); parent.grid_rowconfigure(4, weight=1)
top_frame = ctk.CTkFrame(parent); top_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew"); top_frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(top_frame, text="Target Library:").grid(row=0, column=0, padx=(10, 5), pady=10)
self.reorganize_path_entry = ctk.CTkEntry(top_frame, placeholder_text="Select a library folder to scan..."); self.reorganize_path_entry.grid(row=0, column=1, padx=5, pady=10, sticky="ew")
ctk.CTkButton(top_frame, text="Browse...", width=80, command=lambda: self.browse_folder(self.reorganize_path_entry)).grid(row=0, column=2, padx=5, pady=10)
ctk.CTkButton(top_frame, text="Scan for Files", width=100, command=self.scan_reorganize_folder).grid(row=0, column=3, padx=(5, 10), pady=10)
self.reorganize_files_frame = ctk.CTkScrollableFrame(parent, label_text="Files Found in Target Library"); self.reorganize_files_frame.grid(row=2, column=0, padx=10, pady=(0,5), sticky="nsew")
check_frame = ctk.CTkFrame(parent, fg_color="transparent"); check_frame.grid(row=1, column=0, padx=10, pady=0, sticky="ew"); check_frame.grid_columnconfigure(1, weight=1)
ctk.CTkButton(check_frame, text="Select Page", command=self.reorganize_select_page).pack(side="left")
ctk.CTkButton(check_frame, text="Deselect Page", command=lambda: self.reorganize_select_page(select=False)).pack(side="left", padx=5)
ctk.CTkButton(check_frame, text="Select All", command=self.reorganize_select_all).pack(side="left")
self.reorganize_prev_button = ctk.CTkButton(check_frame, text="< Prev", width=60, command=self.reorganize_previous_page, state="disabled"); self.reorganize_prev_button.pack(side="left", padx=(20,5))
self.reorganize_page_label = ctk.CTkLabel(check_frame, text="Page 0 of 0"); self.reorganize_page_label.pack(side="left")
self.reorganize_next_button = ctk.CTkButton(check_frame, text="Next >", width=60, command=self.reorganize_next_page, state="disabled"); self.reorganize_next_button.pack(side="left", padx=5)
self.reorganize_status_label = ctk.CTkLabel(check_frame, text="Selected: 0"); self.reorganize_status_label.pack(side="right")
self.reorganize_preview_frame = ctk.CTkScrollableFrame(parent, label_text="Rename Preview"); self.reorganize_preview_frame.grid(row=4, column=0, padx=10, pady=5, sticky="nsew")
bottom_frame = ctk.CTkFrame(parent); bottom_frame.grid(row=3, column=0, padx=10, pady=10, sticky="ew"); bottom_frame.grid_columnconfigure((0, 1, 2), weight=1)
self.reorganize_folders_button = ctk.CTkButton(bottom_frame, text="Organize Selected into Folders", command=self.start_folder_reorganization); self.reorganize_folders_button.grid(row=0, column=0, padx=(0, 5), pady=5, sticky="ew")
self.rename_preview_button = ctk.CTkButton(bottom_frame, text="Preview Rename for Selected", command=self.start_rename_preview); self.rename_preview_button.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.apply_rename_button = ctk.CTkButton(bottom_frame, text="Apply Rename", command=self.start_file_renaming, state="disabled"); self.apply_rename_button.grid(row=0, column=2, padx=(5, 0), pady=5, sticky="ew")
self.quick_clean_var = ctk.BooleanVar(value=False); ctk.CTkCheckBox(bottom_frame, text="Quick Clean (No API)", variable=self.quick_clean_var, command=self.clear_rename_preview).grid(row=1, column=1, padx=10, pady=10, sticky="w")
self.reorganize_dry_run_var = ctk.BooleanVar(value=False); ctk.CTkCheckBox(bottom_frame, text="Dry Run (for all reorganize actions)", variable=self.reorganize_dry_run_var).grid(row=1, column=0, padx=10, pady=10, sticky="w")
def create_mismatch_tab(self, parent):
parent.grid_columnconfigure(0, weight=1); parent.grid_rowconfigure(1, weight=1)
cf = ctk.CTkFrame(parent, fg_color="transparent"); cf.grid(row=0, column=0, sticky="ew", padx=5, pady=5); ctk.CTkButton(cf, text="Rescan for Files", command=self.scan_mismatched_files).pack(side="left")
mf = ctk.CTkFrame(parent, fg_color="transparent"); mf.grid(row=1, column=0, sticky="nsew", padx=5, pady=5); mf.grid_columnconfigure(0, weight=1); mf.grid_columnconfigure(1, weight=1); mf.grid_rowconfigure(0, weight=1)
self.mismatched_files_frame = ctk.CTkScrollableFrame(mf, label_text="Files Found in Mismatched Folder"); self.mismatched_files_frame.grid(row=0, column=0, sticky="nsew", padx=(0,5))
ap = ctk.CTkFrame(mf); ap.grid(row=0, column=1, sticky="nsew", padx=(5,0)); ap.grid_columnconfigure(0, weight=1)
self.mismatch_selected_label = ctk.CTkLabel(ap, text="No file selected.", wraplength=350, justify="left"); self.mismatch_selected_label.grid(row=0, column=0, sticky="ew", padx=10, pady=10)
ctk.CTkLabel(ap, text="Enter correct name: Title (Year)").grid(row=1, column=0, sticky="w", padx=10)
self.mismatch_name_entry = ctk.CTkEntry(ap); self.mismatch_name_entry.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 10))
abf = ctk.CTkFrame(ap, fg_color="transparent"); abf.grid(row=3, column=0, sticky="ew", pady=10); abf.grid_columnconfigure((0,1), weight=1)
self.mismatch_reprocess_button = ctk.CTkButton(abf, text="Re-process (API)", command=self.reprocess_selected_file); self.mismatch_reprocess_button.grid(row=0, column=0, padx=(10,5), sticky="ew")
self.mismatch_delete_button = ctk.CTkButton(abf, text="Delete File", fg_color="#D32F2F", hover_color="#B71C1C", command=self.delete_selected_file); self.mismatch_delete_button.grid(row=0, column=1, padx=(5,10), sticky="ew")
ff = ctk.CTkFrame(ap); ff.grid(row=4, column=0, sticky="ew", padx=10, pady=(20, 0)); ff.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(ff, text="Force as (bypasses API):").grid(row=0, column=0, sticky="w", padx=5)
fbf = ctk.CTkFrame(ff, fg_color="transparent"); fbf.grid(row=1, column=0, sticky="ew", pady=5); fbf.grid_columnconfigure((0,1), weight=1)
self.force_movie_btn = ctk.CTkButton(fbf, text="Movie", command=lambda: self.force_reprocess_file(backend.MediaType.MOVIE)); self.force_tv_btn = ctk.CTkButton(fbf, text="TV Show", command=lambda: self.force_reprocess_file(backend.MediaType.TV_SERIES))
self.force_anime_series_btn = ctk.CTkButton(fbf, text="Anime", command=lambda: self.force_reprocess_file(backend.MediaType.ANIME_SERIES)); self.force_anime_movie_btn = ctk.CTkButton(fbf, text="Anime Movie", command=lambda: self.force_reprocess_file(backend.MediaType.ANIME_MOVIE))
self.force_split_lang_movie_btn = ctk.CTkButton(fbf, text="Split Lang Movie", command=lambda: self.force_reprocess_file(backend.MediaType.MOVIE, is_split_lang_override=True))
self.force_movie_btn.grid(row=0, column=0, padx=2, pady=2, sticky="ew"); self.force_tv_btn.grid(row=0, column=1, padx=2, pady=2, sticky="ew")
self.force_anime_series_btn.grid(row=1, column=0, padx=2, pady=2, sticky="ew"); self.force_anime_movie_btn.grid(row=1, column=1, padx=2, pady=2, sticky="ew")
self.force_split_lang_movie_btn.grid(row=2, column=0, padx=2, pady=2, sticky="ew"); self._update_mismatch_panel_state()
def create_settings_tab(self, parent):
parent.grid_columnconfigure(1, weight=1); self.path_entries = {}; row = 0
pm = {'SOURCE_DIR': 'Source Directory', 'MOVIES_DIR': 'Movies Directory', 'TV_SHOWS_DIR': 'TV Shows Directory', 'ANIME_MOVIES_DIR': 'Anime Movies Directory', 'ANIME_SERIES_DIR': 'Anime Series Directory', 'MISMATCHED_DIR': 'Mismatched Files Directory'}
for key, label in pm.items(): row = self._create_path_entry_row(parent, row, key, label)
row = self._create_path_entry_row(parent, row, "SPLIT_MOVIES_DIR", "Split Language Movies Dir")
ctk.CTkLabel(parent, text="Languages to Split").grid(row=row, column=0, padx=5, pady=5, sticky="w"); self.split_languages_entry = ctk.CTkEntry(parent, placeholder_text='e.g., fr, es, de, all'); self.split_languages_entry.grid(row=row, column=1, columnspan=2, padx=5, pady=5, sticky="ew");
if self.config.LANGUAGES_TO_SPLIT: self.split_languages_entry.insert(0, ", ".join(self.config.LANGUAGES_TO_SPLIT)); row += 1
ctk.CTkLabel(parent, text="Sidecar Extensions").grid(row=row, column=0, padx=5, pady=5, sticky="w"); self.sidecar_entry = ctk.CTkEntry(parent, placeholder_text=".srt, .nfo, .txt"); self.sidecar_entry.grid(row=row, column=1, columnspan=2, padx=5, pady=5, sticky="ew");
if self.config.SIDECAR_EXTENSIONS: self.sidecar_entry.insert(0, ", ".join(self.config.SIDECAR_EXTENSIONS)); row += 1
ctk.CTkLabel(parent, text="Custom Strings to Remove").grid(row=row, column=0, padx=5, pady=5, sticky="w"); self.custom_strings_entry = ctk.CTkEntry(parent, placeholder_text="FRENCH, VOSTFR"); self.custom_strings_entry.grid(row=row, column=1, columnspan=2, padx=5, pady=5, sticky="ew");
if self.config.CUSTOM_STRINGS_TO_REMOVE: self.custom_strings_entry.insert(0, ", ".join(self.config.CUSTOM_STRINGS_TO_REMOVE)); row += 1
ctk.CTkLabel(parent, text="Primary Provider").grid(row=row, column=0, padx=5, pady=5, sticky="w"); pf = ctk.CTkFrame(parent, fg_color="transparent"); pf.grid(row=row, column=1, columnspan=2, sticky="ew", padx=5, pady=5)
ctk.CTkSegmentedButton(pf, values=["OMDb", "TMDB", "TVDB"], variable=self.api_provider_var).pack(side="left")
ctk.CTkLabel(pf, text="All configured APIs will be used as fallbacks.", text_color="gray50").pack(side="left", padx=(10,0)); row += 1
ctk.CTkLabel(parent, text="OMDb API Key").grid(row=row, column=0, padx=5, pady=5, sticky="w"); oaf = ctk.CTkFrame(parent, fg_color="transparent"); oaf.grid(row=row, column=1, columnspan=2, sticky="ew"); oaf.grid_columnconfigure(0, weight=1); self.omdb_api_key_entry = ctk.CTkEntry(oaf, placeholder_text="Enter OMDb API key"); self.omdb_api_key_entry.grid(row=0, column=0, sticky="ew");
if self.config.OMDB_API_KEY and self.config.OMDB_API_KEY != "yourkey": self.omdb_api_key_entry.insert(0, self.config.OMDB_API_KEY); self.omdb_api_key_entry.configure(show="*");
self.omdb_api_key_entry.bind("<Key>", lambda e: self.omdb_api_key_entry.configure(show="*")); ctk.CTkButton(oaf, text="Test Key", width=80, command=lambda: self.test_api_key_clicked("omdb")).grid(row=0, column=1, padx=(10,0)); row += 1
ctk.CTkLabel(parent, text="TMDB API Key").grid(row=row, column=0, padx=5, pady=5, sticky="w"); taf = ctk.CTkFrame(parent, fg_color="transparent"); taf.grid(row=row, column=1, columnspan=2, sticky="ew"); taf.grid_columnconfigure(0, weight=1); self.tmdb_api_key_entry = ctk.CTkEntry(taf, placeholder_text="Enter TMDB API key"); self.tmdb_api_key_entry.grid(row=0, column=0, sticky="ew");
if self.config.TMDB_API_KEY and self.config.TMDB_API_KEY != "yourkey": self.tmdb_api_key_entry.insert(0, self.config.TMDB_API_KEY); self.tmdb_api_key_entry.configure(show="*");
self.tmdb_api_key_entry.bind("<Key>", lambda e: self.tmdb_api_key_entry.configure(show="*")); ctk.CTkButton(taf, text="Test Key", width=80, command=lambda: self.test_api_key_clicked("tmdb")).grid(row=0, column=1, padx=(10,0)); row += 1
ctk.CTkLabel(parent, text="TVDB API Key").grid(row=row, column=0, padx=5, pady=5, sticky="w"); tvdb_frame = ctk.CTkFrame(parent, fg_color="transparent"); tvdb_frame.grid(row=row, column=1, columnspan=2, sticky="ew"); tvdb_frame.grid_columnconfigure(0, weight=1); self.tvdb_api_key_entry = ctk.CTkEntry(tvdb_frame, placeholder_text="Enter TVDB API key"); self.tvdb_api_key_entry.grid(row=0, column=0, sticky="ew");
if self.config.TVDB_API_KEY and self.config.TVDB_API_KEY != "yourkey": self.tvdb_api_key_entry.insert(0, self.config.TVDB_API_KEY); self.tvdb_api_key_entry.configure(show="*");
self.tvdb_api_key_entry.bind("<Key>", lambda e: self.tvdb_api_key_entry.configure(show="*")); ctk.CTkButton(tvdb_frame, text="Test Key", width=80, command=lambda: self.test_api_key_clicked("tvdb")).grid(row=0, column=1, padx=(10,0)); row += 1
ctk.CTkLabel(parent, text="TVDB PIN (Optional)").grid(row=row, column=0, padx=5, pady=5, sticky="w"); self.tvdb_pin_entry = ctk.CTkEntry(parent, placeholder_text="Enter TVDB PIN if required"); self.tvdb_pin_entry.grid(row=row, column=1, columnspan=2, padx=5, pady=5, sticky="ew");
if self.config.TVDB_PIN: self.tvdb_pin_entry.insert(0, self.config.TVDB_PIN);
row += 1
ctk.CTkLabel(parent, text="Minimize Behavior").grid(row=row, column=0, padx=5, pady=5, sticky="w")
ctk.CTkSegmentedButton(parent, values=["Tray only", "Tray & Taskbar"], variable=self.minimize_mode_var).grid(row=row, column=1, columnspan=2, padx=5, pady=5, sticky="w")
row += 1
app_options_frame = ctk.CTkFrame(parent, fg_color="transparent")
app_options_frame.grid(row=row, column=1, columnspan=2, padx=5, pady=10, sticky="ew")
startup_checkbox = ctk.CTkCheckBox(app_options_frame, text="Run at Login (and start Watchdog)", variable=self.start_with_windows_var)
startup_checkbox.pack(side="left", padx=(0, 20))
notify_checkbox = ctk.CTkCheckBox(app_options_frame, text="Notify on Mismatch", variable=self.notify_on_mismatch_var)
notify_checkbox.pack(side="left")
if hasattr(sys, "frozen"):
if sys.platform == "win32": startup_checkbox.configure(command=self._toggle_startup)
elif sys.platform == "linux": startup_checkbox.configure(command=self._toggle_startup)
else:
startup_checkbox.configure(state="disabled")
ctk.CTkLabel(app_options_frame, text="To run at login, go to System Settings > General > Login Items.", text_color="gray50").pack(side="left", padx=10)
else: startup_checkbox.configure(state="disabled")
row += 1
ctk.CTkButton(parent, text="Save Settings", command=self.save_settings).grid(row=row, column=1, columnspan=2, padx=5, pady=10, sticky="e")
def create_about_tab(self, parent):
parent.grid_rowconfigure(0, weight=0); parent.grid_rowconfigure(1, weight=0, minsize=370); parent.grid_rowconfigure(2, weight=1); parent.grid_columnconfigure(0, weight=1)
def open_url(url): webbrowser.open_new_tab(url)
ascii_art = """ ██████ ▒█████ ██▀███ ▄▄▄█████▓ ███▄ ▄███▓▓█████ ▓█████▄ ▒█████ █ █░███▄ █ \n ▒██ ▒ ▒██▒ ██▒▓██ ▒ ██▒▓ ██▒ ▓▒ ▓██▒▀█▀ ██▒▓█ ▀ ▒██▀ ██▌▒██▒ ██▒▓█░ █ ░█░██ ▀█ █ \n ░ ▓██▄ ▒██░ ██▒▓██ ░▄█ ▒▒ ▓██░ ▒░ ▓██ ▓██░▒███ ░██ █▌▒██░ ██▒▒█░ █ ░█▓██ ▀█ ██▒\n ▒ ██▒▒██ ██░▒██▀▀█▄ ░ ▓██▓ ░ ▒██ ▒██ ▒▓█ ▄ ░▓█▄ ▌▒██ ██░░█░ █ ░█▓██▒ ▐▌██▒\n ▒██████▒▒░ ████▓▒░░██▓ ▒██▒ ▒██▒ ░ ▒██▒ ░██▒░▒████▒ ░▒████▓ ░ ████▓▒░░░██▒██▓▒██░ ▓██░\n ▒ ▒▓▒ ▒ ░░ ▒░▒░▒░ ░ ▒▓ ░▒▓░ ▒ ░░ ░ ▒░ ░ ░░░ ▒░ ░ ▒▒▓ ▒ ░ ▒░▒░▒░ ░ ▓░▒ ▒ ░ ▒░ ▒ ▒ \n ░ ░▒ ░ ░ ░ ▒ ▒░ ░▒ ░ ▒░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ▒ ░ ▒ ▒░ ▒ ░ ░ ░ ░░ ░ ▒░\n ░ ░ ░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░ \n ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ \n a BangBang GUI """
ctk.CTkLabel(parent, text=ascii_art, font=ctk.CTkFont(family="Courier", size=8), justify="left").grid(row=0, column=0, padx=10, pady=(10,0), sticky="ew")
ttb = ctk.CTkTextbox(parent, wrap="word", font=("Segoe UI", 14), corner_radius=6); ttb.grid(row=1, column=0, padx=10, pady=(5, 5), sticky="nsew")
ttb.insert("end", "\n"); ttb.insert("end", "🗡️ Some tools aren't just built—they're forged. 🗡️\n\n"); ttb.insert("end", "Created with ❤️ by: Frederic LM\n\n"); ttb.insert("end", "If SortMeDown has saved you time, consider showing some support!\n\n")
for i, (text, url) in enumerate([("🍺 Buy Me a beer", "https://coff.ee/drmcwormd")]): lt = f"link-{i}"; ttb.tag_config(lt, foreground="#6495ED", underline=True); ttb.tag_bind(lt, "<Button-1>", lambda e, u=url: open_url(u)); ttb.tag_bind(lt, "<Enter>", lambda e: ttb.configure(cursor="hand2")); ttb.tag_bind(lt, "<Leave>", lambda e: ttb.configure(cursor="")); ttb.insert("end", text, (lt, "center"))
ttb.insert("end", "\n\nHappy sorting! 📂"); eut = "link-ee"; ttb.tag_config(eut, foreground="#6495ED", underline=True); ttb.tag_bind(eut, "<Button-1>", lambda e, u="https://youtu.be/HPCdBJMkN5A?si=UxQbUUR7x6T-EWSL": open_url(u)); ttb.tag_bind(eut, "<Enter>", lambda e: ttb.configure(cursor="hand2")); ttb.tag_bind(eut, "<Leave>", lambda e: ttb.configure(cursor="")); ttb.insert("end", "🎯", eut)
ttb.tag_config("center", justify="center"); ttb.tag_add("center", "1.0", "end"); ttb.configure(state="disabled")
hf = ctk.CTkFrame(parent); hf.grid(row=2, column=0, padx=10, pady=(5, 10), sticky="nsew"); hf.grid_columnconfigure(0, weight=1); hf.grid_rowconfigure(1, weight=1)
ctk.CTkLabel(hf, text="Version History", font=ctk.CTkFont(weight="bold")).grid(row=0, column=0, padx=10, pady=(5, 2), sticky="w")
btb = ctk.CTkTextbox(hf, wrap="word", font=("Courier New", 12)); btb.grid(row=1, column=0, padx=10, pady=(2, 10), sticky="nsew"); btb.insert("1.0", self.version_history); btb.configure(state="disabled", height=200)
def _set_options_state(self, state: str):
self.dry_run_checkbox.configure(state=state); self.watch_interval_entry.configure(state=state)
for cb in self.toggles_map.values(): cb.configure(state=state)
for rb in [self.ignore_radio, self.mismatch_radio, self.tv_radio, self.anime_radio]: rb.configure(state=state)
if state == "normal": self.update_fallback_ui_state()
def _update_mismatch_panel_state(self):
is_file_selected = self.selected_mismatched_file is not None
state = "normal" if is_file_selected else "disabled"
self.mismatch_name_entry.configure(state=state)
self.mismatch_reprocess_button.configure(state=state)
self.mismatch_delete_button.configure(state=state)
self.force_movie_btn.configure(state=state)
self.force_tv_btn.configure(state=state)
self.force_anime_series_btn.configure(state=state)
self.force_anime_movie_btn.configure(state=state)
split_dir_present = self.path_entries.get('SPLIT_MOVIES_DIR', ctk.CTkEntry(self)).get()
split_btn_state = state if split_dir_present else "disabled"
self.force_split_lang_movie_btn.configure(state=split_btn_state)
if not is_file_selected:
self.mismatch_selected_label.configure(text="No file selected.")
self.mismatch_name_entry.delete(0, ctk.END)
else:
self.mismatch_selected_label.configure(text=f"Selected: {self.selected_mismatched_file.name}")
def scan_mismatched_files(self, index_to_select: int = 0):
for w in self.mismatched_files_frame.winfo_children(): w.destroy()
self.mismatch_buttons = {}; self.selected_mismatched_file = None; self._update_mismatch_panel_state()
md = self.config.get_path('MISMATCHED_DIR') or (self.config.get_path('SOURCE_DIR') / '_Mismatched' if self.config.get_path('SOURCE_DIR') else None)
if not md or not md.exists():
ctk.CTkLabel(self.mismatched_files_frame, text="Mismatched directory not found.").pack()
return
mfs = [p for ext in self.config.SUPPORTED_EXTENSIONS for p in md.glob(f'**/*{ext}') if p.is_file()]
if not mfs:
ctk.CTkLabel(self.mismatched_files_frame, text="No media files found.").pack()
return
smfs = sorted(mfs, key=lambda p: p.name)
for fp in smfs:
btn = ctk.CTkButton(self.mismatched_files_frame, text=fp.name, command=lambda f=fp: self.select_mismatched_file(f), fg_color="transparent", anchor="w")
btn.pack(fill="x", padx=2, pady=2)
self.mismatch_buttons[fp] = btn
if smfs:
new_index = max(0, min(index_to_select, len(smfs) - 1))
self.after(50, lambda: self.select_mismatched_file(smfs[new_index]))
def select_mismatched_file(self, file_path: Path):
self.selected_mismatched_file = file_path
for p, b in self.mismatch_buttons.items():
b.configure(fg_color=self.default_button_color if p == file_path else "transparent")
self.update_config_from_ui()
stem = self.selected_mismatched_file.stem
clean_title, year = backend.TitleCleaner.extract_search_terms(stem, self.config.CUSTOM_STRINGS_TO_REMOVE)
suggested_name = f"{clean_title} ({year})" if year else clean_title
self.mismatch_name_entry.delete(0, ctk.END)
self.mismatch_name_entry.insert(0, suggested_name)
self._update_mismatch_panel_state()
def _get_mismatch_index_and_start_task(self, task_lambda):
if not self.selected_mismatched_file: return
try: current_index = list(self.mismatch_buttons.keys()).index(self.selected_mismatched_file)
except (ValueError, AttributeError): current_index = 0
threading.Thread(target=task_lambda, args=(current_index,), daemon=True).start()
def reprocess_selected_file(self):
new_name = self.mismatch_name_entry.get().strip()
if not new_name: messagebox.showwarning("Input Required", "Please enter a corrected name."); return
task = lambda index: (backend.MediaSorter(self.config, self.dry_run_var.get()).sort_item(self.selected_mismatched_file, override_name=new_name), self.after(0, self.scan_mismatched_files, index))
self._get_mismatch_index_and_start_task(task)
def force_reprocess_file(self, media_type: backend.MediaType, is_split_lang_override: bool = False):
folder_name = self.mismatch_name_entry.get().strip()
if not folder_name: messagebox.showwarning("Input Required", "Please enter a name for the folder."); return
task = lambda index: (backend.MediaSorter(self.config, self.dry_run_var.get()).force_move_item(self.selected_mismatched_file, folder_name, media_type, is_split_lang_override), self.after(0, self.scan_mismatched_files, index))
self._get_mismatch_index_and_start_task(task)
def delete_selected_file(self):
if not self.selected_mismatched_file: return
if not messagebox.askyesno("Confirm Deletion", f"Permanently delete '{self.selected_mismatched_file.name}' and its sidecar files?"): return
task = lambda index: (backend.FileManager(self.config, self.dry_run_var.get()).delete_file_group(self.selected_mismatched_file), self.after(0, self.scan_mismatched_files, index))
self._get_mismatch_index_and_start_task(task)
def toggle_log_visibility(self):
self.log_is_visible = not self.log_is_visible
if self.log_is_visible:
if self.tab_view.get() != "About": self.log_textbox.grid()
self.toggle_log_button.configure(text="Hide Log")
else: self.log_textbox.grid_remove(); self.toggle_log_button.configure(text="Show Log")
def open_temp_folder(self):
src = self.config.get_path('SOURCE_DIR')
if not src or not src.exists():
messagebox.showwarning("Source Not Set", "Source directory is not configured or does not exist.")
return
if sys.platform == "win32": os.startfile(src)
elif sys.platform == "darwin": subprocess.Popen(["open", str(src)])
else: subprocess.Popen(["xdg-open", str(src)])
def on_media_type_toggled(self): self.update_fallback_ui_state()
def update_fallback_ui_state(self):
tv_on = self.enabled_vars['TV_SHOWS_ENABLED'].get()
anime_on = self.enabled_vars['ANIME_SERIES_ENABLED'].get()
self.tv_radio.configure(state="normal" if tv_on else "disabled")
self.anime_radio.configure(state="normal" if anime_on else "disabled")
if not tv_on and self.fallback_var.get() == "tv": self.fallback_var.set("mismatched")
if not anime_on and self.fallback_var.get() == "anime": self.fallback_var.set("mismatched")
def stop_running_task(self):
if self.sorter_instance: logging.warning("🛑 User initiated stop..."); self.sorter_instance.signal_stop()
def _create_path_entry_row(self, parent, row, key, label):
ctk.CTkLabel(parent, text=label).grid(row=row, column=0, padx=5, pady=5, sticky="w"); e = ctk.CTkEntry(parent, width=400); e.grid(row=row, column=1, padx=5, pady=5, sticky="ew")
e.insert(0, getattr(self.config, key, "")); self.path_entries[key] = e; ctk.CTkButton(parent, text="Browse...", width=80, command=lambda e=e: self.browse_folder(e)).grid(row=row, column=2, padx=5, pady=5)
return row + 1
def _test_api_key_task(self, provider: str):
if provider == "tvdb":
key, pin = self.tvdb_api_key_entry.get(), self.tvdb_pin_entry.get()
valid, message = self.api_client.test_tvdb_api_key(key, pin)
elif provider == "omdb": valid, message = self.api_client.test_omdb_api_key(self.omdb_api_key_entry.get())
elif provider == "tmdb": valid, message = self.api_client.test_tmdb_api_key(self.tmdb_api_key_entry.get())
else: valid, message = False, "Unknown provider"
messagebox.showinfo(f"{provider.upper()} Test", message)
def test_api_key_clicked(self, provider: str):
threading.Thread(target=self._test_api_key_task, args=(provider,), daemon=True).start()
def browse_folder(self, e):
if fp := filedialog.askdirectory(initialdir=e.get() or str(Path.home())): e.delete(0, ctk.END); e.insert(0, fp)
def save_settings(self):
self.update_config_from_ui(); self.config.save(CONFIG_FILE); logging.info("✅ Settings saved to config.json")
if self.tray_icon: self.tray_icon.update_menu()
def update_config_from_ui(self):
for k, e in self.path_entries.items(): setattr(self.config, k, e.get())
for k, v in self.enabled_vars.items(): setattr(self.config, k, v.get())
self.config.API_PROVIDER = {"OMDb": "omdb", "TMDB": "tmdb", "TVDB": "tvdb"}.get(self.api_provider_var.get(), "omdb")
self.config.OMDB_API_KEY = self.omdb_api_key_entry.get()
self.config.TMDB_API_KEY = self.tmdb_api_key_entry.get()
self.config.TVDB_API_KEY = self.tvdb_api_key_entry.get()
self.config.TVDB_PIN = self.tvdb_pin_entry.get()
self.config.NOTIFY_ON_MISMATCH = self.notify_on_mismatch_var.get()
self.config.MINIMIZE_TO_TRAY_ONLY = self.minimize_mode_var.get() == "Tray only"
self.config.LANGUAGES_TO_SPLIT = [l.strip().lower() for l in self.split_languages_entry.get().split(',') if l.strip()]
self.config.SIDECAR_EXTENSIONS = {f".{e.strip().lstrip('.')}" for e in self.sidecar_entry.get().split(',') if e.strip()}
self.config.CUSTOM_STRINGS_TO_REMOVE = {s.strip().upper() for s in self.custom_strings_entry.get().split(',') if s.strip()}
self.config.FALLBACK_SHOW_DESTINATION = self.fallback_var.get()
try: self.config.WATCH_INTERVAL = int(self.watch_interval_entry.get()) * 60
except (ValueError, TypeError): self.config.WATCH_INTERVAL = 15 * 60
def _update_progress(self, cs: int, ts: int): self.after(0, self._update_progress_ui, cs, ts)
def _update_progress_ui(self, cs: int, ts: int):
if ts > 0: self.progress_bar.set(cs / ts); self.progress_label.configure(text=f"Processing: {cs} / {ts}")
else: self.progress_bar.set(0); self.progress_label.configure(text="No files to process.")
def start_task(self, task_function, is_watcher=False):
if self.is_quitting or (self.sorter_thread and self.sorter_thread.is_alive()): return
self.update_config_from_ui(); self.is_watching = is_watcher
if not self.config.get_path('SOURCE_DIR'): messagebox.showerror("Config Error", "Source Directory is not set."); return
if self.config.SPLIT_MOVIES_DIR and self.config.LANGUAGES_TO_SPLIT: logging.info(f"🔵⚪🔴 Language Split is ON for: {self.config.LANGUAGES_TO_SPLIT}")
if self.dry_run_var.get(): logging.info("🧪 Dry Run is ENABLED for this task.")
self.progress_frame.grid(); self.progress_bar.set(0); self.progress_label.configure(text="Initializing...")
self.sorter_instance = backend.MediaSorter(self.config, self.dry_run_var.get(), self._update_progress)
self.sorter_thread = threading.Thread(target=task_function, args=(self.sorter_instance,), daemon=True); self.sorter_thread.start()
self.monitor_active_task()
def start_sort_now(self): self.start_task(lambda s: s.process_source_directory())
def toggle_watch_mode(self):
if self.sorter_thread and self.sorter_thread.is_alive():
self.stop_running_task()
if self.tray_icon: self.tray_icon.update_menu()
else: self.start_task(lambda s: s.start_watch_mode(), True)
def _start_reorganize_task(self, task_function, action_name: str, task_args: tuple):
if self.sorter_thread and self.sorter_thread.is_alive(): logging.warning("A task is already running."); return
self.update_config_from_ui(); self.progress_frame.grid(); self.progress_bar.set(0); self.progress_label.configure(text="Initializing...")
dry_run = self.reorganize_dry_run_var.get()
if dry_run: logging.info(f"🧪 DRY RUN MODE ENABLED for {action_name} task.")
self.sorter_instance = backend.MediaSorter(self.config, dry_run, self._update_progress)
self.sorter_thread = threading.Thread(target=task_function, args=(self.sorter_instance, *task_args), daemon=True); self.sorter_thread.start()
self.monitor_active_task()
def start_folder_reorganization(self):
library_path_str = self.reorganize_path_entry.get().strip()
if not library_path_str or not Path(library_path_str).is_dir():
messagebox.showerror("Error", "A valid Target Library path must be set.")
return
library_path = Path(library_path_str)
selected_files = self._get_selected_reorganize_files()
if not selected_files:
messagebox.showwarning("No Files Selected", "Please select files to reorganize.")
return
task_args = (library_path, selected_files)
self._start_reorganize_task(
lambda s, p, f: s.reorganize_folder_structure(p, file_list=f),
"reorganize",
task_args
)
def start_rename_preview(self):
selected_files = self._get_selected_reorganize_files()
if not selected_files: messagebox.showwarning("No Files Selected", "Please select files to preview for renaming."); return
self.clear_rename_preview()
task = lambda s, files, qc: self.run_preview_in_thread(s, files, qc)
self._start_reorganize_task(task, "rename-preview", (selected_files, self.quick_clean_var.get()))
def run_preview_in_thread(self, sorter_instance, files_to_preview, quick_clean):
target_path = Path(self.reorganize_path_entry.get())
rename_plan = sorter_instance.generate_rename_plan(target_path, files_to_preview, quick_clean)
self.after(0, self.display_rename_preview, rename_plan)
def display_rename_preview(self, rename_plan: Dict[Path, Path]):
for widget in self.reorganize_preview_frame.winfo_children(): widget.destroy()
self.rename_preview_cache = rename_plan
if not rename_plan:
ctk.CTkLabel(self.reorganize_preview_frame, text="Preview complete. No files need renaming.").pack(pady=5)
self.apply_rename_button.configure(state="disabled")
return
for old_path, new_path in rename_plan.items():
row_frame = ctk.CTkFrame(self.reorganize_preview_frame, fg_color="transparent"); row_frame.pack(fill="x", expand=True)
row_frame.grid_columnconfigure(0, weight=1); row_frame.grid_columnconfigure(2, weight=1)
ctk.CTkLabel(row_frame, text=old_path.name, text_color="gray60", anchor="w").grid(row=0, column=0, sticky="ew", padx=5)
ctk.CTkLabel(row_frame, text="->", text_color="gray80").grid(row=0, column=1, padx=10)
ctk.CTkLabel(row_frame, text=new_path.name, text_color="#4CAF50", anchor="w").grid(row=0, column=2, sticky="ew", padx=5)
self.apply_rename_button.configure(state="normal")
messagebox.showinfo("Preview Ready", f"Preview generated for {len(rename_plan)} files. Review and click 'Apply Rename'.")
def clear_rename_preview(self):
for widget in self.reorganize_preview_frame.winfo_children(): widget.destroy()
self.rename_preview_cache = {}
self.apply_rename_button.configure(state="disabled")
def start_file_renaming(self):
if not self.rename_preview_cache:
messagebox.showerror("Error", "No rename plan found. Please generate a preview first.")
return
if not messagebox.askyesno("Confirm Rename", f"Rename {len(self.rename_preview_cache)} file(s)? This cannot be undone."):
return
self._start_reorganize_task(lambda s, plan: s.rename_files_in_library(plan), "rename", (self.rename_preview_cache,))
self.clear_rename_preview()
def scan_reorganize_folder(self):
target_path_str = self.reorganize_path_entry.get().strip()
if not target_path_str or not Path(target_path_str).is_dir():
messagebox.showerror("Error", "Please select a valid target library folder.")
return
for widget in self.reorganize_files_frame.winfo_children(): widget.destroy()
self.clear_rename_preview()
self.reorganize_all_files = []; self.reorganize_selection_state = {}; self.reorganize_current_page = 0
self.reorganize_page_label.configure(text="Scanning...")
def _scan():
files = sorted([p for ext in self.config.SUPPORTED_EXTENSIONS for p in Path(target_path_str).glob(f'**/*{ext}') if p.is_file()], key=str)
self.after(0, self.finish_reorganize_scan, files, Path(target_path_str))
threading.Thread(target=_scan, daemon=True).start()
def finish_reorganize_scan(self, media_files: List[Path], base_path: Path):
self.reorganize_all_files = media_files
self.reorganize_selection_state = {path: False for path in media_files}
if not media_files: logging.warning("Scan complete. No media files found.")
else: logging.info(f"Scan complete. Found {len(media_files)} media files.")
self.reorganize_display_page()
def reorganize_display_page(self):
for widget in self.reorganize_files_frame.winfo_children(): widget.destroy()
if not self.reorganize_all_files: ctk.CTkLabel(self.reorganize_files_frame, text="No media files found.").pack(); self.reorganize_page_label.configure(text="Page 0 of 0"); return
start = self.reorganize_current_page * self.reorganize_items_per_page
end = start + self.reorganize_items_per_page
page_files = self.reorganize_all_files[start:end]
base_path = Path(self.reorganize_path_entry.get())
for file_path in page_files:
var = ctk.BooleanVar(value=self.reorganize_selection_state.get(file_path, False))
cb = ctk.CTkCheckBox(self.reorganize_files_frame, text=str(file_path.relative_to(base_path)), variable=var, command=lambda path=file_path, v=var: self.reorganize_toggle_selection(path, v))
cb.pack(anchor="w", padx=5)
self.update_reorganize_ui()
def reorganize_toggle_selection(self, path: Path, var: ctk.BooleanVar):
self.reorganize_selection_state[path] = var.get(); self.update_reorganize_ui(); self.clear_rename_preview()
def reorganize_select_page(self, select=True):
start = self.reorganize_current_page * self.reorganize_items_per_page; end = start + self.reorganize_items_per_page
for i in range(start, min(end, len(self.reorganize_all_files))): self.reorganize_selection_state[self.reorganize_all_files[i]] = select
self.reorganize_display_page(); self.clear_rename_preview()
def reorganize_select_all(self):
for path in self.reorganize_all_files: self.reorganize_selection_state[path] = True
self.reorganize_display_page(); self.clear_rename_preview()
def reorganize_previous_page(self):
if self.reorganize_current_page > 0: self.reorganize_current_page -= 1; self.reorganize_display_page()
def reorganize_next_page(self):
if (self.reorganize_current_page + 1) * self.reorganize_items_per_page < len(self.reorganize_all_files): self.reorganize_current_page += 1; self.reorganize_display_page()
def update_reorganize_ui(self):
total_files = len(self.reorganize_all_files)
total_pages = math.ceil(total_files / self.reorganize_items_per_page) if total_files > 0 else 0
self.reorganize_page_label.configure(text=f"Page {self.reorganize_current_page + 1} of {total_pages}")
self.reorganize_prev_button.configure(state="normal" if self.reorganize_current_page > 0 else "disabled")
self.reorganize_next_button.configure(state="normal" if (self.reorganize_current_page + 1) < total_pages else "disabled")
selected_count = sum(1 for v in self.reorganize_selection_state.values() if v)
self.reorganize_status_label.configure(text=f"Selected: {selected_count}")
def _get_selected_reorganize_files(self) -> List[Path]: return [p for p, v in self.reorganize_selection_state.items() if v]
def monitor_active_task(self):
is_running = self.sorter_thread and self.sorter_thread.is_alive()
if is_running:
self._set_options_state("disabled")
self.sort_now_button.configure(state="disabled"); self.reorganize_folders_button.configure(state="disabled")
self.rename_preview_button.configure(state="disabled"); self.apply_rename_button.configure(state="disabled")
self.watch_button.configure(text="Stop Watchdog" if self.is_watching else "Running...", state="normal" if self.is_watching else "disabled")
if self.sorter_instance and self.sorter_instance.is_processing: self.stop_button.configure(state="normal", text="STOP", fg_color="#D32F2F", hover_color="#B71C1C");
elif self.is_watching: self.stop_button.configure(state="disabled", text="IDLE", fg_color="#FBC02D", text_color="black");
if not self.progress_frame.winfo_viewable() and self.sorter_instance and self.sorter_instance.is_processing: self.progress_frame.grid()
self.after(500, self.monitor_active_task)
else:
self._set_options_state("normal"); self.sort_now_button.configure(state="normal"); self.reorganize_folders_button.configure(state="normal")
self.rename_preview_button.configure(state="normal")
if self.is_watching: logging.info("✅ Watchdog stopped.")
else: logging.info("✅ Task finished.")
self.watch_button.configure(text="Launch Watchdog", state="normal")
self.stop_button.configure(state="disabled", text="", fg_color="gray25")
self.progress_frame.grid_remove()
if self.sorter_instance: self.sorter_instance.close()
self.sorter_instance = None; self.sorter_thread = None; self.is_watching = False
if self.tray_icon: self.tray_icon.update_menu()
def create_tray_image(self):
try: return Image.open(str(resource_path("icon.png")))
except: img = Image.new('RGB', (64, 64), "#1F6AA5"); dc = ImageDraw.Draw(img); dc.rectangle(((32, 0), (64, 32)), fill="#144870"); dc.rectangle(((0, 32), (32, 64)), fill="#144870"); return img
def quit_app(self):
if self.is_quitting:
return
self.is_quitting = True
logging.info("Shutting down...")
if self.sorter_instance:
self.sorter_instance.signal_stop()
if self.tray_icon:
self.tray_icon.stop()
self.save_settings()
self.destroy()
def _perform_safe_shutdown(self): self.save_settings(); self.destroy()
def _show_and_focus_tab(self, tab_name: str): self.deiconify(); self.lift(); self.attributes('-topmost', True); self.tab_view.set(tab_name); self.after(100, lambda: self.attributes('-topmost', False))
def show_window(self): self._show_and_focus_tab("Actions")
def show_settings(self): self._show_and_focus_tab("Settings")
def show_reorganize(self): self._show_and_focus_tab("Reorganize")
def show_review(self): self._show_and_focus_tab("Review")
def show_about(self): self._show_and_focus_tab("About")
def hide_to_tray(self): self.withdraw(); self.tray_icon.notify('App is running in the background', 'SortMeDown')
def on_minimize(self, event=None):
if self.state() == 'iconic' and self.config.MINIMIZE_TO_TRAY_ONLY:
self.hide_to_tray()
def set_interval(self, minutes: int): self.watch_interval_entry.delete(0, ctk.END); self.watch_interval_entry.insert(0, str(minutes)); self.save_settings()
def _check_startup_status(self):
if not hasattr(sys, "frozen"): return
is_configured = False
if sys.platform == "win32":
startup_folder = Path(os.getenv('APPDATA')) / 'Microsoft' / 'Windows' / 'Start Menu' / 'Programs' / 'Startup'
shortcut_path = startup_folder / f"{APP_NAME}.lnk"
is_configured = shortcut_path.exists()
elif sys.platform == "linux":
autostart_path = Path.home() / ".config" / "autostart" / f"{APP_NAME}.desktop"
is_configured = autostart_path.exists()
self.start_with_windows_var.set(is_configured)
def _toggle_startup(self):
if not hasattr(sys, "frozen"): return
# Determine the correct path for the application executable and its directory
app_path = sys.executable
app_dir = str(Path(app_path).parent)
app_name = "SortMeDown"
is_enabled = self.start_with_windows_var.get()
try:
if sys.platform == "win32":
script_path = resource_path('create_shortcut.ps1')
if not script_path.exists():
logging.error("create_shortcut.ps1 not found!")
return
action = "Create" if is_enabled else "Delete"
command = [
"powershell", "-ExecutionPolicy", "Bypass", "-File", str(script_path),
"-Action", action,
"-ShortcutName", app_name,
"-AppPath", app_path,
"-AppArgs", "--autostart",
"-AppDir", app_dir
]
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.run(command, check=True)
elif sys.platform == "linux":
autostart_dir = Path.home() / ".config" / "autostart"
desktop_file = autostart_dir / f"{app_name}.desktop"
if is_enabled:
autostart_dir.mkdir(parents=True, exist_ok=True)
desktop_entry = f"""[Desktop Entry]
Type=Application
Exec="{app_path}" --autostart
X-GNOME-Autostart-enabled=true
NoDisplay=false
Hidden=false
Name[en_US]={app_name}
Comment[en_US]=Start the SortMeDown media sorter
Icon={resource_path('icon.png')}
Path={app_dir}
X-GNOME-Autostart-Delay=0
"""
with open(desktop_file, 'w', encoding='utf-8') as f: f.write(desktop_entry)
else:
if desktop_file.exists(): os.remove(desktop_file)
log_message = "Added to" if is_enabled else "Removed from"
logging.info(f"✅ {log_message} system startup.")
except Exception as e:
logging.error(f"Failed to manage startup configuration: {e}")
def setup_tray_icon(self):
image = self.create_tray_image()
menu = (pystray.MenuItem('Show', self.show_window, default=True), pystray.MenuItem('Settings', self.show_settings),pystray.MenuItem('Reorganize Library', self.show_reorganize), pystray.MenuItem('Review Mismatches', self.show_review),
pystray.MenuItem('About', self.show_about), pystray.Menu.SEPARATOR,
pystray.MenuItem('Enable Watch', self.toggle_watch_mode, checked=lambda item: self.is_watching),
pystray.MenuItem('Set Interval', pystray.Menu(pystray.MenuItem('5m', lambda: self.set_interval(5), radio=True, checked=lambda i: self.config.WATCH_INTERVAL == 300),
pystray.MenuItem('15m', lambda: self.set_interval(15), radio=True, checked=lambda i: self.config.WATCH_INTERVAL == 900),
pystray.MenuItem('30m', lambda: self.set_interval(30), radio=True, checked=lambda i: self.config.WATCH_INTERVAL == 1800),
pystray.MenuItem('60m', lambda: self.set_interval(60), radio=True, checked=lambda i: self.config.WATCH_INTERVAL == 3600))),
pystray.Menu.SEPARATOR, pystray.MenuItem('Quit', self.quit_app))
self.tray_icon = pystray.Icon("sortmedown", image, "SortMeDown Sorter", menu)
self.tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True); self.tray_thread.start()
if __name__ == "__main__":
config_dir = get_config_path().parent
lock_file_path = config_dir / f"{APP_NAME}.lock"
if lock_file_path.exists():
messagebox.showerror(f"{APP_NAME} is Already Running",
f"Another instance of {APP_NAME} is already running. Please check your system tray.")
sys.exit(1)
try:
lock_file_path.touch()
autostart = "--autostart" in sys.argv
app = App(start_hidden=autostart)
if autostart:
app.after(1000, app.toggle_watch_mode)
app.mainloop()
finally:
try:
if lock_file_path.exists():
lock_file_path.unlink()
except OSError:
pass