Switched stream status tracking to explicit online state.
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user