Switched stream status tracking to explicit online state.
CI / Formatting (push) Failing after 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 28s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 6s

This commit is contained in:
2026-05-21 16:09:28 -04:00
parent 4d0ae3d1ea
commit c837b916c0
11 changed files with 385 additions and 219 deletions
+11 -7
View File
@@ -78,7 +78,7 @@ def _format_duration(timestamp_str: str, now: datetime) -> str:
if seconds < _SECONDS_PER_DAY: if seconds < _SECONDS_PER_DAY:
hours = seconds // _SECONDS_PER_HOUR hours = seconds // _SECONDS_PER_HOUR
return f"{hours} hour{'s' if hours != 1 else ''}" return f"{hours} hour{'s' if hours != 1 else ''}"
except TypeError, ValueError: except (TypeError, ValueError):
return "unknown duration" return "unknown duration"
else: else:
days = seconds // _SECONDS_PER_DAY days = seconds // _SECONDS_PER_DAY
@@ -185,13 +185,15 @@ class CommandHandler:
# Determine status and duration (as a sub-bullet) # Determine status and duration (as a sub-bullet)
match stream_state.status: match stream_state.status:
case StreamStatus.ONLINE if stream_state.last_connect_time: case StreamStatus.ONLINE if stream_state.status_since:
duration = _format_duration(stream_state.last_connect_time, now) duration = _format_duration(stream_state.status_since, now)
parts.append(f" - Status: Online for {duration} \n") parts.append(f" - Status: Online for {duration} \n")
case StreamStatus.ONLINE:
parts.append(" - Status: Online \n")
case StreamStatus.UNKNOWN: case StreamStatus.UNKNOWN:
parts.append(" - Status: Unknown (instance unreachable) \n") parts.append(" - Status: Unknown (instance unreachable) \n")
case StreamStatus.OFFLINE if stream_state.last_disconnect_time: case StreamStatus.OFFLINE if stream_state.status_since:
duration = _format_duration(stream_state.last_disconnect_time, now) duration = _format_duration(stream_state.status_since, now)
parts.append(f" - Status: Offline for {duration} \n") parts.append(f" - Status: Offline for {duration} \n")
case StreamStatus.OFFLINE: case StreamStatus.OFFLINE:
parts.append(" - Status: Offline \n") parts.append(" - Status: Offline \n")
@@ -255,9 +257,11 @@ class CommandHandler:
parts.append(f" - Title: {safe_title} \n") parts.append(f" - Title: {safe_title} \n")
# Add status with duration # Add status with duration
if stream_state.last_connect_time: if stream_state.status_since:
duration = _format_duration(stream_state.last_connect_time, now) duration = _format_duration(stream_state.status_since, now)
parts.append(f" - Online for {duration} \n") parts.append(f" - Online for {duration} \n")
else:
parts.append(" - Online \n")
# Add stream link # Add stream link
parts.append(f" - Link: https://{domain}\n\n") parts.append(f" - Link: https://{domain}\n\n")
+5 -1
View File
@@ -15,6 +15,7 @@
"""HTTP client for querying Owncast instance APIs.""" """HTTP client for querying Owncast instance APIs."""
import json import json
from datetime import UTC, datetime
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -125,8 +126,11 @@ class OwncastClient:
if new_state is None: if new_state is None:
return None return None
observed_at = datetime.now(UTC)
try: try:
stream_state = StreamState.from_api_response(new_state, domain) stream_state = StreamState.from_api_response(
new_state, domain, observed_at
)
except InvalidApiResponseError as e: except InvalidApiResponseError as e:
self.log.warning( self.log.warning(
"[%s] Rejecting response to request on %s as response " "[%s] Rejecting response to request on %s as response "
+83 -4
View File
@@ -14,6 +14,7 @@
"""Repository and schema upgrade definitions for OwncastSentry.""" """Repository and schema upgrade definitions for OwncastSentry."""
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from mautrix.util.async_db import Connection, UpgradeTable from mautrix.util.async_db import Connection, UpgradeTable
@@ -24,6 +25,7 @@ from .types import (
NotSubscribedError, NotSubscribedError,
RoomSubscription, RoomSubscription,
StreamState, StreamState,
format_status_since,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -33,6 +35,33 @@ if TYPE_CHECKING:
upgrade_table = UpgradeTable() upgrade_table = UpgradeTable()
def _has_legacy_timestamp(value: Any) -> bool:
"""Return whether a legacy timestamp value carries usable content."""
return value is not None and str(value).strip() != ""
def _normalize_legacy_status_since(value: Any) -> str | None:
"""Normalize a legacy timestamp value to the canonical UTC format."""
if value is None:
return None
if isinstance(value, datetime):
parsed = value
elif isinstance(value, str):
value = value.strip()
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
else:
return None
if parsed.tzinfo is None:
return None
return format_status_since(parsed.astimezone(UTC))
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator] @upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
description="Initial revision" description="Initial revision"
) )
@@ -107,6 +136,56 @@ async def upgrade_v3(conn: Connection) -> None:
) )
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
description="Store online status and current status timestamp"
)
async def upgrade_v4(conn: Connection) -> None:
"""Upgrade database schema to version 4 format.
Replaces separate connect and disconnect timestamp columns with an
authoritative online flag and a timestamp for the current status.
:param conn: A connection to run the v4 database migration on.
"""
await conn.execute(
"""CREATE TABLE "streams_new" (
"domain" TEXT NOT NULL UNIQUE,
"name" TEXT,
"title" TEXT,
"online" BOOLEAN NOT NULL DEFAULT false,
"status_since" TEXT,
"failure_counter" INTEGER DEFAULT 0,
PRIMARY KEY("domain")
)"""
)
rows = await conn.fetch(
"""SELECT domain, name, title, last_connect_time, last_disconnect_time,
failure_counter
FROM streams"""
)
for row in rows:
online = _has_legacy_timestamp(row["last_connect_time"])
legacy_timestamp = (
row["last_connect_time"] if online else row["last_disconnect_time"]
)
await conn.execute(
"""INSERT INTO streams_new (
domain, name, title, online, status_since, failure_counter
)
VALUES ($1, $2, $3, $4, $5, $6)""",
row["domain"],
row["name"],
row["title"],
online,
_normalize_legacy_status_since(legacy_timestamp),
row["failure_counter"],
)
await conn.execute("DROP TABLE streams")
await conn.execute("ALTER TABLE streams_new RENAME TO streams")
def get_upgrade_table() -> UpgradeTable: def get_upgrade_table() -> UpgradeTable:
"""Return the repository upgrade table with registered migrations.""" """Return the repository upgrade table with registered migrations."""
return upgrade_table return upgrade_table
@@ -164,15 +243,15 @@ class StreamRepository:
:param state: The StreamState to save. :param state: The StreamState to save.
""" """
query = """UPDATE streams query = """UPDATE streams
SET name=$1, title=$2, last_connect_time=$3, last_disconnect_time=$4 SET name=$1, title=$2, online=$3, status_since=$4
WHERE domain=$5""" WHERE domain=$5"""
async with self.db.acquire() as conn: async with self.db.acquire() as conn:
await conn.execute( await conn.execute(
query, query,
state.name, state.name,
state.title, state.title,
state.last_connect_time, state.online,
state.last_disconnect_time, state.status_since,
state.domain, state.domain,
) )
@@ -313,7 +392,7 @@ class SubscriptionRepository:
FROM subscriptions FROM subscriptions
JOIN streams ON streams.domain = subscriptions.stream_domain JOIN streams ON streams.domain = subscriptions.stream_domain
WHERE subscriptions.room_id=$1 WHERE subscriptions.room_id=$1
AND streams.last_connect_time IS NOT NULL AND streams.online=true
AND streams.failure_counter <= $2 AND streams.failure_counter <= $2
ORDER BY streams.domain""" ORDER BY streams.domain"""
async with self.db.acquire() as conn: async with self.db.acquire() as conn:
+49 -66
View File
@@ -18,7 +18,7 @@ import asyncio
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .types import StreamState, StreamStatus, UpdateResult from .types import StreamState, UpdateResult
if TYPE_CHECKING: if TYPE_CHECKING:
import logging import logging
@@ -178,8 +178,8 @@ class StreamMonitor:
# Backoff is expected behavior, not a failure # Backoff is expected behavior, not a failure
return True return True
# Flag: no connect/disconnect time has been recorded yet, so suppress # Flag: no status timestamp has been recorded yet, so suppress
# notifications for a stream whose initial history state is live. # notifications for a stream's first observed state.
first_update = False first_update = False
# Flag: whether to update the stream's state in the database. # Flag: whether to update the stream's state in the database.
@@ -216,20 +216,20 @@ class StreamMonitor:
# Initialize timer cache entries to prevent KeyError on first access # Initialize timer cache entries to prevent KeyError on first access
self.offline_timer_cache.setdefault(domain, 0) self.offline_timer_cache.setdefault(domain, 0)
# Does the last known stream state lack connect/disconnect? if old_state.status_since is None:
if (
old_state.last_connect_time is None
and old_state.last_disconnect_time is None
):
# No stream history has been recorded yet. Don't send notifications. # No stream history has been recorded yet. Don't send notifications.
update_database = True update_database = True
first_update = True first_update = True
# Does the new state have a connect time but the old one not? if first_update:
if ( self.log.info(
new_state.last_connect_time is not None "[%s] Not sending notifications. This is the first state "
and old_state.last_connect_time is None "update for this stream.",
): domain,
)
# Did the stream become publicly online?
elif new_state.online and not old_state.online:
# Yes! This stream is now live. # Yes! This stream is now live.
update_database = True update_database = True
stream_config = await self.owncast_client.get_stream_config(domain) stream_config = await self.owncast_client.get_stream_config(domain)
@@ -241,59 +241,43 @@ class StreamMonitor:
time.monotonic() - self.offline_timer_cache[domain] time.monotonic() - self.offline_timer_cache[domain]
) )
# Has a prior connect/disconnect time been recorded? # Use fallback values if config fetch failed
if not first_update: stream_name = stream_config.name if stream_config else domain
# Use fallback values if config fetch failed stream_tags = stream_config.tags if stream_config else ()
stream_name = stream_config.name if stream_config else domain
stream_tags = stream_config.tags if stream_config else ()
# Has this stream been offline for a short time? # Has this stream been offline for a short time?
if ( if seconds_since_last_offline < _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN:
seconds_since_last_offline # Did the stream title change?
< _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN if old_state.title != new_state.title:
): # Stream was briefly down; send title change notification.
# Did the stream title change?
if old_state.title != new_state.title:
# Stream was briefly down; send title
# change notification.
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title or "",
stream_tags,
title_change=True,
)
else:
# Briefly offline, no title change. Skip.
self.log.info(
"[%s] Not sending notifications. Stream was only "
"offline for %s of %s seconds and did not change "
"its title.",
domain,
seconds_since_last_offline,
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
)
else:
# Offline for a while. Send a normal notification.
await self.notification_service.notify_stream_live( await self.notification_service.notify_stream_live(
domain, domain,
stream_name, stream_name,
new_state.title or "", new_state.title or "",
stream_tags, stream_tags,
title_change=False, title_change=True,
)
else:
# Briefly offline, no title change. Skip.
self.log.info(
"[%s] Not sending notifications. Stream was only "
"offline for %s of %s seconds and did not change "
"its title.",
domain,
seconds_since_last_offline,
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
) )
else: else:
# No stream history has been recorded yet. # Offline for a while. Send a normal notification.
self.log.info( await self.notification_service.notify_stream_live(
"[%s] Not sending notifications. This is the first state "
"update for this stream.",
domain, domain,
stream_name,
new_state.title or "",
stream_tags,
title_change=False,
) )
if ( elif new_state.online and old_state.online:
new_state.last_connect_time is not None
and old_state.last_connect_time is not None
):
# Did the stream title change mid-session? # Did the stream title change mid-session?
if old_state.title != new_state.title: if old_state.title != new_state.title:
self.log.info("[%s] Stream title was changed!", domain) self.log.info("[%s] Stream title was changed!", domain)
@@ -327,11 +311,8 @@ class StreamMonitor:
title_change=True, title_change=True,
) )
# Did the stream go offline (old had connect, new doesn't)? # Did the stream go offline?
elif ( elif not new_state.online and old_state.online:
new_state.last_connect_time is None
and old_state.last_connect_time is not None
):
# Yep. This stream is now offline. Log it. # Yep. This stream is now offline. Log it.
update_database = True update_database = True
self.offline_timer_cache[domain] = time.monotonic() self.offline_timer_cache[domain] = time.monotonic()
@@ -348,23 +329,25 @@ class StreamMonitor:
self.log.debug("[%s] Updating stream state in database...", domain) self.log.debug("[%s] Updating stream state in database...", domain)
if first_update or old_state.online != new_state.online:
status_since = new_state.status_since
else:
status_since = old_state.status_since
# Create updated state object (title already truncated in new_state) # Create updated state object (title already truncated in new_state)
updated_state = StreamState( updated_state = StreamState(
domain=domain, domain=domain,
name=stream_name, name=stream_name,
title=new_state.title, title=new_state.title,
last_connect_time=new_state.last_connect_time, online=new_state.online,
last_disconnect_time=new_state.last_disconnect_time, status_since=status_since,
) )
await self.stream_repo.update(updated_state) await self.stream_repo.update(updated_state)
# All done. # All done.
self.log.debug("[%s] State update completed.", domain) self.log.debug("[%s] State update completed.", domain)
if new_state.last_connect_time is not None: self.metrics.set_stream_status(domain, new_state.status)
self.metrics.set_stream_status(domain, StreamStatus.ONLINE)
else:
self.metrics.set_stream_status(domain, StreamStatus.OFFLINE)
return True return True
async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None: async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None:
+22 -21
View File
@@ -15,6 +15,7 @@
"""Data containers and domain errors for OwncastSentry.""" """Data containers and domain errors for OwncastSentry."""
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
@@ -47,14 +48,6 @@ def _require_str(response: dict[str, Any], field: str) -> str:
return value return value
def _require_nullable_str(response: dict[str, Any], field: str) -> str | None:
"""Return a required nullable string API response field."""
value = _require_field(response, field)
if value is not None and not isinstance(value, str):
raise InvalidApiResponseError(f"{field} must be a string or null")
return value
def _optional_config_str(response: dict[str, Any], field: str) -> str: def _optional_config_str(response: dict[str, Any], field: str) -> str:
"""Return an optional config string, defaulting to empty when absent.""" """Return an optional config string, defaulting to empty when absent."""
value = response.get(field, "") value = response.get(field, "")
@@ -80,6 +73,13 @@ def _truncate(text: str, max_length: int) -> str:
return text[:max_length] return text[:max_length]
def format_status_since(timestamp: datetime) -> str:
"""Format a status timestamp using the package's canonical UTC format."""
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return timestamp.astimezone(UTC).isoformat(timespec="seconds")
class StreamStatus(Enum): class StreamStatus(Enum):
"""Represents the status of a stream.""" """Represents the status of a stream."""
@@ -95,35 +95,36 @@ class StreamState:
domain: str domain: str
name: str | None = None name: str | None = None
title: str | None = None title: str | None = None
last_connect_time: str | None = None online: bool = False
last_disconnect_time: str | None = None status_since: str | None = None
failure_counter: int = 0 failure_counter: int = 0
@property @property
def status(self) -> StreamStatus: def status(self) -> StreamStatus:
"""Derive stream status from failure count and last connect time. """Derive stream status from failure count and online state.
Returns UNKNOWN if failures exceed the threshold, ONLINE if a Returns UNKNOWN if failures exceed the threshold, ONLINE if the
last connect time is present, or OFFLINE otherwise. stream is online, or OFFLINE otherwise.
""" """
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD: if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
return StreamStatus.UNKNOWN return StreamStatus.UNKNOWN
if self.last_connect_time is not None: if self.online:
return StreamStatus.ONLINE return StreamStatus.ONLINE
return StreamStatus.OFFLINE return StreamStatus.OFFLINE
@classmethod @classmethod
def from_api_response(cls, response: dict[str, Any], domain: str) -> StreamState: def from_api_response(
cls, response: dict[str, Any], domain: str, observed_at: datetime
) -> StreamState:
"""Create a StreamState from an API response. """Create a StreamState from an API response.
:param response: API response as a dictionary (camelCase keys). :param response: API response as a dictionary (camelCase keys).
:param domain: The stream domain. :param domain: The stream domain.
:param observed_at: Local time when this status was observed.
:return: StreamState instance. :return: StreamState instance.
:raises InvalidApiResponseError: If the response shape is invalid. :raises InvalidApiResponseError: If the response shape is invalid.
""" """
stream_title = _require_str(response, "streamTitle") stream_title = _require_str(response, "streamTitle")
last_connect_time = _require_nullable_str(response, "lastConnectTime")
last_disconnect_time = _require_nullable_str(response, "lastDisconnectTime")
online = _require_field(response, "online") online = _require_field(response, "online")
if not isinstance(online, bool): if not isinstance(online, bool):
raise InvalidApiResponseError("online must be a boolean") raise InvalidApiResponseError("online must be a boolean")
@@ -131,8 +132,8 @@ class StreamState:
return cls( return cls(
domain=domain, domain=domain,
title=_truncate(stream_title, _MAX_STREAM_TITLE_LENGTH), title=_truncate(stream_title, _MAX_STREAM_TITLE_LENGTH),
last_connect_time=last_connect_time, online=online,
last_disconnect_time=last_disconnect_time, status_since=format_status_since(observed_at),
) )
@classmethod @classmethod
@@ -146,8 +147,8 @@ class StreamState:
domain=row["domain"], domain=row["domain"],
name=row["name"], name=row["name"],
title=row["title"], title=row["title"],
last_connect_time=row["last_connect_time"], online=bool(row["online"]),
last_disconnect_time=row["last_disconnect_time"], status_since=row["status_since"],
failure_counter=row["failure_counter"], failure_counter=row["failure_counter"],
) )
+16 -10
View File
@@ -262,7 +262,8 @@ class TestSubscriptionsCommand:
domain="stream.logal.dev", domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
title="Playing Games", title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
) )
) )
@@ -297,7 +298,8 @@ class TestSubscriptionsCommand:
domain="stream.logal.dev", domain="stream.logal.dev",
name="*Bold* [link](https://evil.example)\nName", name="*Bold* [link](https://evil.example)\nName",
title="`code` > quote #tag", title="`code` > quote #tag",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
) )
) )
@@ -333,7 +335,7 @@ class TestSubscriptionsCommand:
StreamState( StreamState(
domain="stream.logal.dev", domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T10:00:00+00:00",
) )
) )
@@ -428,14 +430,15 @@ class TestSubscriptionsCommand:
domain="alpha.com", domain="alpha.com",
name="Alpha Stream", name="Alpha Stream",
title="Streaming Live", title="Streaming Live",
last_connect_time="2026-03-13T10:00:00Z", online=True,
status_since="2026-03-13T10:00:00+00:00",
) )
) )
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( StreamState(
domain="beta.com", domain="beta.com",
name="Beta Stream", name="Beta Stream",
last_disconnect_time="2026-03-12T18:00:00Z", status_since="2026-03-12T18:00:00+00:00",
) )
) )
@@ -491,7 +494,7 @@ class TestLiveCommand:
StreamState( StreamState(
domain="stream.logal.dev", domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T10:00:00+00:00",
) )
) )
@@ -524,7 +527,8 @@ class TestLiveCommand:
domain="stream.logal.dev", domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
title="Playing Games", title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
) )
) )
@@ -557,13 +561,14 @@ class TestLiveCommand:
await maubot_test_bot.send("!subscribe beta.com") await maubot_test_bot.send("!subscribe beta.com")
await maubot_test_bot.send("!subscribe alpha.com") await maubot_test_bot.send("!subscribe alpha.com")
# Set both streams online with different connect times # Set both streams online with different status timestamps
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( StreamState(
domain="alpha.com", domain="alpha.com",
name="Alpha Stream", name="Alpha Stream",
title="Morning Show", title="Morning Show",
last_connect_time="2026-03-13T10:00:00Z", online=True,
status_since="2026-03-13T10:00:00+00:00",
) )
) )
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
@@ -571,7 +576,8 @@ class TestLiveCommand:
domain="beta.com", domain="beta.com",
name="Beta Stream", name="Beta Stream",
title="Evening Vibes", title="Evening Vibes",
last_connect_time="2026-03-13T06:00:00Z", online=True,
status_since="2026-03-13T06:00:00+00:00",
) )
) )
+8 -9
View File
@@ -16,9 +16,11 @@
import json import json
import logging import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
import time_machine
from aioresponses import aioresponses from aioresponses import aioresponses
from owncastsentry.metrics import MetricsService from owncastsentry.metrics import MetricsService
@@ -112,17 +114,13 @@ class TestReadLimitedResponseBody:
response = _ChunkedResponse( response = _ChunkedResponse(
( (
b'{"streamTitle":', b'{"streamTitle":',
b'"hello","online":true,', b'"hello","online":true}',
b'"lastConnectTime":null,"lastDisconnectTime":null}',
) )
) )
result = await _read_limited_response_body(response) result = await _read_limited_response_body(response)
assert result == bytearray( assert result == bytearray(b'{"streamTitle":"hello","online":true}')
b'{"streamTitle":"hello","online":true,'
b'"lastConnectTime":null,"lastDisconnectTime":null}'
)
async def test_returns_none_when_content_length_is_too_large(self) -> None: async def test_returns_none_when_content_length_is_too_large(self) -> None:
"""Return None when Content-Length is already over the limit.""" """Return None when Content-Length is already over the limit."""
@@ -152,6 +150,7 @@ class TestReadLimitedResponseBody:
class TestGetStreamState: class TestGetStreamState:
"""Stream state retrieval from the status API.""" """Stream state retrieval from the status API."""
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_returns_state_on_success( async def test_returns_state_on_success(
self, owncast_client: OwncastClient self, owncast_client: OwncastClient
) -> None: ) -> None:
@@ -169,14 +168,14 @@ class TestGetStreamState:
result.title result.title
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501 == "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
) )
assert result.last_connect_time is None assert result.online is False
assert result.last_disconnect_time == "2026-03-04T21:05:32-05:00" assert result.status_since == "2026-03-13T12:00:00+00:00"
async def test_returns_none_on_missing_field( async def test_returns_none_on_missing_field(
self, owncast_client: OwncastClient self, owncast_client: OwncastClient
) -> None: ) -> None:
"""Return None when the response is missing required fields.""" """Return None when the response is missing required fields."""
incomplete = {"streamTitle": "Test Stream", "online": True} incomplete = {"streamTitle": "Test Stream"}
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
"https://stream.logal.dev/api/status", "https://stream.logal.dev/api/status",
+61 -3
View File
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from owncastsentry.repository import _normalize_legacy_status_since
from owncastsentry.types import ( from owncastsentry.types import (
UNKNOWN_STATUS_THRESHOLD, UNKNOWN_STATUS_THRESHOLD,
AlreadySubscribedError, AlreadySubscribedError,
@@ -26,9 +27,64 @@ from owncastsentry.types import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from mautrix.util.async_db import Database
from owncastsentry.repository import StreamRepository, SubscriptionRepository from owncastsentry.repository import StreamRepository, SubscriptionRepository
class TestStreamSchema:
"""Streams table schema after migrations."""
async def test_uses_online_and_status_since_columns(
self, database: Database
) -> None:
"""Create the current schema without legacy connect/disconnect columns."""
async with database.acquire() as conn:
rows = await conn.fetch("PRAGMA table_info(streams)")
assert [row["name"] for row in rows] == [
"domain",
"name",
"title",
"online",
"status_since",
"failure_counter",
]
class TestNormalizeLegacyStatusSince:
"""Legacy timestamp normalization used by the v4 migration."""
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param(
"2026-05-21T19:06:24Z",
"2026-05-21T19:06:24+00:00",
id="utc-z",
),
pytest.param(
"2026-05-21T19:38:40+02:00",
"2026-05-21T17:38:40+00:00",
id="positive-offset",
),
pytest.param(
"2026-05-17T21:23:27-04:00",
"2026-05-18T01:23:27+00:00",
id="negative-offset",
),
pytest.param("", None, id="blank"),
pytest.param(None, None, id="null"),
pytest.param("not a timestamp", None, id="malformed"),
],
)
def test_normalizes_parseable_aware_timestamps(
self, value: str | None, expected: str | None
) -> None:
"""Normalize parseable legacy values and ignore unusable ones."""
assert _normalize_legacy_status_since(value) == expected
class TestStreamExists: class TestStreamExists:
"""Stream existence checks.""" """Stream existence checks."""
@@ -174,7 +230,7 @@ class TestGetLiveRoomSubscriptions:
StreamState( StreamState(
domain="offline.example", domain="offline.example",
name="Offline", name="Offline",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
) )
await stream_repo.create("online.example") await stream_repo.create("online.example")
@@ -182,7 +238,8 @@ class TestGetLiveRoomSubscriptions:
StreamState( StreamState(
domain="online.example", domain="online.example",
name="Online", name="Online",
last_connect_time="2026-01-01T00:00:00Z", online=True,
status_since="2026-01-01T00:00:00+00:00",
) )
) )
await stream_repo.create("unknown.example") await stream_repo.create("unknown.example")
@@ -190,7 +247,8 @@ class TestGetLiveRoomSubscriptions:
StreamState( StreamState(
domain="unknown.example", domain="unknown.example",
name="Unknown", name="Unknown",
last_connect_time="2026-01-01T00:00:00Z", online=True,
status_since="2026-01-01T00:00:00+00:00",
) )
) )
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1): for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
+60 -48
View File
@@ -87,8 +87,8 @@ async def _seed_stream(
room_id: str = "!room:matrix.org", room_id: str = "!room:matrix.org",
name: str | None = "Test Stream", name: str | None = "Test Stream",
title: str | None = "Test Title", title: str | None = "Test Title",
last_connect_time: str | None = None, online: bool = False,
last_disconnect_time: str | None = None, status_since: str | None = None,
) -> None: ) -> None:
"""Insert a stream and subscription into the database.""" """Insert a stream and subscription into the database."""
await stream_repo.create(domain) await stream_repo.create(domain)
@@ -96,8 +96,8 @@ async def _seed_stream(
domain=domain, domain=domain,
name=name, name=name,
title=title, title=title,
last_connect_time=last_connect_time, online=online,
last_disconnect_time=last_disconnect_time, status_since=status_since,
) )
await stream_repo.update(state) await stream_repo.update(state)
await subscription_repo.add(domain, room_id) await subscription_repo.add(domain, room_id)
@@ -224,7 +224,9 @@ class TestUpdateAllStreams:
"""Return an UpdateResult with correct success and failure counts.""" """Return an UpdateResult with correct success and failure counts."""
owncast = _StubOwncastClient( owncast = _StubOwncastClient(
stream_state=StreamState( stream_state=StreamState(
domain="ok.com", last_connect_time="2026-01-01T00:00:00Z" domain="ok.com",
online=True,
status_since="2026-01-01T00:00:00+00:00",
), ),
stream_config=StreamConfig(name="OK"), stream_config=StreamConfig(name="OK"),
) )
@@ -291,8 +293,8 @@ class TestUpdateStreamFirstUpdate:
owncast = _StubOwncastClient( owncast = _StubOwncastClient(
stream_state=StreamState( stream_state=StreamState(
domain="new.com", domain="new.com",
last_connect_time="2026-01-01T00:00:00Z", online=True,
last_disconnect_time="2025-12-31T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
), ),
stream_config=StreamConfig(name="New Stream"), stream_config=StreamConfig(name="New Stream"),
) )
@@ -304,7 +306,7 @@ class TestUpdateStreamFirstUpdate:
client=client, client=client,
) )
# Seed with no connect/disconnect times (brand new) # Seed with no status timestamp (brand new)
await stream_repo.create("new.com") await stream_repo.create("new.com")
await subscription_repo.add("new.com", "!room:matrix.org") await subscription_repo.add("new.com", "!room:matrix.org")
@@ -321,7 +323,7 @@ class TestUpdateStreamFirstUpdate:
owncast = _StubOwncastClient( owncast = _StubOwncastClient(
stream_state=StreamState( stream_state=StreamState(
domain="new.com", domain="new.com",
last_disconnect_time="2025-12-31T00:00:00Z", status_since="2025-12-31T00:00:00+00:00",
), ),
stream_config=StreamConfig(name="New Stream"), stream_config=StreamConfig(name="New Stream"),
) )
@@ -354,8 +356,8 @@ class TestUpdateStreamGoesLive:
stream_state=StreamState( stream_state=StreamState(
domain="live.com", domain="live.com",
title="Now Streaming", title="Now Streaming",
last_connect_time="2026-01-01T12:00:00Z", online=True,
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Live Stream", tags=("gaming",)), stream_config=StreamConfig(name="Live Stream", tags=("gaming",)),
) )
@@ -367,12 +369,12 @@ class TestUpdateStreamGoesLive:
client=client, client=client,
) )
# Seed as offline (has disconnect but no connect) # Seed as offline.
await _seed_stream( await _seed_stream(
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="live.com", domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T10:00:00+00:00",
) )
# Set offline timer to long ago so it's not a brief outage # Set offline timer to long ago so it's not a brief outage
@@ -402,8 +404,8 @@ class TestUpdateStreamGoesLive:
stream_state=StreamState( stream_state=StreamState(
domain="live.com", domain="live.com",
title="Now Streaming", title="Now Streaming",
last_connect_time="2026-01-01T12:00:00Z", online=True,
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=None, stream_config=None,
) )
@@ -419,7 +421,7 @@ class TestUpdateStreamGoesLive:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="live.com", domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T10:00:00+00:00",
) )
monitor.offline_timer_cache["live.com"] = ( monitor.offline_timer_cache["live.com"] = (
@@ -450,7 +452,8 @@ class TestUpdateStreamBriefOffline:
stream_state=StreamState( stream_state=StreamState(
domain="brief.com", domain="brief.com",
title="Same Title", title="Same Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Brief Stream"), stream_config=StreamConfig(name="Brief Stream"),
) )
@@ -468,7 +471,7 @@ class TestUpdateStreamBriefOffline:
subscription_repo, subscription_repo,
domain="brief.com", domain="brief.com",
title="Same Title", title="Same Title",
last_disconnect_time="2026-01-01T11:55:00Z", status_since="2026-01-01T11:55:00+00:00",
) )
# Recently offline (within cooldown) # Recently offline (within cooldown)
@@ -488,7 +491,8 @@ class TestUpdateStreamBriefOffline:
stream_state=StreamState( stream_state=StreamState(
domain="brief.com", domain="brief.com",
title="New Title", title="New Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Brief Stream"), stream_config=StreamConfig(name="Brief Stream"),
) )
@@ -506,7 +510,7 @@ class TestUpdateStreamBriefOffline:
subscription_repo, subscription_repo,
domain="brief.com", domain="brief.com",
title="Old Title", title="Old Title",
last_disconnect_time="2026-01-01T11:55:00Z", status_since="2026-01-01T11:55:00+00:00",
) )
# Recently offline (within cooldown) # Recently offline (within cooldown)
@@ -536,7 +540,8 @@ class TestUpdateStreamTitleChange:
stream_state=StreamState( stream_state=StreamState(
domain="title.com", domain="title.com",
title="Updated Title", title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=None, stream_config=None,
) )
@@ -553,7 +558,8 @@ class TestUpdateStreamTitleChange:
subscription_repo, subscription_repo,
domain="title.com", domain="title.com",
title="Original Title", title="Original Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
) )
now = time.monotonic() now = time.monotonic()
@@ -585,7 +591,8 @@ class TestUpdateStreamTitleChange:
stream_state=StreamState( stream_state=StreamState(
domain="title.com", domain="title.com",
title="Updated Title", title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Title Stream"), stream_config=StreamConfig(name="Title Stream"),
) )
@@ -603,7 +610,8 @@ class TestUpdateStreamTitleChange:
subscription_repo, subscription_repo,
domain="title.com", domain="title.com",
title="Original Title", title="Original Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
) )
# Last notification was long enough ago to pass rate limiting, # Last notification was long enough ago to pass rate limiting,
@@ -637,7 +645,8 @@ class TestUpdateStreamTitleChange:
stream_state=StreamState( stream_state=StreamState(
domain="title.com", domain="title.com",
title="Updated Title", title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Title Stream"), stream_config=StreamConfig(name="Title Stream"),
) )
@@ -654,7 +663,8 @@ class TestUpdateStreamTitleChange:
subscription_repo, subscription_repo,
domain="title.com", domain="title.com",
title="Original Title", title="Original Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
status_since="2026-01-01T12:00:00+00:00",
) )
# Offline timer is MORE recent than last notification, # Offline timer is MORE recent than last notification,
@@ -691,7 +701,7 @@ class TestUpdateStreamGoesOffline:
stream_state=StreamState( stream_state=StreamState(
domain="offline.com", domain="offline.com",
title="Title", title="Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Offline Stream"), stream_config=StreamConfig(name="Offline Stream"),
) )
@@ -708,7 +718,8 @@ class TestUpdateStreamGoesOffline:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="offline.com", domain="offline.com",
last_connect_time="2026-01-01T10:00:00Z", online=True,
status_since="2026-01-01T10:00:00+00:00",
) )
monitor.offline_timer_cache["offline.com"] = 0 monitor.offline_timer_cache["offline.com"] = 0
@@ -743,7 +754,7 @@ class TestUpdateStreamConnectionFailure:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="fail.com", domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
result = await monitor.update_stream("fail.com") result = await monitor.update_stream("fail.com")
@@ -865,7 +876,7 @@ class TestUpdateStreamNoStateChange:
stream_state=StreamState( stream_state=StreamState(
domain="stable.com", domain="stable.com",
title="Same Title", title="Same Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
) )
client = _StubMatrixClient() client = _StubMatrixClient()
@@ -876,13 +887,13 @@ class TestUpdateStreamNoStateChange:
client=client, client=client,
) )
# Seed as offline with same disconnect time and title # Seed as offline with same status timestamp and title.
await _seed_stream( await _seed_stream(
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="stable.com", domain="stable.com",
title="Same Title", title="Same Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
) )
result = await monitor.update_stream("stable.com") result = await monitor.update_stream("stable.com")
@@ -905,7 +916,7 @@ class TestUpdateStreamFailureCounterReset:
stream_state=StreamState( stream_state=StreamState(
domain="recover.com", domain="recover.com",
title="Title", title="Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
) )
client = _StubMatrixClient() client = _StubMatrixClient()
@@ -921,7 +932,7 @@ class TestUpdateStreamFailureCounterReset:
subscription_repo, subscription_repo,
domain="recover.com", domain="recover.com",
title="Title", title="Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
) )
# Simulate prior failures (counter=4 still passes backoff) # Simulate prior failures (counter=4 still passes backoff)
@@ -964,7 +975,7 @@ class TestUpdateAllStreamsMixed:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="fail.com", domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
# "skip.com" will be skipped via backoff (returns True) # "skip.com" will be skipped via backoff (returns True)
@@ -973,7 +984,7 @@ class TestUpdateAllStreamsMixed:
subscription_repo, subscription_repo,
domain="skip.com", domain="skip.com",
room_id="!room2:matrix.org", room_id="!room2:matrix.org",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
for _ in range(5): for _ in range(5):
await stream_repo.increment_failure_counter("skip.com") await stream_repo.increment_failure_counter("skip.com")
@@ -993,7 +1004,7 @@ class TestUpdateAllStreamsMixed:
stream_state=StreamState( stream_state=StreamState(
domain="ok.com", domain="ok.com",
title="Title", title="Title",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
), ),
) )
# Patch get_stream_state to raise for one specific domain # Patch get_stream_state to raise for one specific domain
@@ -1019,7 +1030,7 @@ class TestUpdateAllStreamsMixed:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="raise.com", domain="raise.com",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
await _seed_stream( await _seed_stream(
stream_repo, stream_repo,
@@ -1027,7 +1038,7 @@ class TestUpdateAllStreamsMixed:
domain="ok.com", domain="ok.com",
room_id="!room2:matrix.org", room_id="!room2:matrix.org",
title="Title", title="Title",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
result = await monitor.update_all_streams(["raise.com", "ok.com"]) result = await monitor.update_all_streams(["raise.com", "ok.com"])
@@ -1049,8 +1060,8 @@ class TestStreamMonitorMetrics:
stream_state=StreamState( stream_state=StreamState(
domain="live.com", domain="live.com",
title="Title", title="Title",
last_connect_time="2026-01-01T12:00:00Z", online=True,
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Live Stream"), stream_config=StreamConfig(name="Live Stream"),
) )
@@ -1065,7 +1076,7 @@ class TestStreamMonitorMetrics:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="live.com", domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T10:00:00+00:00",
) )
monitor.offline_timer_cache["live.com"] = 0 monitor.offline_timer_cache["live.com"] = 0
await monitor.update_stream("live.com") await monitor.update_stream("live.com")
@@ -1082,7 +1093,7 @@ class TestStreamMonitorMetrics:
stream_state=StreamState( stream_state=StreamState(
domain="off.com", domain="off.com",
title="Title", title="Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Off Stream"), stream_config=StreamConfig(name="Off Stream"),
) )
@@ -1098,7 +1109,7 @@ class TestStreamMonitorMetrics:
subscription_repo, subscription_repo,
domain="off.com", domain="off.com",
title="Title", title="Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
) )
await monitor.update_stream("off.com") await monitor.update_stream("off.com")
output = generate_metrics_output(metrics) output = generate_metrics_output(metrics)
@@ -1122,7 +1133,7 @@ class TestStreamMonitorMetrics:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="fail.com", domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
await monitor.update_stream("fail.com") await monitor.update_stream("fail.com")
output = generate_metrics_output(metrics) output = generate_metrics_output(metrics)
@@ -1138,7 +1149,7 @@ class TestStreamMonitorMetrics:
stream_state=StreamState( stream_state=StreamState(
domain="recover.com", domain="recover.com",
title="Title", title="Title",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
), ),
stream_config=StreamConfig(name="Recover"), stream_config=StreamConfig(name="Recover"),
) )
@@ -1153,7 +1164,7 @@ class TestStreamMonitorMetrics:
stream_repo, stream_repo,
subscription_repo, subscription_repo,
domain="recover.com", domain="recover.com",
last_disconnect_time="2026-01-01T12:00:00Z", status_since="2026-01-01T12:00:00+00:00",
) )
# Simulate prior failures # Simulate prior failures
for _ in range(3): for _ in range(3):
@@ -1219,7 +1230,8 @@ class TestStreamMonitorMetrics:
owncast = _StubOwncastClient( owncast = _StubOwncastClient(
stream_state=StreamState( stream_state=StreamState(
domain="pop.com", domain="pop.com",
last_connect_time="2026-01-01T00:00:00Z", online=True,
status_since="2026-01-01T00:00:00+00:00",
), ),
stream_config=StreamConfig(name="Popular"), stream_config=StreamConfig(name="Popular"),
) )
+5 -3
View File
@@ -256,7 +256,7 @@ class TestManagerListings:
StreamState( StreamState(
domain="offline.example", domain="offline.example",
name="Offline", name="Offline",
last_disconnect_time="2026-01-01T00:00:00Z", status_since="2026-01-01T00:00:00+00:00",
) )
) )
await stream_repo.create("online.example") await stream_repo.create("online.example")
@@ -264,7 +264,8 @@ class TestManagerListings:
StreamState( StreamState(
domain="online.example", domain="online.example",
name="Online", name="Online",
last_connect_time="2026-01-01T00:00:00Z", online=True,
status_since="2026-01-01T00:00:00+00:00",
) )
) )
await stream_repo.create("unknown.example") await stream_repo.create("unknown.example")
@@ -272,7 +273,8 @@ class TestManagerListings:
StreamState( StreamState(
domain="unknown.example", domain="unknown.example",
name="Unknown", name="Unknown",
last_connect_time="2026-01-01T00:00:00Z", online=True,
status_since="2026-01-01T00:00:00+00:00",
) )
) )
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1): for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
+65 -47
View File
@@ -15,6 +15,7 @@
"""Tests for data models.""" """Tests for data models."""
from dataclasses import FrozenInstanceError from dataclasses import FrozenInstanceError
from datetime import UTC, datetime, timedelta, timezone
import pytest import pytest
@@ -34,6 +35,7 @@ from owncastsentry.types import (
SubscriptionError, SubscriptionError,
UpdateResult, UpdateResult,
_truncate, _truncate,
format_status_since,
) )
@@ -58,107 +60,130 @@ class TestStreamStateStatus:
"""Stream status derivation from state fields.""" """Stream status derivation from state fields."""
@pytest.mark.parametrize( @pytest.mark.parametrize(
("failure_counter", "last_connect_time", "expected"), ("failure_counter", "online", "expected"),
[ [
pytest.param( pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1, UNKNOWN_STATUS_THRESHOLD + 1,
None, False,
StreamStatus.UNKNOWN, StreamStatus.UNKNOWN,
id="above-threshold-offline-returns-unknown", id="above-threshold-offline-returns-unknown",
), ),
pytest.param( pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1, UNKNOWN_STATUS_THRESHOLD + 1,
"2026-01-01T00:00:00Z", True,
StreamStatus.UNKNOWN, StreamStatus.UNKNOWN,
id="above-threshold-online-returns-unknown", id="above-threshold-online-returns-unknown",
), ),
pytest.param( pytest.param(
0, 0,
"2026-01-01T00:00:00Z", True,
StreamStatus.ONLINE, StreamStatus.ONLINE,
id="zero-failures-with-connect-time-returns-online", id="zero-failures-online-returns-online",
), ),
pytest.param( pytest.param(
0, 0,
None, False,
StreamStatus.OFFLINE, StreamStatus.OFFLINE,
id="zero-failures-no-connect-time-returns-offline", id="zero-failures-offline-returns-offline",
), ),
pytest.param( pytest.param(
UNKNOWN_STATUS_THRESHOLD, UNKNOWN_STATUS_THRESHOLD,
"2026-01-01T00:00:00Z", True,
StreamStatus.ONLINE, StreamStatus.ONLINE,
id="at-threshold-with-connect-time-returns-online", id="at-threshold-online-returns-online",
), ),
pytest.param( pytest.param(
UNKNOWN_STATUS_THRESHOLD, UNKNOWN_STATUS_THRESHOLD,
None, False,
StreamStatus.OFFLINE, StreamStatus.OFFLINE,
id="at-threshold-no-connect-time-returns-offline", id="at-threshold-offline-returns-offline",
), ),
], ],
) )
def test_status( def test_status(
self, self,
failure_counter: int, failure_counter: int,
last_connect_time: str | None, online: object,
expected: StreamStatus, expected: StreamStatus,
) -> None: ) -> None:
"""Return the correct status based on failure counter and connect time.""" """Return the correct status based on failure counter and online state."""
state = StreamState( state = StreamState(
domain="example.com", domain="example.com",
failure_counter=failure_counter, failure_counter=failure_counter,
last_connect_time=last_connect_time, online=online is True,
) )
assert state.status is expected assert state.status is expected
class TestFormatStatusSince:
"""Status timestamp formatting."""
def test_formats_as_utc_iso_seconds(self) -> None:
"""Normalize aware datetimes to UTC with second precision."""
timestamp = datetime(
2026,
1,
1,
12,
34,
56,
123456,
tzinfo=timezone(timedelta(hours=-5)),
)
assert format_status_since(timestamp) == "2026-01-01T17:34:56+00:00"
def test_treats_naive_as_utc(self) -> None:
"""Format naive datetimes as UTC."""
timestamp = datetime.fromisoformat("2026-01-01T12:34:56.123456")
assert format_status_since(timestamp) == "2026-01-01T12:34:56+00:00"
class TestStreamStateFromApiResponse: class TestStreamStateFromApiResponse:
"""StreamState construction from an API response dictionary.""" """StreamState construction from an API response dictionary."""
def test_typical_response(self) -> None: def test_typical_response(self) -> None:
"""Populate API-derived fields from a complete stream state response.""" """Populate API-derived fields from a complete stream state response."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
response = { response = {
"streamTitle": "My Stream", "streamTitle": "My Stream",
"lastConnectTime": "2026-01-01T00:00:00Z",
"lastDisconnectTime": "2025-12-31T23:00:00Z",
"online": True, "online": True,
} }
state = StreamState.from_api_response(response, "example.com") state = StreamState.from_api_response(response, "example.com", observed_at)
assert state.domain == "example.com" assert state.domain == "example.com"
assert state.title == "My Stream" assert state.title == "My Stream"
assert state.last_connect_time == "2026-01-01T00:00:00Z" assert state.online is True
assert state.last_disconnect_time == "2025-12-31T23:00:00Z" assert state.status_since == "2026-01-01T00:00:01+00:00"
assert state.name is None assert state.name is None
assert state.failure_counter == 0 assert state.failure_counter == 0
def test_missing_required_field_raises(self) -> None: def test_missing_required_field_raises(self) -> None:
"""Reject API responses without required stream state fields.""" """Reject API responses without required stream state fields."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
with pytest.raises(InvalidApiResponseError): with pytest.raises(InvalidApiResponseError):
StreamState.from_api_response({}, "bare.example.com") StreamState.from_api_response({}, "bare.example.com", observed_at)
def test_nullable_timestamp_fields(self) -> None: def test_offline_response(self) -> None:
"""Accept null values for Owncast timestamp fields.""" """Populate offline state from a status response."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
response = { response = {
"streamTitle": "Offline Stream", "streamTitle": "Offline Stream",
"lastConnectTime": None,
"lastDisconnectTime": None,
"online": False, "online": False,
} }
state = StreamState.from_api_response(response, "example.com") state = StreamState.from_api_response(response, "example.com", observed_at)
assert state.last_connect_time is None assert state.online is False
assert state.last_disconnect_time is None assert state.status_since == "2026-01-01T00:00:01+00:00"
def test_title_truncation(self) -> None: def test_title_truncation(self) -> None:
"""Truncate the stream title to _MAX_STREAM_TITLE_LENGTH.""" """Truncate the stream title to _MAX_STREAM_TITLE_LENGTH."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
long_title = "A" * (_MAX_STREAM_TITLE_LENGTH + 50) long_title = "A" * (_MAX_STREAM_TITLE_LENGTH + 50)
response = { response = {
"streamTitle": long_title, "streamTitle": long_title,
"lastConnectTime": None,
"lastDisconnectTime": None,
"online": True, "online": True,
} }
state = StreamState.from_api_response(response, "example.com") state = StreamState.from_api_response(response, "example.com", observed_at)
assert len(state.title) == _MAX_STREAM_TITLE_LENGTH assert len(state.title) == _MAX_STREAM_TITLE_LENGTH
assert state.title == "A" * _MAX_STREAM_TITLE_LENGTH assert state.title == "A" * _MAX_STREAM_TITLE_LENGTH
@@ -166,27 +191,20 @@ class TestStreamStateFromApiResponse:
("field", "value"), ("field", "value"),
[ [
pytest.param("streamTitle", 123, id="title-not-string"), pytest.param("streamTitle", 123, id="title-not-string"),
pytest.param("lastConnectTime", [], id="connect-time-not-string-or-null"),
pytest.param(
"lastDisconnectTime",
{},
id="disconnect-time-not-string-or-null",
),
pytest.param("online", "true", id="online-not-bool"), pytest.param("online", "true", id="online-not-bool"),
], ],
) )
def test_invalid_field_type_raises(self, field: str, value: object) -> None: def test_invalid_field_type_raises(self, field: str, value: object) -> None:
"""Reject stream state responses with malformed field types.""" """Reject stream state responses with malformed field types."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
response: dict[str, object] = { response: dict[str, object] = {
"streamTitle": "My Stream", "streamTitle": "My Stream",
"lastConnectTime": None,
"lastDisconnectTime": None,
"online": True, "online": True,
} }
response[field] = value response[field] = value
with pytest.raises(InvalidApiResponseError): with pytest.raises(InvalidApiResponseError):
StreamState.from_api_response(response, "example.com") StreamState.from_api_response(response, "example.com", observed_at)
class TestStreamStateFromDbRow: class TestStreamStateFromDbRow:
@@ -198,16 +216,16 @@ class TestStreamStateFromDbRow:
"domain": "example.com", "domain": "example.com",
"name": "Test Instance", "name": "Test Instance",
"title": "Live Now", "title": "Live Now",
"last_connect_time": "2026-01-01T00:00:00Z", "online": True,
"last_disconnect_time": "2025-12-31T23:00:00Z", "status_since": "2026-01-01T00:00:00+00:00",
"failure_counter": 3, "failure_counter": 3,
} }
state = StreamState.from_db_row(row) state = StreamState.from_db_row(row)
assert state.domain == "example.com" assert state.domain == "example.com"
assert state.name == "Test Instance" assert state.name == "Test Instance"
assert state.title == "Live Now" assert state.title == "Live Now"
assert state.last_connect_time == "2026-01-01T00:00:00Z" assert state.online is True
assert state.last_disconnect_time == "2025-12-31T23:00:00Z" assert state.status_since == "2026-01-01T00:00:00+00:00"
assert state.failure_counter == 3 assert state.failure_counter == 3
def test_row_with_none_optional_fields(self) -> None: def test_row_with_none_optional_fields(self) -> None:
@@ -216,16 +234,16 @@ class TestStreamStateFromDbRow:
"domain": "example.com", "domain": "example.com",
"name": None, "name": None,
"title": None, "title": None,
"last_connect_time": None, "online": False,
"last_disconnect_time": None, "status_since": None,
"failure_counter": 0, "failure_counter": 0,
} }
state = StreamState.from_db_row(row) state = StreamState.from_db_row(row)
assert state.domain == "example.com" assert state.domain == "example.com"
assert state.name is None assert state.name is None
assert state.title is None assert state.title is None
assert state.last_connect_time is None assert state.online is False
assert state.last_disconnect_time is None assert state.status_since is None
assert state.failure_counter == 0 assert state.failure_counter == 0