Advertisement
Guest User

imdb_csv_importer.py

a guest
May 4th, 2025
7
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 35.33 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. imdb_csv_importer.py
  4.  
  5. Watches a folder for CSV exports whose filenames contain “feature”, “tv series” or “movies”.
  6. On creation or move, it copies your Chrome URL, sanitizes it into a list name,
  7. prompts you (Continue/Clear/Cancel/List), lets you preview/reorder/delete
  8. via a full-screen dark modal with clickable title-type filters at top
  9. (including a “Fixed Order” toggle and per-type delete “✕”), then renames/
  10. moves the file, runs npm sync, and restores your config.json.
  11.  
  12. Supports click-to-dim and drag-to-reorder on the title-type filters.
  13. """
  14.  
  15. import sys, subprocess, time, shutil, json, re, queue, csv, ctypes
  16. from pathlib import Path
  17.  
  18. # ---- Configuration ----
  19. CLEAN_CLIPBOARD_RULES = True
  20. RENAME_PREFIX = ".IMDb "
  21.  
  22. # ---- Ensure required packages ----
  23. def ensure(name):
  24. try:
  25. __import__(name)
  26. except ImportError:
  27. subprocess.check_call([sys.executable, "-m", "pip", "install", name])
  28. ensure('watchdog')
  29. ensure('pyperclip')
  30.  
  31. import pyperclip
  32. from watchdog.observers import Observer
  33. from watchdog.events import FileSystemEventHandler
  34.  
  35. # ---- GUI imports (root created below) ----
  36. import tkinter as tk
  37. import tkinter.font as tkfont
  38. import tkinter.ttk as ttk
  39. from tkinter import messagebox, filedialog
  40.  
  41. root = None # will be set in __main__
  42.  
  43. # -------------------------------------------------------------------
  44. # Overlay: small status popup in bottom-right
  45. # -------------------------------------------------------------------
  46. class Overlay(tk.Toplevel):
  47. def __init__(self, master, fg='white'):
  48. super().__init__(master)
  49. self.withdraw()
  50. self.overrideredirect(True)
  51. self.attributes('-topmost', True, '-alpha', 0.8)
  52. self.config(bg='#111111', bd=0, highlightthickness=0)
  53. self.label = tk.Label(self, text="", bg='#111111', fg=fg,
  54. font=('Arial',14), justify='left', wraplength=500)
  55. self.label.pack(padx=12, pady=8)
  56. def update_text(self, msg: str):
  57. self.label.config(text=msg)
  58. self.update_idletasks()
  59. w = self.label.winfo_reqwidth() + 20
  60. h = self.label.winfo_reqheight() + 16
  61. sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
  62. x = sw - w - 20
  63. y = sh - h - 60
  64. self.geometry(f"{w}x{h}+{x}+{y}")
  65. self.deiconify()
  66. self.lift()
  67. self.update()
  68.  
  69. # -------------------------------------------------------------------
  70. # Chrome URL copy via keystrokes
  71. # -------------------------------------------------------------------
  72. VK_CONTROL, VK_L, VK_C, VK_ESCAPE = 0x11, 0x4C, 0x43, 0x1B
  73. KEYUP = 0x0002
  74. _user32 = ctypes.WinDLL('user32', use_last_error=True)
  75. def copy_chrome_url():
  76. for vk,flag in [(VK_CONTROL,0),(VK_L,0),(VK_L,KEYUP),
  77. (VK_C,0),(VK_C,KEYUP),
  78. (VK_CONTROL,KEYUP),
  79. (VK_ESCAPE,0),(VK_ESCAPE,KEYUP)]:
  80. _user32.keybd_event(vk,0,flag,0)
  81. time.sleep(0.05)
  82.  
  83. # -------------------------------------------------------------------
  84. # Clipboard helper (pyperclip only)
  85. # -------------------------------------------------------------------
  86. def get_clipboard_text():
  87. try: return pyperclip.paste().strip()
  88. except: return ""
  89.  
  90. # -------------------------------------------------------------------
  91. # Filename sanitization
  92. # -------------------------------------------------------------------
  93. def sanitize_filename(name:str)->str:
  94. name = re.sub(r'[\\/:*?"<>|\r\n]+','_', name)
  95. return re.sub(r'_+','_', name).strip('_')[:255]
  96. def clean_clipboard_text(raw:str)->str:
  97. patterns = [
  98. 'title_type=', '&genres=', 'genres=', 'countries=',
  99. '&primary_language=', '&num_', '&release_date=',
  100. 'keywords=', 'country_of_origin='
  101. ]
  102. for p in patterns:
  103. raw = raw.replace(p,' ')
  104. raw = raw.replace(',',' ')
  105. return re.sub(r'\s+',' ', raw).strip()
  106.  
  107. # -------------------------------------------------------------------
  108. # CSVViewer – full-screen modal with filters, delete, fixed order,
  109. # sorting, drag-and-drop rows, click-to-dim + drag-to-reorder filters
  110. # -------------------------------------------------------------------
  111. class CSVViewer(tk.Toplevel):
  112. TYPE_COLORS = {
  113. 'Movie':'#AEC6CF','TV Series':'#77DD77','Short':'#FDFD96',
  114. 'TV Episode':'#FFB7CE','TV Mini Series':'#CDB4DB','TV Movie':'#FFB347',
  115. 'TV Special':'#98FF98','TV Short':'#B3FFFF','Video Game':'#B39EB5',
  116. 'Video':'#FF6961','Music Video':'#BFFF00',
  117. 'Podcast Series':'#ADD8E6','Podcast Episode':'#FFB6C1'
  118. }
  119. DRAG_BLUE = '#5577bb'
  120.  
  121. def __init__(self, master=None, *, csv_path:Path):
  122. super().__init__(master)
  123. self.csv_path = Path(csv_path)
  124.  
  125. # ─── STATE ─────────────────────────────────────────
  126. self.sort_states = {}
  127. self.undo_stack = []
  128. self.selection = set()
  129. self.fixed_order_active = False
  130.  
  131. # filter-click & drag state
  132. self._filter_press_widget = None
  133. self._filter_press_xy = (0,0)
  134. self._filter_drag_active = False
  135. self._dragging_type = None
  136. self._drag_target_type = None
  137.  
  138. # delete-click & drag state
  139. self._maybe_delete = False
  140. self._delete_dragging = False
  141. self._delete_start_idx = None
  142.  
  143. # row-drag state
  144. self._hover_row = None
  145. self._scroll_dir = 0
  146. self._scroll_speed = 1.0
  147. self._auto_job = None
  148. self._drag_item = None
  149. self._target_item = None
  150. self._press = (0,0,None,None)
  151. # ──────────────────────────────────────────────────
  152.  
  153. # fullscreen setup
  154. self.overrideredirect(True)
  155. self.attributes('-topmost', True)
  156. self.withdraw(); self.update_idletasks()
  157. sw,sh = self.winfo_screenwidth(), self.winfo_screenheight()
  158. self.geometry(f"{sw}x{sh}+0+0")
  159. self.config(bg='#111111')
  160. self.deiconify()
  161.  
  162. # load CSV and backup
  163. with self.csv_path.open(newline='', encoding='utf-8') as f:
  164. rows = list(csv.DictReader(f))
  165. self._backup_data = []
  166. for i,r in enumerate(rows, start=1):
  167. row = r.copy(); row['Position'] = str(i)
  168. self._backup_data.append(row)
  169. self.data = list(self._backup_data)
  170.  
  171. # extract types
  172. self._backup_types = sorted({r['Title Type'] for r in self._backup_data})
  173. self.all_types = list(self._backup_types)
  174. self.visible_types = set(self._backup_types)
  175. self.deleted_types = set()
  176.  
  177. # fonts
  178. hf,cf = ('Arial',16),('Arial',14)
  179. fh = tkfont.Font(family=hf[0], size=hf[1])
  180. fc = tkfont.Font(family=cf[0], size=cf[1])
  181. italic_fc = tkfont.Font(family=cf[0], size=cf[1], slant='italic')
  182.  
  183. # compute column widths
  184. self.columns = ["Position","Const","Title Type","Title","IMDb Rating","Year","Delete"]
  185. cw = {}
  186. for col in self.columns[:-1]:
  187. texts = [r[col] for r in self.data] + [col]
  188. longest = max(texts, key=len)
  189. cw[col] = fc.measure(longest + ' '*10)
  190. cw['Delete'] = max(fh.measure('✕'), fh.measure('Delete')) + 30
  191.  
  192. sb_w = 12
  193. box_w = sum(cw.values()) + sb_w
  194. box_h = sh
  195.  
  196. # filter frame
  197. frm = tk.Frame(self, bg='#222222')
  198. frm.place(x=(sw-box_w)//2, y=0, width=box_w, height=box_h)
  199.  
  200. self._filter_parent = frm
  201. self._filter_font = fc
  202. self._filter_italic_font = italic_fc
  203. self._filter_box_w = box_w
  204. self._filter_tag_h = fc.metrics("linespace") + 8
  205. self._filter_labels = {}
  206. self.fixed_label = None
  207. self._filter_rows = 0
  208. self._render_type_filters()
  209.  
  210. # table offset
  211. header_h = fh.metrics("linespace") + 4
  212. y_off = self._filter_rows * self._filter_tag_h + header_h
  213.  
  214. # treeview styling
  215. style = ttk.Style(frm)
  216. style.theme_use('clam')
  217. style.configure("VT.Treeview",
  218. background="#222222", fieldbackground="#222222",
  219. foreground="white", rowheight=34, font=fc, borderwidth=0)
  220. style.configure("VT.Treeview.Heading",
  221. background="#222222", foreground="white",
  222. font=hf, pady=4, borderwidth=0)
  223. style.map("VT.Treeview.Heading",
  224. background=[('active','#88aacc')])
  225. style.configure("VT.Vertical.TScrollbar",
  226. troughcolor="#222222", background="#88aacc",
  227. arrowcolor="#88aacc", bordercolor="#222222",
  228. lightcolor="#222222", darkcolor="#222222")
  229.  
  230. # tree + scrollbar
  231. self.tree = ttk.Treeview(frm, columns=self.columns,
  232. show='headings', style="VT.Treeview",
  233. selectmode='none')
  234. for col in self.columns:
  235. self.tree.heading(col, text=col,
  236. command=lambda c=col: self.sort_by(c))
  237. self.tree.column(col, width=cw[col], anchor='center', stretch=False)
  238. for t,color in CSVViewer.TYPE_COLORS.items():
  239. if t in self.all_types:
  240. self.tree.tag_configure(t, foreground=color)
  241. self.tree.tag_configure('selected', background=CSVViewer.DRAG_BLUE)
  242. self.tree.tag_configure('drag', background=CSVViewer.DRAG_BLUE)
  243. self.tree.tag_configure('hover', background='#333333')
  244.  
  245. vsb = ttk.Scrollbar(frm, orient='vertical', command=self.tree.yview,
  246. style="VT.Vertical.TScrollbar")
  247. self.tree.configure(yscrollcommand=vsb.set)
  248.  
  249. # show only full rows
  250. row_h = 34
  251. avail_h = box_h - 40 - y_off
  252. disp_h = (avail_h // row_h) * row_h
  253.  
  254. self.tree.place(x=0, y=y_off,
  255. width=box_w - sb_w, height=disp_h)
  256. vsb.place(x=box_w - sb_w, y=y_off,
  257. width=sb_w, height=disp_h)
  258.  
  259. # bindings
  260. self.refresh_tree()
  261. self.tree.bind('<Motion>', self.on_hover, add='+')
  262. self.tree.bind('<Leave>', self.on_leave, add='+')
  263. self.tree.bind('<ButtonPress-1>', self.on_press, add='+')
  264. self.tree.bind('<B1-Motion>', self.on_motion, add='+')
  265. self.tree.bind('<ButtonRelease-1>', self.on_release)
  266. self.bind("<Button-1>", self.on_bg_click, add='+')
  267.  
  268. # bottom buttons
  269. btnf = tk.Frame(frm, bg='#222222')
  270. btnf.place(x=0, y=sh-40, width=box_w, height=40)
  271. for txt,cmd in (
  272. ("Save", self.on_save),
  273. ("Cancel", self.destroy),
  274. ("Delete", self.on_delete_selected),
  275. ("Undo", self.on_undo),
  276. ("Default", self.on_default),
  277. ("Unselect",self.on_unselect),
  278. ("Close", self.destroy),
  279. ):
  280. tk.Button(btnf, text=txt, bg='#222222', fg='#777777',
  281. font=fc, bd=0, highlightthickness=1,
  282. highlightbackground='grey',
  283. activebackground='#222222',
  284. activeforeground='#777777',
  285. relief='flat', command=cmd).pack(side='left', padx=8, pady=4)
  286.  
  287. # ─── FILTER RENDER ────────────────────────────────────────────────
  288. def _render_type_filters(self):
  289. for lbl in self._filter_labels.values():
  290. lbl.destroy()
  291. self._filter_labels.clear()
  292. if self.fixed_label:
  293. self.fixed_label.destroy()
  294.  
  295. cx,row,spacing = 0,0,10
  296. # Fixed Order toggle
  297. wfixed = self._filter_font.measure("Fixed Order")+20
  298. fg = "white" if self.fixed_order_active else "#777777"
  299. self.fixed_label = tk.Label(self._filter_parent, text="Fixed Order",
  300. bg='#222222', fg=fg,
  301. font=self._filter_italic_font,
  302. cursor='hand2')
  303. self.fixed_label.place(x=cx, y=row*self._filter_tag_h,
  304. width=wfixed, height=self._filter_tag_h)
  305. self.fixed_label.bind('<Button-1>',
  306. lambda e: self._toggle_fixed())
  307. cx += wfixed + spacing
  308.  
  309. for t in self.all_types:
  310. if t in self.deleted_types: continue
  311. text_w = self._filter_font.measure(t)
  312. x_w = self._filter_font.measure('✕')
  313. wlbl = text_w + 5 + x_w + 20
  314. if cx + wlbl > self._filter_box_w:
  315. row += 1; cx = 0
  316. frm = tk.Frame(self._filter_parent, bg='#222222')
  317. frm.type_name = t
  318. frm.place(x=cx, y=row*self._filter_tag_h,
  319. width=wlbl, height=self._filter_tag_h)
  320.  
  321. txt_lbl = tk.Label(frm, text=t, bg='#222222',
  322. fg=(CSVViewer.TYPE_COLORS[t]
  323. if t in self.visible_types else '#777777'),
  324. font=self._filter_font, cursor='hand2')
  325. txt_lbl.pack(side='left', padx=(5,0))
  326. txt_lbl.type_name = t
  327. txt_lbl.bind('<ButtonPress-1>', self.on_filter_type_press)
  328. txt_lbl.bind('<B1-Motion>', self.on_filter_type_motion)
  329. txt_lbl.bind('<ButtonRelease-1>', self.on_filter_type_release)
  330.  
  331. x_lbl = tk.Label(frm, text='✕', bg='#222222',
  332. fg=(CSVViewer.TYPE_COLORS[t]
  333. if t in self.visible_types else '#777777'),
  334. font=self._filter_font, cursor='hand2')
  335. x_lbl.pack(side='left', padx=(5,5))
  336. x_lbl.bind('<Button-1>',
  337. lambda e, t=t: self._delete_type(t))
  338.  
  339. self._filter_labels[t] = frm
  340. cx += wlbl + spacing
  341.  
  342. self._filter_rows = row + 1
  343.  
  344. # ─── FILTER CLICK & DRAG HANDLERS ─────────────────────────────────
  345. def on_filter_type_press(self, event):
  346. self._filter_press_widget = event.widget
  347. self._filter_press_xy = (event.x_root, event.y_root)
  348. self._filter_drag_active = False
  349. self._dragging_type = event.widget.type_name
  350. event.widget.config(relief='raised')
  351.  
  352. def on_filter_type_motion(self, event):
  353. dx = event.x_root - self._filter_press_xy[0]
  354. dy = event.y_root - self._filter_press_xy[1]
  355. if not self._filter_drag_active and abs(dx)<5 and abs(dy)<5:
  356. return
  357. self._filter_drag_active = True
  358. target = self._filter_parent.winfo_containing(event.x_root, event.y_root)
  359. widget = target
  360. while widget and not hasattr(widget, 'type_name'):
  361. widget = widget.master
  362. if getattr(widget,'type_name',None) in self.all_types:
  363. self._drag_target_type = widget.type_name
  364. for frm in self._filter_labels.values():
  365. frm.config(highlightthickness=0)
  366. self._filter_labels[self._drag_target_type].config(
  367. highlightthickness=2,
  368. highlightbackground=CSVViewer.TYPE_COLORS[self._dragging_type]
  369. )
  370. else:
  371. self._drag_target_type = None
  372.  
  373. def on_filter_type_release(self, event):
  374. dx = event.x_root - self._filter_press_xy[0]
  375. dy = event.y_root - self._filter_press_xy[1]
  376. if not self._filter_drag_active and abs(dx)<5 and abs(dy)<5:
  377. if self._filter_press_widget:
  378. self._toggle_type(self._filter_press_widget.type_name)
  379. else:
  380. src, dst = self._dragging_type, self._drag_target_type
  381. if self._filter_drag_active and dst and dst!=src:
  382. self.all_types.remove(src)
  383. idx = self.all_types.index(dst)
  384. moved_right = (event.x_root > self._filter_press_xy[0])
  385. insert_idx = idx+1 if moved_right else idx
  386. self.all_types.insert(insert_idx, src)
  387. self._render_type_filters()
  388. self.refresh_tree()
  389. for frm in self._filter_labels.values():
  390. frm.config(relief='flat', highlightthickness=0)
  391. self._filter_press_widget = None
  392. self._filter_drag_active = False
  393. self._dragging_type = None
  394. self._drag_target_type = None
  395.  
  396. # ─── TOGGLE & DELETE FILTERS ───────────────────────────────────────
  397. def _toggle_fixed(self):
  398. self.fixed_order_active = not self.fixed_order_active
  399. self.fixed_label.config(
  400. fg="white" if self.fixed_order_active else "#777777"
  401. )
  402. self.refresh_tree()
  403.  
  404. def _toggle_type(self, t):
  405. if t in self.visible_types:
  406. self.visible_types.remove(t)
  407. else:
  408. self.visible_types.add(t)
  409. self._render_type_filters()
  410. self.refresh_tree()
  411.  
  412. def _delete_type(self, t):
  413. self.deleted_types.add(t)
  414. self.visible_types.discard(t)
  415. if t in self.all_types:
  416. self.all_types.remove(t)
  417. self.data = [r for r in self.data if r['Title Type']!=t]
  418. self._render_type_filters()
  419. self.refresh_tree()
  420.  
  421. # ─── ROW INTERACTIONS ────────────────────────────────────────────
  422. def on_hover(self, ev):
  423. row = self.tree.identify_row(ev.y)
  424. if row==self._hover_row: return
  425. if self._hover_row:
  426. tags = list(self.tree.item(self._hover_row,'tags'))
  427. if 'hover' in tags:
  428. tags.remove('hover')
  429. self.tree.item(self._hover_row,tags=tags)
  430. self._hover_row = row
  431. if row:
  432. vals = self.tree.item(row,'values')
  433. ttype = vals[2]
  434. base = CSVViewer.TYPE_COLORS.get(ttype,'#ffffff')
  435. tint = self._overlay_color(base,0.10)
  436. self.tree.tag_configure('hover', background=tint)
  437. tags = list(self.tree.item(row,'tags'))
  438. if 'hover' not in tags:
  439. tags.append('hover')
  440. self.tree.item(row, tags=tags)
  441.  
  442. def on_leave(self, ev):
  443. if self._hover_row:
  444. tags = list(self.tree.item(self._hover_row,'tags'))
  445. if 'hover' in tags:
  446. tags.remove('hover')
  447. self.tree.item(self._hover_row,tags=tags)
  448. self._hover_row = None
  449.  
  450. def on_bg_click(self, ev):
  451. if ev.widget is self:
  452. self.selection.clear()
  453. self.refresh_tree()
  454.  
  455. def _start_scroll(self, d):
  456. if self._scroll_dir!=d:
  457. self._scroll_dir=d; self._scroll_speed=1.0
  458. self._schedule_scroll()
  459. def _stop_scroll(self):
  460. self._scroll_dir=0
  461. if self._auto_job:
  462. self.after_cancel(self._auto_job)
  463. self._auto_job=None
  464. def _schedule_scroll(self):
  465. if not self._scroll_dir: return
  466. units = max(1,int(self._scroll_speed))
  467. self.tree.yview_scroll(self._scroll_dir*units,'units')
  468. self._scroll_speed*=1.011
  469. self._auto_job=self.after(50,self._schedule_scroll)
  470.  
  471. def on_press(self, ev):
  472. x,y = ev.x, ev.y
  473. row = self.tree.identify_row(y)
  474. col = self.tree.identify_column(x)
  475. self._press = (x,y,row,col)
  476. delcol = f"#{len(self.columns)}"
  477. if col==delcol and row:
  478. # prepare single-click or drag deletion
  479. self._maybe_delete = True
  480. self._delete_dragging = False
  481. iids = list(self.tree.get_children())
  482. self._delete_start_idx = iids.index(row) if row in iids else None
  483. self.selection.clear()
  484. elif row:
  485. self._maybe_delete=False
  486. self._drag_item = row
  487. tags = list(self.tree.item(row,'tags'))
  488. if 'drag' not in tags:
  489. tags.append('drag')
  490. self.tree.item(row,tags=tags)
  491. else:
  492. self._maybe_delete=False
  493. self._drag_item=None
  494.  
  495. def _update_delete_selection(self, start_idx, end_idx):
  496. iids = list(self.tree.get_children())
  497. lo,hi = sorted((start_idx, end_idx))
  498. sel = set(int(iid) for iid in iids[lo:hi+1])
  499. self.selection = sel
  500. self.refresh_tree()
  501.  
  502. def on_motion(self, ev):
  503. h,margin = self.tree.winfo_height(),20
  504. if ev.y<margin: self._start_scroll(-1)
  505. elif ev.y>h-margin: self._start_scroll(1)
  506. else: self._stop_scroll()
  507.  
  508. x0,y0,row0,col0 = self._press
  509. delcol = f"#{len(self.columns)}"
  510. dx,dy = abs(ev.x-x0), abs(ev.y-y0)
  511.  
  512. # start drag-range if moved beyond threshold
  513. if self._maybe_delete and not self._delete_dragging and col0==delcol and row0:
  514. if dx>5 or dy>5:
  515. self._delete_dragging = True
  516. if self._delete_dragging:
  517. over = self.tree.identify_row(ev.y)
  518. iids = list(self.tree.get_children())
  519. if over in iids and self._delete_start_idx is not None:
  520. idx = iids.index(over)
  521. self._update_delete_selection(self._delete_start_idx, idx)
  522. elif self._drag_item:
  523. over=self.tree.identify_row(ev.y)
  524. if self._target_item and self._target_item!=self._drag_item:
  525. if self.tree.exists(self._target_item):
  526. tags=list(self.tree.item(self._target_item,'tags'))
  527. if 'drag' in tags:
  528. tags.remove('drag')
  529. self.tree.item(self._target_item,tags=tags)
  530. self._target_item=over
  531. if over and over!=self._drag_item and self.tree.exists(over):
  532. tags=list(self.tree.item(over,'tags'))
  533. if 'drag' not in tags:
  534. tags.append('drag')
  535. self.tree.item(over,tags=tags)
  536.  
  537. def on_release(self, ev):
  538. self._stop_scroll()
  539. x0,y0,row0,col0 = self._press
  540. dx,dy = abs(ev.x-x0), abs(ev.y-y0)
  541. delcol = f"#{len(self.columns)}"
  542.  
  543. # single-click delete
  544. if self._maybe_delete and not self._delete_dragging and col0==delcol and row0 and dx<5 and dy<5:
  545. idx = int(row0)
  546. self.undo_stack.append(list(self.data))
  547. self.data.pop(idx)
  548. for i,r in enumerate(self.data, start=1):
  549. r['Position'] = str(i)
  550. self.selection.clear()
  551. self.refresh_tree()
  552. self._maybe_delete = False
  553. return
  554.  
  555. # finish delete-range drag
  556. if self._delete_dragging:
  557. self._maybe_delete = False
  558. self._delete_dragging = False
  559. return
  560.  
  561. # row-drag reorder
  562. if self._drag_item:
  563. drop=self.tree.identify_row(ev.y)
  564. if dx<5 and dy<5 and row0 and col0!=delcol:
  565. idx=int(row0)
  566. if idx in self.selection: self.selection.remove(idx)
  567. else: self.selection.add(idx)
  568. elif drop and drop!=self._drag_item:
  569. self.undo_stack.append(list(self.data))
  570. i1,i2=int(self._drag_item),int(drop)
  571. rowo=self.data.pop(i1)
  572. self.data.insert(i2,rowo)
  573. for i,r in enumerate(self.data, start=1):
  574. r['Position'] = str(i)
  575. self._drag_item=None
  576. self.refresh_tree()
  577.  
  578. def on_delete_selected(self):
  579. if self.selection:
  580. self.undo_stack.append(list(self.data))
  581. for idx in sorted(self.selection, reverse=True):
  582. self.data.pop(idx)
  583. for i,r in enumerate(self.data, start=1):
  584. r['Position'] = str(i)
  585. self.selection.clear()
  586. self.refresh_tree()
  587.  
  588. def on_undo(self):
  589. if self.undo_stack:
  590. self.data = self.undo_stack.pop()
  591. for i,r in enumerate(self.data, start=1):
  592. r['Position'] = str(i)
  593. self.selection.clear()
  594. self.refresh_tree()
  595.  
  596. def on_default(self):
  597. self.fixed_order_active = False
  598. self.visible_types = set(self._backup_types)
  599. self.deleted_types = set()
  600. self.all_types = list(self._backup_types)
  601. self.data = list(self._backup_data)
  602. self.sort_states.clear()
  603. self.selection.clear()
  604. for i,r in enumerate(self.data, start=1):
  605. r['Position'] = str(i)
  606. self._render_type_filters()
  607. self.refresh_tree()
  608.  
  609. def on_unselect(self):
  610. self.selection.clear()
  611. self.refresh_tree()
  612.  
  613. def sort_by(self, col):
  614. st = self.sort_states.get(col)
  615. seq = {
  616. 'Position':['desc','asc',None],
  617. 'Const':['desc','asc',None],
  618. 'Title Type':['asc','desc',None],
  619. 'Title':['asc','desc',None],
  620. 'IMDb Rating':['desc','asc',None],
  621. 'Year':['desc','asc',None],
  622. }
  623. nxt = seq[col][(seq[col].index(st)+1)%3]
  624. self.sort_states[col] = nxt
  625. for c in self.columns:
  626. self.tree.heading(c, text=c)
  627. arrow = '▼' if nxt=='desc' else '▲' if nxt=='asc' else ''
  628. self.tree.heading(col, text=col+(' '+arrow if arrow else ''))
  629. def keyf(r):
  630. v=r[col]
  631. if col=='IMDb Rating':
  632. try: return (0,float(v))
  633. except: return (0,-1.0)
  634. if col=='Year':
  635. try: return (0,int(v))
  636. except: return (0,1900)
  637. try: return (0,float(v))
  638. except: return (1,v.lower())
  639. if col!='Title Type' and self.sort_states.get('Title Type'):
  640. order,new=[],[]
  641. for r in self.data:
  642. t=r['Title Type']
  643. if t not in order: order.append(t)
  644. rev=(nxt=='desc')
  645. for grp in order:
  646. grp_rows=[r for r in self.data if r['Title Type']==grp]
  647. grp_rows.sort(key=keyf, reverse=rev)
  648. new.extend(grp_rows)
  649. self.data=new
  650. else:
  651. if nxt is None:
  652. self.data = list(self._backup_data)
  653. else:
  654. self.data.sort(key=keyf, reverse=(nxt=='desc'))
  655. for i,r in enumerate(self.data, start=1):
  656. r['Position'] = str(i)
  657. self.refresh_tree()
  658.  
  659. def _overlay_color(self, hexcol, alpha=0.10):
  660. bg=(34,34,34)
  661. h=hexcol.lstrip('#')
  662. br,bgc,bb = int(h[0:2],16),int(h[2:4],16),int(h[4:6],16)
  663. nr=int(br*alpha+bg[0]*(1-alpha))
  664. ng=int(bgc*alpha+bg[1]*(1-alpha))
  665. nb=int(bb*alpha+bg[2]*(1-alpha))
  666. return f'#{nr:02x}{ng:02x}{nb:02x}'
  667.  
  668. def refresh_tree(self):
  669. self.tree.delete(*self.tree.get_children())
  670. if self.fixed_order_active:
  671. display=[]
  672. for t in self.all_types:
  673. if t not in self.visible_types: continue
  674. display += [r for r in self.data if r['Title Type']==t]
  675. else:
  676. display=[r for r in self.data if r['Title Type'] in self.visible_types]
  677.  
  678. for disp_idx, r in enumerate(display, start=1):
  679. iid = str(self.data.index(r))
  680. tags=[r['Title Type']]
  681. if int(iid) in self.selection: tags.append('selected')
  682. vals = [
  683. str(disp_idx),
  684. r['Const'], r['Title Type'], r['Title'],
  685. r['IMDb Rating'], r['Year'],
  686. '✕' if int(iid) in self.selection else ''
  687. ]
  688. self.tree.insert('', 'end', iid=iid, values=vals, tags=tags)
  689.  
  690. def on_save(self):
  691. # apply current display ordering
  692. if self.fixed_order_active:
  693. ordered=[]
  694. for t in self.all_types:
  695. if t not in self.visible_types: continue
  696. ordered += [r for r in self.data if r['Title Type']==t]
  697. else:
  698. ordered=[r for r in self.data if r['Title Type'] in self.visible_types]
  699. for i,r in enumerate(ordered, start=1):
  700. r['Position']=str(i)
  701. self.data = ordered
  702.  
  703. try:
  704. with self.csv_path.open(newline='',encoding='utf-8') as f:
  705. orig=list(csv.DictReader(f))
  706. fields=orig[0].keys() if orig else []
  707. lookup={r['Const']:r for r in orig}
  708. reordered=[ lookup[r['Const']] for r in self.data ]
  709. with self.csv_path.open('w',newline='',encoding='utf-8') as f:
  710. w=csv.DictWriter(f,fieldnames=fields)
  711. w.writeheader()
  712. for row in reordered:
  713. w.writerow(row)
  714. except Exception as e:
  715. messagebox.showerror("Error",f"Save failed:\n{e}")
  716. self.destroy()
  717.  
  718. # -------------------------------------------------------------------
  719. # PromptOverlay: Continue/Clear/Cancel/List
  720. # -------------------------------------------------------------------
  721. class PromptOverlay(tk.Toplevel):
  722. def __init__(self, master, default, csv_path):
  723. super().__init__(master)
  724. self.overrideredirect(True)
  725. self.attributes('-topmost', True, '-alpha', 0.8)
  726. self.config(bg='#111111')
  727. self.cancelled = False
  728. self.csv_path = csv_path
  729.  
  730. tk.Label(self, text="List Name:", bg='#111111', fg='white',
  731. font=('Arial',14)).pack(anchor='w', padx=12, pady=(12,0))
  732.  
  733. self.var = tk.StringVar(value=default)
  734. char_width = max(75, len(default) + 10)
  735. entry = tk.Entry(self, textvariable=self.var,
  736. width=char_width,
  737. bg='#111111', fg='white', insertbackground='white',
  738. font=('Arial',14), bd=0, highlightthickness=0)
  739. entry.pack(fill='x', padx=12, pady=8)
  740.  
  741. btnf = tk.Frame(self, bg='#111111')
  742. btnf.pack(fill='x', padx=12, pady=(0,12))
  743. for txt, cmd in (
  744. ("Continue", self.on_continue),
  745. ("Clear", self.on_clear),
  746. ("Cancel", self.on_cancel),
  747. ("List", self.on_list),
  748. ):
  749. tk.Button(
  750. btnf, text=txt, bg='#111111', fg='#777777',
  751. font=('Arial',14), bd=0, highlightthickness=1,
  752. highlightbackground='grey',
  753. activebackground='#111111',
  754. activeforeground='#777777',
  755. relief='flat', command=cmd
  756. ).pack(side='left', padx=(0,10))
  757.  
  758. self.update_idletasks()
  759. W,H = self.winfo_reqwidth(), self.winfo_reqheight()
  760. x,y = self.winfo_pointerx(), self.winfo_pointery()
  761. self.geometry(f"{W}x{H}+{x}+{y}")
  762. self.deiconify(); self.lift()
  763. entry.focus_set(); entry.icursor(tk.END)
  764.  
  765. def on_continue(self):
  766. self.destroy()
  767. def on_clear(self):
  768. self.var.set(RENAME_PREFIX)
  769. def on_cancel(self):
  770. self.cancelled = True
  771. self.destroy()
  772. def on_list(self):
  773. CSVViewer(master=self.master, csv_path=self.csv_path)
  774.  
  775. # -------------------------------------------------------------------
  776. class IMDbHandler(FileSystemEventHandler):
  777. # Only trigger when the CSV's header starts with "Position,"
  778. def on_created(self, evt):
  779. p = Path(evt.src_path)
  780. if p.suffix.lower() == '.csv':
  781. try:
  782. with p.open('rb') as f:
  783. header = f.read(9) # b"Position,"
  784. if header == b'Position,':
  785. event_queue.put(p)
  786. except Exception:
  787. pass
  788.  
  789. def on_moved(self, evt):
  790. p = Path(evt.dest_path)
  791. if p.suffix.lower() == '.csv':
  792. try:
  793. with p.open('rb') as f:
  794. header = f.read(9)
  795. if header == b'Position,':
  796. event_queue.put(p)
  797. except Exception:
  798. pass
  799.  
  800. def choose_watch_folder():
  801. default=Path.home()/'Downloads'
  802. use_default=messagebox.askyesno(
  803. "Watch Folder",f"Use Downloads folder?\n\n{default}",parent=root)
  804. if use_default: return default
  805. d=filedialog.askdirectory(
  806. title="Select watch folder",initialdir=default,parent=root)
  807. return Path(d) if d else default
  808.  
  809. def process_imdb_list(csv_path, master):
  810. log=Overlay(master)
  811. def L(m): print(m); log.update_text(m)
  812. L(f"[import] Starting: {csv_path.name}"); time.sleep(0.05)
  813. copy_chrome_url()
  814. raw=get_clipboard_text(); L(f"[import] URL: {raw}")
  815.  
  816. query=raw.split('?',1)[1] if '?' in raw else raw
  817. cleaned=clean_clipboard_text(query) if CLEAN_CLIPBOARD_RULES else query
  818. default=RENAME_PREFIX+cleaned
  819.  
  820. prm=PromptOverlay(master,default,csv_path)
  821. master.wait_window(prm)
  822. if getattr(prm,'cancelled',False):
  823. L("[import] Cancelled"); log.destroy(); return
  824. log.destroy()
  825.  
  826. fileov=Overlay(master)
  827. def L2(m): print(m); fileov.update_text(m)
  828. newname=sanitize_filename(prm.var.get().strip() or default)+csv_path.suffix
  829. L2(f"[import] Moving to: {newname}"); time.sleep(0.05)
  830. dest=Path(__file__).parent/newname
  831. shutil.move(str(csv_path),str(dest)); L2(f"[import] Moved to: {dest.name}")
  832. time.sleep(0.05)
  833.  
  834. cfg=Path(__file__).parent.parent/"config.json"
  835. orig_cfg=None
  836. if cfg.exists():
  837. try: orig_cfg=json.load(cfg.open("r",encoding="utf-8"))
  838. except: pass
  839.  
  840. new_cfg=[{
  841. "name":dest.stem,"imdbId":dest.name,
  842. "tmdbId":None,"sortField":None,"isReverse":False
  843. }]
  844. with cfg.open("w",encoding="utf-8") as f:
  845. json.dump(new_cfg,f,indent=4,ensure_ascii=False)
  846. L2("[import] Config written"); time.sleep(0.05)
  847.  
  848. subprocess.run("npm run sync",shell=True,cwd=str(cfg.parent))
  849. L2("[import] Sync complete"); time.sleep(0.05)
  850.  
  851. if orig_cfg is None:
  852. cfg.write_text("",encoding="utf-8")
  853. else:
  854. with cfg.open("w",encoding="utf-8") as f:
  855. json.dump(orig_cfg,f,indent=4,ensure_ascii=False)
  856. L2("[import] Config restored")
  857. fileov.destroy()
  858.  
  859. if __name__=="__main__":
  860. root=tk.Tk(); root.withdraw()
  861. watch_folder=choose_watch_folder()
  862. if not watch_folder.exists():
  863. messagebox.showwarning(
  864. "Folder Missing",
  865. f"{watch_folder} not found; falling back to Downloads.",
  866. parent=root
  867. )
  868. watch_folder=Path.home()/'Downloads'
  869.  
  870. event_queue=queue.Queue()
  871. handler=IMDbHandler()
  872. obs=Observer()
  873. obs.schedule(handler,str(watch_folder),recursive=False)
  874. obs.start()
  875.  
  876. print(f"Watching {watch_folder} for IMDB CSVs… (CTRL-C to quit)")
  877. try:
  878. while True:
  879. try:
  880. p=event_queue.get(timeout=0.1)
  881. process_imdb_list(p,root)
  882. except queue.Empty:
  883. pass
  884. except Exception as ex:
  885. print("Error:",ex)
  886. except KeyboardInterrupt:
  887. obs.stop()
  888. root.destroy()
  889. obs.join()
  890.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement