Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/matebot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ async def _sync(config: Config) -> int:
return 1
async with GaggiMateClient(config.machine_host) as client:
# WS connection (for profiles) is optional here; HTTP does the rest.
pushed = await sync(client, config.data_repo, site_title=config.site_title)
pushed = await sync(
client, config.data_repo,
site_title=config.site_title, video_keep=config.video_keep,
)
print("synced" if pushed else "nothing new")
return 0

Expand Down Expand Up @@ -117,7 +120,8 @@ def schedule_sync(*, quiet: bool = False):
asyncio.create_task(
sync_soon(
client, config.data_repo, messenger.send,
site_title=config.site_title, state=state, quiet=quiet,
site_title=config.site_title, video_keep=config.video_keep,
state=state, quiet=quiet,
)
)

Expand Down
23 changes: 23 additions & 0 deletions src/matebot/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"/newbag <grams> [name] — start tracking a bean bag (optional feature)\n"
"/bag — how much is left in the open bags\n"
"/tossbag [name] — close out a bag (emptied, binned, or gifted)\n"
"/vsync <±seconds> — nudge the latest shot video's sync (e.g. /vsync +0.5)\n"
"/digest — your last 7 days in espresso\n"
"/help — this list"
)
Expand Down Expand Up @@ -246,6 +247,28 @@ async def _cmd_tossbag(self) -> None:

await self.messenger.send(bags.toss_bag(self.state, " ".join(self._args) or None))

async def _cmd_vsync(self) -> None:
from . import video

if not self.config.data_repo:
await self.messenger.send("No journal repo configured — nothing to sync videos in.")
return
shot = video.latest_video_shot(self.config.data_repo)
if shot is None:
await self.messenger.send("No shot videos yet.")
return
try:
delta = float(self._args[0].replace("+", "")) if self._args else 0.0
except ValueError:
await self.messenger.send("Usage: /vsync <±seconds> — e.g. /vsync +0.5 or /vsync -1")
return
current = video.get_offset(self.config.data_repo, shot) or 0.0
video.set_offset(self.config.data_repo, shot, current + delta)
await self.messenger.send(
f"🎬 Shot #{shot} video offset: {current:+.2f}s → {current + delta:+.2f}s. "
"Journal updates on the next sync."
)

async def _cmd_digest(self) -> None:
text = await build_digest(self.client, self.config)
await self.messenger.send(text or "No shots in the last 7 days. The machine misses you.")
Expand Down
4 changes: 3 additions & 1 deletion src/matebot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"clean_every": "MATEBOT_CLEAN_EVERY",
"water_warn_pct": "MATEBOT_WATER_WARN",
"autoheat_window": "MATEBOT_AUTOHEAT",
"video_keep": "MATEBOT_VIDEO_KEEP",
"plots_enabled": "MATEBOT_PLOTS",
"wake_hook": "MATEBOT_WAKE_HOOK",
"sleep_hook": "MATEBOT_SLEEP_HOOK",
Expand Down Expand Up @@ -63,6 +64,7 @@ class Config:
water_warn_pct: int = 15 # warn when the tank drops below this percent; 0 = off
# e.g. "06:30-07:00": machine powered on in this window switches to brew, once a day
autoheat_window: str = ""
video_keep: int = 15 # newest N shot videos kept in the journal; 0 = keep all
plots_enabled: bool = True # send the shot chart as a photo (needs matplotlib)
wake_hook: str = "" # shell command to power the machine's smart plug ON
sleep_hook: str = "" # shell command to power the smart plug OFF
Expand All @@ -83,7 +85,7 @@ def load(cls, path: str | Path | None = None) -> Config:
continue
if f.name == "min_shot_s":
value = float(value)
elif f.name in ("clean_every", "water_warn_pct"):
elif f.name in ("clean_every", "water_warn_pct", "video_keep"):
value = int(value)
elif f.name in ("sync_enabled", "hints_enabled", "digest_enabled", "plots_enabled"):
value = str(value).lower() in ("1", "true", "yes", "on")
Expand Down
1 change: 1 addition & 0 deletions src/matebot/messengers/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ async def start(self) -> None:
BotCommand("bag", "how much is left in the bean bag"),
BotCommand("newbag", "start tracking a bean bag"),
BotCommand("tossbag", "close out a bag"),
BotCommand("vsync", "nudge shot video sync"),
BotCommand("help", "list commands"),
])
await self.app.updater.start_polling(drop_pending_updates=True)
Expand Down
19 changes: 19 additions & 0 deletions src/matebot/sitegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ def _load_notes(path: Path) -> dict | None:
return None


