Delayed chat archive finalization after stream stops.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 7s
CI / Tests (Python 3.13) (push) Successful in 7s
CI / Tests (Python 3.14) (push) Successful in 6s
CI / Type Checking (push) Successful in 7s
CI / Spelling (push) Successful in 4s

This commit is contained in:
2026-05-27 11:19:12 -04:00
parent 5d113eecaa
commit c93ffc551d
3 changed files with 198 additions and 19 deletions
+11 -6
View File
@@ -13,9 +13,13 @@ While an archive is active, the module records chat messages, user joins, user
parts, name changes, stream title updates, and message visibility updates in the
order Owlbot dispatches them. Events outside an active stream are ignored.
Archive files are stream-scoped. When a stream ends, the active archive is
finalized, and the next live stream starts a new archive file. Module unload
also finalizes the active archive.
Archive files are stream-scoped. When a stream ends, the module keeps that
stream's archive open for a five-minute grace period so it can capture chat
that Owncast still accepts after the stream stops. If no new stream starts
during that window, the archive is finalized when the grace period expires. If a
new stream starts first, the stopped stream's archive is finalized immediately
and a new archive is opened for the new stream. Module unload also finalizes the
active archive.
## Configuration
@@ -51,11 +55,12 @@ Each archive is a single JSON document with these top-level fields:
- `archive_stopped_at`: ISO 8601 timestamp for when the archive was finalized,
or `null` while the archive is active.
- `archive_stop_reason`: reason the archive was finalized, or `null` while the
archive is active. Current values are `stream_stopped_event`,
`module_teardown`, and `replaced_by_stream_started_event`.
archive is active or waiting through the post-stream grace period. Current
values are `stream_stopped_event`, `module_teardown`, and
`replaced_by_stream_started_event`.
- `stream_title`: stream title when the archive started, or `null` if
unavailable.
- `stream_stopped_at`: stream stop timestamp recorded when finalized by a
- `stream_stopped_at`: stream stop timestamp recorded when Owlbot receives a
stream stop event, or `null` otherwise.
- `event_count`: number of objects in `events`.
- `events`: ordered list of captured activity events.
+58 -8
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
@@ -43,6 +44,7 @@ from owlbot.api import (
)
_STATE_KEY = "chat_archive_state"
_STOP_GRACE_SECONDS = 300.0
type ArchiveStartReason = Literal["module_startup", "stream_started_event"]
type ArchiveStopReason = Literal[
@@ -61,6 +63,7 @@ class ArchiveState:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
current_path: Path | None = None
current_data: dict[str, Any] | None = None
stop_task: asyncio.Task[None] | None = None
def _get_state(ctx: ModuleContext) -> ArchiveState:
@@ -134,6 +137,7 @@ async def handle_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
started_at = ctx.event.timestamp or datetime.now(UTC)
async with state.lock:
await _cancel_pending_stop(state)
path = await _start_archive(
ctx.module,
state,
@@ -187,19 +191,31 @@ async def archive_stream_title_update(
@on_event(EventType.STREAM_STOPPED)
async def handle_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
"""Finalize the active archive when Owncast reports that the stream stopped."""
"""Schedule active archive finalization after Owncast's chat grace period."""
state = _get_state(ctx.module)
async with state.lock:
path = await _finalize_archive(
ctx.module,
state,
reason="stream_stopped_event",
stream_stopped_at=ctx.event.timestamp or datetime.now(UTC),
)
await _cancel_pending_stop(state)
data = state.current_data
path = state.current_path
if data is not None and path is not None:
updated_at = datetime.now(UTC)
data["stream_stopped_at"] = (
ctx.event.timestamp or datetime.now(UTC)
).isoformat()
data["archive_updated_at"] = updated_at.isoformat()
await _write_json(path, data, pretty_json=state.pretty_json)
state.stop_task = asyncio.create_task(
_delayed_finalize_after_stream_stop(ctx.module, state),
name="Chat Archive Module - Stream stop grace period",
)
if path is not None:
ctx.logger.info("Chat archive finalized after stream stopped: %s", path)
ctx.logger.info(
"Stream stopped. Chat archive will finalize in %.0f seconds: %s",
_STOP_GRACE_SECONDS,
path,
)
@on_teardown
@@ -208,6 +224,7 @@ async def teardown(ctx: ModuleContext) -> None:
state = _get_state(ctx)
async with state.lock:
await _cancel_pending_stop(state)
await _finalize_archive(ctx, state, reason="module_teardown")
@@ -255,6 +272,39 @@ async def _start_archive(
return path
async def _cancel_pending_stop(state: ArchiveState) -> None:
"""Cancel a delayed stream-stop finalization task, if one exists."""
task = state.stop_task
if task is None:
return
state.stop_task = None
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def _delayed_finalize_after_stream_stop(
ctx: ModuleContext,
state: ArchiveState,
) -> None:
"""Wait for post-stream chat to close, then finalize the active archive."""
await asyncio.sleep(_STOP_GRACE_SECONDS)
async with state.lock:
if state.stop_task is not asyncio.current_task():
return
try:
await _finalize_archive(ctx, state, reason="stream_stopped_event")
except Exception:
ctx.logger.exception(
"Failed to finalize chat archive after stream stop grace period."
)
finally:
state.stop_task = None
async def _archive_event(
ctx: ModuleContext,
event_type: EventType,