Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
4.3 KiB
Python
Executable File
105 lines
4.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Move old SDR recordings from the Pi SD card to the home-server HDD (runs hourly as user sdr).
|
|
|
|
Only when the SD card runs low: free < max(FREE_PCT %, FREE_MIN_GB). Oldest day dirs first,
|
|
never today/yesterday. Per day:
|
|
1. rsync -a DAY/*.mp3 + .cls.json -> sdrarchive@server:DAY/ (rrsync, write-only key)
|
|
2. verify: rsync -a --checksum --dry-run --itemize-changes must report no differences
|
|
3. write DAY/.archived.json (index for the web UI: class, features, peaks, size), then delete
|
|
the local mp3 files. noise/ is never archived (sdr-retention deletes it after 2 days).
|
|
Stops at the first error; safe to re-run. --force-day DAY archives one day regardless of free space.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
# `python3 -I` implies -P (no script dir on sys.path): put the package root (the parent of archive/) there ourselves.
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from radio.config import ARCH_INDEX, CLS_INDEX, REC_DIR # noqa: E402
|
|
from radio.recordings import DAY_RE, FILE_RE # noqa: E402
|
|
from radio.util import read_json, write_json_atomic # noqa: E402
|
|
|
|
DEST = os.environ.get("SDR_ARCHIVE_DEST", "sdrarchive@192.168.88.221:")
|
|
KEY = "/etc/sdr-archive/id_ed25519"
|
|
KNOWN = "/etc/sdr-archive/known_hosts"
|
|
FREE_PCT = 15.0
|
|
FREE_MIN_GB = 4.0
|
|
SSH = f"ssh -i {KEY} -o UserKnownHostsFile={KNOWN} -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=10"
|
|
|
|
|
|
def low_space():
|
|
st = shutil.disk_usage(REC_DIR)
|
|
free_pct = st.free / st.total * 100
|
|
return free_pct < FREE_PCT or st.free < FREE_MIN_GB * 2**30, free_pct, st.free
|
|
|
|
|
|
def rsync(args):
|
|
return subprocess.run(["rsync", "-e", SSH] + args, capture_output=True, text=True, timeout=3600)
|
|
|
|
|
|
def archive_day(day):
|
|
ddir = os.path.join(REC_DIR, day)
|
|
names = sorted(n for n in os.listdir(ddir) if FILE_RE.match(n))
|
|
if not names:
|
|
return 0
|
|
lst = os.path.join("/tmp", f"sdr-archive-{day}.lst")
|
|
with open(lst, "w") as fh:
|
|
fh.write("\n".join(names) + "\n")
|
|
base = ["-a", "--chmod=D755,F644", f"--files-from={lst}", ddir + "/", f"{DEST}{day}/"]
|
|
r = rsync(base)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"rsync {day}: {r.returncode} {r.stderr.strip()[:300]}")
|
|
v = rsync(["--checksum", "--dry-run", "--itemize-changes"] + base)
|
|
diff = [ln for ln in v.stdout.splitlines() if ln.strip() and not ln.startswith(".d")]
|
|
if v.returncode != 0 or diff:
|
|
raise RuntimeError(f"verify {day} failed: rc={v.returncode} diff={diff[:5]} {v.stderr.strip()[:200]}")
|
|
# classifier index travels along as metadata (best effort, it may still change for today)
|
|
rsync(["-a", "--chmod=F644", os.path.join(ddir, CLS_INDEX), f"{DEST}{day}/{CLS_INDEX}"])
|
|
cls = read_json(os.path.join(ddir, CLS_INDEX), {})
|
|
idx_path = os.path.join(ddir, ARCH_INDEX)
|
|
idx = read_json(idx_path, {})
|
|
now = int(time.time())
|
|
for n in names:
|
|
st = os.stat(os.path.join(ddir, n))
|
|
e = dict(cls.get(n) or {"c": "voice"})
|
|
e.update(size=st.st_size, mtime=int(st.st_mtime), archived=now)
|
|
idx[n] = e
|
|
write_json_atomic(idx_path, idx, separators=(",", ":"))
|
|
for n in names: # delete only after the index is safely written
|
|
os.unlink(os.path.join(ddir, n))
|
|
os.unlink(lst)
|
|
return len(names)
|
|
|
|
|
|
def main():
|
|
force = None
|
|
if len(sys.argv) == 3 and sys.argv[1] == "--force-day" and DAY_RE.match(sys.argv[2]):
|
|
force = sys.argv[2]
|
|
today = time.strftime("%Y-%m-%d")
|
|
yday = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400))
|
|
days = sorted(d for d in os.listdir(REC_DIR) if DAY_RE.match(d) and d not in (today, yday))
|
|
if force:
|
|
days = [force]
|
|
total = 0
|
|
for d in days:
|
|
low, pct, free = low_space()
|
|
if not force and not low:
|
|
break
|
|
n = archive_day(d)
|
|
total += n
|
|
print(f"archived {d}: {n} files (SD free before: {pct:.1f}%, {free / 2**30:.1f} GB)", flush=True)
|
|
if total == 0:
|
|
low, pct, free = low_space()
|
|
print(f"nothing to do (SD free {pct:.1f}%, {free / 2**30:.1f} GB)", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as e: # noqa: BLE001 - report and fail the unit
|
|
print(f"ERROR: {e}", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|