The 60-Second Dwell Gate: Debouncing YouTube Live Viewer Alerts with Sliding-Window Filters
β’ Mike Levin
Setting the Stage: Context for the Curious Book Reader
A monitoring loop that reacts to every raw boundary crossing eventually trains its operator to ignore it. In the ongoing Honeybot broadcast studio setup, a background ytwatch daemon polls the YouTube live stream every 30 seconds and triggers a local audio chime, a spoken Piper TTS announcement, and an ntfy mobile push whenever concurrent viewers reach three or more. In practice, a single viewer drifting in and out causes the count to bounce between two and three on consecutive polls, turning transient drive-bys into repeated false summons.
This entry captures a focused surgical refinement to scripts/articles/common.py: introducing a 60-second sliding-window minimum filter (YT_VIEWER_ALERT_DWELL = 60). Instead of firing on the first poll that crosses the threshold, _sustained_viewer_count inspects the timestamped polling history and requires the viewer floor to hold at or above the target across three consecutive 30-second samples before ringing the bell. Walk through the probe, patch, git diff receipt, and tmux daemon restart that quieted the see-saw.
TL;DR: A live-stream monitoring daemon that polls YouTube concurrent viewer counts every 30 seconds will generate noisy, repetitive alerts if it fires immediately whenever viewers cross a numeric threshold. Adding a 60-second sliding-window minimum filter (YT_VIEWER_ALERT_DWELL = 60, configurable via PIPULATE_YT_VIEWER_DWELL) requires the viewer count to remain at or above the threshold across consecutive polls for a full minute before triggering local audio chimes, text-to-speech announcements, or mobile push notifications via ntfy.
Technical Journal Entry Begins
MikeLev.in: Reaching the summon threshold should have a minimum time-delay of about a minute. If they donβt stick around for a minute, I am not interested. It should be like a dampening effect, an 80/20-rule to cut down see-sawing notifications both if that makes sense and if that is the best way.
Catching the Premature Summon in the Logs
From typing ytlog:
π‘ YouTube Watch: LIVE=yes | video=JYa5jrSi4FU | viewers=2 (alert>=3) | chat_items=0 | daemon=RUNNING | title='When t he Sheet Music Jams the Player Piano: Disarming Backtick Cues, Right-Split Unpacking, and...'
π Watching https://www.youtube.com/@MikeLevin/live every 30s...
π‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d
π [VIEWER] 3 watching now (min=3) β When the Sheet Music Jams the Player Piano: Disarming Backtick Cues, Right-Split Unpacking, and...
π Sent ntfy push (HTTP 200 -> pipulate-hb-e12...): [π Honeybot Live Viewer] 3 watching now (min=3) β When the Sheet Music Jams the Player Piano: Disarming Backtick Cues, Right-Split Unpacking, and...
Gemini 4 Argon High: Context verified.
TL;DR: Add a 60-second sliding-window minimum filter (YT_VIEWER_ALERT_DWELL = 60, overridable via PIPULATE_YT_VIEWER_DWELL) to run_youtube_watch in scripts/articles/common.py. A viewer count at or above YT_VIEWER_ALERT_MIN (or a new peak) must hold continuously across polls for at least 60 seconds before ringing the local chime, speaking via Piper TTS, or pushing to ntfy.
Designing the 60-Second Sliding-Window Gate
Raw Viewers: 2 βββΊ 3 βββΊ 2 2 βββΊ 3 βββΊ 3 βββΊ 3
β β β β β
Dwell Clock: 0s reset 0s 30s 60s >= DWELL
β β β
Output: [β³ hold] [π¨ suppressed] [π ALERT + π ntfy]
Why This Stops See-Sawing
- 1-Minute Sustain Gate (
YT_VIEWER_ALERT_DWELL = 60): On a 30-second poll interval, a jump from2 -> 3starts a dwell window (β³ [VIEWER DWELL]) without firing audio orntfy. Only when_sustained_viewer_countconfirms the minimum viewer count over the full 60-second window is>= 3(3 consecutive polls:0s,30s,60s) does the summon fire. - Bounce Suppression: If viewers drop back below
3before 60 seconds elapse, the window clears (π¨ [VIEWER DWELL] ... notification suppressed) and nothing is sent. - Peak Dampening: Once alerted in a contiguous run (
alerted_in_run = True), a 1-poll flicker from3 -> 4 -> 3also has a 60-second floor of3, so it will not trigger a false new-peak alert unless4+viewers also stay for a full minute (and the 300-secondYT_VIEWER_ALERT_COOLDOWNhas elapsed).
Verification Probes and Patch Blueprint
(1) PROBES
Probes verify --broadcast watch --dry-run output and test _sustained_viewer_count against a 30-second transient spike versus a 60-second sustained crowd.
(nix) pipulate $
.venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
.venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"
(2) NEXT CONTEXT
Put in context.txt using context or x:
context.txt
scripts/articles/common.py
! .venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
! .venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"
(3) PATCHES
patch, app, d, m
Target: scripts/articles/common.py
[[[SEARCH]]]
YT_VIEWER_ALERT_MIN = int(os.environ.get("PIPULATE_YT_VIEWER_MIN", "3"))
YT_VIEWER_ALERT_COOLDOWN = int(os.environ.get("PIPULATE_YT_VIEWER_COOLDOWN", "300")) # Seconds between any two viewer alerts
[[[DIVIDER]]]
YT_VIEWER_ALERT_MIN = int(os.environ.get("PIPULATE_YT_VIEWER_MIN", "3"))
YT_VIEWER_ALERT_DWELL = int(os.environ.get("PIPULATE_YT_VIEWER_DWELL", "60")) # Seconds viewers must stay >= min before alerting
YT_VIEWER_ALERT_COOLDOWN = int(os.environ.get("PIPULATE_YT_VIEWER_COOLDOWN", "300")) # Seconds between any two viewer alerts
[[[REPLACE]]]
Target: scripts/articles/common.py
[[[SEARCH]]]
def _save_ytwatch_state(state_obj):
"""Persist ytwatch state to ~/.config/pipulate/ytwatch_state.json."""
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
YTWATCH_STATE_FILE.write_text(json.dumps(state_obj, indent=2) + "\n", encoding="utf-8")
except OSError:
pass
[[[DIVIDER]]]
def _save_ytwatch_state(state_obj):
"""Persist ytwatch state to ~/.config/pipulate/ytwatch_state.json."""
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
YTWATCH_STATE_FILE.write_text(json.dumps(state_obj, indent=2) + "\n", encoding="utf-8")
except OSError:
pass
def _sustained_viewer_count(dwell_window, now_ts, dwell_secs):
"""Return the viewer count continuously sustained for at least dwell_secs, or 0."""
if not dwell_window:
return 0
if dwell_secs <= 0:
return int(dwell_window[-1][1])
run_min = int(dwell_window[-1][1])
for ts, v in reversed(dwell_window):
run_min = min(run_min, int(v))
if (now_ts - float(ts)) >= dwell_secs:
return run_min
return 0
[[[REPLACE]]]
Target: scripts/articles/common.py
[[[SEARCH]]]
if dry_run:
sample = 'var ytInitialPlayerResponse = {"videoDetails":{"videoId":"test1234567","isLive":true}};'
parsed = _extract_yt_json(sample, "ytInitialPlayerResponse =")
ok = (parsed.get("videoDetails") or {}).get("videoId") == "test1234567"
t_label = "configured" if resolved_topic else "unconfigured"
print(f"π‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | ntfy={t_label} | parser_ok={ok}")
return 0 if ok else 1
try:
state = fetch_youtube_live_state(resolved_channel)
except Exception as exc:
print(f"β YouTube Watch fetch failed for {resolved_channel}: {exc}")
return 1
persisted = _load_ytwatch_state()
same_video = (persisted.get("video_id") == state["video_id"] and bool(state["video_id"]))
seen_ids = set(persisted.get("seen_ids") or []) if same_video else set()
had_prior_snapshot = bool(same_video and persisted.get("seen_ids") is not None)
prev_viewers = int(persisted.get("concurrent_viewers") or 0) if same_video else state["concurrent_viewers"]
peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else state["concurrent_viewers"]
prev_comments = persisted.get("comment_count") if same_video else state["comment_count"]
last_viewer_alert = float(persisted.get("last_viewer_alert") or 0.0) if same_video else 0.0
daemon_running = (
subprocess.run(
["tmux", "has-session", "-t", "ytwatch"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
)
c_str = f" | comments={state['comment_count']}" if state["comment_count"] is not None else ""
d_str = " | daemon=RUNNING" if daemon_running else ""
print(
f"π‘ YouTube Watch: LIVE={'yes' if state['is_live'] else 'no'} | "
f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN}) | "
f"chat_items={len(state['chat_messages'])}{c_str}{d_str} | title={state['title']!r}"
)
[[[DIVIDER]]]
if dry_run:
sample = 'var ytInitialPlayerResponse = {"videoDetails":{"videoId":"test1234567","isLive":true}};'
parsed = _extract_yt_json(sample, "ytInitialPlayerResponse =")
ok = (parsed.get("videoDetails") or {}).get("videoId") == "test1234567"
t_label = "configured" if resolved_topic else "unconfigured"
print(f"π‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | dwell={YT_VIEWER_ALERT_DWELL}s | ntfy={t_label} | parser_ok={ok}")
return 0 if ok else 1
try:
state = fetch_youtube_live_state(resolved_channel)
except Exception as exc:
print(f"β YouTube Watch fetch failed for {resolved_channel}: {exc}")
return 1
persisted = _load_ytwatch_state()
same_video = (persisted.get("video_id") == state["video_id"] and bool(state["video_id"]))
seen_ids = set(persisted.get("seen_ids") or []) if same_video else set()
had_prior_snapshot = bool(same_video and persisted.get("seen_ids") is not None)
peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else 0
prev_comments = persisted.get("comment_count") if same_video else state["comment_count"]
last_viewer_alert = float(persisted.get("last_viewer_alert") or 0.0) if same_video else 0.0
alerted_in_run = bool(persisted.get("alerted_in_run")) if (same_video and state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN) else False
daemon_running = (
subprocess.run(
["tmux", "has-session", "-t", "ytwatch"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
)
c_str = f" | comments={state['comment_count']}" if state["comment_count"] is not None else ""
d_str = " | daemon=RUNNING" if daemon_running else ""
print(
f"π‘ YouTube Watch: LIVE={'yes' if state['is_live'] else 'no'} | "
f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN} for {YT_VIEWER_ALERT_DWELL}s) | "
f"chat_items={len(state['chat_messages'])}{c_str}{d_str} | title={state['title']!r}"
)
[[[REPLACE]]]
Target: scripts/articles/common.py
[[[SEARCH]]]
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": state["concurrent_viewers"],
"peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
"comment_count": state["comment_count"],
"last_viewer_alert": last_viewer_alert,
"seen_ids": all_ids,
"updated_at": int(time.time()),
})
return 0
for m in state["chat_messages"]:
seen_ids.add(m["id"])
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": state["concurrent_viewers"],
"peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
"comment_count": state["comment_count"],
"last_viewer_alert": last_viewer_alert,
"seen_ids": list(seen_ids)[-500:],
"updated_at": int(time.time()),
})
print(f"π Watching {resolved_channel} every {interval}s...")
print("π‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d")
try:
while True:
time.sleep(max(5, int(interval)))
try:
state = fetch_youtube_live_state(resolved_channel)
except Exception as exc:
print(f"β οΈ Poll error: {exc}")
continue
viewers = state["concurrent_viewers"]
now = time.time()
cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
crossed_min = prev_viewers < YT_VIEWER_ALERT_MIN and viewers >= YT_VIEWER_ALERT_MIN
new_peak = viewers >= YT_VIEWER_ALERT_MIN and viewers > peak_viewers
if (crossed_min or new_peak) and cooldown_ok:
peak_viewers = max(peak_viewers, viewers)
last_viewer_alert = now
alert_msg = f"{viewers} watching now (min={YT_VIEWER_ALERT_MIN}) β {state['title']}"
print(f"π [VIEWER] {alert_msg}", flush=True)
spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
)
_play_local_ding(ding, spoken_text=spoken_v)
if resolved_topic:
send_ntfy_notification(
alert_msg,
title="π Honeybot Live Viewer",
priority="default",
tags="eyes",
topic=resolved_topic,
)
prev_viewers = viewers
[[[DIVIDER]]]
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": state["concurrent_viewers"],
"peak_viewers": peak_viewers,
"alerted_in_run": alerted_in_run,
"comment_count": state["comment_count"],
"last_viewer_alert": last_viewer_alert,
"seen_ids": all_ids,
"updated_at": int(time.time()),
})
return 0
for m in state["chat_messages"]:
seen_ids.add(m["id"])
init_ts = time.time()
dwell_window = [(init_ts, state["concurrent_viewers"])] if state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN else []
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": state["concurrent_viewers"],
"peak_viewers": peak_viewers,
"alerted_in_run": alerted_in_run,
"comment_count": state["comment_count"],
"last_viewer_alert": last_viewer_alert,
"seen_ids": list(seen_ids)[-500:],
"updated_at": int(init_ts),
})
print(f"π Watching {resolved_channel} every {interval}s (dwell={YT_VIEWER_ALERT_DWELL}s)...")
print("π‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d")
try:
while True:
time.sleep(max(5, int(interval)))
poll_ts = time.time()
try:
state = fetch_youtube_live_state(resolved_channel)
except Exception as exc:
print(f"β οΈ Poll error: {exc}")
continue
viewers = state["concurrent_viewers"]
now = time.time()
if viewers >= YT_VIEWER_ALERT_MIN:
dwell_window.append((poll_ts, viewers))
cutoff_ts = poll_ts - max(YT_VIEWER_ALERT_DWELL * 3, int(interval) * 4)
dwell_window = [(ts, v) for ts, v in dwell_window if ts >= cutoff_ts]
if len(dwell_window) == 1 and YT_VIEWER_ALERT_DWELL > 0:
print(
f"β³ [VIEWER DWELL] {viewers} watching (min={YT_VIEWER_ALERT_MIN}) β "
f"holding {YT_VIEWER_ALERT_DWELL}s to confirm they stick around...",
flush=True,
)
sustained = _sustained_viewer_count(dwell_window, poll_ts, YT_VIEWER_ALERT_DWELL)
cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
if sustained >= YT_VIEWER_ALERT_MIN and (not alerted_in_run or sustained > peak_viewers) and cooldown_ok:
alerted_in_run = True
peak_viewers = max(peak_viewers, sustained)
last_viewer_alert = now
dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
alert_msg = (
f"{viewers} watching now (sustained >={dwell_elapsed}s, min={YT_VIEWER_ALERT_MIN}) β "
f"{state['title']}"
)
print(f"π [VIEWER] {alert_msg}", flush=True)
spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
)
_play_local_ding(ding, spoken_text=spoken_v)
if resolved_topic:
send_ntfy_notification(
alert_msg,
title="π Honeybot Live Viewer",
priority="default",
tags="eyes",
topic=resolved_topic,
)
else:
if dwell_window and not alerted_in_run and YT_VIEWER_ALERT_DWELL > 0:
dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
print(
f"π¨ [VIEWER DWELL] Dropped to {viewers} after {dwell_elapsed}s "
f"(<{YT_VIEWER_ALERT_DWELL}s dwell) β notification suppressed.",
flush=True,
)
dwell_window.clear()
alerted_in_run = False
[[[REPLACE]]]
Target: scripts/articles/common.py
[[[SEARCH]]]
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": viewers,
"peak_viewers": peak_viewers,
"comment_count": cur_comments,
"last_viewer_alert": last_viewer_alert,
"seen_ids": list(seen_ids)[-500:],
"updated_at": int(now),
})
[[[DIVIDER]]]
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": viewers,
"peak_viewers": peak_viewers,
"alerted_in_run": alerted_in_run,
"comment_count": cur_comments,
"last_viewer_alert": last_viewer_alert,
"seen_ids": list(seen_ids)[-500:],
"updated_at": int(now),
})
[[[REPLACE]]]
Ignition β restart the running ytwatch tmux daemon so it loads the patched common.py:
(nix) pipulate $
ytwatch restart
(4) PROMPT
Use prompt or p to create prompt.md from copy:
Verify the AFTER receipts for the 60-second `YT_VIEWER_ALERT_DWELL` dampening filter in `scripts/articles/common.py` and confirm `ytwatch` status.
(5) EXTERNAL DELIVERABLES
No external deliverables required. YT_VIEWER_ALERT_DWELL defaults to 60 seconds and can be tuned anytime via PIPULATE_YT_VIEWER_DWELL in ~/.config/pipulate/.env.
Applying the Patch and Inspecting the Diff Receipt
MikeLev.in:
THE AI-EDIT METHOD
Same commands, run twice, one change between them. Where the readings differ is what the change did; the diff in the middle is the receipt.
1: BEFORE (PROBE):
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
GIT repo clean. Take BEFORE reading, make CHANGE, record AFTER diff.
(nix) pipulate $ .venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
.venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"
π‘ [DRY RUN] YouTube Watch: channel=https://www.youtube.com/@MikeLevin/live | min_viewers=3 | ntfy=configured | parser_ok=True
dwell= None spike_30s= None sustained_60s= None
(nix) pipulate $
2: AFTER (NEXT CONTEXT):
# Content 1
# context.txt
# flake.nix
#
# # HOME-HOSTED (NOT BEHIND CDN) NGINX WEBSERVER HONEYBOT AI OBSERVATORY
# nixops.sh
# remotes/honeybot/hooks/post-receive
# remotes/honeybot/nixos/configuration.nix
# remotes/honeybot/scripts/score.py
# remotes/honeybot/scripts/card.py
# remotes/honeybot/scripts/forest.py
# remotes/honeybot/scripts/test_forest.py
# remotes/honeybot/scripts/logs.py
# remotes/honeybot/scripts/content_loader.py
# remotes/honeybot/scripts/db.py
#
# scripts/articles/common.py
# Context 2
context.txt
scripts/articles/common.py
! .venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
! .venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"
3: CHANGE (PATCHES):
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
GIT repo clean. Take BEFORE reading, make CHANGE, record AFTER diff.
(nix) pipulate $ patch
(nix) pipulate $ app
β
DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
β
DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
β
DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
β
DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
β
DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
(nix) pipulate $ d
diff --git a/scripts/articles/common.py b/scripts/articles/common.py
index 4b29712..923ca17 100644
--- a/scripts/articles/common.py
+++ b/scripts/articles/common.py
@@ -17,6 +17,7 @@ LAST_PUBLISHED_FILE = CONFIG_DIR / "last_published.json"
# Minimum simultaneous viewers required to ring the bell + send an ntfy push
# (2 = viewer + their trusted advisor; lone 1-viewer drive-bys stay silent unless they comment)
YT_VIEWER_ALERT_MIN = int(os.environ.get("PIPULATE_YT_VIEWER_MIN", "3"))
+YT_VIEWER_ALERT_DWELL = int(os.environ.get("PIPULATE_YT_VIEWER_DWELL", "60")) # Seconds viewers must stay >= min before alerting
YT_VIEWER_ALERT_COOLDOWN = int(os.environ.get("PIPULATE_YT_VIEWER_COOLDOWN", "300")) # Seconds between any two viewer alerts
DEFAULT_POSTS_DIR = Path(
@@ -1758,6 +1759,20 @@ def _save_ytwatch_state(state_obj):
pass
+def _sustained_viewer_count(dwell_window, now_ts, dwell_secs):
+ """Return the viewer count continuously sustained for at least dwell_secs, or 0."""
+ if not dwell_window:
+ return 0
+ if dwell_secs <= 0:
+ return int(dwell_window[-1][1])
+ run_min = int(dwell_window[-1][1])
+ for ts, v in reversed(dwell_window):
+ run_min = min(run_min, int(v))
+ if (now_ts - float(ts)) >= dwell_secs:
+ return run_min
+ return 0
+
+
SPOKEN_ALERT_TABLE = {
"viewers_threshold": "Web Bot Observatory alert. YouTube concurrent viewers reached {count}, meeting the summon threshold of {min_viewers}.",
"viewers_above_2": "Web Bot Observatory alert. YouTube concurrent viewers climbed above two, now at {count}.",
@@ -2006,7 +2021,7 @@ def run_youtube_watch(
parsed = _extract_yt_json(sample, "ytInitialPlayerResponse =")
ok = (parsed.get("videoDetails") or {}).get("videoId") == "test1234567"
t_label = "configured" if resolved_topic else "unconfigured"
- print(f"π‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | ntfy={t_label} | parser_ok={ok}")
+ print(f"π‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | dwell={YT_VIEWER_ALERT_DWELL}s | ntfy={t_label} | parser_ok={ok}")
return 0 if ok else 1
try:
state = fetch_youtube_live_state(resolved_channel)
@@ -2017,10 +2032,10 @@ def run_youtube_watch(
same_video = (persisted.get("video_id") == state["video_id"] and bool(state["video_id"]))
seen_ids = set(persisted.get("seen_ids") or []) if same_video else set()
had_prior_snapshot = bool(same_video and persisted.get("seen_ids") is not None)
- prev_viewers = int(persisted.get("concurrent_viewers") or 0) if same_video else state["concurrent_viewers"]
- peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else state["concurrent_viewers"]
+ peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else 0
prev_comments = persisted.get("comment_count") if same_video else state["comment_count"]
last_viewer_alert = float(persisted.get("last_viewer_alert") or 0.0) if same_video else 0.0
+ alerted_in_run = bool(persisted.get("alerted_in_run")) if (same_video and state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN) else False
daemon_running = (
subprocess.run(
["tmux", "has-session", "-t", "ytwatch"],
@@ -2032,7 +2047,7 @@ def run_youtube_watch(
d_str = " | daemon=RUNNING" if daemon_running else ""
print(
f"π‘ YouTube Watch: LIVE={'yes' if state['is_live'] else 'no'} | "
- f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN}) | "
+ f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN} for {YT_VIEWER_ALERT_DWELL}s) | "
f"chat_items={len(state['chat_messages'])}{c_str}{d_str} | title={state['title']!r}"
)
if not loop:
@@ -2059,7 +2074,8 @@ def run_youtube_watch(
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": state["concurrent_viewers"],
- "peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
+ "peak_viewers": peak_viewers,
+ "alerted_in_run": alerted_in_run,
"comment_count": state["comment_count"],
"last_viewer_alert": last_viewer_alert,
"seen_ids": all_ids,
@@ -2068,20 +2084,24 @@ def run_youtube_watch(
return 0
for m in state["chat_messages"]:
seen_ids.add(m["id"])
+ init_ts = time.time()
+ dwell_window = [(init_ts, state["concurrent_viewers"])] if state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN else []
_save_ytwatch_state({
"video_id": state["video_id"],
"concurrent_viewers": state["concurrent_viewers"],
- "peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
+ "peak_viewers": peak_viewers,
+ "alerted_in_run": alerted_in_run,
"comment_count": state["comment_count"],
"last_viewer_alert": last_viewer_alert,
"seen_ids": list(seen_ids)[-500:],
- "updated_at": int(time.time()),
+ "updated_at": int(init_ts),
})
- print(f"π Watching {resolved_channel} every {interval}s...")
+ print(f"π Watching {resolved_channel} every {interval}s (dwell={YT_VIEWER_ALERT_DWELL}s)...")
print("π‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d")
try:
while True:
time.sleep(max(5, int(interval)))
+ poll_ts = time.time()
try:
state = fetch_youtube_live_state(resolved_channel)
except Exception as exc:
@@ -2089,27 +2109,50 @@ def run_youtube_watch(
continue
viewers = state["concurrent_viewers"]
now = time.time()
- cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
- crossed_min = prev_viewers < YT_VIEWER_ALERT_MIN and viewers >= YT_VIEWER_ALERT_MIN
- new_peak = viewers >= YT_VIEWER_ALERT_MIN and viewers > peak_viewers
- if (crossed_min or new_peak) and cooldown_ok:
- peak_viewers = max(peak_viewers, viewers)
- last_viewer_alert = now
- alert_msg = f"{viewers} watching now (min={YT_VIEWER_ALERT_MIN}) β {state['title']}"
- print(f"π [VIEWER] {alert_msg}", flush=True)
- spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
- count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
- )
- _play_local_ding(ding, spoken_text=spoken_v)
- if resolved_topic:
- send_ntfy_notification(
- alert_msg,
- title="π Honeybot Live Viewer",
- priority="default",
- tags="eyes",
- topic=resolved_topic,
+ if viewers >= YT_VIEWER_ALERT_MIN:
+ dwell_window.append((poll_ts, viewers))
+ cutoff_ts = poll_ts - max(YT_VIEWER_ALERT_DWELL * 3, int(interval) * 4)
+ dwell_window = [(ts, v) for ts, v in dwell_window if ts >= cutoff_ts]
+ if len(dwell_window) == 1 and YT_VIEWER_ALERT_DWELL > 0:
+ print(
+ f"β³ [VIEWER DWELL] {viewers} watching (min={YT_VIEWER_ALERT_MIN}) β "
+ f"holding {YT_VIEWER_ALERT_DWELL}s to confirm they stick around...",
+ flush=True,
+ )
+ sustained = _sustained_viewer_count(dwell_window, poll_ts, YT_VIEWER_ALERT_DWELL)
+ cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
+ if sustained >= YT_VIEWER_ALERT_MIN and (not alerted_in_run or sustained > peak_viewers) and cooldown_ok:
+ alerted_in_run = True
+ peak_viewers = max(peak_viewers, sustained)
+ last_viewer_alert = now
+ dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
+ alert_msg = (
+ f"{viewers} watching now (sustained >={dwell_elapsed}s, min={YT_VIEWER_ALERT_MIN}) β "
+ f"{state['title']}"
+ )
+ print(f"π [VIEWER] {alert_msg}", flush=True)
+ spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
+ count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
+ )
+ _play_local_ding(ding, spoken_text=spoken_v)
+ if resolved_topic:
+ send_ntfy_notification(
+ alert_msg,
+ title="π Honeybot Live Viewer",
+ priority="default",
+ tags="eyes",
+ topic=resolved_topic,
+ )
+ else:
+ if dwell_window and not alerted_in_run and YT_VIEWER_ALERT_DWELL > 0:
+ dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
+ print(
+ f"π¨ [VIEWER DWELL] Dropped to {viewers} after {dwell_elapsed}s "
+ f"(<{YT_VIEWER_ALERT_DWELL}s dwell) β notification suppressed.",
+ flush=True,
)
- prev_viewers = viewers
+ dwell_window.clear()
+ alerted_in_run = False
for msg in state["chat_messages"]:
if msg["id"] in seen_ids:
continue
@@ -2186,6 +2229,7 @@ def run_youtube_watch(
"video_id": state["video_id"],
"concurrent_viewers": viewers,
"peak_viewers": peak_viewers,
+ "alerted_in_run": alerted_in_run,
"comment_count": cur_comments,
"last_viewer_alert": last_viewer_alert,
"seen_ids": list(seen_ids)[-500:],
(nix) pipulate $ m
π Committing: chore: Update YouTube viewer alert dwell duration
[main 43617c3] chore: Update YouTube viewer alert dwell duration
1 file changed, 72 insertions(+), 28 deletions(-)
(nix) pipulate $ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 48 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 1.61 KiB | 825.00 KiB/s, done.
Total 5 (delta 4), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.
To github.com:pipulate/pipulate.git
0f50624..43617c3 main -> main
(nix) pipulate $
Ignition: often βexitβ, βnix developβ. Instructions may vary.
Ignition is restarting with ytwatch restart. Okay.
Restarting the Tmux Daemon in Live Mode
(sys) ~ $ p
(sys) pipulate $ ndq
(nix) pipulate $ ytlog
π 'ytwatch' daemon was not running β starting it now...
π Started background 'ytwatch' daemon in tmux (polling every 30s).
β’ Watch live stream: ytwatch log (or `ytlog` β press `q` or `Ctrl+b d` to exit)
β’ Check status: ytwatch status
β’ Stop daemon: ytwatch stop
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π ENTERING YTWATCH LIVE TMUX MONITOR (Read-Only Safe Mode) β
β β’ Muscle-Memory Detach : Press Ctrl+b, release both, then press d β
β β’ Quick Escape Hatch : Press q (detaches & keeps daemon running!) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
[detached (from session ytwatch)]
β
Detached from 'ytwatch' monitor (daemon is STILL RUNNING in background).
(nix) pipulate $ ytlog
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π ENTERING YTWATCH LIVE TMUX MONITOR (Read-Only Safe Mode) β
β β’ Muscle-Memory Detach : Press Ctrl+b, release both, then press d β
β β’ Quick Escape Hatch : Press q (detaches & keeps daemon running!) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
[detached (from session ytwatch)]
β
Detached from 'ytwatch' monitor (daemon is STILL RUNNING in background).
(nix) pipulate $ ytwatch restart
π Started background 'ytwatch' daemon in tmux (polling every 30s).
β’ Watch live stream: ytwatch log (or `ytlog` β press `q` or `Ctrl+b d` to exit)
β’ Check status: ytwatch status
β’ Stop daemon: ytwatch stop
(nix) pipulate $ ytlog
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π ENTERING YTWATCH LIVE TMUX MONITOR (Read-Only Safe Mode) β
β β’ Muscle-Memory Detach : Press Ctrl+b, release both, then press d β
β β’ Quick Escape Hatch : Press q (detaches & keeps daemon running!) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
[detached (from session ytwatch)]
β
Detached from 'ytwatch' monitor (daemon is STILL RUNNING in background).
(nix) pipulate $
That looks like it worked. I guess Iβll just wait and hear.
4: PROMPT:
Verify the AFTER receipts for the 60-second YT_VIEWER_ALERT_DWELL dampening filter in scripts/articles/common.py and confirm ytwatch status.
5: DELIVERABLE:
This seems done. Okay, this was a quick one-off but even these deserve their own mini-streams. I just have to make sure the previous article had at least one full read-through storytime stream. Reducing notification noise on this very closed feedback loop seems on-brand and something that shouldnβt be a gap in the continuous record of this project.
Sealing the Session at the Notary Beat
Hop off the ride. This rideβs stated goal is reached β dismount. This is the NOTARY BEAT: the ride ends here, is witnessed here, and is sealed here. Answer all seven beats, briefly:
- TL;DR: a short, dry, neutral abstract for the TOP of the published article β written for an unfamiliar reader or AI summarizer who has never seen this system. No hype, no insider handles unexplained.
- VERIFY: restate the goal from the top of this article and confirm (or deny) it was met, citing THIS compileβs receipts, not memory. Name any ignition this ride required that never fired β an AFTER tap taken without one is a stale BEFORE wearing the AFTERβs label.
- BANK: name everything that graduates β rule, earmark, todo, pin β as SEARCH/REPLACE patch cars against raw source present in THIS context (Target line inside the fence, one car per commit story), deletions included. A βpaste-readyβ line with no Target and no SEARCH anchor is not banked; it is a hand edit the operator will not make. These BANK cars are the ONLY patches a dismount emits.
- DANGLING: what carries forward unbanked? One line each, no essays.
- SEED: the context.txt lines (and TODO_SLUGS if narrative context is needed) for the next rideβs first compile.
- CLOSING: a closing summary for the BOTTOM of the article β the final take-away, tied to the bookβs larger arc where it fits naturally, never forced. Storytelling over inventory.
- NOTARIZE: read the exact Deed line from THIS compileβs final QA ARCHIVE SEAL footer, outside the payload, not a quoted older footer or a newest-file guess. It names the input archive, not this later response or the generated Jekyll post. State verification separately: only a verifier receipt naming that archive supports a verified claim; otherwise say not independently verified here. Count this Manifestβs live-command receipts, including failures. Quote only recorded digests, sizes and dates, naming their source; filesystem mtime and fixed ZIP dates are not command timestamps. Do not demand absent wc -c or dated-receipt fields. If the footer is missing, say so; otherwise close with its exact Deed line.
FINALITY: after beat 6, this discussion is CLOSED. Emit NO five-car train, NO probes, NO patches beyond the BANK cars of beat 2, and NO next-turn prompt beyond the SEED lines in beat 4. Any reader or model encountering this article later should treat it as a finished, notarized document β an archive entry, not an open thread.
Gemini 4 Argon High:
1. VERIFY
- Stated Goal: Add a ~60-second minimum time-delay (dwell dampening filter) to the YouTube viewer summon threshold (
YT_VIEWER_ALERT_MIN = 3) inscripts/articles/common.pyso brief viewer spikes do not trigger see-sawing notifications unless the viewers stay for at least a minute. - Status: Met in code and ignited; live AFTER
!compile receipt skipped. - BEFORE Receipt: Hand-run probe at
(nix) pipulate $confirmeddwell= None spike_30s= None sustained_60s= Noneand dry-run output withoutdwell. - CHANGE Receipt: All five
apply.pyblocks applied cleanly toscripts/articles/common.py(4b29712..923ca17), addingYT_VIEWER_ALERT_DWELL = 60,_sustained_viewer_count(), and thedwell_windowsustain/suppression gates inrun_youtube_watch(), committed as[main 43617c3] chore: Update YouTube viewer alert dwell duration(1 file changed, 72 insertions(+), 28 deletions(-)) and pushed (0f50624..43617c3). - Ignition Receipt:
ytwatch restartwas executed at(nix) pipulate $, restarting the backgroundtmuxsession (π Started background 'ytwatch' daemon in tmux (polling every 30s).). - AFTER Probe Note: Because this dismount turn was pasted directly into the conversation without running a second
prompt_foo.pycompile, the two!probe lines staged incontext.txtwere not executed asLIVE COMMAND RECEIPTSin a new Manifest.
2. BANK
No BANK patch cars emitted. scripts/articles/common.py is already committed and pushed in 43617c3, and foo_files.py is not present in raw source in this context.
3. DANGLING
- Observe
β³ [VIEWER DWELL]andπ¨ [VIEWER DWELL]inytlogunder live stream traffic during the next brief viewer bounce vs. 60-second sustained crowd.
4. SEED
context.txt
flake.nix
nixops.sh
remotes/honeybot/hooks/post-receive
remotes/honeybot/nixos/configuration.nix
remotes/honeybot/scripts/score.py
remotes/honeybot/scripts/card.py
remotes/honeybot/scripts/forest.py
remotes/honeybot/scripts/test_forest.py
remotes/honeybot/scripts/logs.py
remotes/honeybot/scripts/content_loader.py
remotes/honeybot/scripts/db.py
scripts/articles/common.py
5. CLOSING
An automated observatory only works as a human instrument if its alarms respect human attention. When a sensor polls a noisy boundaryβlike concurrent viewers drifting between two and three on a 24/7 broadcastβan instantaneous trigger turns every one-poll flicker into a false summon. Requiring the signal to hold across a 60-second sliding window before ringing the bell separates drive-by noise from genuine company, keeping the operatorβs feedback loop calm enough to stay switched on all day.
6. NOTARIZE
- This Dismount Turn: The
=== QA ARCHIVE SEAL ===footer andManifestare missing on this final turn because the transcript was sent directly in chat rather than through a freshprompt_foo.pycompile (0live-command receipts in this turn). - Session Opening Compile (Turn 1):
- Manifest Live-Command Receipts:
0!receipts (16 static codebase files totaling124,418codebase tokens; Prompt Summary:133,955summed tokens,135,952verified tokens,57,784words,539,261chars,543,679UTF-8 bytes). - Pasted Git Commit Receipt (Turn 2):
43617c3(0f50624..43617c3onmain,scripts/articles/common.pyindex4b29712..923ca17). - Verification Status: Not independently verified here (no
scripts/foo_cartridge.pyreceipt was captured in this session). - Turn 1 QA Archive Seal Footer:
Deed: qamy.ai_274-9c8dfdda.zip(no footer present on Turn 2).
Book Analysis
Ai Editorial Take
What stands out in this small patch is that it implements software contact debouncing for human attention. In digital electronics, a mechanical switch doesnβt close cleanly; its metal contacts bounce for a few milliseconds, and without a Schmitt trigger or a timing capacitor, a microcontroller counts a single button press as a dozen rapid interrupts. A live-stream audience counter behaves the exact same way at the human timescaleβbouncing across the integer boundary of 2 and 3 as browser tabs open, buffer, or close. Crucially, a cooldown timer alone (YT_VIEWER_ALERT_COOLDOWN = 300) only rate-limits the noise after the first false positive has already interrupted the operator. Pairing a trailing cooldown with a leading 60-second sustain gate creates a true low-pass filter on the summon channel, proving that the difference between an annoying script and a trusted ambient instrument is often twenty lines of sliding-window arithmetic.
π¦ X.com Promo Tweet
Brief viewer spikes shouldn't ring the bell. A 60-second sliding-window dwell filter in Python confirms viewers stay before firing TTS or ntfy.
https://mikelev.in/futureproof/dwell-gate-debouncing-ytwatch-viewer-alerts/
#Python #Linux #Automation
π¨ Hero Banner Image Prompt
A wide 16:9 widescreen graphic-novel ink-and-wash illustration set in a deep obsidian-slate (#111111) environment, featuring a precision brass mechanical sliding-window gate and glowing amber hourglass vertically centered in the composition. From the left, jagged cyan telemetry waves representing erratic signal spikes enter a series of three brass chronometer rings; transient spikes dissipate into wisps of silver vapor at the first ring, while a steady, continuous golden beam passes cleanly through all three aligned rings to strike a resonant copper bell on the right, sending warm amber acoustic ripples outward into the dark slate workshop. High-contrast chiaroscuro lighting, crisp architectural linework, and intricate analog-digital instrument textures extend continuously to every edge of the scene. Full-bleed edge-to-edge composition filling 100% of the canvas with no borders, no frames, no matting, no margins, and no text overlays or lettering.
πΌοΈ YAML Frontmatter Image Override
image:
path: /assets/images/dwell-gate-debouncing-ytwatch-viewer-alerts.jpeg
alt: "A brass mechanical hourglass and sliding-window gate filtering erratic signal spikes before striking a resonant chime."
Title Brainstorm
- Title Option: The 60-Second Dwell Gate: Debouncing YouTube Live Viewer Alerts with Sliding-Window Filters
- Filename:
dwell-gate-debouncing-ytwatch-viewer-alerts.md - Rationale: Directly names the core engineering mechanism (the 60-second dwell gate and sliding-window filter) and connects it to the familiar hardware/software concept of debouncing noisy signals.
- Filename:
- Title Option: Suppressing the See-Saw: Sliding-Window Dwell Filters for Live Stream Telemetry
- Filename:
sliding-window-dwell-filters-ytwatch-ntfy.md - Rationale: Echoes the operatorβs exact βsee-sawing notificationsβ framing while highlighting the concrete Python telemetry and ntfy integration.
- Filename:
- Title Option: Holding the Bell: Engineering a 60-Second Sustain Filter for YouTube Watch Notifications
- Filename:
holding-the-bell-ytwatch-sustain-filter.md - Rationale: Focuses on the human-attention ergonomics of holding an acoustic and mobile summon until attendance is verified across multiple polls.
- Filename:
- Title Option: Three Polls to Ring: Taming Notification Bounce in Python Polling Daemons
- Filename:
three-polls-to-ring-ytwatch-bounce-suppression.md - Rationale: Highlights the discrete sampling math (0s, 30s, 60s polls) that makes the sliding window work in a lightweight polling loop.
- Filename:
Content Potential And Polish
- Core Strengths:
- Clear, visual ASCII timing diagram that immediately explains how a 30-second poll interval interacts with a 60-second sustain window.
- Pure-function design in
_sustained_viewer_count(dwell_window, now_ts, dwell_secs), making the time-series minimum check trivial to test via a one-line CLI probe without mocking network calls. - Explicit console observability for suppressed events (
β³ [VIEWER DWELL]andπ¨ [VIEWER DWELL]), ensuring that silence on the audio bus is still auditable in theytlogscrollback.
- Suggestions For Polish:
- Run the staged
context.txt!probe commands after applying the patch so the post-change output (dwell= 60 spike_30s= 0 sustained_60s= 3) is captured directly in the transcript alongside the git diff. - Note how
dwell_windowis held in-memory inside thewhile Trueloop whereasalerted_in_runis persisted toytwatch_state.json, explaining what happens across a manual daemon restart mid-stream.
- Run the staged
Next Step Prompts
- Run the one-liner Python probe from
context.txtto capture the liveAFTERverification receipt for_sustained_viewer_countagainst the 30-second spike and 60-second sustained test tuples. - Review
ytlogoutput after the next live broadcast session to verify howβ³ [VIEWER DWELL]andπ¨ [VIEWER DWELL]log lines render during real viewer fluctuations.
Accept: text/markdown, RFC 9111 Vary: Accept, and RFC 9421 Web Bot Auth) can save you that money. Contact Mike for a consultation (miklevin@gmail.com)
β’ RFC 9110/9111 Edge Advisory
β’ LinkedIn
