Enforced timestamps for online streams.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 34s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 10s

This commit is contained in:
2026-05-25 21:49:21 -04:00
parent 85d3adf775
commit aeab5e1320
3 changed files with 271 additions and 11 deletions
+6 -9
View File
@@ -15,7 +15,7 @@
"""Command handlers for OwncastSentry bot commands.""" """Command handlers for OwncastSentry bot commands."""
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, cast
from .types import ( from .types import (
AlreadySubscribedError, AlreadySubscribedError,
@@ -187,11 +187,11 @@ 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.status_since:
duration = _format_duration(stream_state.status_since, now)
parts.append(f" - Status: Online for {duration} \n")
case StreamStatus.ONLINE: case StreamStatus.ONLINE:
parts.append(" - Status: Online \n") duration = _format_duration(
cast("str", stream_state.status_since), now
)
parts.append(f" - Status: Online for {duration} \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.status_since: case StreamStatus.OFFLINE if stream_state.status_since:
@@ -259,11 +259,8 @@ 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.status_since: duration = _format_duration(cast("str", stream_state.status_since), 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")
+114
View File
@@ -313,6 +313,120 @@ async def upgrade_v5(conn: Connection) -> None:
) )
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
description="Require timestamps for online streams"
)
async def upgrade_v6(conn: Connection) -> None:
"""Upgrade database schema to version 6 format.
Rebuilds the stream table with a constraint that requires online streams
to have a status timestamp. Legacy rows that are marked online without a
timestamp are treated as offline because their live start time is unknown.
:param conn: A connection to run the v6 database migration on.
"""
await conn.execute(
"""CREATE TABLE "streams_new" (
"domain" TEXT NOT NULL CHECK(length(trim("domain")) > 0),
"name" TEXT,
"title" TEXT,
"online" INTEGER NOT NULL DEFAULT 0 CHECK("online" IN (0, 1)),
"status_since" TEXT,
"failure_counter" INTEGER NOT NULL DEFAULT 0
CHECK("failure_counter" >= 0),
CHECK("online" = 0 OR "status_since" IS NOT NULL),
PRIMARY KEY("domain")
)"""
)
await conn.execute(
"""INSERT INTO streams_new (
domain, name, title, online, status_since, failure_counter
)
SELECT domain,
name,
title,
CASE
WHEN online = 1 AND status_since IS NOT NULL THEN 1
ELSE 0
END,
status_since,
failure_counter
FROM streams"""
)
await conn.execute(
"""CREATE TABLE "subscriptions_v6_copy" (
"stream_domain" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
PRIMARY KEY("room_id", "stream_domain")
)"""
)
await conn.execute(
"""INSERT INTO subscriptions_v6_copy (stream_domain, room_id)
SELECT stream_domain, room_id
FROM subscriptions
WHERE EXISTS (
SELECT 1
FROM streams_new
WHERE streams_new.domain = subscriptions.stream_domain
)"""
)
await conn.execute('DROP TRIGGER IF EXISTS "create_stream_for_subscription"')
await conn.execute('DROP TRIGGER IF EXISTS "delete_unsubscribed_stream"')
await conn.execute('DROP INDEX IF EXISTS "subscriptions_stream_domain_idx"')
await conn.execute("DROP TABLE subscriptions")
await conn.execute("DROP TABLE streams")
await conn.execute("ALTER TABLE streams_new RENAME TO streams")
await conn.execute(
"""CREATE TABLE "subscriptions_new" (
"stream_domain" TEXT NOT NULL
CHECK(length(trim("stream_domain")) > 0),
"room_id" TEXT NOT NULL CHECK(length(trim("room_id")) > 0),
PRIMARY KEY("room_id", "stream_domain"),
FOREIGN KEY("stream_domain")
REFERENCES "streams"("domain")
ON DELETE CASCADE
)"""
)
await conn.execute(
"""INSERT INTO subscriptions_new (stream_domain, room_id)
SELECT stream_domain, room_id
FROM subscriptions_v6_copy"""
)
await conn.execute("DROP TABLE subscriptions_v6_copy")
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
await conn.execute(
"""CREATE INDEX "subscriptions_stream_domain_idx"
ON "subscriptions"("stream_domain")"""
)
await conn.execute(
"""CREATE TRIGGER "create_stream_for_subscription"
BEFORE INSERT ON "subscriptions"
BEGIN
INSERT INTO "streams" ("domain")
VALUES (NEW."stream_domain")
ON CONFLICT("domain") DO NOTHING;
END"""
)
await conn.execute(
"""CREATE TRIGGER "delete_unsubscribed_stream"
AFTER DELETE ON "subscriptions"
WHEN NOT EXISTS (
SELECT 1
FROM "subscriptions"
WHERE "stream_domain" = OLD."stream_domain"
)
BEGIN
DELETE FROM "streams"
WHERE "domain" = OLD."stream_domain";
END"""
)
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
+150 -1
View File
@@ -88,6 +88,11 @@ class TestStreamSchema:
("bad-online.example", 2), ("bad-online.example", 2),
id="invalid-online", id="invalid-online",
), ),
pytest.param(
"INSERT INTO streams (domain, online) VALUES ($1, $2)",
("missing-status.example", 1),
id="online-without-status",
),
pytest.param( pytest.param(
"""INSERT INTO streams (domain, failure_counter) """INSERT INTO streams (domain, failure_counter)
VALUES ($1, $2)""", VALUES ($1, $2)""",
@@ -191,6 +196,7 @@ class TestSubscriptionSchema:
assert stream is not None assert stream is not None
assert stream["online"] == 0 assert stream["online"] == 0
assert stream["status_since"] is None
assert stream["failure_counter"] == 0 assert stream["failure_counter"] == 0
async def test_subscription_insert_rolls_back_created_stream_on_failure( async def test_subscription_insert_rolls_back_created_stream_on_failure(
@@ -566,11 +572,145 @@ class TestConstraintMigration:
assert preserved["online"] == 1 assert preserved["online"] == 1
assert preserved["status_since"] == "2026-01-01T12:00:00+00:00" assert preserved["status_since"] == "2026-01-01T12:00:00+00:00"
assert preserved["failure_counter"] == 7 assert preserved["failure_counter"] == 7
assert streams["truthy-online.example"]["online"] == 1 assert streams["truthy-online.example"]["online"] == 0
assert streams["truthy-online.example"]["status_since"] is None
assert streams["falsy-online.example"]["online"] == 0 assert streams["falsy-online.example"]["online"] == 0
assert streams["negative-counter.example"]["failure_counter"] == 0 assert streams["negative-counter.example"]["failure_counter"] == 0
assert streams["null-counter.example"]["failure_counter"] == 0 assert streams["null-counter.example"]["failure_counter"] == 0
async def test_v6_repairs_online_stream_without_status_timestamp(
self, tmp_path: Path
) -> None:
"""Repair v5 stream rows that were marked online without a timestamp."""
db_path = tmp_path / "legacy.db"
with closing(sqlite3.connect(db_path)) as conn, conn:
conn.executescript(
"""
CREATE TABLE version (version INTEGER PRIMARY KEY);
INSERT INTO version (version) VALUES (5);
CREATE TABLE streams (
domain TEXT NOT NULL CHECK(length(trim(domain)) > 0),
name TEXT,
title TEXT,
online INTEGER NOT NULL DEFAULT 0 CHECK(online IN (0, 1)),
status_since TEXT,
failure_counter INTEGER NOT NULL DEFAULT 0
CHECK(failure_counter >= 0),
PRIMARY KEY(domain)
);
CREATE TABLE subscriptions (
stream_domain TEXT NOT NULL
CHECK(length(trim(stream_domain)) > 0),
room_id TEXT NOT NULL CHECK(length(trim(room_id)) > 0),
PRIMARY KEY(room_id, stream_domain),
FOREIGN KEY(stream_domain)
REFERENCES streams(domain)
ON DELETE CASCADE
);
CREATE INDEX subscriptions_stream_domain_idx
ON subscriptions(stream_domain);
CREATE TRIGGER create_stream_for_subscription
BEFORE INSERT ON subscriptions
BEGIN
INSERT INTO streams (domain)
VALUES (NEW.stream_domain)
ON CONFLICT(domain) DO NOTHING;
END;
CREATE TRIGGER delete_unsubscribed_stream
AFTER DELETE ON subscriptions
WHEN NOT EXISTS (
SELECT 1
FROM subscriptions
WHERE stream_domain = OLD.stream_domain
)
BEGIN
DELETE FROM streams
WHERE domain = OLD.stream_domain;
END;
INSERT INTO streams (
domain, name, title, online, status_since, failure_counter
)
VALUES
(
'valid-live.example',
'Valid Live',
'Valid Title',
1,
'2026-01-01T12:00:00+00:00',
3
),
('missing-status.example', 'Missing Status', NULL, 1, NULL, 4),
('offline.example', 'Offline', NULL, 0, NULL, 5);
INSERT INTO subscriptions (stream_domain, room_id)
VALUES
('valid-live.example', '!valid:example.com'),
('missing-status.example', '!missing:example.com'),
('offline.example', '!offline:example.com');
"""
)
db = Database.create(f"sqlite:///{db_path}", upgrade_table=get_upgrade_table())
await db.start()
try:
async with db.acquire() as conn:
rows = await conn.fetch(
"""SELECT domain, name, title, online, status_since, failure_counter
FROM streams
ORDER BY domain"""
)
subscriptions = await conn.fetch(
"""SELECT stream_domain, room_id
FROM subscriptions
ORDER BY stream_domain"""
)
foreign_key_errors = await conn.fetch("PRAGMA foreign_key_check")
await conn.execute(
"""INSERT INTO subscriptions (stream_domain, room_id)
VALUES ($1, $2)""",
"trigger-created.example",
"!trigger:example.com",
)
trigger_created_stream = await conn.fetchrow(
"SELECT * FROM streams WHERE domain=$1",
"trigger-created.example",
)
finally:
await db.stop()
streams = {row["domain"]: row for row in rows}
valid_live = streams["valid-live.example"]
assert valid_live["online"] == 1
assert valid_live["status_since"] == "2026-01-01T12:00:00+00:00"
assert valid_live["failure_counter"] == 3
missing_status = streams["missing-status.example"]
assert missing_status["online"] == 0
assert missing_status["status_since"] is None
assert missing_status["failure_counter"] == 4
offline = streams["offline.example"]
assert offline["online"] == 0
assert offline["status_since"] is None
assert offline["failure_counter"] == 5
assert [(row["stream_domain"], row["room_id"]) for row in subscriptions] == [
("missing-status.example", "!missing:example.com"),
("offline.example", "!offline:example.com"),
("valid-live.example", "!valid:example.com"),
]
assert foreign_key_errors == []
assert trigger_created_stream is not None
assert trigger_created_stream["online"] == 0
assert trigger_created_stream["status_since"] is None
async def test_removes_invalid_legacy_subscriptions(self, tmp_path: Path) -> None: async def test_removes_invalid_legacy_subscriptions(self, tmp_path: Path) -> None:
"""Drop legacy subscriptions with unusable domains or room IDs.""" """Drop legacy subscriptions with unusable domains or room IDs."""
db_path = tmp_path / "legacy.db" db_path = tmp_path / "legacy.db"
@@ -759,6 +899,15 @@ class TestStreamUpdate:
assert state.title == "Original Title" assert state.title == "Original Title"
assert state.status_since == "2026-01-01T12:00:00+00:00" assert state.status_since == "2026-01-01T12:00:00+00:00"
async def test_rejects_online_without_status_since(
self, stream_repo: StreamRepository
) -> None:
"""Reject online stream updates that leave the status timestamp unknown."""
await stream_repo.create("example.com")
with pytest.raises(sqlite3.IntegrityError):
await stream_repo.update("example.com", online=True)
class TestStreamFailureCounter: class TestStreamFailureCounter:
"""Stream failure counter updates.""" """Stream failure counter updates."""