Over the last 10 years, I have had a lot of changes go on in my library, so I have, using chatgpt, created a script to clean that up. Please take a look and let me know what you think. Below is the complete, ready-to-run User Scripts–compatible script. It is a bash wrapper that runs an embedded Python tool. It: Operates directly on /mnt/disk* (never touches /mnt/user), as requested. Consolidates matching movie/series folders across disks onto the disk with most free space among the participating disks. Uses one worker thread per disk. Serializes writes only: a job that writes to a disk will acquire that disk’s write lock. Reads are not blocked. If a task cannot acquire required write lock(s), it is requeued with a short backoff. Renames seasons to Season_{n}, renames episode video files to your requested formats (best-effort), picks the highest-quality duplicate in a folder and deletes / moves the rest to --trash if provided. Dry-run by default; pass --apply to actually perform moves/deletes. When --apply completes, runs a correcting parity check (mdcmd check) as you requested. Safe for Unraid — must be run on the Unraid host in User Scripts. Important: run the script once without --apply to review the dry-run plan printed to the console. How to useIn Unraid User Scripts, create a new script, paste the code below into it, save. Run without arguments (dry-run): ./your_script_name
To apply changes and run the correcting parity check at the end: sudo ./your_script_name --apply --trash /mnt/disk1/Trash
Script (paste into User Scripts)#!/bin/bash
# =====================================================================
# Unraid User Scripts launcher - Disk-aware media consolidator (final)
# Dry-run by default. Use --apply to perform changes.
# =====================================================================
PYTHON_BIN=$(command -v python3 || command -v python)
if [ -z "$PYTHON_BIN" ]; then
echo "ERROR: python3 not found on this system."
exit 1
fi
# Pass all args to embedded Python
$PYTHON_BIN - "$@" <<'PYCODE'
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
import random
from pathlib import Path
from queue import Queue
from threading import Thread, Lock
from typing import List, Dict, Tuple, Optional
# ------------------ Configuration ------------------
VIDEO_EXTS = {".mp4", ".mkv", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".ts", ".webm", ".m2ts"}
DISK_ROOT = Path("/mnt")
DISK_GLOB_PREFIX = "disk"
MOVIES_SUBPATH = "movies"
TV_SUBPATH = "media/tv"
PARITY_CMD = "/usr/local/sbin/mdcmd" # run: mdcmd check
LOCK_BACKOFF = (0.02, 0.08) # seconds
TASK_REQUEUE_LIMIT = 100
LOCK_ACQUIRE_TIMEOUT = 5
# ---------------------------------------------------
def log(msg: str):
print(f"[{time.strftime('%F %T')}] {msg}", flush=True)
def list_disks() -> List[Path]:
return sorted([p for p in DISK_ROOT.glob(DISK_GLOB_PREFIX + "*") if p.is_dir()])
def disk_free_bytes(disk: Path) -> int:
try:
st = shutil.disk_usage(str(disk))
return st.free
except Exception:
return -1
def normalize_for_key(name: str) -> str:
s = name.lower()
s = re.sub(r"[ _\-.]+", "", s)
s = re.sub(r"[^\w]", "", s)
return s
def prefer_canonical_name(cands: List[str]) -> str:
if not cands: return ""
for c in cands:
if "_" in c: return c
for c in cands:
if " " in c: return c
return max(cands, key=len)
def cleanup_target_name(name: str) -> str:
s = re.sub(r"\s+", " ", name).strip()
s = s.replace(" ", "_")
s = re.sub(r"[/:<>?\\|]+", "_", s)
return s
# Quality heuristics
def quality_score(fname: str) -> int:
s = 0
ln = fname.lower()
if re.search(r"(2160|4k)", ln): s += 500
if "1080p" in ln: s += 300
if "720p" in ln: s += 200
if re.search(r"bluray|bdrip|uhd|remux", ln): s += 250
if re.search(r"web-dl|webdl|webrip", ln): s += 150
if re.search(r"dolbyvision|dolby|dv|hdr", ln): s += 100
if re.search(r"proper|repack", ln): s += 30
return s
def choose_best(files: List[Path]):
best = None
best_val = -1
for f in files:
try:
sc = quality_score(f.name)
size = f.stat().st_size
val = sc * (10**12) + size
if val > best_val:
best_val = val
best = f
except Exception:
continue
return best
# TV parsing helpers
def extract_episode_numbers(name: str):
nums = []
for m in re.finditer(r'[sS](\d{1,2})[eE](\d{2})', name):
nums.append(int(m.group(2)))
if not nums:
m = re.search(r'(?:Episode|Ep)[\s._-]*([0-9]{1,3})', name, re.I)
if m: nums = [int(m.group(1))]
if not nums:
m2 = re.search(r'[^0-9]([0-9]{1,3})[^0-9]', f' {name} ')
if m2:
n = int(m2.group(1))
if 0 < n < 300: nums = [n]
# dedupe preserve order
out = []; seen=set()
for n in nums:
if n not in seen:
out.append(n); seen.add(n)
return out
def is_daily(name: str) -> bool:
return bool(re.match(r'^\d{4}-\d{2}-\d{2}$', name))
def derive_ep_title(foldername: str, folderpath: Path) -> str:
t = re.sub(r'[sS]\d{1,2}[eE]\d{2}(?:[eE]\d{2})*', '', foldername)
t = re.sub(r'^(Episode|Ep)[\s._-]*\d{1,3}', '', t, flags=re.I)
t = re.sub(r'^\d{4}-\d{2}-\d{2}', '', t)
t = re.sub(r'[_\.\-]+', ' ', t).strip()
if t:
return t.replace(" ", "_")
files = [p for p in folderpath.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS]
if files:
return files[0].stem.replace(" ", "_")
return "Episode"
def build_tv_name(series_norm: str, season_num: int, ep_nums: List[int], ep_title: str, quality: str, is_daily_flag: bool, daily_date: str="") -> str:
if is_daily_flag and daily_date:
return f"{series_norm}_{daily_date}_{ep_title}_{quality}"
if is_daily_flag:
return f"{series_norm}_UNKNOWNDATE_{ep_title}_{quality}"
if ep_nums:
epseq = f"S{season_num:02d}" + "".join(f"E{e:02d}" for e in ep_nums)
return f"{series_norm}_{epseq}_{ep_title}_{quality}"
else:
return f"{series_norm}_S{season_num:02d}E00_{ep_title}_{quality}"
# Worker system
class WorkerSystem:
def __init__(self, disks: List[Path], apply: bool, trash: Optional[Path]):
self.disks = disks
self.apply = apply
self.trash = trash
self.disk_names = [d.name for d in disks]
self.queue = Queue()
# write lock per disk: serialize writes (destinations / deletes)
self.write_locks: Dict[str, Lock] = {d.name: Lock() for d in disks}
self.threads: Dict[str, Thread] = {}
self.stop_flag = False
def start(self):
for dn in self.disk_names:
t = Thread(target=self.worker_loop, args=(dn,), daemon=True)
self.threads[dn] = t
t.start()
def stop(self):
# put sentinel None per worker
for _ in self.threads:
self.queue.put(None)
for t in self.threads.values():
t.join(timeout=5)
def enqueue(self, src: Path, dst_dir: Path, src_disk_name: str, dst_disk_name: str):
# tuple: src, dst_dir, src_disk_name, dst_disk_name, requeue_count
self.queue.put((str(src), str(dst_dir), src_disk_name, dst_disk_name, 0))
def worker_loop(self, my_disk_name: str):
while True:
item = self.queue.get()
if item is None:
self.queue.task_done()
break
src_str, dst_dir_str, src_disk, dst_disk, rq = item
src = Path(src_str); dst_dir = Path(dst_dir_str)
# For writes, we need to lock destination (and source if deleting source)
disks_to_lock = sorted(list({dst_disk, src_disk}))
acquired = []
got_all = True
for dn in disks_to_lock:
lock = self.write_locks.get(dn)
if lock is None:
continue
# try to acquire non-blocking
if not lock.acquire(blocking=False):
got_all = False
break
acquired.append(dn)
if not got_all:
# release any acquired and requeue with backoff
for dn in acquired:
try:
self.write_locks[dn].release()
except Exception:
pass
rq += 1
if rq > TASK_REQUEUE_LIMIT:
log(f"[SKIP] too many requeues for task: {src} -> {dst_dir}")
self.queue.task_done()
continue
time.sleep(random.uniform(*LOCK_BACKOFF))
self.queue.put((src_str, dst_dir_str, src_disk, dst_disk, rq))
self.queue.task_done()
continue
# perform move under locks
try:
if not dst_dir.exists():
if self.apply:
dst_dir.mkdir(parents=True, exist_ok=True)
log(f"[MKDIR] {dst_dir}")
else:
log(f"[DRY MKDIR] {dst_dir}")
dst_full = dst_dir / src.name
# avoid moving onto itself
try:
if dst_full.exists() and dst_full.resolve() == src.resolve():
log(f"[SKIP] same file: {src}")
continue
except Exception:
pass
# handle name collision
if dst_full.exists() and dst_full.resolve() != src.resolve():
base = dst_full.with_suffix(''); ext = dst_full.suffix; i = 1
candidate = Path(f"{base}_{i}{ext}")
while candidate.exists():
i += 1
candidate = Path(f"{base}_{i}{ext}")
dst_full = candidate
if self.apply:
shutil.move(str(src), str(dst_full))
log(f"[MOVE] {src} -> {dst_full}")
else:
log(f"[DRY MOVE] {src} -> {dst_full}")
except Exception as e:
log(f"[ERROR] moving {src} -> {dst_dir}: {e}")
finally:
# release locks
for dn in acquired:
try:
self.write_locks[dn].release()
except Exception:
pass
self.queue.task_done()
# Scan groups
def scan_groups(disks: List[Path]):
movie_groups = {}
tv_groups = {}
for d in disks:
mroot = d / MOVIES_SUBPATH
if mroot.exists() and mroot.is_dir():
for child in mroot.iterdir():
if child.is_dir():
key = normalize_for_key(child.name)
movie_groups.setdefault(key, []).append((d, child, child.name))
tvroot = d / TV_SUBPATH
if tvroot.exists() and tvroot.is_dir():
for s in tvroot.iterdir():
if s.is_dir():
key = normalize_for_key(s.name)
tv_groups.setdefault(key, []).append((d, s, s.name))
return movie_groups, tv_groups
def pick_target_disk(members: List[Tuple[Path,Path,str]]) -> Path:
best = None; best_free = -1; seen=set()
for d,_,_ in members:
if d in seen: continue
seen.add(d)
f = disk_free_bytes(d)
if f > best_free:
best_free = f; best = d
return best or members[0][0]
def canonical_name_from_members(members):
names = [m[2] for m in members]
pref = prefer_canonical_name(names)
return cleanup_target_name(pref)
# Enqueue moves to consolidate a group onto the chosen disk
def enqueue_group_moves(ws: WorkerSystem, members, apply: bool):
if not members: return [], None, None
target_disk = pick_target_disk(members)
cname = canonical_name_from_members(members)
sample = members[0][1]
rel_parent = sample.parent.relative_to(members[0][0])
target_parent = target_disk / rel_parent
target_path = target_parent / cname
if not target_parent.exists():
if apply:
target_parent.mkdir(parents=True, exist_ok=True)
log(f"[PLAN] ensure parent: {target_parent}")
if not target_path.exists():
if apply:
target_path.mkdir(parents=True, exist_ok=True)
log(f"[MKDIR] {target_path}")
else:
log(f"[DRY MKDIR] {target_path}")
logs = []
# enqueue moves for each member that's not on the target disk
for disk, path, name in members:
if disk.resolve() == target_disk.resolve():
continue
for root, dirs, files in os.walk(str(path)):
rel = os.path.relpath(root, str(path))
if rel == ".":
rel_dst = str(target_path)
else:
rel_dst = str(target_path / rel)
for f in files:
src = Path(root) / f
# allow moving all files (subs, nfo, etc.) to keep dir intact
ws.enqueue(src, Path(rel_dst), disk.name, target_disk.name)
logs.append(f"[ENQUEUE] {src} -> {rel_dst} (dst disk {target_disk.name})")
return logs, target_disk, target_path
# Post-consolidation normalization (seasons, file renames, duplicate handling)
def normalize_seasons_and_files(target_series_path: Path, ws: WorkerSystem, apply: bool, trash: Optional[Path]):
logs = []
if not target_series_path.exists():
return logs
# Normalize season folders
for child in sorted([p for p in target_series_path.iterdir() if p.is_dir()]):
m = re.search(r'[sS]eason[_\s\-]*([0-9]{1,2})', child.name)
if m:
season_num = int(m.group(1))
else:
m2 = re.search(r'\b([0-9]{1,2})\b', child.name)
if m2:
season_num = int(m2.group(1))
else:
continue
target = child.parent / f"Season_{season_num}"
if child.resolve() == target.resolve():
continue
if target.exists():
# merge child into target: move items
for item in sorted(child.iterdir()):
dest = target / item.name
ws.enqueue(item, dest.parent, item.resolve().parts[2], dest.resolve().parts[2])
logs.append(f"[ENQUEUE MERGE] {item} -> {dest}")
else:
# rename (local rename is a write -> enqueue a move from child to target under same disk)
ws.enqueue(child, target.parent, child.resolve().parts[2], child.resolve().parts[2])
logs.append(f"[ENQUEUE RENAME] {child} -> {target}")
# Per-episode file handling
for season in sorted([p for p in target_series_path.iterdir() if p.is_dir()]):
season_num = 1
m = re.search(r'Season[_\s\-]*([0-9]{1,2})', season.name, re.I)
if m:
season_num = int(m.group(1))
for ep in sorted([p for p in season.iterdir() if p.is_dir()]):
ep_name = ep.name
daily_flag = is_daily(ep_name)
daily_date = ep_name if daily_flag else ""
ep_title = derive_ep_title(ep_name, ep)
ep_nums = extract_episode_numbers(ep_name)
vids = [p for p in ep.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS]
if not vids:
logs.append(f"[NO-VIDS] {ep}")
continue
best = choose_best(vids)
if not best:
logs.append(f"[NO-BEST] {ep}")
continue
ln = best.name.lower(); q = ""
if "web-dl" in ln or "webdl" in ln: q="WEBDL"
elif "webrip" in ln or "web-rip" in ln: q="WEBRIP"
elif "bluray" in ln or "bdrip" in ln or "remux" in ln: q="BLURAY"
if "2160" in ln or "4k" in ln: q = (q + "-" if q else "") + "2160p"
elif "1080p" in ln: q = (q + "-" if q else "") + "1080p"
elif "720p" in ln: q = (q + "-" if q else "") + "720p"
if re.search(r'dolbyvision|dolby|dv', ln): q = (q + "_DV") if q else "DV"
if not q: q = "UNKNOWN"
series_norm = cleanup_target_name(target_series_path.name)
if re.search(r'anime', series_norm, re.I): q = f"{q}_v2"
final_base = build_tv_name(series_norm, season_num, ep_nums, ep_title, q, daily_flag, daily_date)
final_base = re.sub(r'_+', '_', final_base).strip('_')
ext = best.suffix
final_name = f"{final_base}{ext}"
dst = ep / final_name
# enqueue rename of best to dst (same-disk write)
ws.enqueue(best, dst.parent, best.resolve().parts[2], dst.resolve().parts[2])
logs.append(f"[ENQUEUE RENAME-BEST] {best} -> {dst}")
# inferior files: move to trash if provided, else delete (enqueue trash move)
for other in vids:
if other.resolve() == best.resolve(): continue
if trash:
ws.enqueue(other, Path(trash), other.resolve().parts[2], other.resolve().parts[2])
logs.append(f"[ENQUEUE TRASH] {other} -> {trash}")
else:
# try immediate delete under write lock, else enqueue move to a hidden .delete folder
wl = ws.write_locks.get(other.resolve().parts[2])
if wl and wl.acquire(blocking=False):
try:
if ws.apply:
other.unlink()
logs.append(f"[DELETE] {other}")
else:
logs.append(f"[DRY DELETE] {other}")
except Exception as e:
logs.append(f"[ERROR DELETE] {other}: {e}")
finally:
wl.release()
else:
# enqueue move to .delete folder on same disk
delete_folder = other.parent / ".delete"
ws.enqueue(other, delete_folder, other.resolve().parts[2], other.resolve().parts[2])
logs.append(f"[ENQUEUE DELETE] {other} -> {delete_folder}")
return logs
def disk_from_path(p: Path) -> Path:
parts = p.resolve().parts
for i, part in enumerate(parts):
if part.startswith("disk"):
return Path("/" + os.path.join(*parts[:i+1]))
return Path("/mnt")
def main():
parser = argparse.ArgumentParser(description="Unraid disk-aware media consolidator (dry-run default).")
parser.add_argument("--apply", action="store_true", help="Perform operations (default: dry-run).")
parser.add_argument("--trash", type=str, default="", help="If set, move inferior files to this path.")
args = parser.parse_args()
apply = args.apply
trash = Path(args.trash) if args.trash else None
disks = list_disks()
if not disks:
log("No /mnt/disk* found. Exiting.")
return 1
log(f"Disks discovered: {', '.join(d.name for d in disks)}")
ws = WorkerSystem(disks, apply, trash)
ws.start()
movie_groups, tv_groups = scan_groups(disks)
log(f"Scanned groups: {len(movie_groups)} movie keys, {len(tv_groups)} tv keys")
all_logs = []
# Process movie groups
for key, members in movie_groups.items():
logs, tdisk, tpath = enqueue_group_moves(ws, members, apply)
all_logs.extend(logs)
# normalization of target folder and file renames will be handled in post-normalize pass
# Process tv groups and track targets for normalization
tv_targets = []
for key, members in tv_groups.items():
logs, tdisk, tpath = enqueue_group_moves(ws, members, apply)
all_logs.extend(logs)
if tpath:
tv_targets.append((tpath, tdisk))
# Wait for initial consolidation moves
log("Waiting for consolidation move tasks to complete...")
ws.queue.join()
log("Consolidation moves complete.")
# Attempt to remove empty source directories (acquire write lock per disk)
_, movie_groups_post = scan_groups(disks), tv_groups # simple refresh
# simply attempt cleanup across all disks
for d in disks:
for root, dirs, files in os.walk(str(d), topdown=False):
try:
p = Path(root)
if p.is_dir() and not any(p.iterdir()):
dn = p.parts[2] if len(p.parts) > 2 else d.name
wl = ws.write_locks.get(dn)
if wl and wl.acquire(timeout=LOCK_ACQUIRE_TIMEOUT):
try:
p.rmdir()
log(f"[RMDIR] {p}")
except Exception:
pass
finally:
wl.release()
except Exception:
pass
# Post-normalize TVs across all disks: enqueue renames and duplicate handling
log("Enqueuing post-normalization across TV series...")
for d in disks:
tvroot = d / TV_SUBPATH
if not tvroot.exists(): continue
for series in sorted([p for p in tvroot.iterdir() if p.is_dir()]):
logs = normalize_seasons_and_files(series, ws, apply, trash)
for l in logs: log(l)
# Wait for post-normalization tasks
log("Waiting for post-normalization tasks...")
ws.queue.join()
log("Post-normalization complete.")
# Final empty-directory cleanup
log("Final empty-directory cleanup pass...")
for d in disks:
for root, dirs, files in os.walk(str(d), topdown=False):
p = Path(root)
try:
if p.is_dir() and not any(p.iterdir()):
dn = p.parts[2] if len(p.parts) > 2 else d.name
wl = ws.write_locks.get(dn)
if wl and wl.acquire(timeout=LOCK_ACQUIRE_TIMEOUT):
try:
p.rmdir()
log(f"[RMDIR] {p}")
except Exception:
pass
finally:
wl.release()
except Exception:
pass
# Stop workers
ws.stop()
log("All tasks done. Summary (first 200 actions):")
for i, line in enumerate(all_logs[:200]):
log(line)
if apply:
# Run parity check (correcting)
log("Running correcting parity check (this will modify parity if mismatches are found).")
try:
subprocess.run([PARITY_CMD, "check"], check=True)
log("Parity check command completed.")
except Exception as e:
log(f"[ERROR] parity check failed: {e}")
return 0
if __name__ == '__main__':
sys.exit(main())
PYCODE
Short plain-language description (paste into User Scripts description field)This job scans all data disks (/mnt/disk*) for movies/ and media/tv/ folders, groups same-named media, and consolidates scattered copies onto the disk with the most free space among the disks currently holding parts. It renames season folders to Season_n, renames episodes to your requested standardized format (Standard/Daily/Anime heuristics), deduplicates files keeping the highest-quality copy, and moves/deletes inferior copies (optionally to --trash). The tool serializes writes only (no two writes will occur on the same disk at once); reads are unrestricted. Default is dry-run; pass --apply to make changes. When --apply completes, a correcting parity check (mdcmd check) is run.