def _video_info(shots_dir: Path, out_dir: Path, sid: str) -> dict:
"""Copy the shot's video into the site (if any) and return payload fields."""
mp4 = shots_dir / f"{sid}.mp4"
if not mp4.exists():
return {}
dest = out_dir / "video" / f"{sid}.mp4"
dest.parent.mkdir(parents=True, exist_ok=True)
if not dest.exists() or dest.stat().st_mtime < mp4.stat().st_mtime:
dest.write_bytes(mp4.read_bytes())
offset = 0.0
try:
offset = float(json.loads((shots_dir / f"{sid}.video.json").read_text())["offset"])
except (OSError, ValueError, KeyError):
pass
return {"video": f"video/{sid}.mp4", "video_offset": offset}


def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot Journal") -> int:
"""Build the site; returns the number of shots included."""
shots_dir, out_dir = Path(shots_dir), Path(out_dir)
Expand All @@ -89,6 +106,7 @@ def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot J
sid = slog_path.stem
notes = _load_notes(slog_path.with_suffix(".json"))
payload = _shot_payload(shot, notes)
payload.update(_video_info(shots_dir, out_dir, sid))
(out_dir / "shots" / f"{sid}.json").write_text(
json.dumps(payload, separators=(",", ":"))
)
Expand All @@ -102,6 +120,7 @@ def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot J
"final_g": shot.final_weight_g,
"peak_bar": max(cp) if cp else 0,
"rating": (notes or {}).get("rating") or 0,
"has_video": (shots_dir / f"{sid}.mp4").exists(),
"bean": (notes or {}).get("beanType", ""),
"ratio": (notes or {}).get("ratio", ""),
"taste": (notes or {}).get("balanceTaste", ""),
Expand Down
10 changes: 7 additions & 3 deletions src/matebot/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ def _safe_name(label: str) -> str:


async def sync(
client: GaggiMateClient, repo: str | Path, *, site_title: str = "Shot Journal"
client: GaggiMateClient, repo: str | Path, *,
site_title: str = "Shot Journal", video_keep: int = 15,
) -> bool:
"""Pull, mirror machine state into the repo, regenerate site, commit, push.

Expand Down Expand Up @@ -109,6 +110,9 @@ async def sync(
log.info("settings skipped (%s)", exc)

# --- site ---
from .video import prune_videos

prune_videos(repo, keep=video_keep)
generate(shots_dir, repo / "docs", title=site_title)

# --- commit + push ---
Expand All @@ -132,15 +136,15 @@ async def sync(

async def sync_soon(
client: GaggiMateClient, repo: str | Path, notify, *,
site_title: str = "Shot Journal", state=None, quiet: bool = False,
site_title: str = "Shot Journal", video_keep: int = 15, state=None, quiet: bool = False,
) -> None:
"""Post-shot sync wrapper: run, report problems, never raise.

A failed sync is remembered in *state* (``sync_pending``) so the caller
can retry when the machine comes back online.
"""
try:
await sync(client, repo, site_title=site_title)
await sync(client, repo, site_title=site_title, video_keep=video_keep)
except SyncConflict as exc:
await notify(f"⚠️ Shot journal sync hit a git conflict — fix it manually:\n{exc}")
except (TimeoutError, aiohttp.ClientError, OSError) as exc:
Expand Down
88 changes: 88 additions & 0 deletions src/matebot/video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Shot video handling: transcode, sidecar metadata, retention pruning.

Videos live next to the shot files as ``shots/NNNNNN.mp4`` with a sidecar
``shots/NNNNNN.video.json`` (currently ``{"offset": seconds}`` — where shot
t=0 sits relative to video t=0). The sync pipeline copies them into
``docs/video/`` for GitHub Pages and prunes to the newest N so the repo
doesn't grow unbounded (~3 MB per clip).
"""

