fix undo log persistence, non-English titles, season-folder absolute numbering, and rename race handling
This commit is contained in:
@@ -132,6 +132,7 @@
|
|||||||
- Extras
|
- Extras
|
||||||
- Sample
|
- Sample
|
||||||
- Samples
|
- Samples
|
||||||
|
- Soundtrack
|
||||||
- Behind the Scenes
|
- Behind the Scenes
|
||||||
- Deleted Scenes
|
- Deleted Scenes
|
||||||
- Trailers
|
- Trailers
|
||||||
|
|||||||
+112
-30
@@ -64,23 +64,38 @@ LEGACY_SEE_PATTERN = re.compile(r'(?<![A-Za-z0-9])([1-9])(\d{2})(?![A-Za-z0-9])'
|
|||||||
class RenameLog:
|
class RenameLog:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.entries = []
|
self.entries = []
|
||||||
|
self.label = None
|
||||||
|
self.log_path = None
|
||||||
|
|
||||||
def record(self, from_path, to_path):
|
def set_label(self, label):
|
||||||
self.entries.append({"from": str(from_path), "to": str(to_path)})
|
self.label = label
|
||||||
|
|
||||||
def save(self, label=None):
|
def _ensure_path(self):
|
||||||
if not self.entries:
|
if self.log_path is not None:
|
||||||
return None
|
return
|
||||||
LOG_DIR.mkdir(exist_ok=True)
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
suffix = f"-{re.sub(r'[^A-Za-z0-9]+', '_', label).strip('_')}" if label else ""
|
suffix = f"-{re.sub(r'[^A-Za-z0-9]+', '_', self.label).strip('_')}" if self.label else ""
|
||||||
log_path = LOG_DIR / f"{timestamp}{suffix}.json"
|
log_path = LOG_DIR / f"{timestamp}{suffix}.json"
|
||||||
counter = 1
|
counter = 1
|
||||||
while log_path.exists():
|
while log_path.exists():
|
||||||
log_path = LOG_DIR / f"{timestamp}{suffix}-{counter}.json"
|
log_path = LOG_DIR / f"{timestamp}{suffix}-{counter}.json"
|
||||||
counter += 1
|
counter += 1
|
||||||
log_path.write_text(json.dumps(self.entries, indent=2))
|
self.log_path = log_path
|
||||||
return log_path
|
|
||||||
|
def record(self, from_path, to_path):
|
||||||
|
self.entries.append({"from": str(from_path), "to": str(to_path)})
|
||||||
|
self._ensure_path()
|
||||||
|
self.log_path.write_text(json.dumps(self.entries, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def save(self, label=None):
|
||||||
|
if not self.entries:
|
||||||
|
return None
|
||||||
|
if label and self.label is None:
|
||||||
|
self.set_label(label)
|
||||||
|
self._ensure_path()
|
||||||
|
self.log_path.write_text(json.dumps(self.entries, indent=2), encoding="utf-8")
|
||||||
|
return self.log_path
|
||||||
|
|
||||||
|
|
||||||
SUPERSCRIPT_DIGITS = {
|
SUPERSCRIPT_DIGITS = {
|
||||||
@@ -111,6 +126,10 @@ def clean_and_squeeze(name):
|
|||||||
return squeeze_spaces(clean_name(name))
|
return squeeze_spaces(clean_name(name))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_display_title(name):
|
||||||
|
return squeeze_spaces(normalize_special_chars(name))
|
||||||
|
|
||||||
|
|
||||||
def extract_year(name):
|
def extract_year(name):
|
||||||
paren_years = re.findall(r'\((19\d{2}|20\d{2})\)', name)
|
paren_years = re.findall(r'\((19\d{2}|20\d{2})\)', name)
|
||||||
if paren_years:
|
if paren_years:
|
||||||
@@ -712,6 +731,16 @@ def same_existing_path(dest, src):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def safe_rename(src, dest):
|
||||||
|
try:
|
||||||
|
src.rename(dest)
|
||||||
|
return True, None
|
||||||
|
except FileExistsError:
|
||||||
|
return False, "target already exists"
|
||||||
|
except OSError as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def confirm(prompt):
|
def confirm(prompt):
|
||||||
answer = input(f"{prompt} [y/N] ").strip().lower()
|
answer = input(f"{prompt} [y/N] ").strip().lower()
|
||||||
return answer in ("y", "yes")
|
return answer in ("y", "yes")
|
||||||
@@ -1054,17 +1083,18 @@ def lookup_folder(api_key, media_type, raw_name, hint_year=None):
|
|||||||
|
|
||||||
query_norm = clean_and_squeeze(query).lower()
|
query_norm = clean_and_squeeze(query).lower()
|
||||||
title = result_title(media_type, match)
|
title = result_title(media_type, match)
|
||||||
final_name = clean_and_squeeze(title)
|
final_name = clean_display_title(title)
|
||||||
|
title_norm = clean_and_squeeze(title).lower()
|
||||||
vprint(f" Best match: {title!r} (id={match.get('id')})")
|
vprint(f" Best match: {title!r} (id={match.get('id')})")
|
||||||
|
|
||||||
if final_name.lower() != query_norm:
|
if title_norm != query_norm:
|
||||||
vprint(" Title differs from query, checking alternative titles...")
|
vprint(" Title differs from query, checking alternative titles...")
|
||||||
for candidate in results[:ALT_TITLE_CHECK_LIMIT]:
|
for candidate in results[:ALT_TITLE_CHECK_LIMIT]:
|
||||||
alt_titles = tmdb_alternative_titles(api_key, media_type, candidate.get("id"))
|
alt_titles = tmdb_alternative_titles(api_key, media_type, candidate.get("id"))
|
||||||
hit = next((a for a in alt_titles if clean_and_squeeze(a).lower() == query_norm), None)
|
hit = next((a for a in alt_titles if clean_and_squeeze(a).lower() == query_norm), None)
|
||||||
if hit:
|
if hit:
|
||||||
match = candidate
|
match = candidate
|
||||||
final_name = clean_and_squeeze(hit)
|
final_name = clean_display_title(hit)
|
||||||
vprint(f" Alternative title match: {hit!r} (id={candidate.get('id')})")
|
vprint(f" Alternative title match: {hit!r} (id={candidate.get('id')})")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1375,7 +1405,10 @@ def rename_consolidated_subtitles(subtitles, new_video_path, log):
|
|||||||
continue
|
continue
|
||||||
dest.parent.mkdir(exist_ok=True)
|
dest.parent.mkdir(exist_ok=True)
|
||||||
source_dir = item.parent
|
source_dir = item.parent
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping subtitle ({err}): {item.name}")
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
label = "subtitle index" if dest.suffix.lower() == ".idx" else "subtitle"
|
label = "subtitle index" if dest.suffix.lower() == ".idx" else "subtitle"
|
||||||
if source_dir == dest.parent:
|
if source_dir == dest.parent:
|
||||||
@@ -1426,7 +1459,10 @@ def organize_subtitle_folder(folder, log):
|
|||||||
print(f"Skipping (target already exists): {entry.name} -> {canonical_name}")
|
print(f"Skipping (target already exists): {entry.name} -> {canonical_name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
entry.rename(dest)
|
ok, err = safe_rename(entry, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {entry.name} -> {canonical_name}")
|
||||||
|
continue
|
||||||
log.record(entry, dest)
|
log.record(entry, dest)
|
||||||
print(f"Renamed folder: {entry.name} -> {canonical_name}")
|
print(f"Renamed folder: {entry.name} -> {canonical_name}")
|
||||||
|
|
||||||
@@ -1473,21 +1509,32 @@ def organize_season_folders(folder, log):
|
|||||||
if dest.exists() and not same_existing_path(dest, sub):
|
if dest.exists() and not same_existing_path(dest, sub):
|
||||||
print(f"Skipping (target already exists): {sub.name} -> {new_name}")
|
print(f"Skipping (target already exists): {sub.name} -> {new_name}")
|
||||||
continue
|
continue
|
||||||
sub.rename(dest)
|
ok, err = safe_rename(sub, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {sub.name} -> {new_name}")
|
||||||
|
continue
|
||||||
log.record(sub, dest)
|
log.record(sub, dest)
|
||||||
print(f"Renamed folder: {sub.name} -> {new_name}")
|
print(f"Renamed folder: {sub.name} -> {new_name}")
|
||||||
renamed += 1
|
renamed += 1
|
||||||
return renamed
|
return renamed
|
||||||
|
|
||||||
|
|
||||||
def resolve_season_folder_file(item, season):
|
def resolve_season_folder_file(item, season, api_key=None, tmdb_id=None):
|
||||||
se = parse_season_episode(item.name)
|
se = parse_season_episode(item.name)
|
||||||
if se:
|
if se:
|
||||||
return se[0], se[1], se[2]
|
return se[0], se[1], se[2]
|
||||||
return parse_season_only(item.name), parse_episode_only(item.name), 'E'
|
|
||||||
|
file_season = parse_season_only(item.name)
|
||||||
|
episode = parse_episode_only(item.name)
|
||||||
|
if episode is None:
|
||||||
|
resolved = resolve_absolute_episode(api_key, tmdb_id, item.name)
|
||||||
|
if resolved is not None:
|
||||||
|
return resolved[0], resolved[1], 'E'
|
||||||
|
|
||||||
|
return file_season, episode, 'E'
|
||||||
|
|
||||||
|
|
||||||
def preview_season_folder_files(folder, final_name):
|
def preview_season_folder_files(folder, final_name, api_key=None, tmdb_id=None):
|
||||||
for sub in list_subfolders(folder):
|
for sub in list_subfolders(folder):
|
||||||
season = parse_season_folder_name(sub.name)
|
season = parse_season_folder_name(sub.name)
|
||||||
if season is None:
|
if season is None:
|
||||||
@@ -1497,7 +1544,7 @@ def preview_season_folder_files(folder, final_name):
|
|||||||
|
|
||||||
for item in list_video_files(sub):
|
for item in list_video_files(sub):
|
||||||
ext = item.suffix.lower().lstrip(".")
|
ext = item.suffix.lower().lstrip(".")
|
||||||
file_season, episode, marker = resolve_season_folder_file(item, season)
|
file_season, episode, marker = resolve_season_folder_file(item, season, api_key, tmdb_id)
|
||||||
target_season = file_season if file_season is not None else season
|
target_season = file_season if file_season is not None else season
|
||||||
subtitles = gather_video_subtitles(item, True)
|
subtitles = gather_video_subtitles(item, True)
|
||||||
|
|
||||||
@@ -1525,7 +1572,7 @@ def preview_season_folder_files(folder, final_name):
|
|||||||
preview_consolidated_subtitles(subtitles, item)
|
preview_consolidated_subtitles(subtitles, item)
|
||||||
|
|
||||||
|
|
||||||
def rename_season_folder_files(folder, final_name, log):
|
def rename_season_folder_files(folder, final_name, log, api_key=None, tmdb_id=None):
|
||||||
renamed = 0
|
renamed = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
@@ -1538,7 +1585,7 @@ def rename_season_folder_files(folder, final_name, log):
|
|||||||
|
|
||||||
for item in list_video_files(sub):
|
for item in list_video_files(sub):
|
||||||
ext = item.suffix.lower().lstrip(".")
|
ext = item.suffix.lower().lstrip(".")
|
||||||
file_season, episode, marker = resolve_season_folder_file(item, season)
|
file_season, episode, marker = resolve_season_folder_file(item, season, api_key, tmdb_id)
|
||||||
target_season = file_season if file_season is not None else season
|
target_season = file_season if file_season is not None else season
|
||||||
target_season_folder = season_folder_name(target_season)
|
target_season_folder = season_folder_name(target_season)
|
||||||
target_dir = folder / target_season_folder if target_season != season else sub
|
target_dir = folder / target_season_folder if target_season != season else sub
|
||||||
@@ -1564,7 +1611,11 @@ def rename_season_folder_files(folder, final_name, log):
|
|||||||
|
|
||||||
if target_dir != sub:
|
if target_dir != sub:
|
||||||
target_dir.mkdir(exist_ok=True)
|
target_dir.mkdir(exist_ok=True)
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {item.name}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
if target_dir != sub:
|
if target_dir != sub:
|
||||||
print(f"Moved: {item.name} -> {target_season_folder}/{new_name}")
|
print(f"Moved: {item.name} -> {target_season_folder}/{new_name}")
|
||||||
@@ -1618,7 +1669,11 @@ def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_na
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
item_target_dir.mkdir(exist_ok=True)
|
item_target_dir.mkdir(exist_ok=True)
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {item.name}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
print(f"Moved: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
|
print(f"Moved: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
|
||||||
moved += 1
|
moved += 1
|
||||||
@@ -1658,7 +1713,11 @@ def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_na
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
item_target_dir.mkdir(exist_ok=True)
|
item_target_dir.mkdir(exist_ok=True)
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {item.name}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
print(f"Moved: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
|
print(f"Moved: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
|
||||||
moved += 1
|
moved += 1
|
||||||
@@ -1803,7 +1862,11 @@ def rename_video_files(folder, media_type, final_name, log, api_key, tmdb_id=Non
|
|||||||
print(f"Skipping (target already exists): {item.name}")
|
print(f"Skipping (target already exists): {item.name}")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {item.name}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
print(f"Renamed file: {item.name} -> {new_name}")
|
print(f"Renamed file: {item.name} -> {new_name}")
|
||||||
renamed += 1
|
renamed += 1
|
||||||
@@ -1844,7 +1907,11 @@ def rename_video_files(folder, media_type, final_name, log, api_key, tmdb_id=Non
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
season_dir.mkdir(exist_ok=True)
|
season_dir.mkdir(exist_ok=True)
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {item.name}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
print(f"Moved: {item.name} -> {target_season_folder}/{new_name}")
|
print(f"Moved: {item.name} -> {target_season_folder}/{new_name}")
|
||||||
renamed += 1
|
renamed += 1
|
||||||
@@ -2217,6 +2284,7 @@ def remove_empty_folders(share, confirm_all, dry_run=False):
|
|||||||
def run_cleanup(args, log):
|
def run_cleanup(args, log):
|
||||||
path_arg = args.cleanup if isinstance(args.cleanup, str) else None
|
path_arg = args.cleanup if isinstance(args.cleanup, str) else None
|
||||||
share = resolve_share(path_arg)
|
share = resolve_share(path_arg)
|
||||||
|
log.set_label(f"{Path(share).name}-cleanup")
|
||||||
|
|
||||||
delete_folder_names = load_delete_folder_names()
|
delete_folder_names = load_delete_folder_names()
|
||||||
delete_file_patterns = load_delete_file_patterns()
|
delete_file_patterns = load_delete_file_patterns()
|
||||||
@@ -2391,7 +2459,11 @@ def process_loose_movie_files(share, api_key, log, test_mode, test_limit, args):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
target_folder.mkdir(exist_ok=True)
|
target_folder.mkdir(exist_ok=True)
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {item.name}")
|
||||||
|
folders_skipped += 1
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
print(f"Moved: {item.name} -> {folder_name}/{new_name}")
|
print(f"Moved: {item.name} -> {folder_name}/{new_name}")
|
||||||
folders_renamed += 1
|
folders_renamed += 1
|
||||||
@@ -2468,7 +2540,11 @@ def process_loose_tv_files(share, api_key, log, test_mode, test_limit, args):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
(target_folder / target_season_folder_name).mkdir(parents=True, exist_ok=True)
|
(target_folder / target_season_folder_name).mkdir(parents=True, exist_ok=True)
|
||||||
item.rename(dest)
|
ok, err = safe_rename(item, dest)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {item.name}")
|
||||||
|
files_skipped += 1
|
||||||
|
continue
|
||||||
log.record(item, dest)
|
log.record(item, dest)
|
||||||
print(f"Moved: {item.name} -> {folder_name}/{target_season_folder_name}/{new_name}")
|
print(f"Moved: {item.name} -> {folder_name}/{target_season_folder_name}/{new_name}")
|
||||||
folders_renamed += 1
|
folders_renamed += 1
|
||||||
@@ -2482,6 +2558,7 @@ def process_loose_tv_files(share, api_key, log, test_mode, test_limit, args):
|
|||||||
def run_scan(args, log):
|
def run_scan(args, log):
|
||||||
path_arg = args.rename if isinstance(args.rename, str) else None
|
path_arg = args.rename if isinstance(args.rename, str) else None
|
||||||
share = resolve_share(path_arg)
|
share = resolve_share(path_arg)
|
||||||
|
log.set_label(Path(share).name)
|
||||||
|
|
||||||
cache = load_scan_cache()
|
cache = load_scan_cache()
|
||||||
share_key = str(Path(share).resolve())
|
share_key = str(Path(share).resolve())
|
||||||
@@ -2583,7 +2660,7 @@ def run_scan(args, log):
|
|||||||
if media_type == "tv":
|
if media_type == "tv":
|
||||||
simulated_tv_folder_names.add(folder_name)
|
simulated_tv_folder_names.add(folder_name)
|
||||||
preview_season_folders(folder)
|
preview_season_folders(folder)
|
||||||
preview_season_folder_files(folder, final_name)
|
preview_season_folder_files(folder, final_name, api_key, match_id)
|
||||||
preview_video_files(folder, media_type, final_name, api_key, match_id)
|
preview_video_files(folder, media_type, final_name, api_key, match_id)
|
||||||
examples_shown += 1
|
examples_shown += 1
|
||||||
continue
|
continue
|
||||||
@@ -2614,7 +2691,11 @@ def run_scan(args, log):
|
|||||||
folders_skipped += 1
|
folders_skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
folder.rename(new_folder)
|
ok, err = safe_rename(folder, new_folder)
|
||||||
|
if not ok:
|
||||||
|
print(f"Skipping ({err}): {raw_name} -> {folder_name}")
|
||||||
|
folders_skipped += 1
|
||||||
|
continue
|
||||||
log.record(folder, new_folder)
|
log.record(folder, new_folder)
|
||||||
print(f"Renamed folder: {raw_name} -> {folder_name}")
|
print(f"Renamed folder: {raw_name} -> {folder_name}")
|
||||||
folders_renamed += 1
|
folders_renamed += 1
|
||||||
@@ -2624,7 +2705,7 @@ def run_scan(args, log):
|
|||||||
|
|
||||||
if media_type == "tv":
|
if media_type == "tv":
|
||||||
organize_season_folders(folder, log)
|
organize_season_folders(folder, log)
|
||||||
sub_renamed, sub_skipped = rename_season_folder_files(folder, final_name, log)
|
sub_renamed, sub_skipped = rename_season_folder_files(folder, final_name, log, api_key, match_id)
|
||||||
files_renamed += sub_renamed
|
files_renamed += sub_renamed
|
||||||
files_skipped += sub_skipped
|
files_skipped += sub_skipped
|
||||||
|
|
||||||
@@ -2844,6 +2925,7 @@ def run_backup(args):
|
|||||||
|
|
||||||
|
|
||||||
def run_manual_rename(current_name, new_name, log):
|
def run_manual_rename(current_name, new_name, log):
|
||||||
|
log.set_label("manual")
|
||||||
src = Path(current_name)
|
src = Path(current_name)
|
||||||
dst = Path(new_name)
|
dst = Path(new_name)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user