Files

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)