from __future__ import annotations

import asyncio
import json
import logging
from pathlib import Path

log = logging.getLogger(__name__)

FFMPEG_ARGS = [
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
"-vf", "scale='min(1280,iw)':-2",
"-c:a", "aac", "-b:a", "96k",
"-movflags", "+faststart",
]


class VideoError(RuntimeError):
pass


async def attach_video(
repo: str | Path, shot_id: int, source: str | Path, *, offset: float = 0.0
) -> Path:
"""Transcode *source* into the repo as the shot's video; returns the mp4 path."""
repo = Path(repo)
out = repo / "shots" / f"{shot_id:06d}.mp4"
out.parent.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source), *FFMPEG_ARGS, str(out),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0 or not out.exists() or out.stat().st_size == 0:
out.unlink(missing_ok=True)
raise VideoError(f"ffmpeg failed ({proc.returncode}): {stdout.decode()[-300:]}")
set_offset(repo, shot_id, offset)
log.info("video attached to shot %06d (%.1f MB)", shot_id, out.stat().st_size / 1e6)
return out


def sidecar_path(repo: str | Path, shot_id: int) -> Path:
return Path(repo) / "shots" / f"{shot_id:06d}.video.json"


def set_offset(repo: str | Path, shot_id: int, offset: float) -> None:
sidecar_path(repo, shot_id).write_text(json.dumps({"offset": round(offset, 2)}))


def get_offset(repo: str | Path, shot_id: int) -> float | None:
"""Offset for the shot's video, or None if the shot has no video."""
if not (Path(repo) / "shots" / f"{shot_id:06d}.mp4").exists():
return None
try:
return float(json.loads(sidecar_path(repo, shot_id).read_text())["offset"])
except (OSError, ValueError, KeyError):
return 0.0


def latest_video_shot(repo: str | Path) -> int | None:
videos = sorted((Path(repo) / "shots").glob("[0-9]*.mp4"))
return int(videos[-1].stem) if videos else None


