265 lines
11 KiB
Python
265 lines
11 KiB
Python
import tkinter as tk
|
|
from tkinter import filedialog, scrolledtext, ttk, messagebox
|
|
import os
|
|
import re
|
|
import io
|
|
import sys
|
|
import requests # Add this import
|
|
import xml.etree.ElementTree as ET # Add this import
|
|
|
|
HISTORY_FILE = "regex_history.txt"
|
|
|
|
def fetch_anidb_data():
|
|
"""Fetch episode data from AniDB based on folder name pattern {anidbN-X}"""
|
|
folder_path = folder_entry.get()
|
|
if not folder_path:
|
|
messagebox.showerror("Error", "Please select a folder first.")
|
|
return
|
|
|
|
# Get just the folder name from the path
|
|
folder_name = os.path.basename(folder_path)
|
|
|
|
# Extract anidb ID using regex (handles patterns like {anidb4-18874})
|
|
match = re.search(r'\{anidb\d+-(\d+)\}', folder_name)
|
|
if not match:
|
|
messagebox.showerror("Error", "Could not find valid AniDB ID in folder name.\nFolder name should contain pattern like: {anidb4-18874}")
|
|
return
|
|
|
|
aid = match.group(1)
|
|
print(f"Found AniDB ID: {aid}")
|
|
|
|
# Fetch XML from AniDB API
|
|
url = f"http://api.anidb.net:9001/httpapi?request=anime&client=testdesktop&clientver=1&protover=1&aid={aid}"
|
|
try:
|
|
print(f"Fetching data from: {url}")
|
|
response = requests.get(url, timeout=10)
|
|
response.raise_for_status() # Raise exception for HTTP errors
|
|
except requests.exceptions.RequestException as e:
|
|
messagebox.showerror("Network Error", f"Failed to fetch AniDB data:\n{str(e)}")
|
|
return
|
|
|
|
# Parse XML and extract episodes
|
|
try:
|
|
root = ET.fromstring(response.content)
|
|
except ET.ParseError as e:
|
|
messagebox.showerror("XML Error", f"Failed to parse AniDB response:\n{str(e)}")
|
|
return
|
|
|
|
# Extract regular episodes (type="1") with their numbers and titles
|
|
episodes = []
|
|
for episode in root.findall('.//episode'):
|
|
epno_elem = episode.find('epno')
|
|
if epno_elem is not None and epno_elem.get('type') == '1':
|
|
try:
|
|
epno = int(epno_elem.text)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
# Get all title elements for this episode
|
|
title_elements = episode.findall('title')
|
|
|
|
# Create a dictionary of titles by language
|
|
titles_by_lang = {}
|
|
for title_elem in title_elements:
|
|
lang = title_elem.get('{http://www.w3.org/XML/1998/namespace}lang')
|
|
if lang and title_elem.text:
|
|
titles_by_lang[lang] = title_elem.text
|
|
|
|
# Try to get English title first, then romanized (x-jat), then any available
|
|
title = None
|
|
for lang in ['en', 'x-jat', 'ja']:
|
|
if lang in titles_by_lang:
|
|
title = titles_by_lang[lang]
|
|
break
|
|
|
|
# Use a fallback if no title found
|
|
if title is None and title_elements:
|
|
# Get the first non-empty title
|
|
for title_elem in title_elements:
|
|
if title_elem.text and title_elem.text.strip():
|
|
title = title_elem.text
|
|
break
|
|
|
|
if title is None:
|
|
title = f"Episode {epno}"
|
|
|
|
episodes.append((epno, title))
|
|
|
|
if not episodes:
|
|
messagebox.showwarning("No Episodes Found", "No regular episodes were found in the AniDB response.")
|
|
return
|
|
|
|
# Sort episodes by number
|
|
episodes.sort(key=lambda x: x[0])
|
|
|
|
# Format for the titles text area (tab-separated)
|
|
formatted_titles = "\n".join([f"{epno}\t{title}" for epno, title in episodes])
|
|
|
|
# Update the titles text area
|
|
titles_text.delete("1.0", tk.END)
|
|
titles_text.insert(tk.END, formatted_titles)
|
|
print(f"Successfully loaded {len(episodes)} episodes from AniDB")
|
|
|
|
def format_titles(titles_data):
|
|
lines = titles_data.strip().split('\n')
|
|
return {
|
|
int(parts[0]): parts[1].replace("`", "'")
|
|
for line in lines if (parts := line.split('\t')) and len(parts) >= 2
|
|
}
|
|
|
|
def is_valid_windows_filename(filename):
|
|
invalid_chars = r'[<>:"/\\|?*]'
|
|
if re.search(invalid_chars, filename): return False
|
|
if filename.endswith(' ') or filename.endswith('.'): return False
|
|
reserved = {'CON', 'PRN', 'AUX', 'NUL'} | {f'COM{i}' for i in range(1, 10)} | {f'LPT{i}' for i in range(1, 10)}
|
|
return filename.upper() not in reserved
|
|
|
|
def validate_override_title(event=None):
|
|
title = override_entry.get()
|
|
valid = is_valid_windows_filename(title)
|
|
override_entry.config(style="TEntry" if valid else "Invalid.TEntry")
|
|
override_label.config(text="Override Show Title:" if valid else "Invalid Windows filename!", foreground="black" if valid else "red")
|
|
|
|
def rename_files(folder_path, dry_run, regex_pattern, episode_offset, override_title, episode_group):
|
|
episode_titles_dic = format_titles(titles_text.get("1.0", tk.END))
|
|
for filename in os.listdir(folder_path):
|
|
full_path = os.path.join(folder_path, filename)
|
|
match = re.match(regex_pattern, filename)
|
|
if match:
|
|
series_title = override_title if override_title else match.group(1)
|
|
episode_number = int(match.group(episode_group)) + episode_offset
|
|
episode_title = episode_titles_dic.get(episode_number, "Unknown Title")
|
|
episode_title = re.sub(r'[\\/:*?"<>|]', '_', episode_title)
|
|
new_filename = f"{series_title} - EP{episode_number:02d} - {episode_title}.mkv"
|
|
new_full_path = os.path.join(folder_path, new_filename)
|
|
if not dry_run:
|
|
os.rename(full_path, new_full_path)
|
|
print(f"Renamed '{filename}' to '{new_filename}'")
|
|
|
|
def select_folder():
|
|
folder_path = filedialog.askdirectory()
|
|
if folder_path:
|
|
folder_entry.delete(0, tk.END)
|
|
folder_entry.insert(0, folder_path)
|
|
|
|
def save_regex_to_history(pattern):
|
|
if os.path.exists(HISTORY_FILE):
|
|
with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
|
|
history = [line.strip() for line in f if line.strip()]
|
|
else:
|
|
history = []
|
|
|
|
if pattern not in history:
|
|
history.insert(0, pattern)
|
|
with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(history[:20])) # limit history
|
|
|
|
def load_regex_history():
|
|
if os.path.exists(HISTORY_FILE):
|
|
with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
|
|
return [line.strip() for line in f if line.strip()]
|
|
return []
|
|
|
|
def start_renaming():
|
|
folder_path = folder_entry.get()
|
|
dry_run = dry_run_var.get()
|
|
regex_pattern = regex_entry.get()
|
|
episode_offset = int(offset_entry.get())
|
|
override_title = override_entry.get()
|
|
episode_group = int(episode_group_entry.get())
|
|
|
|
if not folder_path:
|
|
messagebox.showerror("Error", "Please select a folder.")
|
|
return
|
|
|
|
if override_title and not is_valid_windows_filename(override_title):
|
|
messagebox.showerror("Error", "The override title is not a valid Windows filename.")
|
|
return
|
|
|
|
save_regex_to_history(regex_pattern)
|
|
regex_entry['values'] = load_regex_history()
|
|
|
|
old_stdout = sys.stdout
|
|
sys.stdout = io.StringIO()
|
|
|
|
rename_files(folder_path, dry_run, regex_pattern, episode_offset, override_title, episode_group)
|
|
|
|
log_output = sys.stdout.getvalue()
|
|
sys.stdout = old_stdout
|
|
|
|
log_text.delete("1.0", tk.END)
|
|
log_text.insert(tk.END, log_output)
|
|
|
|
# --- GUI ---
|
|
root = tk.Tk()
|
|
root.title("File Renamer")
|
|
root.geometry("850x650") # Slightly larger to accommodate new button
|
|
|
|
style = ttk.Style()
|
|
style.configure("Invalid.TEntry", fieldbackground="pink")
|
|
|
|
notebook = ttk.Notebook(root)
|
|
notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
main_frame = ttk.Frame(notebook, padding="10")
|
|
options_frame = ttk.Frame(notebook, padding="10")
|
|
notebook.add(main_frame, text="Main")
|
|
notebook.add(options_frame, text="Options")
|
|
|
|
# Main Tab
|
|
ttk.Label(main_frame, text="Folder Path:").grid(column=0, row=0, sticky=tk.W, pady=2)
|
|
folder_entry = ttk.Entry(main_frame)
|
|
folder_entry.grid(column=1, row=0, sticky="ew", padx=5, pady=2)
|
|
ttk.Button(main_frame, text="Browse", command=select_folder).grid(column=2, row=0, sticky=tk.W, padx=5, pady=2)
|
|
|
|
# Add the AniDB fetch button
|
|
ttk.Button(main_frame, text="Fetch from AniDB", command=fetch_anidb_data).grid(column=3, row=0, sticky=tk.W, padx=5, pady=2)
|
|
|
|
ttk.Label(main_frame, text="Regex Pattern:").grid(column=0, row=1, sticky=tk.W, pady=2)
|
|
regex_entry = ttk.Combobox(main_frame, values=load_regex_history())
|
|
regex_entry.grid(column=1, row=1, columnspan=3, sticky="ew", padx=5, pady=2)
|
|
regex_entry.set(r'\[SubsPlease\] (.+?) - (\d{2}) \(1080p\) \[\w+\]\.mkv')
|
|
|
|
ttk.Label(main_frame, text="Episode Titles:").grid(column=0, row=2, sticky=tk.W, pady=2)
|
|
titles_text = scrolledtext.ScrolledText(main_frame, height=8)
|
|
titles_text.grid(column=0, row=3, columnspan=4, sticky="nsew", padx=5, pady=2)
|
|
|
|
dry_run_var = tk.BooleanVar(value=True)
|
|
ttk.Checkbutton(main_frame, text="Dry Run", variable=dry_run_var).grid(column=0, row=4, sticky=tk.W, pady=2)
|
|
ttk.Button(main_frame, text="Rename Files", command=start_renaming).grid(column=3, row=4, sticky=tk.E, pady=2)
|
|
|
|
ttk.Label(main_frame, text="Log:").grid(column=0, row=5, sticky=tk.W, pady=2)
|
|
log_text = scrolledtext.ScrolledText(main_frame)
|
|
log_text.grid(column=0, row=6, columnspan=4, sticky="nsew", padx=5, pady=2)
|
|
|
|
# Options Tab
|
|
ttk.Label(options_frame, text="Episode Number Offset:").grid(column=0, row=0, sticky=tk.W, pady=2)
|
|
offset_entry = ttk.Entry(options_frame, width=10)
|
|
offset_entry.grid(column=1, row=0, sticky=tk.W, padx=5, pady=2)
|
|
offset_entry.insert(0, "0")
|
|
|
|
override_label = ttk.Label(options_frame, text="Override Show Title:")
|
|
override_label.grid(column=0, row=1, sticky=tk.W, pady=2)
|
|
override_entry = ttk.Entry(options_frame)
|
|
override_entry.grid(column=1, row=1, sticky="ew", padx=5, pady=2)
|
|
override_entry.bind("<KeyRelease>", validate_override_title)
|
|
|
|
ttk.Label(options_frame, text="Episode Number Regex Group:").grid(column=0, row=2, sticky=tk.W, pady=2)
|
|
episode_group_entry = ttk.Entry(options_frame, width=10)
|
|
episode_group_entry.grid(column=1, row=2, sticky=tk.W, padx=5, pady=2)
|
|
episode_group_entry.insert(0, "2")
|
|
|
|
# Expandable configuration
|
|
for i in range(4): # Updated to include the new column
|
|
main_frame.columnconfigure(i, weight=1 if i == 1 else 0)
|
|
main_frame.rowconfigure(3, weight=1)
|
|
main_frame.rowconfigure(6, weight=2)
|
|
|
|
options_frame.columnconfigure(1, weight=1)
|
|
|
|
# Add instructions label
|
|
instructions = ttk.Label(main_frame, text="Note: Folder name must contain pattern {anidb4-12345} to fetch from AniDB",
|
|
font=("Arial", 8), foreground="gray")
|
|
instructions.grid(column=0, row=7, columnspan=4, sticky=tk.W, pady=(5,0))
|
|
|
|
root.mainloop() |