apiVersion: v1 kind: ConfigMap metadata: name: immich-rsync-script namespace: photo data: rsync.py: | import os import re import subprocess import threading import time import json from datetime import datetime, timezone SRC = "/source/" DST = "/data/" OUT_DIR = "/www" state_lock = threading.Lock() state = { "status": "starting", "percent": 0.0, "processed": 0, "total": 0, "rate": "", "eta": "", "bytes_transferred": "", "started_at": None, "finished_at": None, "error": None, "returncode": None, } # matches lines from `rsync --info=progress2`, e.g.: # 1,234,567 43% 12.34MB/s 0:00:12 (xfr#12, to-chk=345/400) # the "(xfr#N, to-chk=X/Y)" suffix only appears on some lines (once a # file completes), so processed/total only update when it's present. LINE_RE = re.compile( r"^\s*([\d,]+)\s+(\d+)%\s+(\S+/s)\s+([\d:]+)" r"(?:\s+\(xfr#(\d+),\s+(?:to-chk|ir-chk)=(\d+)/(\d+)\))?\s*$" ) def render_html(s): progress_html = "" if s["status"] == "scanning": progress_html = "

Building file list...

" elif s["status"] in ("syncing", "done", "error"): progress_html = f"""

Progress: {s['processed']} / {s['total']} files ({s['percent']:.1f}%)

Transferred: {s['bytes_transferred']} bytes · {s['rate']} · ETA {s['eta']}

""" error_html = f"

Error: {s['error']}

" if s["error"] else "" return f""" Immich NAS rsync

Immich NAS rsync

{SRC} → {DST}

Status: {s['status']}

{progress_html} {error_html}
Started{s['started_at'] or '-'}
Finished{s['finished_at'] or '-'}
Exit code{s['returncode'] if s['returncode'] is not None else '-'}
""" def write_output(): with state_lock: s = dict(state) html = render_html(s) tmp = os.path.join(OUT_DIR, "index.html.tmp") with open(tmp, "w") as f: f.write(html) os.replace(tmp, os.path.join(OUT_DIR, "index.html")) tmp = os.path.join(OUT_DIR, "stats.json.tmp") with open(tmp, "w") as f: json.dump(s, f, indent=2) os.replace(tmp, os.path.join(OUT_DIR, "stats.json")) def run_rsync(): with state_lock: state.update(status="scanning", started_at=datetime.now(timezone.utc).isoformat()) write_output() proc = subprocess.Popen( [ "rsync", "-a", "--info=progress2", "--no-inc-recursive", "--outbuf=L", SRC, DST, ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) last_write = 0.0 tail = [] for line in proc.stdout: tail.append(line) tail[:] = tail[-20:] m = LINE_RE.match(line) if m: bytes_transferred, percent, rate, eta, _xfr, to_chk_left, to_chk_total = m.groups() with state_lock: state["status"] = "syncing" state["bytes_transferred"] = bytes_transferred state["percent"] = float(percent) state["rate"] = rate state["eta"] = eta if to_chk_total is not None: total = int(to_chk_total) left = int(to_chk_left) state["total"] = total state["processed"] = total - left now = time.time() if now - last_write > 2: write_output() last_write = now returncode = proc.wait() with state_lock: state["returncode"] = returncode state["finished_at"] = datetime.now(timezone.utc).isoformat() if returncode == 0: state["status"] = "done" state["percent"] = 100.0 else: state["status"] = "error" state["error"] = "".join(tail[-5:]).strip() or f"rsync exited {returncode}" write_output() def main(): os.makedirs(OUT_DIR, exist_ok=True) write_output() run_rsync() # stay alive so the pod doesn't restart-loop after a one-shot sync; # the status page keeps showing the final result. while True: time.sleep(3600) if __name__ == "__main__": main()