def prune_videos(repo: str | Path, keep: int) -> int:
"""Delete all but the newest *keep* videos (shots/ and docs/video/); returns count removed."""
repo = Path(repo)
videos = sorted((repo / "shots").glob("[0-9]*.mp4"))
removed = 0
for mp4 in videos[:-keep] if keep > 0 else []:
shot = mp4.stem
mp4.unlink(missing_ok=True)
(repo / "shots" / f"{shot}.video.json").unlink(missing_ok=True)
(repo / "docs" / "video" / f"{shot}.mp4").unlink(missing_ok=True)
removed += 1
if removed:
log.info("pruned %d old shot videos (keeping %d)", removed, keep)
return removed
36 changes: 36 additions & 0 deletions src/matebot/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ function renderDetail(id, shot, compareId, compare) {

const charts = $("#charts");
charts.innerHTML = "";
VIDEO = null;
if (shot.video) {
const holder = document.createElement("div");
holder.className = "chart-card video-card";
holder.innerHTML = `<video src="${esc(shot.video)}" playsinline muted preload="metadata"></video>
<button class="pbtn vunmute" title="Sound">🔇</button>`;
charts.appendChild(holder);
VIDEO = holder.querySelector("video");
VIDEO_OFFSET = shot.video_offset || 0;
const mute = holder.querySelector(".vunmute");
mute.addEventListener("click", () => {
VIDEO.muted = !VIDEO.muted;
mute.textContent = VIDEO.muted ? "🔇" : "🔊";
});
}
const t = shot.series.t;
const S = (key) => shot.series[key] && shot.series[key].some(v => v !== 0) ? shot.series[key] : null;

Expand Down Expand Up @@ -131,6 +146,16 @@ function rgba(hex, alpha) {
}

let CHART = null;
let VIDEO = null;
let VIDEO_OFFSET = 0;

function videoSeek(time) {
if (!VIDEO) return;
const vt = time + VIDEO_OFFSET;
if (VIDEO.readyState >= 1 && Math.abs(VIDEO.currentTime - vt) > 0.25) {
VIDEO.currentTime = Math.max(0, vt);
}
}

function combinedChart(parent, t, phases, S, overlay) {
if (!t || t.length < 2 || typeof Chart === "undefined") return;
Expand Down Expand Up @@ -321,6 +346,7 @@ function attachPlayer(card, t, xMax, S, overlay) {
ph.display = playing || time < xMax;
ph.xMin = ph.xMax = time;
CHART.update("none");
if (!playing) videoSeek(time);
const i = idxAt(time);
for (const [k] of TILES) {
const el = $(`#tile-${k}`);
Expand All @@ -338,6 +364,7 @@ function attachPlayer(card, t, xMax, S, overlay) {
if (raf) cancelAnimationFrame(raf);
raf = null; lastTs = null;
playBtn.innerHTML = ICON_PLAY;
VIDEO?.pause();
}

function tick(ts) {
Expand All @@ -350,6 +377,7 @@ function attachPlayer(card, t, xMax, S, overlay) {
return;
}
renderAt(playT, true);
videoSeek(playT);
}
lastTs = ts;
raf = requestAnimationFrame(tick);
Expand All @@ -359,8 +387,16 @@ function attachPlayer(card, t, xMax, S, overlay) {
if (raf) { stop(); return; }
if (playT >= xMax) playT = 0;
playBtn.innerHTML = ICON_PAUSE;
if (VIDEO) {
VIDEO.playbackRate = Number($("#speed").value);
videoSeek(playT);
VIDEO.play().catch(() => {});
}
raf = requestAnimationFrame(tick);
});
$("#speed").addEventListener("change", () => {
if (VIDEO) VIDEO.playbackRate = Number($("#speed").value);
});
$("#scrub").addEventListener("input", () => {
stop();
playT = Number($("#scrub").value);
Expand Down
5 changes: 5 additions & 0 deletions src/matebot/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,8 @@ body.theater-open { overflow: hidden; }
.ptime { display: none; }
.chart-card.theater .chart-holder { height: calc(100vh - 230px); }
}

/* ---- shot video ---- */
.video-card { padding: 0; overflow: hidden; position: relative; }
.video-card video { display: block; width: 100%; max-height: 420px; background: #000; }
.video-card .vunmute { position: absolute; right: 10px; bottom: 10px; opacity: 0.85; }
19 changes: 19 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,22 @@ async def test_brew_enforcer_gives_up_loudly(setup, monkeypatch):
clock["t"] += 9
await router.on_frame({"tp": "evt:status", "m": 0, "ct": 25.0, "tt": 0})
assert len(client.requests) == 1


@pytest.mark.asyncio
async def test_vsync_adjusts_latest_video_offset(setup, tmp_path):
from matebot import video as videomod

router, client, state, convo, fm, cache = setup
router.config.data_repo = str(tmp_path)
await router.handle("/vsync +0.5")
assert "No shot videos yet" in fm.sent[-1]

(tmp_path / "shots").mkdir()
(tmp_path / "shots" / "000080.mp4").write_bytes(b"v")
videomod.set_offset(tmp_path, 80, -1.0)
await router.handle("/vsync +0.5")
assert videomod.get_offset(tmp_path, 80) == -0.5
assert "#80" in fm.sent[-1]
await router.handle("/vsync -0.25")
assert videomod.get_offset(tmp_path, 80) == -0.75
4 changes: 2 additions & 2 deletions tests/test_sync_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ async def __call__(self, text):

@pytest.mark.asyncio
async def test_machine_offline_sets_pending_flag(tmp_path, monkeypatch):
async def boom(client, repo, site_title="x"):
async def boom(client, repo, site_title="x", video_keep=15):
raise aiohttp.ClientConnectionError("machine off")

monkeypatch.setattr(sync_mod, "sync", boom)
Expand All @@ -33,7 +33,7 @@ async def boom(client, repo, site_title="x"):

@pytest.mark.asyncio
async def test_success_clears_pending_flag(tmp_path, monkeypatch):
async def ok(client, repo, site_title="x"):
async def ok(client, repo, site_title="x", video_keep=15):
return True

monkeypatch.setattr(sync_mod, "sync", ok)
Expand Down
Loading
Loading