#!/usr/bin/env python3 import argparse import datetime as dt import html import os import re import shutil import statistics from pathlib import Path from typing import List, Tuple, Optional FNAME_RE = re.compile(r"^stationary-(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.opus$") def parse_start(path: Path, tz) -> Optional[dt.datetime]: m = FNAME_RE.match(path.name) if not m: return None date_str, time_str = m.groups() start = dt.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H-%M-%S") return start.replace(tzinfo=tz) def get_duration_seconds(path: Path) -> Optional[float]: try: from mutagen.oggopus import OggOpus # optional audio = OggOpus(path) return float(getattr(audio.info, "length", 0.0)) or None except Exception: return None def human_delta(delta: dt.timedelta) -> str: secs = int(abs(delta).total_seconds()) d, rem = divmod(secs, 86400) h, rem = divmod(rem, 3600) m, s = divmod(rem, 60) parts = [] if d: parts.append(f"{d}d") if h: parts.append(f"{h}h") if m: parts.append(f"{m}m") if not parts: parts.append(f"{s}s") return " ".join(parts) def merge_intervals(intervals: List[Tuple[dt.datetime, dt.datetime]]) -> List[Tuple[dt.datetime, dt.datetime]]: if not intervals: return [] intervals.sort(key=lambda x: x[0]) merged = [intervals[0]] for s, e in intervals[1:]: ls, le = merged[-1] if s <= le: merged[-1] = (ls, max(le, e)) else: merged.append((s, e)) return merged def day_bounds(d: dt.date, tz): start = dt.datetime(d.year, d.month, d.day, tzinfo=tz) return start, start + dt.timedelta(days=1) def intersect(a: Tuple[dt.datetime, dt.datetime], b: Tuple[dt.datetime, dt.datetime]) -> Optional[Tuple[dt.datetime, dt.datetime]]: s1, e1 = a; s2, e2 = b s = max(s1, s2); e = min(e1, e2) return (s, e) if s < e else None def pct(x, total): return 0 if total <= 0 else max(0, min(100, (x/total)*100)) def fmt_ampm(t: dt.datetime) -> str: s = t.strftime("%I:%M%p") # 01:05PM return s.lstrip("0") def build(args): tz = dt.datetime.now().astimezone().tzinfo rec_dir = Path(args.dir).expanduser().resolve() files = [p for p in rec_dir.glob("*.opus") if p.is_file()] files.sort() # Gather metadata items = [] # (path, start_dt, size_bytes, duration_opt) for p in files: s = parse_start(p, tz) if not s: continue dur = get_duration_seconds(p) if args.read_durations else None try: size = p.stat().st_size except Exception: size = 0 items.append((p, s, size, dur)) items.sort(key=lambda x: x[1]) starts = [s for _, s, _, _ in items] deltas = [(b - a).total_seconds() for a, b in zip(starts, starts[1:])] # Duration policy (defaults: NO inference, assume 2h) if args.infer: sane = [d for d in deltas if args.infer_min_sec <= d <= args.infer_max_sec] inferred_default = (statistics.median(sane) if sane else args.assume_duration_sec or args.infer_default_sec) inferred_default = max(args.infer_min_sec, min(args.infer_max_sec, inferred_default)) else: inferred_default = args.assume_duration_sec or args.infer_default_sec # Build intervals intervals: List[Tuple[dt.datetime, dt.datetime]] = [] for idx, (p, s, _size, dur) in enumerate(items): if dur is None or dur <= 0: dur_use = float(inferred_default) else: dur_use = dur if idx < len(items)-1 and args.guard_gap_sec > 0: to_next = (items[idx+1][1] - s).total_seconds() dur_use = min(dur_use, max(1.0, to_next - args.guard_gap_sec)) e = s + dt.timedelta(seconds=max(1.0, dur_use)) intervals.append((s, e)) gen_time = dt.datetime.now(tz) last_start = starts[-1] if starts else None # FS usage for recordings partition try: total_b, used_b, free_b = shutil.disk_usage(str(rec_dir)) except Exception: total_b = used_b = free_b = 0 # Window today = gen_time.date() start_date = today - dt.timedelta(days=args.days - 1) big_gap = dt.timedelta(minutes=args.gap_yellow_min) green_gap = dt.timedelta(minutes=args.gap_green_min) rows = [] window_total_bytes = 0 for i in range(args.days): d = start_date + dt.timedelta(days=i) d_start, d_end = day_bounds(d, tz) # Per-day file count and bytes (count files that START this day) day_files = [(p, s, size) for (p, s, size, _du) in [(p, s, size, du) for (p, s, size, du) in items] if d_start <= s < d_end] day_bytes = sum(size for (_p, _s, size) in day_files) window_total_bytes += day_bytes # Intervals overlapping this day day_intervals = [] for (s, e) in intervals: inter = intersect((s, e), (d_start, d_end)) if inter: day_intervals.append(inter) merged = merge_intervals(day_intervals) covered = sum((e - s).total_seconds() for s, e in merged) day_seconds = 86400.0 coverage_pct = pct(covered, day_seconds) # Max gap includes edges if not merged: max_gap = dt.timedelta(days=1) else: gaps = [merged[0][0] - d_start] gaps += [b[0] - a[1] for a, b in zip(merged, merged[1:])] gaps.append(d_end - merged[-1][1]) max_gap = max(gaps) if gaps else dt.timedelta(0) # Status count = len(day_files) if count == 0: status = "red" elif max_gap <= green_gap and coverage_pct >= args.green_cover_pct: status = "green" elif max_gap >= big_gap or coverage_pct < args.yellow_cover_pct: status = "yellow" else: status = "yellow" # Mini timeline segments segs = [] for s, e in merged: left = pct((s - d_start).total_seconds(), day_seconds) width = pct((e - s).total_seconds(), day_seconds) if width > 0: segs.append((left, width)) # Details list: show ALL spans; AM/PM times details_txt = ", ".join(f"{fmt_ampm(s)}–{fmt_ampm(e)}" for s, e in merged) rows.append({ "date": d.isoformat(), "count": count, "bytes": day_bytes, "max_gap": max_gap, "coverage_pct": coverage_pct, "status": status, "segments": segs, "details": details_txt or "—", }) # HTML def hhmm(td: dt.timedelta) -> str: total = int(td.total_seconds()) h = total // 3600 m = (total % 3600) // 60 return f"{h:02d}:{m:02d}" def fmt_bytes(n: int) -> str: units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB") x = float(n) for u in units: if x < 1024.0 or u == units[-1]: return f"{int(x)} {u}" if u == "B" else f"{x:.1f} {u}" x /= 1024.0 title = args.title last_str = "—" if last_start: last_str = f"{last_start.strftime('%Y-%m-%d %I:%M:%S %p %Z')} ({human_delta(gen_time - last_start)} ago)" # FS usage strings total_str = fmt_bytes(total_b) used_str = fmt_bytes(used_b) free_str = fmt_bytes(free_b) window_bytes_str = fmt_bytes(window_total_bytes) html_out = f"""
| Date | # Recs | Data | Max gap | Coverage | Spans | Status |
|---|---|---|---|---|---|---|
| {r['date']} | {r['count']} | {data_str} | {hhmm(r['max_gap'])} |
{cov}%
|
{html.escape(r['details'])} |
{r['status']} |
Legend: green = nearly continuous • yellow = gaps or low coverage • red = no recordings
""" out_path = Path(args.out).resolve() out_path.parent.mkdir(parents=True, exist_ok=True) tmp = out_path.with_suffix(out_path.suffix + ".tmp") with open(tmp, "w", encoding="utf-8") as f: f.write(html_out) os.replace(tmp, out_path) def main(): p = argparse.ArgumentParser(description="Generate recordings status HTML") p.add_argument("--dir", default=str(Path.home() / "recordings"), help="Directory containing .opus files (default: ~/recordings)") p.add_argument("--out", default="/var/www/html/index.html", help="HTML output path (default: /var/www/html/index.html)") # Defaults tuned so cron needs no flags: p.add_argument("--days", type=int, default=365, help="How many days to show (default: 365)") p.add_argument("--gap-yellow-min", type=int, default=120, help="Gap (minutes) that marks a day as yellow (default: 120)") p.add_argument("--gap-green-min", type=int, default=15, help="Max gap (minutes) to still be green (default: 15)") p.add_argument("--read-durations", action="store_true", help="Use mutagen to read exact file durations (optional; default off).") p.add_argument("--green-cover-pct", type=float, default=90.0, help="Min coverage%% for green (default: 90)") p.add_argument("--yellow-cover-pct", type=float, default=10.0, help="Coverage%% below which day is yellow (default: 10)") # Duration policy: default is NO inference, assume 2h p.add_argument("--assume-duration-sec", type=int, default=7200, help="Assumed duration (s) when real duration is unavailable. Default: 7200 (2h).") p.add_argument("--infer", action="store_true", help="Enable inferring duration from median start deltas (disabled by default).") p.add_argument("--infer-min-sec", type=int, default=60, help="Ignore deltas smaller than this when inferring (default: 60s)") p.add_argument("--infer-max-sec", type=int, default=6*3600, help="Ignore deltas larger than this when inferring (default: 6h)") p.add_argument("--infer-default-sec", type=int, default=20*60, help="Fallback segment length if nothing else is available (default: 1200s)") p.add_argument("--guard-gap-sec", type=int, default=0, help="Don’t let a segment extend within this many seconds of next start (default: 0)") p.add_argument("--title", default="Portable Recorder Status", help="Page title") args = p.parse_args() build(args) if __name__ == "__main__": main()