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()