Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- """
- imdb_csv_importer.py
- Watches a folder for “Advanced title search…csv” exports from IMDb.
- On creation, it copies your Chrome URL, sanitizes it into a list name,
- prompts you (Continue/Clear/Cancel/List), lets you preview/reorder/delete
- via a full-screen dark modal, then renames/moves the file, runs npm sync,
- and restores your config.json.
- """
- import sys, subprocess, time, shutil, json, re, queue, csv, ctypes
- from pathlib import Path
- # ---- Configuration ----
- CLEAN_CLIPBOARD_RULES = True
- RENAME_PREFIX = ".IMDb "
- # ---- Ensure required packages ----
- def ensure(name):
- try: __import__(name)
- except ImportError:
- subprocess.check_call([sys.executable, "-m", "pip", "install", name])
- ensure('watchdog'); ensure('pyperclip')
- import pyperclip
- from watchdog.observers import Observer
- from watchdog.events import FileSystemEventHandler
- # ---- GUI imports & root ----
- import tkinter as tk
- import tkinter.font as tkfont
- import tkinter.ttk as ttk
- from tkinter import messagebox, filedialog
- root = tk.Tk(); root.withdraw()
- # -------------------------------------------------------------------------------
- # Overlay: small status popup in bottom-right
- # -------------------------------------------------------------------------------
- class Overlay(tk.Toplevel):
- def __init__(self, master, fg='white'):
- super().__init__(master)
- self.withdraw()
- self.overrideredirect(True)
- self.attributes('-topmost', True, '-alpha', 0.8)
- self.config(bg='#111111', bd=0, highlightthickness=0)
- self.label = tk.Label(
- self, text="", bg='#111111', fg=fg,
- font=('Arial',14), justify='left', wraplength=500
- )
- self.label.pack(padx=12, pady=8)
- def update_text(self, msg: str):
- self.label.config(text=msg)
- self.update_idletasks()
- w = self.label.winfo_reqwidth() + 20
- h = self.label.winfo_reqheight() + 16
- sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
- x = sw - w - 20
- y = sh - h - 60
- self.geometry(f"{w}x{h}+{x}+{y}")
- self.deiconify(); self.lift(); self.update()
- # ---- Chrome URL copy via simulated keystrokes ----
- VK_CONTROL, VK_L, VK_C, VK_ESCAPE = 0x11, 0x4C, 0x43, 0x1B
- KEYUP = 0x0002
- _user32 = ctypes.WinDLL('user32', use_last_error=True)
- def copy_chrome_url():
- _user32.keybd_event(VK_CONTROL,0,0,0)
- _user32.keybd_event(VK_L, 0,0,0)
- _user32.keybd_event(VK_L, 0,KEYUP,0)
- time.sleep(0.05)
- _user32.keybd_event(VK_C, 0,0,0)
- _user32.keybd_event(VK_C, 0,KEYUP,0)
- time.sleep(0.05)
- _user32.keybd_event(VK_CONTROL,0,KEYUP,0)
- time.sleep(0.05)
- _user32.keybd_event(VK_ESCAPE,0,0,0)
- _user32.keybd_event(VK_ESCAPE,0,KEYUP,0)
- time.sleep(0.05)
- # ---- Clipboard helper ----
- def get_clipboard_text():
- try:
- t = pyperclip.paste().strip()
- if t: return t
- except:
- pass
- r = tk.Tk(); r.withdraw()
- try: return r.clipboard_get().strip()
- except tk.TclError: return ""
- finally: r.destroy()
- # ---- Sanitization ----
- def sanitize_filename(name: str) -> str:
- name = re.sub(r'[\\/:*?"<>|\r\n]+', '_', name)
- name = re.sub(r'_+', '_', name).strip('_')
- return name[:255]
- def clean_clipboard_text(raw: str) -> str:
- patterns = [
- 'title_type=', '&genres=', 'genres=', 'countries=',
- '&primary_language=', '&num_', '&release_date=',
- 'keywords=', 'country_of_origin='
- ]
- for p in patterns:
- raw = raw.replace(p, ' ')
- raw = raw.replace(',', ' ')
- return re.sub(r'\s+', ' ', raw).strip()
- # -------------------------------------------------------------------------------
- # CSVViewer – full-screen, dark, sortable, deletable, draggable, undo/restore.
- # -------------------------------------------------------------------------------
- class CSVViewer(tk.Toplevel):
- # pastel colours per title type
- TYPE_COLORS = {
- 'Movie': '#AEC6CF',
- 'TV Series': '#77DD77',
- 'Short': '#FDFD96',
- 'TV Episode': '#FFB7CE',
- 'TV Mini Series': '#CDB4DB',
- 'TV Movie': '#FFB347',
- 'TV Special': '#98FF98',
- 'TV Short': '#B3FFFF',
- 'Video Game': '#B39EB5',
- 'Video': '#FF6961',
- 'Music Video': '#BFFF00',
- 'Podcast Series': '#ADD8E6',
- 'Podcast Episode': '#FFB6C1'
- }
- def __init__(self, master=None, *, csv_path):
- super().__init__(master)
- self.csv_path = Path(csv_path)
- self.overrideredirect(True)
- self.attributes('-topmost', True)
- self.config(bg='#111111')
- self.withdraw(); self.update_idletasks()
- # State
- self.sort_states = {}
- self.undo_stack = []
- self.selection = set()
- # Full screen
- sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
- self.geometry(f"{sw}x{sh}+0+0")
- self.deiconify()
- # Load CSV data
- try:
- with self.csv_path.open(newline='', encoding='utf-8') as f:
- orig = list(csv.DictReader(f))
- except Exception as e:
- messagebox.showerror("Error", f"Could not load CSV:\n{e}")
- self.destroy(); return
- self.original_data = []
- for i,row in enumerate(orig, start=1):
- r = row.copy(); r['Position'] = str(i)
- self.original_data.append(r)
- self.data = list(self.original_data)
- # Columns & fonts
- self.columns = ["Position","Const","Title Type","Title","IMDb Rating","Year","Delete"]
- hf, cf = ('Arial',16), ('Arial',14)
- fh = tkfont.Font(family=hf[0], size=hf[1])
- fc = tkfont.Font(family=cf[0], size=cf[1])
- # Compute column widths
- cw = {}
- for col in self.columns[:-1]:
- texts = [r[col] for r in self.data] + [col]
- longest = max(texts, key=len)
- cw[col] = fc.measure(longest + ' '*10)
- cw['Delete'] = max(fh.measure('Delete'), fh.measure('✕')) + 30
- sb_w = 12
- box_w = sum(cw[c] for c in self.columns) + sb_w
- box_h = sh
- frm = tk.Frame(self, bg='#222222')
- frm.place(x=(sw-box_w)//2, y=0, width=box_w, height=box_h)
- style = ttk.Style(frm); style.theme_use('clam')
- style.configure("VT.Treeview",
- background="#222222",
- fieldbackground="#222222",
- foreground="white",
- rowheight=34,
- font=fc,
- borderwidth=0)
- style.configure("VT.Treeview.Heading",
- background="#222222",
- foreground="white",
- font=hf,
- pady=4,
- borderwidth=0)
- style.map("VT.Treeview.Heading",
- background=[('active','#88aacc')])
- style.configure("VT.Vertical.TScrollbar",
- troughcolor="#222222",
- background="#88aacc",
- arrowcolor="#88aacc",
- bordercolor="#222222",
- lightcolor="#222222",
- darkcolor="#222222")
- # Treeview
- self.tree = ttk.Treeview(
- frm,
- columns=self.columns,
- show='headings',
- style="VT.Treeview",
- selectmode='none'
- )
- for col in self.columns:
- self.tree.heading(col, text=col,
- command=lambda c=col: self.sort_by(c))
- self.tree.column(col, width=cw[col], anchor='center', stretch=False)
- # configure pastel-foreground tags per type
- for ttype, col in CSVViewer.TYPE_COLORS.items():
- self.tree.tag_configure(ttype, foreground=col)
- # selection & drag highlight
- self.tree.tag_configure('selected', background='#335599')
- self.tree.tag_configure('drag', background='#5577bb')
- vsb = ttk.Scrollbar(frm,
- orient='vertical',
- command=self.tree.yview,
- style="VT.Vertical.TScrollbar")
- self.tree.configure(yscrollcommand=vsb.set)
- header_h = fh.metrics("linespace") + 4
- self.tree.place(x=0, y=header_h,
- width=box_w-sb_w,
- height=box_h-40-header_h)
- vsb.place(x=box_w-sb_w,
- y=header_h,
- width=sb_w,
- height=box_h-40-header_h)
- self.refresh_tree()
- # Drag/select support
- self._press = self._drag_item = self._target_item = None
- self._drag_select = False
- self._drag_start_idx = None
- self.tree.bind('<ButtonPress-1>', self.on_press, add='+')
- self.tree.bind('<B1-Motion>', self.on_motion, add='+')
- self.tree.bind('<ButtonRelease-1>', self.on_release)
- # Bottom buttons
- btnf = tk.Frame(frm, bg='#222222')
- btnf.place(x=0, y=sh-40, width=box_w, height=40)
- for txt, cmd in (
- ("Save", self.on_save),
- ("Cancel", self.destroy),
- ("Delete", self.on_delete_selected),
- ("Undo", self.on_undo),
- ("Default", self.on_default),
- ("Unselect",self.on_unselect),
- ("Close", self.destroy),
- ):
- tk.Button(
- btnf,
- text=txt,
- bg='#222222',
- fg='#777777',
- font=cf,
- bd=0,
- highlightthickness=1,
- highlightbackground='grey',
- activebackground='#222222',
- activeforeground='#777777',
- relief='flat',
- command=cmd
- ).pack(side='left', padx=8, pady=4)
- def refresh_tree(self):
- self.tree.delete(*self.tree.get_children())
- for idx,row in enumerate(self.data):
- tags = [ row['Title Type'] ]
- if idx in self.selection:
- tags.append('selected')
- vals = [row[c] for c in self.columns[:-1]]
- vals.append('✕' if idx in self.selection else '')
- self.tree.insert('', 'end',
- iid=str(idx),
- values=vals,
- tags=tags)
- def sort_by(self, col):
- st = self.sort_states.get(col)
- seq = {
- 'Position': ['desc','asc',None],
- 'Const': ['desc','asc',None],
- 'Title Type': ['asc','desc',None],
- 'Title': ['asc','desc',None],
- 'IMDb Rating': ['desc','asc',None],
- 'Year': ['desc','asc',None],
- }
- nxt = seq[col][(seq[col].index(st)+1) % 3]
- self.sort_states[col] = nxt
- for c in self.columns:
- self.tree.heading(c, text=c)
- arrow = '▼' if nxt=='desc' else '▲' if nxt=='asc' else ''
- self.tree.heading(col, text=col + (' '+arrow if arrow else ''))
- def keyf(r):
- v = r[col]
- # treat missing/non-numeric ratings as -1.0
- if col == 'IMDb Rating':
- try:
- return float(v)
- except:
- return -1.0
- # treat missing/non-numeric years as 1900
- if col == 'Year':
- try:
- return int(v)
- except:
- return 1900
- # fallback: numeric if possible, else lexicographic
- try:
- return float(v)
- except:
- return v.lower()
- if col != 'Title Type' and self.sort_states.get('Title Type'):
- group_order = []
- for r in self.data:
- t = r['Title Type']
- if t not in group_order:
- group_order.append(t)
- new_data = []
- rev = (nxt=='desc')
- for grp in group_order:
- grp_rows = [r for r in self.data if r['Title Type']==grp]
- grp_rows.sort(key=keyf, reverse=rev)
- new_data.extend(grp_rows)
- self.data = new_data
- else:
- if nxt is None:
- self.data = list(self.original_data)
- else:
- rev = (nxt=='desc')
- self.data.sort(key=keyf, reverse=rev)
- for i,r in enumerate(self.data, start=1):
- r['Position'] = str(i)
- self.refresh_tree()
- def on_press(self, ev):
- x,y = ev.x, ev.y
- row = self.tree.identify_row(y)
- col = self.tree.identify_column(x)
- self._press = (x,y,row,col)
- if col == f"#{len(self.columns)}" and row:
- idx = int(row)
- self._drag_select = True
- self._drag_start_idx = idx
- self.selection = {idx}
- self.refresh_tree()
- else:
- self._drag_item = row
- if row:
- tags = list(self.tree.item(row,'tags'))
- if 'drag' not in tags:
- tags.append('drag')
- self.tree.item(row, tags=tags)
- def on_motion(self, ev):
- if self._drag_select:
- over = self.tree.identify_row(ev.y)
- if over:
- idx = int(over)
- lo,hi = sorted([self._drag_start_idx, idx])
- self.selection = set(range(lo,hi+1))
- self.refresh_tree()
- else:
- over = self.tree.identify_row(ev.y)
- if over != self._target_item:
- if self._target_item and self._target_item != self._drag_item:
- tags = list(self.tree.item(self._target_item,'tags'))
- if 'drag' in tags:
- tags.remove('drag')
- self.tree.item(self._target_item, tags=tags)
- self._target_item = over
- if over and over != self._drag_item:
- tags = list(self.tree.item(over,'tags'))
- if 'drag' not in tags:
- tags.append('drag')
- self.tree.item(over, tags=tags)
- def on_release(self, ev):
- x0,y0,row0,col0 = self._press
- dx,dy = abs(ev.x-x0), abs(ev.y-y0)
- if self._drag_select:
- self._drag_select = False
- return
- for itm in (self._drag_item, self._target_item):
- if itm:
- tags = list(self.tree.item(itm,'tags'))
- if 'drag' in tags:
- tags.remove('drag')
- self.tree.item(itm, tags=tags)
- if dx<5 and dy<5 and row0:
- idx = int(row0)
- if col0 == f"#{len(self.columns)}":
- self.undo_stack.append(list(self.data))
- self.data.pop(idx)
- self.selection.discard(idx)
- for i,r in enumerate(self.data,start=1):
- r['Position'] = str(i)
- self.refresh_tree()
- else:
- if idx in self.selection:
- self.selection.remove(idx)
- else:
- self.selection.add(idx)
- self.refresh_tree()
- else:
- drop = self.tree.identify_row(ev.y)
- if self._drag_item and drop and drop!=self._drag_item:
- self.undo_stack.append(list(self.data))
- i1,i2 = int(self._drag_item), int(drop)
- rowo = self.data.pop(i1)
- self.data.insert(i2,rowo)
- for i,r in enumerate(self.data,start=1):
- r['Position'] = str(i)
- self.refresh_tree()
- self._drag_item = None
- self._target_item = None
- def on_delete_selected(self):
- if self.selection:
- self.undo_stack.append(list(self.data))
- for idx in sorted(self.selection, reverse=True):
- self.data.pop(idx)
- self.selection.clear()
- for i,r in enumerate(self.data,start=1):
- r['Position'] = str(i)
- self.refresh_tree()
- def on_undo(self):
- if self.undo_stack:
- self.data = self.undo_stack.pop()
- for i,r in enumerate(self.data,start=1):
- r['Position'] = str(i)
- self.selection.clear()
- self.refresh_tree()
- def on_default(self):
- self.data = list(self.original_data)
- self.undo_stack.clear()
- self.selection.clear()
- for i,r in enumerate(self.data,start=1):
- r['Position'] = str(i)
- self.refresh_tree()
- def on_unselect(self):
- self.selection.clear()
- self.refresh_tree()
- def on_save(self):
- try:
- with self.csv_path.open(newline='', encoding='utf-8') as f:
- orig = list(csv.DictReader(f))
- fields = orig[0].keys() if orig else []
- lookup = {r['Const']:r for r in orig}
- reordered = [lookup[r['Const']] for r in self.data]
- with self.csv_path.open('w', newline='', encoding='utf-8') as f:
- w = csv.DictWriter(f, fieldnames=fields)
- w.writeheader()
- for row in reordered:
- w.writerow(row)
- except Exception as e:
- messagebox.showerror("Error", f"Save failed: {e}")
- self.destroy()
- # -------------------------------------------------------------------------------
- # PromptOverlay – pops the dark translucent prompt with Continue/Clear/Cancel/List
- # -------------------------------------------------------------------------------
- class PromptOverlay(tk.Toplevel):
- def __init__(self, master, default, csv_path):
- super().__init__(master)
- self.overrideredirect(True)
- self.attributes('-topmost', True, '-alpha', 0.8)
- self.config(bg='#111111')
- self.cancelled = False
- self.csv_path = csv_path
- tk.Label(
- self, text="List Name:", bg='#111111', fg='white',
- font=('Arial',14)
- ).pack(anchor='w', padx=12, pady=(12,0))
- self.var = tk.StringVar(value=default)
- entry = tk.Entry(
- self, textvariable=self.var,
- bg='#111111', fg='white', insertbackground='white',
- font=('Arial',14), bd=0, highlightthickness=0
- )
- entry.pack(fill='x', padx=12, pady=8)
- btnf = tk.Frame(self, bg='#111111')
- btnf.pack(fill='x', padx=12, pady=(0,12))
- for txt, cmd in (
- ("Continue", self.on_continue),
- ("Clear", self.on_clear),
- ("Cancel", self.on_cancel),
- ("List", self.on_list),
- ):
- b = tk.Button(
- btnf, text=txt, bg='#111111', fg='#777777',
- font=('Arial',14), bd=0, highlightthickness=1,
- highlightbackground='grey',
- activebackground='#111111',
- activeforeground='#777777',
- relief='flat', command=cmd
- )
- b.pack(side='left', padx=(0,10))
- self.update_idletasks()
- font = tkfont.Font(family='Arial', size=14)
- text_px = font.measure(self.var.get())
- extra_px = font.measure(' ' * 10)
- entry_required = text_px + extra_px
- btns_width = sum(w.winfo_reqwidth() for w in btnf.winfo_children())
- W = max(int(entry_required) + 24, btns_width + 24)
- x, y = self.winfo_pointerx(), self.winfo_pointery()
- self.geometry(f"{W}x{self.winfo_reqheight()}+{x}+{y}")
- self.deiconify(); self.lift()
- entry.focus_set(); entry.icursor(tk.END)
- def on_continue(self): self.destroy()
- def on_clear(self): self.var.set(RENAME_PREFIX)
- def on_cancel(self): self.cancelled = True; self.destroy()
- def on_list(self):
- CSVViewer(master=self.master, csv_path=self.csv_path)
- # -------------------------------------------------------------------------------
- # Watchdog handler & main loop
- # -------------------------------------------------------------------------------
- class IMDbHandler(FileSystemEventHandler):
- def on_created(self, evt):
- p = Path(evt.src_path)
- if re.match(r"^Advanced title search.*\.csv$", p.name, re.IGNORECASE):
- event_queue.put(p)
- def on_moved(self, evt):
- p = Path(evt.dest_path)
- if re.match(r"^Advanced title search.*\.csv$", p.name, re.IGNORECASE):
- event_queue.put(p)
- def choose_watch_folder():
- dlg = tk.Tk(); dlg.withdraw()
- use_default = messagebox.askyesno(
- "Watch Folder",
- f"Use Downloads folder?\n\n{Path.home()/'Downloads'}"
- )
- if use_default:
- fld = Path.home()/"Downloads"
- else:
- sel = filedialog.askdirectory(
- title="Select watch folder",
- initialdir=Path.home()/"Downloads"
- )
- fld = Path(sel) if sel else Path.home()/"Downloads"
- dlg.destroy()
- return fld
- def process_imdb_list(csv_path: Path, master: tk.Tk):
- # PHASE 1: pre-prompt overlay
- log = Overlay(master)
- def L1(msg):
- print(msg); log.update_text(msg)
- L1(f"[import] Starting: {csv_path.name}")
- time.sleep(0.05)
- copy_chrome_url()
- raw = get_clipboard_text()
- L1(f"[import] URL: {raw}")
- query = raw.split('?',1)[1] if '?' in raw else raw
- cleaned = clean_clipboard_text(query) if CLEAN_CLIPBOARD_RULES else query
- default = RENAME_PREFIX + cleaned
- prm = PromptOverlay(master, default, csv_path)
- master.wait_window(prm)
- if getattr(prm, 'cancelled', False):
- L1("[import] Cancelled")
- log.destroy()
- return
- # PHASE 2: post-prompt overlay
- log.destroy()
- fileov = Overlay(master)
- def L2(msg):
- print(msg); fileov.update_text(msg)
- newname = sanitize_filename(prm.var.get().strip() or default) + csv_path.suffix
- L2(f"[import] Moving to: {newname}")
- time.sleep(0.05)
- dest = Path(__file__).parent / newname
- shutil.move(str(csv_path), str(dest))
- L2(f"[import] Moved to: {dest.name}")
- time.sleep(0.05)
- cfg = Path(__file__).parent.parent / "config.json"
- orig_cfg = None
- if cfg.exists():
- try: orig_cfg = json.load(cfg.open("r", encoding="utf-8"))
- except: pass
- new_cfg = [{
- "name": dest.stem,
- "imdbId": dest.name,
- "tmdbId": None,
- "sortField": None,
- "isReverse": False
- }]
- with cfg.open("w", encoding="utf-8") as f:
- json.dump(new_cfg, f, indent=4, ensure_ascii=False)
- L2("[import] Config written")
- time.sleep(0.05)
- subprocess.run("npm run sync", shell=True, cwd=str(cfg.parent))
- L2("[import] Sync complete")
- time.sleep(0.05)
- if orig_cfg is None:
- cfg.write_text("", encoding="utf-8")
- else:
- with cfg.open("w", encoding="utf-8") as f:
- json.dump(orig_cfg, f, indent=4, ensure_ascii=False)
- L2("[import] Config restored")
- fileov.destroy()
- if __name__ == "__main__":
- watch_folder = choose_watch_folder()
- if not watch_folder.exists():
- messagebox.showwarning(
- "Folder Missing",
- f"{watch_folder} not found; falling back to Downloads."
- )
- watch_folder = Path.home()/"Downloads"
- event_queue = queue.Queue()
- handler = IMDbHandler()
- obs = Observer()
- obs.schedule(handler, str(watch_folder), recursive=False)
- obs.start()
- print(f"Watching {watch_folder} for IMDB CSVs… (CTRL-C to quit)")
- try:
- while True:
- try:
- p = event_queue.get(timeout=0.1)
- process_imdb_list(p, root)
- except queue.Empty:
- pass
- except Exception as ex:
- print("Error:", ex)
- except KeyboardInterrupt:
- obs.stop()
- root.destroy()
- obs.join()
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement