Remove redundant cursor styling
Some checks failed
Deploy API / deploy (push) Failing after 1s
Deploy UI / deploy (push) Successful in 1s

This commit is contained in:
Your Name 2026-05-19 19:48:13 -04:00
parent 080b4bdaf1
commit b24dd72ae0
93 changed files with 9058 additions and 1 deletions

View file

@ -0,0 +1,352 @@
#!/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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="300">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
:root {{
--green:#30a46c; --yellow:#f5d90a; --red:#e5484d;
--bg-green:#e9f7f0; --bg-yellow:#fff8cc; --bg-red:#fdeaea;
}}
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 1.5rem; }}
h1 {{ margin: 0 0 .4rem 0; }}
.muted {{ color: #666; font-size: .9rem; }}
table {{ border-collapse: collapse; width: 100%; max-width: 1200px; }}
th, td {{ padding: .5rem .6rem; border-bottom: 1px solid #eee; text-align: left; vertical-align: middle; }}
tr:hover td {{ background: #fafafa; }}
.right {{ text-align: right; }}
.row-green td {{ background: var(--bg-green); }}
.row-yellow td {{ background: var(--bg-yellow); }}
.row-red td {{ background: var(--bg-red); }}
.status-dot {{ display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:middle;margin-right:.4rem;}}
.green {{ background: var(--green); }}
.yellow {{ background: var(--yellow); }}
.red {{ background: var(--red); }}
.bar {{ position: relative; height: 12px; background: #eee; border-radius: 6px; overflow: hidden; }}
.bar .seg {{ position: absolute; top:0; bottom:0; background: var(--green); opacity:.9; }}
.cover-label {{ font-size:.85rem; white-space:nowrap; }}
.nowrap {{ white-space:nowrap; }}
.hdr {{ margin-top:.6rem; margin-bottom:.8rem; }}
.kvs span {{ margin-right: 1rem; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
<div class="muted">Generated at {gen_time.strftime('%Y-%m-%d %I:%M:%S %p %Z')}</div>
<div class="hdr kvs muted">
<span><strong>Last recording started:</strong> {html.escape(last_str)}</span>
<span><strong>Window:</strong> {args.days} days</span>
<span><strong>Dir:</strong> {html.escape(str(rec_dir))}</span>
</div>
<div class="hdr kvs">
<span><strong>Data in window:</strong> {window_bytes_str}</span>
<span><strong>Filesystem usage (recordings partition):</strong> used {used_str} free {free_str} total {total_str}</span>
</div>
<table>
<thead>
<tr>
<th>Date</th>
<th class="right"># Recs</th>
<th class="right">Data</th>
<th class="right">Max gap</th>
<th>Coverage</th>
<th>Spans</th>
<th>Status</th>
</tr>
</thead>
<tbody>
"""
for r in reversed(rows):
cls = f"row-{r['status']}"
cov = int(round(r["coverage_pct"]))
seg_divs = "".join(
f'<div class="seg" style="left:{left:.3f}%;width:{width:.3f}%"></div>'
for left, width in r["segments"]
)
data_str = fmt_bytes(r["bytes"])
html_out += f""" <tr class="{cls}">
<td class="nowrap">{r['date']}</td>
<td class="right">{r['count']}</td>
<td class="right">{data_str}</td>
<td class="right">{hhmm(r['max_gap'])}</td>
<td>
<div class="bar" title="{cov}% covered">{seg_divs}</div>
<div class="cover-label">{cov}%</div>
</td>
<td><div class="muted">{html.escape(r['details'])}</div></td>
<td><span class="status-dot {r['status']}"></span>{r['status']}</td>
</tr>
"""
html_out += """ </tbody>
</table>
<p class="muted">Legend: <span class="status-dot green"></span> green = nearly continuous <span class="status-dot yellow"></span> yellow = gaps or low coverage <span class="status-dot red"></span> red = no recordings</p>
</body>
</html>
"""
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="Dont 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()

31
clio-pi/bin/record.sh Executable file
View file

@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
# Configuration
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:1}" # ALSA device (run 'arecord -l' to list)
SEGMENT_SECONDS="${SEGMENT_SECONDS:-7200}" # 2 hours per file
RECORDINGS_DIR="${RECORDINGS_DIR:-$HOME/recordings}"
SOURCE_NAME="${SOURCE_NAME:-stationary}" # Prefix for filenames
mkdir -p "${RECORDINGS_DIR}"
echo "Starting recording..."
echo " Device: ${AUDIO_DEVICE}"
echo " Segment: ${SEGMENT_SECONDS}s"
echo " Output: ${RECORDINGS_DIR}/${SOURCE_NAME}-*.opus"
while true; do
ffmpeg -f alsa -ac 1 -i "${AUDIO_DEVICE}" \
-c:a libopus \
-b:a 32k \
-f segment \
-segment_time "${SEGMENT_SECONDS}" \
-strftime 1 \
-reset_timestamps 1 \
"${RECORDINGS_DIR}/${SOURCE_NAME}-%Y-%m-%d_%H-%M-%S.opus" \
2>&1 | while read line; do echo "[ffmpeg] $line"; done
echo "Recording stopped, restarting in 5s..."
sleep 5
done