Asanajm

sort files with exif

Jul 12th, 2025
150
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 20.86 KB | Source Code | 0 0
  1. import os
  2. import shutil
  3. import subprocess
  4. import threading
  5. import queue
  6. import customtkinter as ctk
  7. from tkinter import filedialog, messagebox
  8.  
  9. ctk.set_appearance_mode("System")
  10. ctk.set_default_color_theme("blue")
  11.  
  12. class SubKeywordRow(ctk.CTkFrame):
  13.     def __init__(self, master, sub_keyword, on_remove, on_move_up, on_move_down):
  14.         super().__init__(master)
  15.         self.sub_keyword = sub_keyword
  16.         self.synonyms = []
  17.         self.on_remove = on_remove
  18.         self.on_move_up = on_move_up
  19.         self.on_move_down = on_move_down
  20.  
  21.         self.label = ctk.CTkLabel(self, text=sub_keyword, width=180, anchor="w")
  22.         self.label.grid(row=0, column=0, padx=(5, 0), sticky="w")
  23.  
  24.         ctk.CTkButton(self, text="⬆️", width=30, command=self.on_move_up).grid(row=0, column=1, padx=2)
  25.         ctk.CTkButton(self, text="⬇️", width=30, command=self.on_move_down).grid(row=0, column=2, padx=2)
  26.         ctk.CTkButton(self, text="➕ Syn", width=60, command=self.add_synonym).grid(row=0, column=3, padx=2)
  27.         ctk.CTkButton(self, text="❌", width=30, fg_color="red", command=self.on_remove).grid(row=0, column=4, padx=2)
  28.  
  29.         self.syn_label = ctk.CTkLabel(self, text="", anchor="w")
  30.         self.syn_label.grid(row=1, column=0, columnspan=5, sticky="w", padx=5)
  31.  
  32.     def add_synonym(self):
  33.         popup = ctk.CTkInputDialog(title="Add Synonym", text=f"Synonym for '{self.sub_keyword}':")
  34.         value = popup.get_input()
  35.         if value and value not in self.synonyms:
  36.             self.synonyms.append(value)
  37.             self.update_syn_label()
  38.  
  39.     def update_syn_label(self):
  40.         if self.synonyms:
  41.             self.syn_label.configure(text="Synonyms: " + ", ".join(self.synonyms))
  42.         else:
  43.             self.syn_label.configure(text="")
  44.  
  45.     def get_data(self):
  46.         return {
  47.             "main": self.sub_keyword,
  48.             "match": [self.sub_keyword.lower()] + [s.lower() for s in self.synonyms]
  49.         }
  50.  
  51. class KeywordRow(ctk.CTkFrame):
  52.     def __init__(self, master, keyword, on_update, move_up_callback, move_down_callback):
  53.         super().__init__(master)
  54.         self.keyword = keyword
  55.         self.synonyms = []
  56.         self.sub_rows = []
  57.         self.on_update = on_update
  58.         self.move_up_callback = move_up_callback
  59.         self.move_down_callback = move_down_callback
  60.  
  61.         self.pack(fill="x", pady=4, padx=5)
  62.  
  63.         # Top Controls
  64.         top_frame = ctk.CTkFrame(self)
  65.         top_frame.pack(fill="x")
  66.  
  67.         ctk.CTkLabel(top_frame, text=keyword, width=180, anchor="w").pack(side="left")
  68.         ctk.CTkButton(top_frame, text="⬆️", width=30, command=self.move_up_callback).pack(side="left", padx=2)
  69.         ctk.CTkButton(top_frame, text="⬇️", width=30, command=self.move_down_callback).pack(side="left", padx=2)
  70.         ctk.CTkButton(top_frame, text="➕ Sub", width=60, command=self.add_subkeyword).pack(side="left", padx=2)
  71.         ctk.CTkButton(top_frame, text="➕ Syn", width=60, command=self.add_synonym).pack(side="left", padx=2)
  72.         ctk.CTkButton(top_frame, text="❌", width=30, fg_color="red", command=self.remove_self).pack(side="left", padx=2)
  73.  
  74.         # Synonyms display
  75.         self.syn_label = ctk.CTkLabel(self, text="", anchor="w")
  76.         self.syn_label.pack(fill="x", padx=5)
  77.  
  78.         self.sub_frame = None  # Will be created when subkeywords are added
  79.  
  80.     def add_synonym(self):
  81.         popup = ctk.CTkInputDialog(title="Add Synonym", text=f"Synonym for '{self.keyword}':")
  82.         value = popup.get_input()
  83.         if value and value not in self.synonyms:
  84.             self.synonyms.append(value)
  85.             self.update_syn_label()
  86.  
  87.     def update_syn_label(self):
  88.         if self.synonyms:
  89.             self.syn_label.configure(text="Synonyms: " + ", ".join(self.synonyms))
  90.         else:
  91.             self.syn_label.configure(text="")
  92.  
  93.     def add_subkeyword(self):
  94.         popup = ctk.CTkInputDialog(title="Add Sub-keyword", text=f"Sub-keyword for '{self.keyword}':")
  95.         value = popup.get_input()
  96.         if value and value not in [row.sub_keyword for row in self.sub_rows]:
  97.             self.create_subkeyword_row(value)
  98.             self.on_update()
  99.  
  100.     def create_subkeyword_row(self, subkeyword):
  101.         if not self.sub_frame:
  102.             self.sub_frame = ctk.CTkFrame(self)
  103.             self.sub_frame.pack(fill="x")
  104.  
  105.         def remove():
  106.             self.sub_rows.remove(row)
  107.             row.destroy()
  108.             self.on_update()
  109.  
  110.         def move_up():
  111.             idx = self.sub_rows.index(row)
  112.             if idx > 0:
  113.                 self.sub_rows[idx], self.sub_rows[idx - 1] = self.sub_rows[idx - 1], self.sub_rows[idx]
  114.                 self.refresh_subkeywords()
  115.                 self.on_update()
  116.  
  117.         def move_down():
  118.             idx = self.sub_rows.index(row)
  119.             if idx < len(self.sub_rows) - 1:
  120.                 self.sub_rows[idx], self.sub_rows[idx + 1] = self.sub_rows[idx + 1], self.sub_rows[idx]
  121.                 self.refresh_subkeywords()
  122.                 self.on_update()
  123.  
  124.         row = SubKeywordRow(self.sub_frame, subkeyword, remove, move_up, move_down)
  125.         self.sub_rows.append(row)
  126.         row.pack(fill="x", pady=2, padx=10)
  127.  
  128.     def refresh_subkeywords(self):
  129.         if self.sub_frame:
  130.             for widget in self.sub_frame.winfo_children():
  131.                 widget.pack_forget()
  132.             for row in self.sub_rows:
  133.                 row.pack(fill="x", pady=2, padx=10)
  134.  
  135.     def remove_self(self):
  136.         self.destroy()
  137.         self.on_update()
  138.  
  139.     def get_data(self):
  140.         return {
  141.             "match": [self.keyword.lower()] + [s.lower() for s in self.synonyms],
  142.             "subs": [row.get_data() for row in self.sub_rows]
  143.         }
  144.  
  145.  
  146. import json
  147. from tkinter import filedialog
  148.  
  149. class KeywordManager(ctk.CTkFrame):
  150.     def __init__(self, master):
  151.         super().__init__(master)
  152.         self.rows = []
  153.  
  154.         # Top input + Add
  155.         top_frame = ctk.CTkFrame(self)
  156.         top_frame.pack(fill="x", pady=(0, 5))
  157.  
  158.         self.input_var = ctk.StringVar()
  159.         self.entry = ctk.CTkEntry(top_frame, textvariable=self.input_var)
  160.         self.entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
  161.  
  162.         self.add_btn = ctk.CTkButton(top_frame, text="➕ Add", command=self.add_main_keyword)
  163.         self.add_btn.pack(side="left")
  164.  
  165.         # Save/Load Buttons
  166.         file_frame = ctk.CTkFrame(self)
  167.         file_frame.pack(fill="x", pady=(0, 5))
  168.  
  169.         ctk.CTkButton(file_frame, text="💾 Save Profile", command=self.save_profile).pack(side="left", padx=4)
  170.         ctk.CTkButton(file_frame, text="📂 Load Profile", command=self.load_profile).pack(side="left", padx=4)
  171.  
  172.         # Scroll Frame
  173.         self.scroll_frame = ctk.CTkScrollableFrame(self, height=420, fg_color=self.cget("fg_color"))
  174.         self.scroll_frame.pack(fill="both", expand=True, pady=5)
  175.  
  176.  
  177.     def add_main_keyword(self):
  178.         keyword = self.input_var.get().strip()
  179.         if keyword and not any(row.keyword == keyword for row in self.rows):
  180.             self.create_keyword_row(keyword)
  181.             self.input_var.set("")
  182.  
  183.     def create_keyword_row(self, keyword, synonyms=None, subs=None):
  184.         def on_update():
  185.             self.safe_refresh_list()
  186.  
  187.         row = None  # declare here for closure access
  188.  
  189.         def move_up():
  190.             if row in self.rows:
  191.                 idx = self.rows.index(row)
  192.                 self.move_row(idx, -1)
  193.  
  194.         def move_down():
  195.             if row in self.rows:
  196.                 idx = self.rows.index(row)
  197.                 self.move_row(idx, 1)
  198.  
  199.         row = KeywordRow(self.scroll_frame, keyword, on_update, move_up, move_down)
  200.  
  201.         if synonyms:
  202.             row.synonyms = synonyms
  203.             row.update_syn_label()
  204.         if subs:
  205.             for sub in subs:
  206.                 row.create_subkeyword_row(sub["main"])
  207.                 row.sub_rows[-1].synonyms = sub.get("match", [])[1:]  # skip main
  208.                 row.sub_rows[-1].update_syn_label()
  209.  
  210.         self.rows.append(row)
  211.         row.pack(fill="x", pady=4, padx=5)
  212.  
  213.  
  214.     def move_row(self, index, direction):
  215.         new_index = index + direction
  216.         if 0 <= new_index < len(self.rows):
  217.             self.rows[index], self.rows[new_index] = self.rows[new_index], self.rows[index]
  218.             self.refresh_list()
  219.  
  220.     def safe_refresh_list(self):
  221.         self.rows = [row for row in self.rows if row.winfo_exists()]
  222.         for widget in self.scroll_frame.winfo_children():
  223.             widget.pack_forget()
  224.         for row in self.rows:
  225.             row.pack(fill="x", pady=4, padx=5)
  226.  
  227.     def refresh_list(self):
  228.         self.safe_refresh_list()
  229.  
  230.     def get_keyword_map(self):
  231.         self.safe_refresh_list()
  232.         return {row.keyword.lower(): row.get_data() for row in self.rows}
  233.  
  234.     def save_profile(self):
  235.         path = filedialog.asksaveasfilename(
  236.             defaultextension=".json",
  237.             filetypes=[("JSON files", "*.json")],
  238.             title="Save Keyword Profile"
  239.         )
  240.         if path:
  241.             try:
  242.                 with open(path, "w", encoding="utf-8") as f:
  243.                     json.dump(self.get_keyword_map(), f, indent=2)
  244.                 messagebox.showinfo("✅ Saved", "Profile saved successfully.")
  245.             except Exception as e:
  246.                 messagebox.showerror("Error", f"Failed to save profile:\n{e}")
  247.  
  248.     def load_profile(self):
  249.         path = filedialog.askopenfilename(
  250.             filetypes=[("JSON files", "*.json")],
  251.             title="Load Keyword Profile"
  252.         )
  253.         if path:
  254.             try:
  255.                 with open(path, "r", encoding="utf-8") as f:
  256.                     data = json.load(f)
  257.  
  258.                 # Clear old rows
  259.                 for row in self.rows:
  260.                     row.destroy()
  261.                 self.rows.clear()
  262.  
  263.                 # Restore from profile
  264.                 for keyword, value in data.items():
  265.                     synonyms = value.get("match", [])[1:]  # skip main keyword
  266.                     subs = value.get("subs", [])
  267.                     self.create_keyword_row(keyword, synonyms, subs)
  268.  
  269.                 self.safe_refresh_list()
  270.                 messagebox.showinfo("✅ Loaded", "Profile loaded successfully.")
  271.             except Exception as e:
  272.                 messagebox.showerror("Error", f"Failed to load profile:\n{e}")
  273.  
  274. class ImageSorterApp:
  275.     def __init__(self, root):
  276.         self.root = root
  277.         self.root.title("🧠 Smart Image Sorter")
  278.         self.root.geometry("800x1300")  # Increased height for better keyword visibility
  279.         self.root.resizable(False, False)
  280.         self.queue = queue.Queue()
  281.  
  282.         self.frame = ctk.CTkFrame(root, corner_radius=10)
  283.         self.frame.pack(padx=20, pady=20, fill="both", expand=True)
  284.  
  285.         # ========== Input Folders ==========
  286.         ctk.CTkLabel(self.frame, text="📂 Input Folders").grid(row=0, column=0, sticky="w", pady=(0, 5))
  287.  
  288.         input_frame = ctk.CTkFrame(self.frame)
  289.         input_frame.grid(row=0, column=1, columnspan=2, sticky="ew", padx=10)
  290.         input_frame.grid_columnconfigure(0, weight=1)
  291.  
  292.         self.input_listbox = ctk.CTkTextbox(input_frame, height=100)
  293.         self.input_listbox.grid(row=0, column=0, sticky="ew")
  294.  
  295.         input_buttons_frame = ctk.CTkFrame(input_frame)
  296.         input_buttons_frame.grid(row=1, column=0, pady=(5, 0), sticky="ew")
  297.         ctk.CTkButton(input_buttons_frame, text="➕ Add Folder", command=self.add_input_folder).pack(side="left", padx=(0, 5))
  298.         ctk.CTkButton(input_buttons_frame, text="🗑️ Clear All", command=self.clear_input_folders).pack(side="left")
  299.  
  300.         self.include_subfolders = ctk.CTkCheckBox(self.frame, text="Include Subfolders")
  301.         self.include_subfolders.grid(row=1, column=1, columnspan=2, sticky="w", padx=10)
  302.  
  303.         self.input_folders = []
  304.  
  305.         # ========== Output Folder ==========
  306.         ctk.CTkLabel(self.frame, text="📁 Output Folder").grid(row=2, column=0, sticky="w", pady=(10, 5))
  307.  
  308.         self.output_entry = ctk.CTkEntry(self.frame)
  309.         self.output_entry.grid(row=2, column=1, padx=10, sticky="ew")
  310.         ctk.CTkButton(self.frame, text="Browse", command=self.browse_output).grid(row=2, column=2, sticky="w")
  311.  
  312.         # ========== Keywords & Sub-keywords ==========
  313.         ctk.CTkLabel(self.frame, text="🔖 Keywords + Sub-keywords").grid(row=3, column=0, sticky="nw", pady=(20, 5))
  314.  
  315.         self.keyword_manager = KeywordManager(self.frame)
  316.         self.keyword_manager.grid(row=3, column=1, columnspan=2, sticky="nsew", pady=(10, 0))
  317.  
  318.         # Save/Load Buttons
  319.         button_row = ctk.CTkFrame(self.frame)
  320.         button_row.grid(row=4, column=1, columnspan=2, sticky="w", pady=(5, 0), padx=5)
  321.         ctk.CTkButton(button_row, text="💾 Save Profile", command=self.keyword_manager.save_profile).pack(side="left", padx=5)
  322.         ctk.CTkButton(button_row, text="📂 Load Profile", command=self.keyword_manager.load_profile).pack(side="left", padx=5)
  323.  
  324.  
  325.         # ========== Progress Bar ==========
  326.         self.progress = ctk.CTkProgressBar(self.frame)
  327.         self.progress.grid(row=5, column=0, columnspan=3, pady=(30, 10), sticky="ew", padx=5)
  328.         self.progress.set(0.0)
  329.  
  330.         # ========== Start Button ==========
  331.         self.start_btn = ctk.CTkButton(self.frame, text="🚀 Start Sorting", command=self.start_sorting)
  332.         self.start_btn.grid(row=6, column=0, columnspan=3, pady=(0, 10))
  333.  
  334.         # Make center column expandable
  335.         self.frame.grid_columnconfigure(1, weight=1)
  336.         self.frame.grid_rowconfigure(3, weight=1)
  337.  
  338.  
  339.     def add_input_folder(self):
  340.         initial_dir = os.path.dirname(self.last_folder) if hasattr(self, 'last_folder') and os.path.isdir(self.last_folder) else os.getcwd()
  341.         folder = filedialog.askdirectory(title="Select Input Folder", initialdir=initial_dir)
  342.         if folder:
  343.             self.last_folder = folder
  344.             if folder not in self.input_folders:
  345.                 self.input_folders.append(folder)
  346.                 self.update_input_display()
  347.  
  348.  
  349.     def clear_input_folders(self):
  350.         self.input_folders.clear()
  351.         self.update_input_display()
  352.  
  353.     def update_input_display(self):
  354.         self.input_listbox.delete("1.0", "end")
  355.         for folder in self.input_folders:
  356.             self.input_listbox.insert("end", folder + "\n")
  357.  
  358.     def browse_output(self):
  359.         folder = filedialog.askdirectory()
  360.         if folder:
  361.             self.output_entry.delete(0, "end")
  362.             self.output_entry.insert(0, folder)
  363.  
  364.     def start_sorting(self):
  365.         input_folders = self.input_folders
  366.         output_folder = self.output_entry.get()
  367.         include_sub = self.include_subfolders.get()
  368.         keyword_map = self.keyword_manager.get_keyword_map()
  369.  
  370.         if not input_folders:
  371.             messagebox.showerror("Error", "Please select at least one input folder.")
  372.             return
  373.  
  374.         if not output_folder:
  375.             messagebox.showerror("Error", "Please select an output folder.")
  376.             return
  377.  
  378.         if output_folder in input_folders:
  379.             messagebox.showerror("Error", "Output folder must be different from input folders.")
  380.             return
  381.  
  382.         if not keyword_map:
  383.             messagebox.showerror("Error", "Please enter at least one keyword.")
  384.             return
  385.  
  386.         self.start_btn.configure(state="disabled")
  387.         self.progress.set(0)
  388.         threading.Thread(target=self.sort_thread, args=(input_folders, output_folder, keyword_map, include_sub), daemon=True).start()
  389.         self.root.after(100, self.check_queue)
  390.  
  391.     def check_queue(self):
  392.         try:
  393.             while True:
  394.                 value = self.queue.get_nowait()
  395.                 if isinstance(value, float):
  396.                     self.progress.set(value)
  397.                 elif value == "done":
  398.                     self.start_btn.configure(state="normal")
  399.                     messagebox.showinfo("✅ Done", "Images sorted.")
  400.                     return
  401.         except queue.Empty:
  402.             pass
  403.         self.root.after(100, self.check_queue)
  404.  
  405.     def get_exif_parameters(self, filepath):
  406.         try:
  407.             result = subprocess.run(
  408.                 ['exiftool', '-Parameters', filepath],
  409.                 stdout=subprocess.PIPE,
  410.                 stderr=subprocess.DEVNULL,
  411.                 text=True,
  412.                 encoding='utf-8',  # Force UTF-8 decoding
  413.                 check=True
  414.             )
  415.             if result.stdout:
  416.                 for line in result.stdout.splitlines():
  417.                     if line.startswith("Parameters"):
  418.                         text = line.split(":", 1)[1].strip().lower()
  419.                         if "negative prompt" in text:
  420.                             return text.split("negative prompt")[0].strip()
  421.                         return text
  422.         except Exception as e:
  423.             print(f"[EXIF Error]: {e}")
  424.         return ""
  425.  
  426.  
  427.     def collect_files(self, folder, include_sub):
  428.         image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
  429.         video_exts = ('.mp4', '.webm', '.mov', '.mkv')
  430.         allowed_exts = image_exts + video_exts
  431.         files = []
  432.  
  433.         if include_sub:
  434.             for root, _, filenames in os.walk(folder):
  435.                 for f in filenames:
  436.                     if f.lower().endswith(allowed_exts):
  437.                         files.append(os.path.join(root, f))
  438.         else:
  439.             # Only look in the top-level folder
  440.             for f in os.listdir(folder):
  441.                 full_path = os.path.join(folder, f)
  442.                 if os.path.isfile(full_path) and f.lower().endswith(allowed_exts):
  443.                     files.append(full_path)
  444.  
  445.         return files
  446.  
  447.  
  448.  
  449.        
  450.     def safe_move(self, src, dst):
  451.         try:
  452.             if os.path.exists(src):
  453.                 shutil.move(src, dst)
  454.         except Exception as e:
  455.             print(f"[Move Error]: {src} -> {dst} | {e}")
  456.  
  457.     def sort_thread(self, input_folders, output_folder, keyword_map, include_sub):
  458.         try:
  459.             all_files = []
  460.             for folder in input_folders:
  461.                 all_files.extend(self.collect_files(folder, include_sub))
  462.  
  463.             total = len(all_files)
  464.             if total == 0:
  465.                 self.queue.put("done")
  466.                 return
  467.  
  468.             for i, filepath in enumerate(all_files, start=1):
  469.                 filename = os.path.basename(filepath)
  470.                 ext = os.path.splitext(filename)[1].lower()
  471.  
  472.                 # Video file
  473.                 if ext in [".mp4", ".webm", ".mov", ".mkv"]:
  474.                     target = os.path.join(output_folder, "videos", filename)
  475.                     os.makedirs(os.path.dirname(target), exist_ok=True)
  476.                     self.safe_move(filepath, target)
  477.                     self.queue.put(i / total)
  478.                     continue
  479.  
  480.                 # Get EXIF
  481.                 exif = self.get_exif_parameters(filepath)
  482.                 if not exif:
  483.                     target = os.path.join(output_folder, "unsorted", "noexif", filename)
  484.                     os.makedirs(os.path.dirname(target), exist_ok=True)
  485.                     self.safe_move(filepath, target)
  486.                     self.queue.put(i / total)
  487.                     continue
  488.  
  489.                 exif = exif.lower()
  490.                 matched = False
  491.  
  492.                 for kw, data in keyword_map.items():
  493.                     if any(term in exif for term in data["match"]):
  494.                         # Check subkeywords
  495.                         for sub in data["subs"]:
  496.                             if any(sub_term in exif for sub_term in sub["match"]):
  497.                                 target = os.path.join(output_folder, kw.replace(" ", "_"), sub["main"].replace(" ", "_"), filename)
  498.                                 os.makedirs(os.path.dirname(target), exist_ok=True)
  499.                                 self.safe_move(filepath, target)
  500.                                 matched = True
  501.                                 break
  502.                         if not matched:
  503.                             # No sub match, just keyword
  504.                             target = os.path.join(output_folder, kw.replace(" ", "_"), filename)
  505.                             os.makedirs(os.path.dirname(target), exist_ok=True)
  506.                             self.safe_move(filepath, target)
  507.                             matched = True
  508.                         break
  509.  
  510.                 if not matched:
  511.                     target = os.path.join(output_folder, "unsorted", filename)
  512.                     os.makedirs(os.path.dirname(target), exist_ok=True)
  513.                     self.safe_move(filepath, target)
  514.  
  515.                 self.queue.put(i / total)
  516.  
  517.         except Exception as e:
  518.             print(f"[Thread Error]: {e}")
  519.  
  520.         self.queue.put("done")
  521.  
  522.  
  523.  
  524. if __name__ == "__main__":
  525.     ctk.set_appearance_mode("System")
  526.     ctk.set_default_color_theme("blue")
  527.     app = ctk.CTk()
  528.     ImageSorterApp(app)
  529.     app.mainloop()
  530.  
Tags: sorting EXIF
Advertisement
Add Comment
Please, Sign In to add comment