Enforced timestamps for online streams.
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user