Compare commits
55
Commits
v1.0.3
...
d02202295c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02202295c
|
||
|
|
58ad476a94
|
||
|
|
38e91add32
|
||
|
|
c837b916c0
|
||
|
|
4d0ae3d1ea
|
||
|
|
2f6d0bc1ed
|
||
|
|
d905740755
|
||
|
|
795184cd17
|
||
|
|
48a4fb9bbf
|
||
|
|
ec42266af3
|
||
|
|
33cc434354
|
||
|
|
0620c675d9
|
||
|
|
179d087e33
|
||
|
|
a60de6dec7
|
||
|
|
60f747725b
|
||
|
|
c42f16416f
|
||
|
|
89e60c5955
|
||
|
|
4464595e7b
|
||
|
|
33d5a8a0da
|
||
|
|
def15b22f9
|
||
|
|
69714e59fa
|
||
|
|
8c209e2bb3
|
||
|
|
0dfe602d3f
|
||
|
|
47c7e97ac0
|
||
|
|
c690702113
|
||
|
|
9be932c0c7
|
||
|
|
295a8c90a9
|
||
|
|
653c27c97c
|
||
|
|
1112f7c0cd
|
||
|
|
90aa6afcc6
|
||
|
|
a01ad9440c
|
||
|
|
8931db33e8
|
||
|
|
99b257b90a
|
||
|
|
58993ff5ae
|
||
|
|
440b2130f4
|
||
|
|
1429e2de11
|
||
|
|
afa1584ec5
|
||
|
|
a70fe972c4
|
||
|
|
b8447e190c
|
||
|
|
282b16b51c
|
||
|
|
d05d73eddc
|
||
|
|
314e1bf399
|
||
|
|
8c772ab19e
|
||
|
|
548f90f0c0
|
||
|
|
f62764a2b2
|
||
|
|
dc0df47257
|
||
|
|
35086cb751
|
||
|
|
b6beef0e48
|
||
|
|
c6430a4110
|
||
|
|
7c10d15dd6
|
||
|
|
b177114540
|
||
|
|
1d35ee6d7c
|
||
|
|
764fab9879
|
||
|
|
0659f70e1a
|
||
|
|
ee61ea8562
|
@@ -0,0 +1,35 @@
|
||||
name: Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
push:
|
||||
paths: [uv.lock]
|
||||
pull_request:
|
||||
paths: [uv.lock]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_PYTHON_DOWNLOADS: never
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependencies
|
||||
runs-on: logaldeveloper-archlinux-ci
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Audit dependencies with pip-audit
|
||||
run: uv run pip-audit --skip-editable
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Build Maubot Plugin Artifact
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build artifact
|
||||
run: zip -v -9 OwncastSentry-v${{env.GITHUB_SHA}}.mbp LICENSE.txt maubot.yaml owncastsentry.py
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Maubot Plugin
|
||||
path: OwncastSentry-v${{env.GITHUB_SHA}}.mbp
|
||||
@@ -0,0 +1,75 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# packages: write # not yet supported by Gitea
|
||||
|
||||
env:
|
||||
UV_PYTHON_DOWNLOADS: never
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: logaldeveloper-archlinux-ci
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --no-dev
|
||||
|
||||
- name: Generate package metadata
|
||||
id: metadata
|
||||
run: |
|
||||
version="${{ gitea.ref_name }}"
|
||||
version="${version#v}"
|
||||
printf 'version=%s\n' "$version" | tee -a "$GITEA_OUTPUT"
|
||||
|
||||
- name: Write version into maubot.yaml
|
||||
run: |
|
||||
sed -i "s/^version: .*/version: ${{ steps.metadata.outputs.version }}/" maubot.yaml
|
||||
grep "^version:" maubot.yaml
|
||||
|
||||
- name: Build plugin
|
||||
run: 7z a -tzip -mx=9 -xr!__pycache__ -xr!README.md owncastsentry-v${{ steps.metadata.outputs.version }}.mbp owncastsentry/ maubot.yaml base-config.yaml LICENSE.txt
|
||||
|
||||
- name: Print artifact checksums
|
||||
run: |
|
||||
echo "MD5: $(md5sum owncastsentry-v${{ steps.metadata.outputs.version }}.mbp)"
|
||||
echo "SHA1: $(sha1sum owncastsentry-v${{ steps.metadata.outputs.version }}.mbp)"
|
||||
echo "SHA256: $(sha256sum owncastsentry-v${{ steps.metadata.outputs.version }}.mbp)"
|
||||
|
||||
- name: Generate SBOM
|
||||
env:
|
||||
SYFT_CHECK_FOR_APP_UPDATE: "false"
|
||||
run: |
|
||||
syft scan dir:.venv \
|
||||
--override-default-catalogers python-installed-package-cataloger \
|
||||
--select-catalogers=-file \
|
||||
--source-name git.logal.dev/LogalDeveloper/OwncastSentry \
|
||||
--source-version "${{ steps.metadata.outputs.version }}" \
|
||||
--output syft-table \
|
||||
--output cyclonedx-json=owncastsentry-${{ steps.metadata.outputs.version }}.cyclonedx.json
|
||||
sha256sum owncastsentry-${{ steps.metadata.outputs.version }}.cyclonedx.json
|
||||
zstd -T0 --ultra -22 \
|
||||
owncastsentry-${{ steps.metadata.outputs.version }}.cyclonedx.json
|
||||
|
||||
- name: Upload plugin artifact
|
||||
uses: https://github.com/christopherhx/gitea-upload-artifact@8818363695ca2d5782c64f6453273341374767b7 # v7
|
||||
with:
|
||||
path: owncastsentry-v${{ steps.metadata.outputs.version }}.mbp
|
||||
archive: "false"
|
||||
|
||||
- name: Upload SBOM artifact
|
||||
uses: https://github.com/christopherhx/gitea-upload-artifact@8818363695ca2d5782c64f6453273341374767b7 # v7
|
||||
with:
|
||||
name: owncastsentry-cyclonedx-${{ steps.metadata.outputs.version }}
|
||||
path: owncastsentry-${{ steps.metadata.outputs.version }}.cyclonedx.json.zst
|
||||
if-no-files-found: error
|
||||
archive: "false"
|
||||
@@ -0,0 +1,110 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_PYTHON_DOWNLOADS: never
|
||||
|
||||
jobs:
|
||||
formatting:
|
||||
name: Formatting
|
||||
runs-on: logaldeveloper-archlinux-ci
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Check formatting with Ruff
|
||||
run: uv run ruff format --check --diff .
|
||||
|
||||
linting:
|
||||
name: Linting
|
||||
runs-on: logaldeveloper-archlinux-ci
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Check linting with Ruff
|
||||
run: uv run ruff check .
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: logaldeveloper-archlinux-ci
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Run unit tests with Pytest
|
||||
run: uv run pytest -v --cov --cov-report=
|
||||
|
||||
- name: Report code coverage
|
||||
run: uv run coverage report
|
||||
|
||||
type-checking:
|
||||
name: Type Checking
|
||||
runs-on: logaldeveloper-archlinux-ci
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Check types with Mypy
|
||||
run: uv run mypy .
|
||||
|
||||
spelling:
|
||||
name: Spelling
|
||||
runs-on: logaldeveloper-archlinux-ci
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Check spelling with codespell
|
||||
run: uv run codespell
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Lint Source Code
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python environment
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
|
||||
- name: Run linter
|
||||
uses: psf/black@stable
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
owncastsentry/_version.py
|
||||
.coverage
|
||||
+27
-1
@@ -1,6 +1,7 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
@@ -174,3 +175,28 @@
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Prometheus metrics endpoint.
|
||||
# When enabled, a /metrics endpoint is exposed via the maubot webapp.
|
||||
# Disabled by default.
|
||||
metrics_enabled: false
|
||||
+15
-9
@@ -1,9 +1,15 @@
|
||||
maubot: 0.1.0
|
||||
id: dev.logal.owncastsentry
|
||||
version: 1.0.3
|
||||
license: Apache-2.0
|
||||
modules:
|
||||
- owncastsentry
|
||||
main_class: OwncastSentry
|
||||
database: true
|
||||
database_type: asyncpg
|
||||
maubot: 0.1.0
|
||||
id: dev.logal.owncastsentry
|
||||
version: 1.1.0
|
||||
license: Apache-2.0
|
||||
modules:
|
||||
- owncastsentry
|
||||
main_class: OwncastSentry
|
||||
database: true
|
||||
database_type: asyncpg
|
||||
config: true
|
||||
webapp: true
|
||||
dependencies:
|
||||
- prometheus_client>=0.24.1
|
||||
extra_files:
|
||||
- base-config.yaml
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
# Copyright 2025 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import sqlite3
|
||||
import aiohttp
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from maubot import Plugin, MessageEvent
|
||||
from maubot.handlers import command
|
||||
from mautrix.types import TextMessageEventContent, MessageType
|
||||
from mautrix.util.async_db import UpgradeTable, Connection
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# Path to the GetStatus API call on Owncast instances
|
||||
OWNCAST_STATUS_PATH = "/api/status"
|
||||
|
||||
# Path to GetWebConfig API call on Owncast instances
|
||||
OWNCAST_CONFIG_PATH = "/api/config"
|
||||
|
||||
# User agent to send with all HTTP requests.
|
||||
USER_AGENT = (
|
||||
"OwncastSentry/1.0.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
|
||||
)
|
||||
|
||||
|
||||
# ===== DATABASE MIGRATIONS =====
|
||||
upgrade_table = UpgradeTable()
|
||||
|
||||
|
||||
@upgrade_table.register(description="Initial revision")
|
||||
async def upgrade_v1(conn: Connection) -> None:
|
||||
"""
|
||||
Runs migrations to upgrade database schema to verison 1 format.
|
||||
Version 1 is the initial format of the database.
|
||||
|
||||
:param conn: A connection to run the v1 database migration on.
|
||||
:return: Nothing.
|
||||
"""
|
||||
await conn.execute(
|
||||
"""CREATE TABLE "streams" (
|
||||
"domain" TEXT NOT NULL UNIQUE,
|
||||
"name" TEXT,
|
||||
"title" TEXT,
|
||||
"last_connect_time" TEXT,
|
||||
"last_disconnect_time" TEXT,
|
||||
PRIMARY KEY("domain")
|
||||
)"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""CREATE TABLE "subscriptions" (
|
||||
"stream_domain" INTEGER NOT NULL,
|
||||
"room_id" TEXT NOT NULL,
|
||||
UNIQUE("room_id","stream_domain")
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
# ===== MAIN BOT CLASS =====
|
||||
class OwncastSentry(Plugin):
|
||||
# Helper variables for setting up a special HTTP ClientSession for monitoring streams.
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT
|
||||
} # Override User Agent to special static value to identify to servers our purpose.
|
||||
cookie_jar = aiohttp.DummyCookieJar() # Ignore cookies.
|
||||
baseconnctor = aiohttp.TCPConnector(
|
||||
use_dns_cache=False, limit=1000, limit_per_host=1, keepalive_timeout=120
|
||||
) # Set up timeouts to keep one long-lived connection open per monitored stream.
|
||||
timeouts = aiohttp.ClientTimeout(
|
||||
sock_connect=5, sock_read=5
|
||||
) # Tighten up timeouts for faster responses to users in case of broken servers.
|
||||
|
||||
# Final HTTP ClientSession for all requests to Owncast instances.
|
||||
session = aiohttp.ClientSession(
|
||||
headers=headers, cookie_jar=cookie_jar, timeout=timeouts, connector=baseconnctor
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_db_upgrade_table(cls) -> UpgradeTable | None:
|
||||
"""
|
||||
Helper method for telling Maubot about our database migrations.
|
||||
|
||||
:return: An UpgradeTable with our registered migrations.
|
||||
"""
|
||||
return upgrade_table
|
||||
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
Method called by Maubot upon startup of the instance.
|
||||
Registers a recurring task every minute to update the state of all subscribed streams.
|
||||
|
||||
:return: Nothing.
|
||||
"""
|
||||
self.sched.run_periodically(60, self.update_all_stream_states)
|
||||
|
||||
@command.new(help="Subscribes to a new Owncast stream.")
|
||||
@command.argument("url")
|
||||
async def subscribe(self, evt: MessageEvent, url: str) -> None:
|
||||
"""
|
||||
"!subscribe" command handler for users to subscribe a room to a given stream's notifications.
|
||||
|
||||
:param evt: MessageEvent of the message calling the command.
|
||||
:param url: A string containing the user supplied URL to a stream to try and subscribe to.
|
||||
:return: Nothing.
|
||||
"""
|
||||
|
||||
# Convert the user input to only a domain.
|
||||
stream_domain = self.domainify(url)
|
||||
|
||||
# How many subscriptions already exist for this domain?
|
||||
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
|
||||
async with self.database.acquire() as connection:
|
||||
result = await connection.fetchrow(query, stream_domain)
|
||||
|
||||
if result[0] == 0:
|
||||
# There are 0 subscriptions, we need to validate this domain is an Owncast stream.
|
||||
# Attempt to fetch the stream state from this domain.
|
||||
stream_state = await self.get_stream_state(stream_domain)
|
||||
if len(stream_state) == 0:
|
||||
# The stream state fetch returned nothing. Probably not an Owncast stream.
|
||||
await evt.reply(
|
||||
"The URL you supplied does not appear to be a valid Owncast instance. You may have specified an invalid domain, or the instance is offline."
|
||||
)
|
||||
return
|
||||
|
||||
# Try to add a new subscription for the requested stream domain in the room the command was executed in.
|
||||
try:
|
||||
query = "INSERT INTO subscriptions (stream_domain, room_id) VALUES ($1, $2)"
|
||||
async with self.database.acquire() as connection:
|
||||
await connection.execute(query, stream_domain, evt.room_id)
|
||||
except sqlite3.IntegrityError as exception:
|
||||
# Something weird happened... Was it due to attempting to insert a duplicate row?
|
||||
if "UNIQUE constraint failed" in exception.args[0]:
|
||||
# Yes, this is an expected condition. Tell the user the room is already subscribed and give up.
|
||||
await evt.reply(
|
||||
"This room is already subscribed to notifications for "
|
||||
+ stream_domain
|
||||
+ "."
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Nope... Something unexpected happened. Give up.
|
||||
self.log.error(
|
||||
f"[{stream_domain}] An error occurred while attempting to add subscription in room {evt.room_id}: {e}"
|
||||
)
|
||||
raise exception
|
||||
|
||||
# The subscription was successfully added! Try to add a placeholder row for the stream's state in the streams table.
|
||||
try:
|
||||
query = "INSERT INTO streams (domain) VALUES ($1)"
|
||||
|
||||
async with self.database.acquire() as connection:
|
||||
await connection.execute(query, stream_domain)
|
||||
|
||||
# The insert was successful, so this is the first time we're seeing this stream. Log it.
|
||||
self.log.info(f"[{stream_domain}] Discovered new stream!")
|
||||
except sqlite3.IntegrityError as exception:
|
||||
# Attempts to add rows for streams already known is an expected condition. What is anything except that?
|
||||
if "UNIQUE constraint failed" not in exception.args[0]:
|
||||
# Something unexpected happened. Give up.
|
||||
self.log.error(
|
||||
f"[{stream_domain}] An error occurred while attempting to add stream information after adding subscription: {e}"
|
||||
)
|
||||
raise exception
|
||||
|
||||
# All went well! We added a new subscription and (at least tried) to add a row for the stream state. Tell the user.
|
||||
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
|
||||
await evt.reply(
|
||||
"Subscription added! This room will receive notifications when "
|
||||
+ stream_domain
|
||||
+ " goes live."
|
||||
)
|
||||
|
||||
@command.new(help="Unsubscribes from an Owncast stream.")
|
||||
@command.argument("url")
|
||||
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
|
||||
"""
|
||||
"!unsubscribe" command handler for users to unsubscribe a room from a given stream's notifications.
|
||||
|
||||
:param evt: MessageEvent of the message calling the command.
|
||||
:param url: A string containing the user supplied URL to a stream to try and unsubscribe from.
|
||||
:return: Nothing.
|
||||
"""
|
||||
# Convert the user input to only a domain.
|
||||
stream_domain = self.domainify(url)
|
||||
|
||||
# Attempt to delete the requested subscription from the database.
|
||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
|
||||
async with self.database.acquire() as connection:
|
||||
result = await connection.execute(query, stream_domain, evt.room_id)
|
||||
|
||||
# Did it work?
|
||||
if result.rowcount == 1:
|
||||
# Yes, one row was deleted. Tell the user.
|
||||
self.log.info(
|
||||
f"[{stream_domain}] Subscription removed for room {evt.room_id}."
|
||||
)
|
||||
await evt.reply(
|
||||
"Subscription removed! This room will no longer receive notifications for "
|
||||
+ stream_domain
|
||||
+ "."
|
||||
)
|
||||
elif result.rowcount == 0:
|
||||
# No, nothing changed. Tell the user.
|
||||
await evt.reply(
|
||||
"This room is already not subscribed to notifications for "
|
||||
+ stream_domain
|
||||
+ "."
|
||||
)
|
||||
else:
|
||||
# Somehow more than 1 (or even less than 0 ???) rows were changed... Log it!
|
||||
self.log.error(
|
||||
"Encountered strange situation! Expected 0 or 1 rows on DELETE query for removing subscription; got "
|
||||
+ result.rowcount
|
||||
+ " instead. Something very bad may have happened!!!!"
|
||||
)
|
||||
|
||||
async def update_all_stream_states(self) -> None:
|
||||
"""
|
||||
Checks the status of all streams with active subscriptions.
|
||||
Updates for all streams are performed asynchronously, with the method returning when the slowest update completes.
|
||||
|
||||
:return: Nothing.
|
||||
"""
|
||||
self.log.debug("Updating all stream states...")
|
||||
|
||||
# Make a list of all stream domains with an active subscription.
|
||||
query = "SELECT DISTINCT stream_domain FROM subscriptions"
|
||||
async with self.database.acquire() as connection:
|
||||
results = await connection.fetch(query)
|
||||
|
||||
# Build a list of async tasks which update the state for each stream domain fetched.
|
||||
tasks = []
|
||||
for row in results:
|
||||
domain = row["stream_domain"]
|
||||
|
||||
tasks.append(asyncio.create_task(self.update_stream_state(domain)))
|
||||
|
||||
# Run the tasks in parallel.
|
||||
await asyncio.gather(*tasks)
|
||||
self.log.debug("Update complete.")
|
||||
|
||||
async def update_stream_state(self, domain: str) -> None:
|
||||
"""
|
||||
Updates the state of a given stream domain and sends notifications to subscribed Matrix rooms if it goes live.
|
||||
|
||||
:param domain: The domain of the stream to update.
|
||||
:return: Nothing.
|
||||
"""
|
||||
|
||||
# A flag indicating whether to send a notification.
|
||||
# Used for the first state update of a brand-new stream to avoid sending notifications if its already live.
|
||||
send_notifications = True
|
||||
|
||||
# Fetch the latest stream state from the server.
|
||||
new_state = await self.get_stream_state(domain)
|
||||
|
||||
# Skip the update if the fetch failed for any reason.
|
||||
if new_state == {}:
|
||||
return
|
||||
|
||||
# Fetch the last known stream state from the database.
|
||||
query = "SELECT last_connect_time, last_disconnect_time FROM streams WHERE domain=$1"
|
||||
async with self.database.acquire() as connection:
|
||||
old_state = await connection.fetchrow(query, domain)
|
||||
|
||||
# Does the last known stream state not have a value for the last connect and disconnect time?
|
||||
if (
|
||||
old_state["last_connect_time"] is None
|
||||
and old_state["last_disconnect_time"] is None
|
||||
):
|
||||
# Yes, this is the first update. Don't send any notifications.
|
||||
send_notifications = False
|
||||
|
||||
# Does the latest stream state have a last connect time and the old state not have one?
|
||||
if (
|
||||
new_state["lastConnectTime"] is not None
|
||||
and old_state["last_connect_time"] is None
|
||||
):
|
||||
# Yes! This stream is now live. Send notifications and log it, if allowed.
|
||||
if send_notifications:
|
||||
self.log.info(
|
||||
f"[{domain}] Stream is now live! Notifying subscribed rooms..."
|
||||
)
|
||||
await self.notify_rooms_of_stream_online(
|
||||
domain, new_state["streamTitle"]
|
||||
)
|
||||
else:
|
||||
self.log.info(
|
||||
f"[{domain}] Stream is live, but performed first update. WIll not notify subscribed rooms."
|
||||
)
|
||||
|
||||
# Does the latest stream state no longer have a last connect time but the old state does?
|
||||
elif (
|
||||
new_state["lastConnectTime"] is None
|
||||
and old_state["last_connect_time"] is not None
|
||||
):
|
||||
# Yep. This stream is now offline. Log it.
|
||||
if send_notifications:
|
||||
self.log.info(f"[{domain}] Stream is now offline.")
|
||||
else:
|
||||
self.log.info(f"[{domain}] Stream is offline.")
|
||||
|
||||
# Update the database with the current stream state.
|
||||
# TODO: Only update the database if a change actually occurred. This is probably generating useless writes.
|
||||
update_query = "UPDATE streams SET name=$1, last_connect_time=$2, last_disconnect_time=$3 WHERE domain=$4"
|
||||
async with self.database.acquire() as connection:
|
||||
await connection.execute(
|
||||
update_query,
|
||||
new_state["streamTitle"],
|
||||
new_state["lastConnectTime"],
|
||||
new_state["lastDisconnectTime"],
|
||||
domain,
|
||||
)
|
||||
|
||||
# All done.
|
||||
self.log.debug(f"[{domain}] State update completed.")
|
||||
|
||||
async def notify_rooms_of_stream_online(self, domain: str, title: str) -> None:
|
||||
"""
|
||||
Sends notifications to rooms with subscriptions to the provided stream domain.
|
||||
|
||||
:param domain: The domain of the stream to send notifications for.
|
||||
:param title: The title of the stream to include in the message.
|
||||
:return: Nothing.
|
||||
"""
|
||||
# Get a list of room IDs with active subscriptions to the stream domain.
|
||||
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
|
||||
async with self.database.acquire() as connection:
|
||||
results = await connection.fetch(query, domain)
|
||||
|
||||
# Set up some counters for a high level statistics.
|
||||
successful_notifications = 0
|
||||
failed_notifications = 0
|
||||
|
||||
stream_config = await self.get_stream_config(domain)
|
||||
|
||||
# Build the message body text.
|
||||
if "name" in stream_config:
|
||||
body_text = "🎥 " + stream_config["name"] + " is now live!"
|
||||
else:
|
||||
# Turns out it is possible to set an empty name for an Owncast stream if you know how.
|
||||
# We'll account for that... Just in case.
|
||||
body_text = "🎥 " + domain + " is now live!"
|
||||
|
||||
# Streams can have no title. If there is none, don't even mention it.
|
||||
if title != "":
|
||||
body_text += "\nStream Title: " + title
|
||||
|
||||
body_text += "\n\nTo tune in, visit: https://" + domain + "/"
|
||||
|
||||
if "tags" in stream_config and len(stream_config["tags"]) > 0:
|
||||
body_text += "\n\n"
|
||||
body_text += " ".join("#" + tag for tag in stream_config["tags"])
|
||||
|
||||
# Iterate over the subscribed rooms and try to send a message to each.
|
||||
# TODO: This should probably be made async.
|
||||
for rows in results:
|
||||
room_id = rows["room_id"]
|
||||
try:
|
||||
# Try and send a message.
|
||||
content = TextMessageEventContent(
|
||||
msgtype=MessageType.TEXT, body=body_text
|
||||
)
|
||||
await self.client.send_message(room_id, content)
|
||||
|
||||
# It worked! Increment the counter.
|
||||
successful_notifications += 1
|
||||
except Exception as exception:
|
||||
# Something didn't work. Log it and move on to the next one.
|
||||
self.log.warning(
|
||||
f"[{domain}] Failed to send notification message to room [{room_id}]: {exception}"
|
||||
)
|
||||
failed_notifications += 1
|
||||
|
||||
# All done!
|
||||
self.log.info(
|
||||
f"[{domain}] Completed sending notifications! {successful_notifications} succeeded, {failed_notifications} failed."
|
||||
)
|
||||
|
||||
# ========== HELPER METHODS ==========
|
||||
async def get_stream_state(self, domain):
|
||||
"""
|
||||
Get the current stream state for a given domain.
|
||||
HTTPS on port 443 is assumed, no other protocols or ports are supported.
|
||||
|
||||
:param domain: The domain (not URL) where the stream is hosted.
|
||||
:return: A dictionary containing the lastConnectTime, lastDisconnectTime, streamTitle, and online values if available. An empty dictionary if an error occurred.
|
||||
"""
|
||||
self.log.debug(f"[{domain}] Fetching current stream state...")
|
||||
# Build a URL to the status API in Owncast. (https://owncast.online/api/latest/#tag/Internal/operation/GetStatus)
|
||||
# Only use HTTPS, even if the user specified something else.
|
||||
status_url = "https://" + domain + OWNCAST_STATUS_PATH
|
||||
|
||||
# Make a request to the endpoint.
|
||||
try:
|
||||
response = await self.session.request(
|
||||
"GET", status_url, allow_redirects=False
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
f"[{domain}] Error making GET request to {OWNCAST_STATUS_PATH}: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
# Check the response code is success
|
||||
# TODO: Handle 429 rate limiting?
|
||||
if response.status != 200:
|
||||
self.log.warning(
|
||||
f"[{domain}] Response to request on {OWNCAST_STATUS_PATH} was not 200, got {response.status} instead."
|
||||
)
|
||||
return {}
|
||||
|
||||
# Try and interpret the response as JSON.
|
||||
try:
|
||||
new_state = json.loads(await response.read())
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as could not be interpreted as JSON: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
# Validate the response to ensure it contains all the basic info needed to function.
|
||||
if "lastConnectTime" not in new_state:
|
||||
self.log.warning(
|
||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have last connect time parameter."
|
||||
)
|
||||
return {}
|
||||
elif "lastDisconnectTime" not in new_state:
|
||||
self.log.warning(
|
||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have last disconnect time parameter."
|
||||
)
|
||||
return {}
|
||||
elif "streamTitle" not in new_state:
|
||||
self.log.warning(
|
||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have stream title parameter."
|
||||
)
|
||||
return {}
|
||||
elif "online" not in new_state:
|
||||
self.log.warning(
|
||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have online status parameter."
|
||||
)
|
||||
return {}
|
||||
|
||||
return new_state
|
||||
|
||||
async def get_stream_config(self, domain):
|
||||
"""
|
||||
Get the current stream config for a given domain.
|
||||
HTTPS on port 443 is assumed, no other protocols or ports are supported.
|
||||
|
||||
:param domain: The domain (not URL) where the stream is hosted.
|
||||
:return: A dictionary containing the stream's configuration.
|
||||
"""
|
||||
self.log.debug(f"[{domain}] Fetching current stream config...")
|
||||
# Build a URL to the config API in Owncast. (https://owncast.online/api/latest/#tag/Internal/operation/GetWebConfig)
|
||||
# Only use HTTPS, even if the user specified something else.
|
||||
status_url = "https://" + domain + OWNCAST_CONFIG_PATH
|
||||
|
||||
# Make a request to the endpoint.
|
||||
try:
|
||||
response = await self.session.request(
|
||||
"GET", status_url, allow_redirects=False
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
f"[{domain}] Error making GET request to {OWNCAST_STATUS_PATH}: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
# Check the response code is success
|
||||
# TODO: Handle 429 rate limiting?
|
||||
if response.status != 200:
|
||||
self.log.warning(
|
||||
f"[{domain}] Response to request on {OWNCAST_STATUS_PATH} was not 200, got {response.status} instead."
|
||||
)
|
||||
return {}
|
||||
|
||||
# Try and interpret the response as JSON.
|
||||
try:
|
||||
config = json.loads(await response.read())
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as could not be interpreted as JSON: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
return config
|
||||
|
||||
def domainify(self, url) -> str:
|
||||
"""
|
||||
Take a given URL and convert it to just the domain.
|
||||
|
||||
:param url:
|
||||
:return:
|
||||
"""
|
||||
# Take whatever input the user provided and try to turn it into just the domain.
|
||||
# Examples:
|
||||
# "stream.logal.dev" -> "stream.logal.dev"
|
||||
# "https://stream.logal.dev" -> "stream.logal.dev"
|
||||
# "stream.logal.dev/embed/chat/readwrite" -> "stream.logal.dev"
|
||||
# "https://stream.logal.dev/abcdefghijklmno/123456789" -> "stream.logal.dev"
|
||||
# "notify@stream.logal.dev" -> "stream.logal.dev"
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
domain = (parsed_url.netloc or parsed_url.path).lower()
|
||||
|
||||
if "@" in domain:
|
||||
return domain.split("@")[-1]
|
||||
|
||||
return domain
|
||||
@@ -0,0 +1,317 @@
|
||||
# OwncastSentry Package
|
||||
|
||||
This package contains the Maubot plugin runtime code for OwncastSentry. It
|
||||
tracks room subscriptions to Owncast instances, polls subscribed streams,
|
||||
detects stream state changes, and sends Matrix notifications.
|
||||
|
||||
This README gives maintainers a high-level map of how the package fits
|
||||
together and where the important behavior lives. User-facing setup and command
|
||||
usage belong outside this package. Function-level details belong in docstrings
|
||||
or tests.
|
||||
|
||||
## Files
|
||||
|
||||
- `__init__.py`: Maubot plugin entry point, service wiring, command
|
||||
decorators, scheduler loop, shutdown, and metrics endpoint.
|
||||
- `commands.py`: Maubot command handlers for subscribe, unsubscribe,
|
||||
subscriptions, and live listings.
|
||||
- `config.py`: Maubot configuration proxy for the Prometheus metrics endpoint
|
||||
setting.
|
||||
- `metrics.py`: Prometheus counters, gauges, registry setup, and response timing
|
||||
helpers.
|
||||
- `notification_service.py`: Matrix notification formatting, broadcast,
|
||||
per-domain notification cooldowns, cleanup notices, and delivery metrics.
|
||||
- `owncast_client.py`: bounded aiohttp client for Owncast `/api/status` and
|
||||
`/api/config` requests.
|
||||
- `repository.py`: database schema migrations plus stream and subscription
|
||||
repositories.
|
||||
- `stream_monitor.py`: stream update orchestration, state transition detection,
|
||||
progressive failure backoff, cleanup thresholds, notification decisions, and
|
||||
stream metrics.
|
||||
- `subscription_manager.py`: subscription use cases, domain normalization, and
|
||||
first-subscription Owncast validation.
|
||||
- `types.py`: immutable value objects, Owncast API response validation, derived
|
||||
stream status, update results, and subscription domain errors.
|
||||
|
||||
The package has a service-layer shape: `OwncastSentry.start()` builds the
|
||||
services, commands call `CommandHandler`, command handlers call
|
||||
`SubscriptionManager`, and scheduled updates call `StreamMonitor`.
|
||||
`StreamMonitor` runs the main polling workflow: it checks Owncast streams,
|
||||
applies backoff and cleanup rules, classifies state transitions, triggers any
|
||||
live/title notifications, persists changed stream state, and records stream,
|
||||
failure, and subscription metrics.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
A few terms are used throughout the package:
|
||||
|
||||
- A stream is a tracked [Owncast](https://owncast.online/) instance, identified
|
||||
by its normalized bare domain. The stored entry for that domain is the stream
|
||||
record. There is one stream record per domain, even if multiple Matrix rooms
|
||||
subscribe to it.
|
||||
- A subscription is a Matrix room's request to receive notifications for one
|
||||
stream domain.
|
||||
- `StreamStateObservation` is one successful Owncast status API sample used by
|
||||
the monitor when updating stored stream state.
|
||||
- `StreamConfigObservation` is metadata fetched from Owncast's config API,
|
||||
currently the instance name and tags used in notifications and periodic
|
||||
display-name refreshes.
|
||||
- `StreamState` is an immutable snapshot of the latest persisted state for a
|
||||
stream: display metadata, the latest Owncast online flag, when that status was
|
||||
first observed by the bot, failure count, and the derived online, offline, or
|
||||
unknown status.
|
||||
|
||||
Durable state lives in the database. The `streams` table stores the latest
|
||||
known display and state fields for each Owncast domain, and the `subscriptions`
|
||||
table stores which Matrix rooms follow each domain.
|
||||
|
||||
Subscriptions belong to individual Matrix rooms, while stream state is shared
|
||||
per domain. Notification delivery fans out by domain to the rooms currently
|
||||
subscribed to that domain. Live/title notification text is built from the fresh
|
||||
status observation and, when available, fresh config metadata for the update
|
||||
being processed.
|
||||
|
||||
Stream identity is the normalized bare domain. Repositories, metrics,
|
||||
notification state, and Owncast API calls all use that domain rather than the
|
||||
original user-supplied URL.
|
||||
|
||||
Failures stay isolated at plugin boundaries. A bad command, failed stream
|
||||
check, or room delivery is logged and counted without stopping unrelated
|
||||
commands, stream updates, or notifications.
|
||||
|
||||
`StreamStateObservation`, `StreamConfigObservation`, `StreamState`,
|
||||
`UpdateResult`, and `RoomSubscription` are immutable snapshots. State changes
|
||||
are represented by new values that are persisted through repositories instead of
|
||||
mutating existing objects.
|
||||
|
||||
## Plugin Boundary
|
||||
|
||||
Maubot interacts with the package through `OwncastSentry`. That plugin class is
|
||||
the boundary between Maubot's runtime and the internal services that implement
|
||||
OwncastSentry behavior.
|
||||
|
||||
`OwncastSentry.start()` is the composition root for one plugin instance. It
|
||||
loads config, creates the Owncast client, repositories, metrics, notification
|
||||
service, stream monitor, subscription manager, and command handler, then passes
|
||||
dependencies into each service explicitly.
|
||||
|
||||
The plugin boundary owns Maubot integration: command registration, scheduled
|
||||
polling, and lifecycle cleanup. Scheduled ticks load subscribed domains and
|
||||
call `StreamMonitor.update_all_streams()`. `StreamMonitor` owns the per-stream
|
||||
polling workflow, while notification delivery and delivery cooldowns stay in
|
||||
`NotificationService`.
|
||||
|
||||
Internal services do not depend on Maubot globals, which keeps command handling,
|
||||
scheduled polling, and tests wired through explicit dependencies.
|
||||
|
||||
## Subscription Management
|
||||
|
||||
`SubscriptionManager` coordinates the room-to-domain relationship. Command
|
||||
handlers pass it user-supplied stream targets, and it turns those targets into
|
||||
normalized domains before creating or removing stored data.
|
||||
|
||||
Subscribing a room creates a subscription for the normalized domain and creates
|
||||
the shared stream record when needed. Domains with no current subscribers are
|
||||
validated through `OwncastClient`; domains that already have subscribers reuse
|
||||
the existing stream record instead of revalidating.
|
||||
|
||||
Unsubscribing removes one room's subscription to a domain. It does not delete
|
||||
the shared stream record or make remote Owncast requests.
|
||||
|
||||
Listing methods return the subscriptions for one Matrix room with shared stream
|
||||
state attached, so commands can display each instance's name, title, link,
|
||||
status, and how long it has been online or offline.
|
||||
|
||||
## Owncast API Client
|
||||
|
||||
`OwncastClient` owns the aiohttp session used for Owncast API requests. It
|
||||
fetches stream status and instance config, validates response shape, and turns
|
||||
accepted responses into the value objects used by the rest of the package.
|
||||
|
||||
The session uses a plugin-specific User-Agent, a dummy cookie jar, no DNS cache,
|
||||
a global connection limit, a per-host connection limit of one, and connect/read
|
||||
socket timeouts. aiohttp's own DNS cache is disabled; resolver selection is left
|
||||
to aiohttp and the runtime environment.
|
||||
|
||||
The per-host connection limit and keepalive settings are intentional. Each
|
||||
Owncast instance is intended to keep at most one long-lived connection warm, so
|
||||
minute-by-minute status checks avoid unnecessary TLS renegotiation.
|
||||
|
||||
Responses are accepted only when they are HTTP 200 JSON objects under the
|
||||
configured size limit. Invalid JSON, non-object JSON, oversized bodies,
|
||||
unexpected status codes, malformed API fields, connection errors, and timeouts
|
||||
are logged and ignored.
|
||||
|
||||
The response-time metric is recorded only when a request completes and the
|
||||
parsed response shape is valid. Failed requests remove any stale timing label
|
||||
for that domain.
|
||||
|
||||
## Stream Monitoring
|
||||
|
||||
`StreamMonitor` runs the package's main polling workflow for tracked Owncast
|
||||
instances. `update_stream()` owns the per-domain state machine: it compares the
|
||||
stored stream state with the latest Owncast status response, decides whether to
|
||||
send a notification, updates persistent state when needed, and records stream
|
||||
metrics. Each poll first handles stored stream lookup, polling backoff, and
|
||||
failed Owncast requests. Only a valid status response enters the stream transition
|
||||
policy. `update_all_streams()` wraps this flow for many domains, isolates
|
||||
per-domain exceptions, and records subscription-count metrics.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["update_stream(domain)"] --> Stored["load stored stream state"]
|
||||
Stored --> Query{"backoff allows query?"}
|
||||
Query -->|no| Defer["skip query for backoff"]
|
||||
Defer --> Cleanup["apply cleanup policy"]
|
||||
Cleanup --> Done["done"]
|
||||
Query -->|yes| Status["fetch stream status from Owncast"]
|
||||
Status --> Observation{"valid observation?"}
|
||||
Observation -->|no| Failure["record failed check"]
|
||||
Failure --> Cleanup
|
||||
Observation -->|yes| Compare["compare observation with stored state"]
|
||||
Compare --> First{"first observation?"}
|
||||
First -->|yes| FirstNoNotify["no notification"]
|
||||
First -->|no| LiveCheck{"went live?"}
|
||||
LiveCheck -->|yes| Brief{"offline less than 7 minutes?"}
|
||||
Brief -->|no| LiveNotify["select go-live notification"]
|
||||
Brief -->|yes| BriefTitle{"title changed?"}
|
||||
BriefTitle -->|no| BriefNoNotify["no notification"]
|
||||
BriefTitle -->|yes| TitleNotify["select title-change notification"]
|
||||
LiveCheck -->|no| TitleChanged{"title changed while online?"}
|
||||
TitleChanged -->|yes| TitleNotify
|
||||
TitleChanged -->|no| WentOffline{"went offline?"}
|
||||
WentOffline -->|yes| Offline["record when stream went offline"]
|
||||
WentOffline -->|no| NoNotify["no notification"]
|
||||
FirstNoNotify --> Metadata
|
||||
BriefNoNotify --> Metadata
|
||||
NoNotify --> Metadata
|
||||
Offline --> Metadata
|
||||
LiveNotify --> Metadata
|
||||
TitleNotify --> Metadata
|
||||
Metadata["fetch instance config from Owncast if display metadata is needed"]
|
||||
Metadata --> Notify["attempt sending notification if selected"]
|
||||
Notify --> Save["save stream update"]
|
||||
Save --> Done
|
||||
```
|
||||
|
||||
Each stream has a failure counter that tracks consecutive failed checks and
|
||||
queries skipped for backoff. The counter drives both status and polling behavior:
|
||||
a stream is displayed as unknown after more than 15 minutes of failed or
|
||||
skipped checks, and later failures progressively reduce query frequency until
|
||||
the monitor checks only every 15 minutes. Skipped queries still increment
|
||||
the counter so cleanup can progress.
|
||||
|
||||
After each successful fetch from Owncast's
|
||||
[`/api/status`](https://owncast.online/api/latest/#tag/Internal/operation/GetStatus)
|
||||
endpoint, the monitor compares the stored stream state with the latest
|
||||
observation to identify transitions, such as going online, going offline, or
|
||||
changing title. Online/offline transitions are based on Owncast's `online` field.
|
||||
The stream status timestamp is the bot's UTC observation time for the first
|
||||
successful poll or an online/offline transition.
|
||||
|
||||
The first successful observation stores state and fetches config but suppresses
|
||||
notifications, even if the stream is already live. After that, only
|
||||
offline-to-online transitions can select go-live notifications; mid-session
|
||||
title changes select title-change notifications.
|
||||
|
||||
Brief outages are treated as transient to avoid spamming Matrix rooms when an
|
||||
instance is spotty. They do not select go-live notifications. If a stream returns
|
||||
in under seven minutes with the same title, the monitor stores the new online
|
||||
state without notifying rooms. If the title changed during that brief outage, the
|
||||
monitor selects a title-change notification instead.
|
||||
|
||||
Instance config is fetched from Owncast's
|
||||
[`/api/config`](https://owncast.online/api/latest/#tag/Internal/operation/GetWebConfig)
|
||||
endpoint when a live/title notification is selected, on a stream's first
|
||||
successful observation, and during the hourly refresh window for successfully
|
||||
polled streams. The config response supplies the display name and tags for
|
||||
notifications. If config is fetched successfully during an update, the persisted
|
||||
stream name is refreshed from it; otherwise the stored name is left unchanged.
|
||||
|
||||
Selecting a notification does not guarantee a Matrix message. The notification
|
||||
service can still suppress delivery when its per-domain live/title cooldown is
|
||||
active, which prevents repeated messages from noisy stream state changes. That
|
||||
delivery behavior is covered below.
|
||||
|
||||
## Cleanup Policy
|
||||
|
||||
A domain that remains unreachable is eventually cleaned up. The failure counter
|
||||
represents minute ticks, including skipped backoff ticks.
|
||||
|
||||
After 83 days of continued failed or skipped checks, `StreamMonitor` sends a
|
||||
cleanup warning to all rooms subscribed to the domain. After 90 days or more of
|
||||
continued failed or skipped checks, it sends a deletion notice, deletes all
|
||||
subscriptions for the domain, deletes the stream record, clears the monitor and
|
||||
notification caches for that domain, and asks `MetricsService` to remove
|
||||
existing per-domain metric labels. The enclosing update cycle may later recreate
|
||||
the subscription-count gauge for that domain at zero.
|
||||
|
||||
## Notification Delivery
|
||||
|
||||
`NotificationService` sends notifications to every room subscribed to a domain.
|
||||
Delivery failures are isolated per room, so one failed Matrix send does not
|
||||
block notifications to other subscribed rooms.
|
||||
|
||||
Live and title-change notifications share a per-domain cooldown. The cooldown
|
||||
is recorded only when at least one room receives the message. If there are no
|
||||
subscribed rooms, or every delivery fails, no cooldown is recorded.
|
||||
|
||||
Live/title notification text normalizes remote stream metadata before composing
|
||||
the message, so instance-provided names, titles, and tags cannot add unexpected
|
||||
line breaks or otherwise distort the notification. Messages fall back to the
|
||||
domain when a display name is unavailable.
|
||||
|
||||
Cleanup warning and deletion notices bypass the live/title cooldown, but still
|
||||
record delivery metrics.
|
||||
|
||||
## Command Handling
|
||||
|
||||
`OwncastSentry` exposes Maubot command decorators in `__init__.py`, but command
|
||||
behavior lives in `CommandHandler`. The plugin entry points are thin wrappers
|
||||
that catch unexpected exceptions and record command error metrics.
|
||||
|
||||
`CommandHandler.subscribe()` and `CommandHandler.unsubscribe()` translate
|
||||
domain errors into user-facing Matrix replies. `subscriptions()` and `live()`
|
||||
read room subscription listings with stream state attached and render Markdown
|
||||
output with escaped untrusted text.
|
||||
|
||||
Command listings are limited to the current room and ordered by domain through
|
||||
repository queries. `subscriptions()` includes online, offline, and unknown
|
||||
streams. `live()` includes only streams marked online with a failure count at or
|
||||
below the unknown threshold.
|
||||
|
||||
## Persistence
|
||||
|
||||
Maubot owns the database connection and runs the schema upgrades registered by
|
||||
`repository.py`. The repository classes wrap that database handle and expose the
|
||||
package's stream and subscription persistence operations.
|
||||
|
||||
The persistence model stores one stream record per normalized domain and one
|
||||
subscription row per room/domain pair. `SubscriptionManager` normalizes user
|
||||
input before repository calls. `StreamRepository` writes display and state
|
||||
fields, while failure counters use dedicated methods.
|
||||
`SubscriptionRepository` raises domain-specific errors for duplicate adds and
|
||||
missing removes.
|
||||
|
||||
Room subscription listings join `subscriptions` to `streams`, which means
|
||||
orphaned subscription entries without a matching stream record are skipped in
|
||||
room display queries.
|
||||
|
||||
## Metrics
|
||||
|
||||
`MetricsService` uses a private `CollectorRegistry` so plugin metrics do not mix
|
||||
with process-global Prometheus collectors.
|
||||
|
||||
The registered metrics cover:
|
||||
|
||||
- notification delivery attempts by type and result;
|
||||
- current stream status per domain (`1` online, `0` offline, `-1` unknown);
|
||||
- subscription count per domain;
|
||||
- consecutive check failures per domain;
|
||||
- successful Owncast API response duration per domain;
|
||||
- build/version information;
|
||||
- open aiohttp connection count;
|
||||
- internal scheduler and command errors.
|
||||
|
||||
Known counter label combinations are initialized to zero. This keeps the
|
||||
Prometheus output stable before any events have occurred.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""OwncastSentry maubot plugin."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from maubot import MessageEvent, Plugin # type: ignore[attr-defined]
|
||||
from maubot.handlers import command
|
||||
from prometheus_client.exposition import choose_encoder
|
||||
|
||||
from .commands import CommandHandler
|
||||
from .config import Config
|
||||
from .metrics import ErrorSource, MetricsService
|
||||
from .notification_service import NotificationService
|
||||
from .owncast_client import OwncastClient
|
||||
from .repository import StreamRepository, SubscriptionRepository, get_upgrade_table
|
||||
from .stream_monitor import StreamMonitor
|
||||
from .subscription_manager import SubscriptionManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mautrix.util.async_db import Database, UpgradeTable
|
||||
from mautrix.util.config import BaseProxyConfig
|
||||
|
||||
|
||||
class OwncastSentry(Plugin):
|
||||
"""Main plugin class for OwncastSentry."""
|
||||
|
||||
@classmethod
|
||||
def get_db_upgrade_table(cls) -> UpgradeTable | None:
|
||||
"""Return the database upgrade table for Maubot."""
|
||||
return get_upgrade_table()
|
||||
|
||||
@classmethod
|
||||
def get_config_class(cls) -> type[BaseProxyConfig]:
|
||||
"""Return the configuration class for Maubot."""
|
||||
return Config
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Initialize services and schedule stream state updates.
|
||||
|
||||
Starts a scheduler coroutine that updates subscribed streams at the top
|
||||
of each wall-clock minute.
|
||||
"""
|
||||
# Load configuration
|
||||
config: Config = self.config # type: ignore[assignment]
|
||||
config.load_and_update()
|
||||
db: Database = self.database # type: ignore[assignment]
|
||||
|
||||
# Initialize metrics service and register web endpoint (if enabled)
|
||||
self.metrics_service = MetricsService()
|
||||
self.metrics_service.set_build_info(str(self.loader.meta.version))
|
||||
if config.metrics_enabled and self.webapp is not None:
|
||||
self.webapp.add_route(
|
||||
method="GET", path="/metrics", handler=self._metrics_endpoint
|
||||
)
|
||||
|
||||
# Initialize the Owncast API client
|
||||
self.owncast_client = OwncastClient(
|
||||
self.log, str(self.loader.meta.version), metrics=self.metrics_service
|
||||
)
|
||||
self.metrics_service.register_open_connections_gauge(
|
||||
lambda: self.owncast_client.open_connection_count
|
||||
)
|
||||
|
||||
# Initialize repositories
|
||||
self.stream_repo = StreamRepository(db)
|
||||
self.subscription_repo = SubscriptionRepository(db)
|
||||
|
||||
# Initialize notification service
|
||||
self.notification_service = NotificationService(
|
||||
self.client,
|
||||
self.subscription_repo,
|
||||
self.log,
|
||||
metrics=self.metrics_service,
|
||||
)
|
||||
|
||||
# Initialize stream monitor
|
||||
self.stream_monitor = StreamMonitor(
|
||||
self.owncast_client,
|
||||
self.stream_repo,
|
||||
self.subscription_repo,
|
||||
self.notification_service,
|
||||
self.log,
|
||||
metrics=self.metrics_service,
|
||||
)
|
||||
|
||||
# Initialize subscription manager
|
||||
self.subscription_manager = SubscriptionManager(
|
||||
self.owncast_client,
|
||||
self.stream_repo,
|
||||
self.subscription_repo,
|
||||
self.log,
|
||||
)
|
||||
|
||||
# Initialize command handler
|
||||
self.command_handler = CommandHandler(
|
||||
self.subscription_manager,
|
||||
)
|
||||
|
||||
# Schedule stream state updates at the top of each wall-clock minute.
|
||||
self.sched.run_later(0, self._run_stream_updates_every_minute())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Clean up resources by closing the HTTP session."""
|
||||
await self.owncast_client.close()
|
||||
|
||||
@command.new(help="Subscribes to a new Owncast stream.")
|
||||
@command.argument("url")
|
||||
async def subscribe(self, evt: MessageEvent, url: str) -> None:
|
||||
"""Delegate subscribe command to CommandHandler."""
|
||||
try:
|
||||
await self.command_handler.subscribe(evt, url)
|
||||
except Exception:
|
||||
self.metrics_service.record_error(ErrorSource.COMMAND)
|
||||
self.log.exception("Unhandled exception in subscribe command.")
|
||||
await evt.reply("An unexpected error occurred. Please try again later.")
|
||||
|
||||
@command.new(help="Unsubscribes from an Owncast stream.")
|
||||
@command.argument("url")
|
||||
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
|
||||
"""Delegate unsubscribe command to CommandHandler."""
|
||||
try:
|
||||
await self.command_handler.unsubscribe(evt, url)
|
||||
except Exception:
|
||||
self.metrics_service.record_error(ErrorSource.COMMAND)
|
||||
self.log.exception("Unhandled exception in unsubscribe command.")
|
||||
await evt.reply("An unexpected error occurred. Please try again later.")
|
||||
|
||||
@command.new(help="Lists all stream subscriptions in this room.")
|
||||
async def subscriptions(self, evt: MessageEvent) -> None:
|
||||
"""Delegate subscriptions command to CommandHandler."""
|
||||
try:
|
||||
await self.command_handler.subscriptions(evt)
|
||||
except Exception:
|
||||
self.metrics_service.record_error(ErrorSource.COMMAND)
|
||||
self.log.exception("Unhandled exception in subscriptions command.")
|
||||
await evt.reply("An unexpected error occurred. Please try again later.")
|
||||
|
||||
@command.new(help="Lists currently live streams in this room.")
|
||||
async def live(self, evt: MessageEvent) -> None:
|
||||
"""Delegate live command to CommandHandler."""
|
||||
try:
|
||||
await self.command_handler.live(evt)
|
||||
except Exception:
|
||||
self.metrics_service.record_error(ErrorSource.COMMAND)
|
||||
self.log.exception("Unhandled exception in live command.")
|
||||
await evt.reply("An unexpected error occurred. Please try again later.")
|
||||
|
||||
async def _run_stream_updates_every_minute(self) -> None:
|
||||
"""Run stream state updates at the top of each wall-clock minute."""
|
||||
while True:
|
||||
current_time = datetime.now(UTC)
|
||||
delay = 60 - current_time.second - (current_time.microsecond / 1_000_000)
|
||||
await asyncio.sleep(delay)
|
||||
await self._update_all_stream_states()
|
||||
|
||||
async def _update_all_stream_states(self) -> None:
|
||||
"""Update stream states for domains with active subscriptions."""
|
||||
try:
|
||||
# Get list of all stream domains with active subscriptions
|
||||
subscribed_domains = (
|
||||
await self.subscription_repo.get_all_subscribed_domains()
|
||||
)
|
||||
|
||||
# Delegate to stream monitor
|
||||
await self.stream_monitor.update_all_streams(subscribed_domains)
|
||||
except Exception:
|
||||
self.metrics_service.record_error(ErrorSource.SCHEDULER_LOOP)
|
||||
self.log.exception("Unhandled exception in scheduler loop.")
|
||||
|
||||
async def _metrics_endpoint(self, request: web.Request) -> web.Response:
|
||||
"""Serve Prometheus metrics."""
|
||||
accept = request.headers.get("Accept", "")
|
||||
encoder, content_type = choose_encoder(accept)
|
||||
output = encoder(self.metrics_service.registry)
|
||||
response = web.Response(body=output)
|
||||
response.headers["Content-Type"] = content_type
|
||||
return response
|
||||
@@ -0,0 +1,270 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Command handlers for OwncastSentry bot commands."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .types import (
|
||||
AlreadySubscribedError,
|
||||
InvalidOwncastInstanceError,
|
||||
NotSubscribedError,
|
||||
StreamStatus,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from maubot import MessageEvent # type: ignore[attr-defined]
|
||||
|
||||
from .subscription_manager import SubscriptionManager
|
||||
|
||||
|
||||
_MARKDOWN_ESCAPE_TABLE = str.maketrans({c: f"\\{c}" for c in r"\*_[]()~`#+-=|{}.!<>&"})
|
||||
_SECONDS_PER_MINUTE = 60
|
||||
_SECONDS_PER_HOUR = 60 * _SECONDS_PER_MINUTE
|
||||
_SECONDS_PER_DAY = 24 * _SECONDS_PER_HOUR
|
||||
|
||||
|
||||
def _sanitize_for_plain_text(text: str) -> str:
|
||||
"""Sanitize text before Markdown escaping."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
sanitized = text.replace("\n", " ").replace("\r", " ")
|
||||
return " ".join(sanitized.split())
|
||||
|
||||
|
||||
def _escape_markdown(text: str) -> str:
|
||||
"""Escape Markdown special characters in untrusted text."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
return text.translate(_MARKDOWN_ESCAPE_TABLE)
|
||||
|
||||
|
||||
def _sanitize_for_markdown(text: str) -> str:
|
||||
"""Sanitize text for safe Markdown rendering."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
return _escape_markdown(_sanitize_for_plain_text(text))
|
||||
|
||||
|
||||
def _format_duration(timestamp_str: str, now: datetime) -> str:
|
||||
"""Calculate and format the duration from a timestamp to now."""
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
delta = now - timestamp
|
||||
|
||||
seconds = int(delta.total_seconds())
|
||||
if seconds < 0:
|
||||
return "unknown duration"
|
||||
if seconds < _SECONDS_PER_MINUTE:
|
||||
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
||||
if seconds < _SECONDS_PER_HOUR:
|
||||
minutes = seconds // _SECONDS_PER_MINUTE
|
||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||
if seconds < _SECONDS_PER_DAY:
|
||||
hours = seconds // _SECONDS_PER_HOUR
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
except TypeError, ValueError:
|
||||
return "unknown duration"
|
||||
else:
|
||||
days = seconds // _SECONDS_PER_DAY
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
"""Handles bot commands for managing stream subscriptions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_manager: SubscriptionManager,
|
||||
) -> None:
|
||||
"""Initialize the command handler.
|
||||
|
||||
:param subscription_manager: Subscription domain workflow coordinator.
|
||||
"""
|
||||
self.subscription_manager = subscription_manager
|
||||
|
||||
async def subscribe(self, evt: MessageEvent, url: str) -> None:
|
||||
"""Subscribe a room to a stream's notifications.
|
||||
|
||||
:param evt: MessageEvent of the message calling the command.
|
||||
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||
"""
|
||||
try:
|
||||
stream_domain = await self.subscription_manager.subscribe(evt.room_id, url)
|
||||
except InvalidOwncastInstanceError:
|
||||
await evt.reply(
|
||||
"The URL you supplied does not appear to "
|
||||
"be a valid Owncast instance. You may have "
|
||||
"specified an invalid domain, or the "
|
||||
"instance is offline."
|
||||
)
|
||||
return
|
||||
except AlreadySubscribedError as e:
|
||||
await evt.reply(
|
||||
f"This room is already subscribed to notifications for {e.domain}."
|
||||
)
|
||||
return
|
||||
|
||||
await evt.reply(
|
||||
f"Subscription added! This room will receive "
|
||||
f"notifications when {stream_domain} goes live."
|
||||
)
|
||||
|
||||
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
|
||||
"""Unsubscribe a room from a stream's notifications.
|
||||
|
||||
:param evt: MessageEvent of the message calling the command.
|
||||
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||
"""
|
||||
try:
|
||||
stream_domain = await self.subscription_manager.unsubscribe(
|
||||
evt.room_id, url
|
||||
)
|
||||
except NotSubscribedError as e:
|
||||
await evt.reply(
|
||||
f"This room is already not subscribed to notifications for {e.domain}."
|
||||
)
|
||||
return
|
||||
|
||||
await evt.reply(
|
||||
f"Subscription removed! This room will no "
|
||||
f"longer receive notifications for {stream_domain}."
|
||||
)
|
||||
|
||||
async def subscriptions(self, evt: MessageEvent) -> None:
|
||||
"""List all stream subscriptions in the current room.
|
||||
|
||||
:param evt: MessageEvent of the message calling the command.
|
||||
"""
|
||||
subscriptions = await self.subscription_manager.list_room_subscriptions(
|
||||
evt.room_id
|
||||
)
|
||||
|
||||
if not subscriptions:
|
||||
await evt.reply(
|
||||
"This room is not subscribed to any Owncast "
|
||||
"instances.\n\nTo subscribe to an Owncast "
|
||||
"instance, use `!subscribe <domain>`",
|
||||
markdown=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Build the response message body as Markdown
|
||||
count = len(subscriptions)
|
||||
parts = [f"**Subscriptions for this room ({count}):**\n\n"]
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for subscription in subscriptions:
|
||||
domain = subscription.domain
|
||||
stream_state = subscription.stream_state
|
||||
stream_name = stream_state.name or domain
|
||||
safe_stream_name = _sanitize_for_markdown(stream_name)
|
||||
|
||||
# Start building this stream's entry with stream name as main bullet
|
||||
parts.append(f"- **{safe_stream_name}** \n")
|
||||
|
||||
# Add title if stream is online (as a sub-bullet)
|
||||
if stream_state.status == StreamStatus.ONLINE and stream_state.title:
|
||||
safe_title = _sanitize_for_markdown(stream_state.title)
|
||||
parts.append(f" - Title: {safe_title} \n")
|
||||
|
||||
# Determine status and duration (as a sub-bullet)
|
||||
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:
|
||||
parts.append(" - Status: Online \n")
|
||||
case StreamStatus.UNKNOWN:
|
||||
parts.append(" - Status: Unknown (instance unreachable) \n")
|
||||
case StreamStatus.OFFLINE if stream_state.status_since:
|
||||
duration = _format_duration(stream_state.status_since, now)
|
||||
parts.append(f" - Status: Offline for {duration} \n")
|
||||
case StreamStatus.OFFLINE:
|
||||
parts.append(" - Status: Offline \n")
|
||||
|
||||
# Add stream link (as a sub-bullet)
|
||||
parts.append(f" - Link: https://{domain}\n\n")
|
||||
|
||||
# Add help text for unsubscribing
|
||||
parts.append(
|
||||
"\nTo unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
# Send the response as Markdown
|
||||
await evt.reply("".join(parts), markdown=True)
|
||||
|
||||
async def live(self, evt: MessageEvent) -> None:
|
||||
"""List currently live streams in the current room.
|
||||
|
||||
:param evt: MessageEvent of the message calling the command.
|
||||
"""
|
||||
live_streams = await self.subscription_manager.list_live_room_subscriptions(
|
||||
evt.room_id
|
||||
)
|
||||
|
||||
if not live_streams:
|
||||
if not await self.subscription_manager.has_room_subscriptions(evt.room_id):
|
||||
await evt.reply(
|
||||
"This room is not subscribed to any Owncast "
|
||||
"instances.\n\nTo subscribe to an Owncast "
|
||||
"instance, use `!subscribe <domain>`",
|
||||
markdown=True,
|
||||
)
|
||||
return
|
||||
|
||||
await evt.reply(
|
||||
"No subscribed Owncast instances are currently "
|
||||
"live.\n\nUse `!subscriptions` to list all "
|
||||
"subscriptions.",
|
||||
markdown=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Build the response message body as Markdown
|
||||
count = len(live_streams)
|
||||
parts = [f"**Live Owncast instances ({count}):**\n\n"]
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for subscription in live_streams:
|
||||
domain = subscription.domain
|
||||
stream_state = subscription.stream_state
|
||||
stream_name = stream_state.name or domain
|
||||
safe_stream_name = _sanitize_for_markdown(stream_name)
|
||||
|
||||
# Start building this stream's entry with stream name as main bullet
|
||||
parts.append(f"- **{safe_stream_name}** \n")
|
||||
|
||||
# Add title when available
|
||||
if stream_state.title:
|
||||
safe_title = _sanitize_for_markdown(stream_state.title)
|
||||
parts.append(f" - Title: {safe_title} \n")
|
||||
|
||||
# Add status with duration
|
||||
if stream_state.status_since:
|
||||
duration = _format_duration(stream_state.status_since, now)
|
||||
parts.append(f" - Online for {duration} \n")
|
||||
else:
|
||||
parts.append(" - Online \n")
|
||||
|
||||
# Add stream link
|
||||
parts.append(f" - Link: https://{domain}\n\n")
|
||||
|
||||
# Send the response as Markdown
|
||||
await evt.reply("".join(parts).rstrip(), markdown=True)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Configuration module for the OwncastSentry plugin."""
|
||||
|
||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
"""Configuration class for OwncastSentry plugin."""
|
||||
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
"""Update configuration with user-provided values.
|
||||
|
||||
:param helper: ConfigUpdateHelper for copying values.
|
||||
"""
|
||||
helper.copy("metrics_enabled")
|
||||
|
||||
@property
|
||||
def metrics_enabled(self) -> bool:
|
||||
"""Return whether the Prometheus metrics endpoint is enabled."""
|
||||
return self["metrics_enabled"] # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,236 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Prometheus metrics service for OwncastSentry."""
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager, suppress
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from prometheus_client import CollectorRegistry, Counter, Gauge, Info
|
||||
|
||||
from .types import StreamStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Generator
|
||||
|
||||
|
||||
class NotificationType(StrEnum):
|
||||
"""Notification type labels for the delivery counter."""
|
||||
|
||||
LIVE = "live"
|
||||
TITLE_CHANGE = "title_change"
|
||||
CLEANUP_WARNING = "cleanup_warning"
|
||||
CLEANUP_DELETION = "cleanup_deletion"
|
||||
|
||||
|
||||
class ErrorSource(StrEnum):
|
||||
"""Error source labels for the error counter."""
|
||||
|
||||
SCHEDULER_LOOP = "scheduler_loop"
|
||||
COMMAND = "command"
|
||||
|
||||
|
||||
# Mapping from StreamStatus enum to numeric gauge values
|
||||
_STATUS_VALUES: dict[StreamStatus, float] = {
|
||||
StreamStatus.ONLINE: 1.0,
|
||||
StreamStatus.OFFLINE: 0.0,
|
||||
StreamStatus.UNKNOWN: -1.0,
|
||||
}
|
||||
|
||||
|
||||
class _ResponseTimer:
|
||||
"""Timer that only records an observation when explicitly marked successful."""
|
||||
|
||||
__slots__ = ("_domain", "_gauge", "_should_observe", "_start")
|
||||
|
||||
def __init__(self, gauge: Gauge, domain: str) -> None:
|
||||
self._gauge = gauge
|
||||
self._domain = domain
|
||||
self._start = time.monotonic()
|
||||
self._should_observe = False
|
||||
|
||||
def success(self) -> None:
|
||||
"""Mark the request as successful so the duration is recorded."""
|
||||
self._should_observe = True
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Record or discard the timing observation when the block exits."""
|
||||
if self._should_observe:
|
||||
self._gauge.labels(domain=self._domain).set(
|
||||
max(time.monotonic() - self._start, 0)
|
||||
)
|
||||
else:
|
||||
with suppress(KeyError):
|
||||
self._gauge.remove(self._domain)
|
||||
|
||||
|
||||
class MetricsService:
|
||||
"""Manages Prometheus metrics with an isolated registry."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize metrics with a custom collector registry."""
|
||||
self.registry = CollectorRegistry()
|
||||
|
||||
self.notification_delivery_total = Counter(
|
||||
"owncastsentry_notification_delivery_total",
|
||||
"Total notification delivery attempts to individual rooms",
|
||||
["type", "result"],
|
||||
registry=self.registry,
|
||||
)
|
||||
self.stream_status = Gauge(
|
||||
"owncastsentry_stream_status",
|
||||
"Current stream status (1=online, 0=offline, -1=unknown)",
|
||||
["domain"],
|
||||
registry=self.registry,
|
||||
)
|
||||
self.stream_subscriptions = Gauge(
|
||||
"owncastsentry_stream_subscriptions",
|
||||
"Number of room subscriptions per stream domain",
|
||||
["domain"],
|
||||
registry=self.registry,
|
||||
)
|
||||
self.check_failures = Gauge(
|
||||
"owncastsentry_check_failures",
|
||||
"Consecutive check failure count per stream domain",
|
||||
["domain"],
|
||||
registry=self.registry,
|
||||
)
|
||||
self.api_response_seconds = Gauge(
|
||||
"owncastsentry_api_response_seconds",
|
||||
"Last successful Owncast API query duration in seconds per stream domain",
|
||||
["domain"],
|
||||
registry=self.registry,
|
||||
)
|
||||
self.build_info = Info(
|
||||
"owncastsentry",
|
||||
"OwncastSentry build information",
|
||||
registry=self.registry,
|
||||
)
|
||||
self.open_connections = Gauge(
|
||||
"owncastsentry_http_connections_open",
|
||||
"Current number of open HTTP connections (idle and active)",
|
||||
registry=self.registry,
|
||||
)
|
||||
self.errors_total = Counter(
|
||||
"owncastsentry_errors_total",
|
||||
"Internal errors by source",
|
||||
["source"],
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# Initialize known counter label combinations so they start at 0
|
||||
for notification_type in NotificationType:
|
||||
for result in ("success", "failure"):
|
||||
self.notification_delivery_total.labels(
|
||||
type=notification_type, result=result
|
||||
)
|
||||
for source in ErrorSource:
|
||||
self.errors_total.labels(source=source)
|
||||
|
||||
def record_delivery(
|
||||
self,
|
||||
notification_type: NotificationType,
|
||||
*,
|
||||
successful: int = 0,
|
||||
failed: int = 0,
|
||||
) -> None:
|
||||
"""Record notification delivery results.
|
||||
|
||||
:param notification_type: The type of notification delivered.
|
||||
:param successful: Number of successful room deliveries.
|
||||
:param failed: Number of failed room deliveries.
|
||||
"""
|
||||
self.notification_delivery_total.labels(
|
||||
type=notification_type, result="success"
|
||||
).inc(successful)
|
||||
self.notification_delivery_total.labels(
|
||||
type=notification_type, result="failure"
|
||||
).inc(failed)
|
||||
|
||||
def set_stream_status(self, domain: str, status: StreamStatus) -> None:
|
||||
"""Set the status gauge for a stream domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param status: The current stream status.
|
||||
"""
|
||||
self.stream_status.labels(domain=domain).set(_STATUS_VALUES[status])
|
||||
|
||||
def set_check_failures(self, domain: str, count: int) -> None:
|
||||
"""Set the consecutive failure count for a stream domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param count: The current failure counter value.
|
||||
"""
|
||||
self.check_failures.labels(domain=domain).set(count)
|
||||
|
||||
def set_subscription_count(self, domain: str, count: int) -> None:
|
||||
"""Set the subscription count for a stream domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param count: The number of room subscriptions.
|
||||
"""
|
||||
self.stream_subscriptions.labels(domain=domain).set(count)
|
||||
|
||||
@contextmanager
|
||||
def response_timer(self, domain: str) -> Generator[_ResponseTimer]:
|
||||
"""Return a context manager that times an Owncast API query.
|
||||
|
||||
Call ``timer.success()`` inside the block to record the duration.
|
||||
If ``success()`` is never called, any existing timing label for the
|
||||
domain is removed.
|
||||
|
||||
:param domain: The stream domain being queried.
|
||||
"""
|
||||
timer = _ResponseTimer(self.api_response_seconds, domain)
|
||||
try:
|
||||
yield timer
|
||||
finally:
|
||||
timer.finalize()
|
||||
|
||||
def set_build_info(self, version: str) -> None:
|
||||
"""Set the build version info metric.
|
||||
|
||||
:param version: The plugin version string.
|
||||
"""
|
||||
self.build_info.info({"version": version})
|
||||
|
||||
def register_open_connections_gauge(self, callback: Callable[[], float]) -> None:
|
||||
"""Register a gauge that reads open connection count on scrape.
|
||||
|
||||
:param callback: Function returning the current open count.
|
||||
"""
|
||||
self.open_connections.set_function(callback)
|
||||
|
||||
def record_error(self, source: ErrorSource) -> None:
|
||||
"""Increment the internal error counter.
|
||||
|
||||
:param source: The source of the error.
|
||||
"""
|
||||
self.errors_total.labels(source=source).inc()
|
||||
|
||||
def remove_stream(self, domain: str) -> None:
|
||||
"""Remove a stream's gauge labels after cleanup deletion.
|
||||
|
||||
:param domain: The stream domain to remove.
|
||||
"""
|
||||
with suppress(KeyError):
|
||||
self.stream_status.remove(domain)
|
||||
with suppress(KeyError):
|
||||
self.check_failures.remove(domain)
|
||||
with suppress(KeyError):
|
||||
self.stream_subscriptions.remove(domain)
|
||||
with suppress(KeyError):
|
||||
self.api_response_seconds.remove(domain)
|
||||
@@ -0,0 +1,307 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Notification service for sending Matrix messages."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mautrix.types import MessageType, TextMessageEventContent
|
||||
|
||||
from .metrics import NotificationType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .metrics import MetricsService
|
||||
from .repository import SubscriptionRepository
|
||||
|
||||
|
||||
_SECONDS_BETWEEN_NOTIFICATIONS = 20 * 60
|
||||
|
||||
_CLEANUP_WARNING_DAYS = 83
|
||||
_CLEANUP_DELETE_DAYS = 90
|
||||
|
||||
|
||||
def _sanitize_for_plain_text(text: str) -> str:
|
||||
"""Sanitize text for plain text rendering."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for sending Matrix notifications about stream events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
logger: logging.Logger,
|
||||
metrics: MetricsService,
|
||||
) -> None:
|
||||
"""Initialize the notification service.
|
||||
|
||||
:param client: The Matrix client for sending messages.
|
||||
:param subscription_repo: Repository for managing subscriptions.
|
||||
:param logger: Logger instance for debugging.
|
||||
:param metrics: Metrics service for recording counters.
|
||||
"""
|
||||
self.client = client
|
||||
self.subscription_repo = subscription_repo
|
||||
self.log = logger
|
||||
self.metrics = metrics
|
||||
|
||||
# Cache for tracking when notifications were last sent
|
||||
self.notification_timers_cache: dict[str, float] = {}
|
||||
|
||||
async def notify_stream_live(
|
||||
self,
|
||||
domain: str,
|
||||
name: str,
|
||||
title: str,
|
||||
tags: Sequence[str],
|
||||
*,
|
||||
title_change: bool = False,
|
||||
) -> None:
|
||||
"""Send notifications to rooms subscribed to a stream.
|
||||
|
||||
:param domain: The stream domain to send notifications for.
|
||||
:param name: The stream name to include in the message.
|
||||
:param title: The stream title to include in the message.
|
||||
:param tags: List of stream tags to include in the message.
|
||||
:param title_change: Whether this is a title change notification.
|
||||
"""
|
||||
# Has enough time passed since the last notification was sent?
|
||||
if not self._can_notify(domain):
|
||||
seconds_since_last = round(
|
||||
time.monotonic() - self.notification_timers_cache[domain]
|
||||
)
|
||||
self.log.info(
|
||||
"[%s] Not sending notifications. Only %s of required "
|
||||
"%s seconds have passed since last notification.",
|
||||
domain,
|
||||
seconds_since_last,
|
||||
_SECONDS_BETWEEN_NOTIFICATIONS,
|
||||
)
|
||||
return
|
||||
|
||||
# Build the notification message
|
||||
body_text = self._format_message(
|
||||
name, title, domain, tags, title_change=title_change
|
||||
)
|
||||
|
||||
# Send notifications to all subscribed rooms in parallel
|
||||
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
||||
|
||||
# Record that a notification was sent if at least one room received it.
|
||||
if successful > 0:
|
||||
self._record_notification(domain)
|
||||
|
||||
# Log completion
|
||||
notification_type = "title change" if title_change else "going live"
|
||||
self.log.info(
|
||||
"[%s] Completed sending %s notifications! %s succeeded, %s failed.",
|
||||
domain,
|
||||
notification_type,
|
||||
successful,
|
||||
failed,
|
||||
)
|
||||
|
||||
self.metrics.record_delivery(
|
||||
NotificationType.TITLE_CHANGE if title_change else NotificationType.LIVE,
|
||||
successful=successful,
|
||||
failed=failed,
|
||||
)
|
||||
|
||||
async def send_cleanup_warning(self, domain: str) -> None:
|
||||
"""Send cleanup warning notification to all subscribed rooms.
|
||||
|
||||
:param domain: The stream domain.
|
||||
"""
|
||||
remaining_days = _CLEANUP_DELETE_DAYS - _CLEANUP_WARNING_DAYS
|
||||
body_text = (
|
||||
"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
|
||||
f"The Owncast instance at {domain} has been "
|
||||
f"unreachable for {_CLEANUP_WARNING_DAYS} days. If it remains "
|
||||
f"unreachable for {remaining_days} more days "
|
||||
f"({_CLEANUP_DELETE_DAYS} days total), this subscription "
|
||||
f"will be automatically removed."
|
||||
)
|
||||
|
||||
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
||||
self.log.info(
|
||||
"[%s] Sent cleanup warning to %s rooms (%s failed).",
|
||||
domain,
|
||||
successful,
|
||||
failed,
|
||||
)
|
||||
|
||||
self.metrics.record_delivery(
|
||||
NotificationType.CLEANUP_WARNING, successful=successful, failed=failed
|
||||
)
|
||||
|
||||
async def send_cleanup_deletion(self, domain: str) -> None:
|
||||
"""Send cleanup deletion notification to all subscribed rooms.
|
||||
|
||||
:param domain: The stream domain.
|
||||
"""
|
||||
body_text = (
|
||||
"🗑️ Subscription Automatically Removed\n\n"
|
||||
f"The Owncast instance at {domain} has been "
|
||||
f"unreachable for {_CLEANUP_DELETE_DAYS} days and has been "
|
||||
f"automatically removed from subscriptions in this "
|
||||
f"room.\n\n"
|
||||
f"If the instance comes online again and you want to "
|
||||
f"resubscribe, run `!subscribe {domain}`."
|
||||
)
|
||||
|
||||
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
||||
self.log.info(
|
||||
"[%s] Sent cleanup deletion notice to %s rooms (%s failed).",
|
||||
domain,
|
||||
successful,
|
||||
failed,
|
||||
)
|
||||
|
||||
self.metrics.record_delivery(
|
||||
NotificationType.CLEANUP_DELETION, successful=successful, failed=failed
|
||||
)
|
||||
|
||||
def get_last_notification_time(self, domain: str) -> float:
|
||||
"""Get the cached live/title-change notification timestamp for a domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: Monotonic timestamp of the last successful live/title-change
|
||||
notification, or 0 if none has been recorded.
|
||||
"""
|
||||
return self.notification_timers_cache.get(domain, 0)
|
||||
|
||||
def clear_notification_state(self, domain: str) -> None:
|
||||
"""Clear cached notification state for a deleted domain.
|
||||
|
||||
:param domain: The stream domain to remove from local caches.
|
||||
"""
|
||||
self.notification_timers_cache.pop(domain, None)
|
||||
|
||||
async def _send_notification(
|
||||
self, room_id: str, body_text: str, domain: str
|
||||
) -> None:
|
||||
"""Send a notification to a single room.
|
||||
|
||||
:param room_id: The Matrix room ID to send to.
|
||||
:param body_text: The message body text.
|
||||
:param domain: The stream domain (for logging).
|
||||
Propagates send errors so broadcast delivery accounting can count failures.
|
||||
"""
|
||||
try:
|
||||
content = TextMessageEventContent(msgtype=MessageType.TEXT, body=body_text)
|
||||
await self.client.send_message(room_id, content)
|
||||
except Exception as exception:
|
||||
self.log.warning(
|
||||
"[%s] Failed to send notification message to room [%s]: %s",
|
||||
domain,
|
||||
room_id,
|
||||
exception,
|
||||
)
|
||||
raise
|
||||
|
||||
def _format_message(
|
||||
self,
|
||||
name: str,
|
||||
title: str,
|
||||
domain: str,
|
||||
tags: Sequence[str],
|
||||
*,
|
||||
title_change: bool,
|
||||
) -> str:
|
||||
"""Format the notification message body.
|
||||
|
||||
:param name: The stream name.
|
||||
:param title: The stream title.
|
||||
:param domain: The stream domain.
|
||||
:param tags: List of stream tags.
|
||||
:param title_change: Whether this is a title change notification.
|
||||
:return: Formatted message body.
|
||||
"""
|
||||
# Use name if available, fallback to domain
|
||||
stream_name = name or domain
|
||||
safe_stream_name = _sanitize_for_plain_text(stream_name)
|
||||
|
||||
# Choose message based on notification type
|
||||
if title_change:
|
||||
parts = [f"📝 {safe_stream_name} has changed its stream title!"]
|
||||
else:
|
||||
parts = [f"🎥 {safe_stream_name} is now live!"]
|
||||
|
||||
# Add title if present
|
||||
if title:
|
||||
safe_title = _sanitize_for_plain_text(title)
|
||||
parts.append(f"\nStream Title: {safe_title}")
|
||||
|
||||
# Add stream URL
|
||||
parts.append(f"\n\nTo tune in, visit: https://{domain}/")
|
||||
|
||||
# Add tags if present
|
||||
if tags:
|
||||
tag_text = " ".join(
|
||||
f"#{safe_tag}"
|
||||
for tag in tags
|
||||
if (safe_tag := _sanitize_for_plain_text(tag))
|
||||
and not safe_tag.startswith(".")
|
||||
)
|
||||
|
||||
if tag_text:
|
||||
parts.append(f"\n\n{tag_text}")
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def _can_notify(self, domain: str) -> bool:
|
||||
"""Check if enough time has passed to send another notification.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: True if notification can be sent, False otherwise.
|
||||
"""
|
||||
last_notification_time = self.notification_timers_cache.get(domain)
|
||||
return (
|
||||
last_notification_time is None
|
||||
or time.monotonic() - last_notification_time
|
||||
>= _SECONDS_BETWEEN_NOTIFICATIONS
|
||||
)
|
||||
|
||||
def _record_notification(self, domain: str) -> None:
|
||||
"""Record that a notification was sent at the current time.
|
||||
|
||||
:param domain: The stream domain.
|
||||
"""
|
||||
self.notification_timers_cache[domain] = time.monotonic()
|
||||
|
||||
async def _broadcast_to_rooms(self, domain: str, body_text: str) -> tuple[int, int]:
|
||||
"""Send a message to all rooms subscribed to a domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param body_text: The message body text.
|
||||
:return: Tuple of (successful, failed) counts.
|
||||
"""
|
||||
room_ids = await self.subscription_repo.get_subscribed_rooms(domain)
|
||||
tasks = [
|
||||
self._send_notification(room_id, body_text, domain) for room_id in room_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
failed = sum(1 for r in results if isinstance(r, BaseException))
|
||||
successful = len(results) - failed
|
||||
return successful, failed
|
||||
@@ -0,0 +1,257 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""HTTP client for querying Owncast instance APIs."""
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .types import (
|
||||
InvalidApiResponseError,
|
||||
StreamConfigObservation,
|
||||
StreamStateObservation,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import logging
|
||||
|
||||
from .metrics import MetricsService
|
||||
|
||||
|
||||
_OWNCAST_STATUS_PATH = "/api/status"
|
||||
_OWNCAST_CONFIG_PATH = "/api/config"
|
||||
_MAX_JSON_RESPONSE_BYTES = 1024 * 1024
|
||||
_JSON_READ_CHUNK_BYTES = 64 * 1024
|
||||
_HTTP_CONNECTION_LIMIT = 1000
|
||||
_HTTP_CONNECTION_LIMIT_PER_HOST = 1
|
||||
_HTTP_KEEPALIVE_TIMEOUT_SECONDS = 120
|
||||
_HTTP_CONNECT_TIMEOUT_SECONDS = 5
|
||||
_HTTP_READ_TIMEOUT_SECONDS = 5
|
||||
|
||||
|
||||
def _user_agent(version: str) -> str:
|
||||
"""Build the User-Agent header string for HTTP requests."""
|
||||
return (
|
||||
f"OwncastSentry/{version}"
|
||||
" (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
|
||||
)
|
||||
|
||||
|
||||
async def _read_limited_response_body(
|
||||
response: aiohttp.ClientResponse,
|
||||
) -> bytearray | None:
|
||||
"""Read a response body while enforcing the maximum JSON response size."""
|
||||
# Check Content-Length first when the server provides it so clearly
|
||||
# oversized responses can be rejected before buffering any body bytes.
|
||||
if (
|
||||
response.content_length is not None
|
||||
and response.content_length > _MAX_JSON_RESPONSE_BYTES
|
||||
):
|
||||
return None
|
||||
|
||||
body = bytearray()
|
||||
# Read until EOF instead of using one read(n) call. aiohttp's read(n)
|
||||
# may return a partial body as soon as data is available.
|
||||
async for chunk in response.content.iter_chunked(_JSON_READ_CHUNK_BYTES):
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_JSON_RESPONSE_BYTES:
|
||||
return None
|
||||
|
||||
return body
|
||||
|
||||
|
||||
class OwncastClient:
|
||||
"""HTTP client for communicating with Owncast instances."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger: logging.Logger,
|
||||
version: str,
|
||||
metrics: MetricsService,
|
||||
) -> None:
|
||||
"""Initialize the Owncast client with an HTTP session.
|
||||
|
||||
:param logger: Logger instance for debugging
|
||||
:param version: Plugin version string for the User-Agent header
|
||||
:param metrics: Metrics service for recording response times.
|
||||
"""
|
||||
self.log = logger
|
||||
self.metrics = metrics
|
||||
|
||||
# Set up HTTP session configuration
|
||||
headers = {"User-Agent": _user_agent(version)}
|
||||
cookie_jar = aiohttp.DummyCookieJar()
|
||||
connector = aiohttp.TCPConnector(
|
||||
use_dns_cache=False,
|
||||
limit=_HTTP_CONNECTION_LIMIT,
|
||||
limit_per_host=_HTTP_CONNECTION_LIMIT_PER_HOST,
|
||||
keepalive_timeout=_HTTP_KEEPALIVE_TIMEOUT_SECONDS,
|
||||
)
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
sock_connect=_HTTP_CONNECT_TIMEOUT_SECONDS,
|
||||
sock_read=_HTTP_READ_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
self.session = aiohttp.ClientSession(
|
||||
headers=headers,
|
||||
cookie_jar=cookie_jar,
|
||||
timeout=timeout,
|
||||
connector=connector,
|
||||
)
|
||||
|
||||
async def get_stream_state(self, domain: str) -> StreamStateObservation | None:
|
||||
"""Get the current stream state for a given domain.
|
||||
|
||||
HTTPS on port 443 is assumed, no other protocols or ports
|
||||
are supported.
|
||||
|
||||
:param domain: The domain (not URL) where the stream is hosted.
|
||||
:return: A StreamStateObservation if available, None on error.
|
||||
"""
|
||||
self.log.debug("[%s] Fetching current stream state...", domain)
|
||||
with self.metrics.response_timer(domain) as timer:
|
||||
new_state = await self._fetch_json(domain, _OWNCAST_STATUS_PATH)
|
||||
|
||||
if new_state is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
stream_observation = StreamStateObservation.from_api_response(
|
||||
new_state, domain
|
||||
)
|
||||
except InvalidApiResponseError as e:
|
||||
self.log.warning(
|
||||
"[%s] Rejecting response to request on %s as response "
|
||||
"shape is invalid: %s",
|
||||
domain,
|
||||
_OWNCAST_STATUS_PATH,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
timer.success()
|
||||
return stream_observation
|
||||
|
||||
async def get_stream_config(self, domain: str) -> StreamConfigObservation | None:
|
||||
"""Get the current stream config for a given domain.
|
||||
|
||||
HTTPS on port 443 is assumed, no other protocols or ports
|
||||
are supported.
|
||||
|
||||
:param domain: The domain (not URL) where the stream is hosted.
|
||||
:return: A StreamConfigObservation, or None if fetch failed.
|
||||
"""
|
||||
self.log.debug("[%s] Fetching current stream config...", domain)
|
||||
with self.metrics.response_timer(domain) as timer:
|
||||
config = await self._fetch_json(domain, _OWNCAST_CONFIG_PATH)
|
||||
if config is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
stream_config = StreamConfigObservation.from_api_response(config)
|
||||
except InvalidApiResponseError as e:
|
||||
self.log.warning(
|
||||
"[%s] Rejecting response to request on %s as response "
|
||||
"shape is invalid: %s",
|
||||
domain,
|
||||
_OWNCAST_CONFIG_PATH,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
timer.success()
|
||||
return stream_config
|
||||
|
||||
async def validate_instance(self, domain: str) -> bool:
|
||||
"""Validate that a domain is a valid Owncast instance.
|
||||
|
||||
:param domain: The domain to validate
|
||||
:return: True if valid Owncast instance, False otherwise
|
||||
"""
|
||||
state = await self.get_stream_state(domain)
|
||||
return state is not None
|
||||
|
||||
@property
|
||||
def open_connection_count(self) -> int:
|
||||
"""Return the total number of open HTTP connections."""
|
||||
connector = self.session.connector
|
||||
if connector is None:
|
||||
return 0
|
||||
|
||||
# aiohttp does not expose public live pool counts; this metric intentionally
|
||||
# mirrors its internal idle and active connection pools.
|
||||
idle = sum(len(conns) for conns in connector._conns.values()) # noqa: SLF001
|
||||
active = len(connector._acquired) # noqa: SLF001
|
||||
return idle + active
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP session."""
|
||||
await self.session.close()
|
||||
|
||||
async def _fetch_json(self, domain: str, path: str) -> dict[str, Any] | None:
|
||||
"""Fetch and parse JSON from an Owncast API endpoint.
|
||||
|
||||
:param domain: The domain to query.
|
||||
:param path: The API path to request.
|
||||
:return: Parsed JSON response, or None on error.
|
||||
"""
|
||||
url = f"https://{domain}{path}"
|
||||
try:
|
||||
async with self.session.get(url, allow_redirects=False) as response:
|
||||
if response.status != HTTPStatus.OK:
|
||||
self.log.warning(
|
||||
"[%s] Response to request on %s was not 200, got %s instead.",
|
||||
domain,
|
||||
path,
|
||||
response.status,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
body = await _read_limited_response_body(response)
|
||||
if body is None:
|
||||
self.log.warning(
|
||||
"[%s] Rejecting response to request on %s as it "
|
||||
"was larger than %s bytes.",
|
||||
domain,
|
||||
path,
|
||||
_MAX_JSON_RESPONSE_BYTES,
|
||||
)
|
||||
return None
|
||||
|
||||
result = json.loads(body)
|
||||
if not isinstance(result, dict):
|
||||
self.log.warning(
|
||||
"[%s] Rejecting response to request on %s as JSON "
|
||||
"was not an object.",
|
||||
domain,
|
||||
path,
|
||||
)
|
||||
return None
|
||||
except ValueError as e:
|
||||
self.log.warning(
|
||||
"[%s] Rejecting response to request on %s as could not "
|
||||
"be interpreted as JSON: %s",
|
||||
domain,
|
||||
path,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
return result
|
||||
except (aiohttp.ClientError, TimeoutError, OSError) as e:
|
||||
self.log.warning("[%s] Error making GET request to %s: %s", domain, path, e)
|
||||
return None
|
||||
@@ -0,0 +1,493 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Repository and schema upgrade definitions for OwncastSentry.
|
||||
|
||||
Only SQLite is supported as a database backend for now.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mautrix.util.async_db import Connection, UpgradeTable
|
||||
|
||||
from .types import (
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
NotSubscribedError,
|
||||
RoomSubscription,
|
||||
StreamState,
|
||||
format_status_since,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
|
||||
upgrade_table = UpgradeTable()
|
||||
|
||||
|
||||
class _Unset:
|
||||
"""Sentinel type for omitted partial stream update fields."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
_UNSET = _Unset()
|
||||
|
||||
|
||||
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]
|
||||
description="Initial revision"
|
||||
)
|
||||
async def upgrade_v1(conn: Connection) -> None:
|
||||
"""Create the initial database schema.
|
||||
|
||||
Creates the streams and subscriptions tables.
|
||||
|
||||
:param conn: A connection to run the v1 database migration on.
|
||||
"""
|
||||
await conn.execute(
|
||||
"""CREATE TABLE "streams" (
|
||||
"domain" TEXT NOT NULL UNIQUE,
|
||||
"name" TEXT,
|
||||
"title" TEXT,
|
||||
"last_connect_time" TEXT,
|
||||
"last_disconnect_time" TEXT,
|
||||
PRIMARY KEY("domain")
|
||||
)"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""CREATE TABLE "subscriptions" (
|
||||
"stream_domain" INTEGER NOT NULL,
|
||||
"room_id" TEXT NOT NULL,
|
||||
UNIQUE("room_id","stream_domain")
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||
description="Fix stream_domain column type from INTEGER to TEXT"
|
||||
)
|
||||
async def upgrade_v2(conn: Connection) -> None:
|
||||
"""Upgrade database schema to version 2 format.
|
||||
|
||||
Fixes the stream_domain column type in the subscriptions table
|
||||
from INTEGER to TEXT.
|
||||
|
||||
:param conn: A connection to run the v2 database migration on.
|
||||
"""
|
||||
await conn.execute(
|
||||
"""CREATE TABLE "subscriptions_new" (
|
||||
"stream_domain" TEXT NOT NULL,
|
||||
"room_id" TEXT NOT NULL,
|
||||
UNIQUE("room_id","stream_domain")
|
||||
)"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""INSERT INTO subscriptions_new (stream_domain, room_id)
|
||||
SELECT stream_domain, room_id FROM subscriptions"""
|
||||
)
|
||||
|
||||
await conn.execute("DROP TABLE subscriptions")
|
||||
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
|
||||
|
||||
|
||||
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||
description="Add failure_counter column for backoff and auto-cleanup"
|
||||
)
|
||||
async def upgrade_v3(conn: Connection) -> None:
|
||||
"""Upgrade database schema to version 3 format.
|
||||
|
||||
Adds the failure_counter column to track connection failures
|
||||
for backoff and auto-cleanup.
|
||||
|
||||
:param conn: A connection to run the v3 database migration on.
|
||||
"""
|
||||
await conn.execute(
|
||||
"""ALTER TABLE streams ADD COLUMN failure_counter INTEGER DEFAULT 0"""
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
last_connect_time = row["last_connect_time"]
|
||||
online = last_connect_time is not None and str(last_connect_time).strip() != ""
|
||||
legacy_timestamp = 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:
|
||||
"""Return the repository upgrade table with registered migrations."""
|
||||
return upgrade_table
|
||||
|
||||
|
||||
class StreamRepository:
|
||||
"""Repository for managing stream data in the database."""
|
||||
|
||||
def __init__(self, database: Database) -> None:
|
||||
"""Initialize the stream repository.
|
||||
|
||||
:param database: The maubot database instance.
|
||||
"""
|
||||
self.db: Any = database
|
||||
|
||||
async def create(self, domain: str) -> bool:
|
||||
"""Create a new stream entry in the database.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: True if created, False if the stream already existed.
|
||||
"""
|
||||
query = """INSERT INTO streams (domain)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (domain) DO NOTHING"""
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.execute(query, domain)
|
||||
return int(result.rowcount) > 0
|
||||
|
||||
async def get_by_domain(self, domain: str) -> StreamState | None:
|
||||
"""Get a stream's state by domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: StreamState if found, None otherwise.
|
||||
"""
|
||||
query = "SELECT * FROM streams WHERE domain=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
row = await conn.fetchrow(query, domain)
|
||||
return StreamState.from_db_row(row) if row else None
|
||||
|
||||
async def exists(self, domain: str) -> bool:
|
||||
"""Check if a stream exists in the database.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: True if exists, False otherwise.
|
||||
"""
|
||||
result = await self.get_by_domain(domain)
|
||||
return result is not None
|
||||
|
||||
async def update(
|
||||
self,
|
||||
domain: str,
|
||||
*,
|
||||
name: str | None | _Unset = _UNSET,
|
||||
title: str | None | _Unset = _UNSET,
|
||||
online: bool | _Unset = _UNSET,
|
||||
status_since: str | None | _Unset = _UNSET,
|
||||
) -> None:
|
||||
"""Update only the supplied stream fields in the database.
|
||||
|
||||
Passing None writes NULL for nullable fields. Omitting a field leaves
|
||||
that column unchanged.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param name: Optional stream display name update.
|
||||
:param title: Optional stream title update.
|
||||
:param online: Optional stream online state update.
|
||||
:param status_since: Optional current status timestamp update.
|
||||
"""
|
||||
# _UNSET marks omitted fields so None can still be written as SQL NULL.
|
||||
update_name = not isinstance(name, _Unset)
|
||||
update_title = not isinstance(title, _Unset)
|
||||
update_online = not isinstance(online, _Unset)
|
||||
update_status_since = not isinstance(status_since, _Unset)
|
||||
|
||||
if not (update_name or update_title or update_online or update_status_since):
|
||||
return
|
||||
|
||||
name_value = None if isinstance(name, _Unset) else name
|
||||
title_value = None if isinstance(title, _Unset) else title
|
||||
online_value = None if isinstance(online, _Unset) else online
|
||||
status_since_value = None if isinstance(status_since, _Unset) else status_since
|
||||
|
||||
# SQLite's IS NOT gives null-safe comparisons for the supported backend.
|
||||
query = """UPDATE streams
|
||||
SET name = CASE WHEN $1 THEN $2 ELSE name END,
|
||||
title = CASE WHEN $3 THEN $4 ELSE title END,
|
||||
online = CASE WHEN $5 THEN $6 ELSE online END,
|
||||
status_since = CASE WHEN $7 THEN $8 ELSE status_since END
|
||||
WHERE domain=$9
|
||||
AND (($1 AND name IS NOT $2)
|
||||
OR ($3 AND title IS NOT $4)
|
||||
OR ($5 AND online IS NOT $6)
|
||||
OR ($7 AND status_since IS NOT $8))"""
|
||||
async with self.db.acquire() as conn:
|
||||
await conn.execute(
|
||||
query,
|
||||
update_name,
|
||||
name_value,
|
||||
update_title,
|
||||
title_value,
|
||||
update_online,
|
||||
online_value,
|
||||
update_status_since,
|
||||
status_since_value,
|
||||
domain,
|
||||
)
|
||||
|
||||
async def delete(self, domain: str) -> None:
|
||||
"""Delete a stream record from the database.
|
||||
|
||||
:param domain: The stream domain.
|
||||
"""
|
||||
query = "DELETE FROM streams WHERE domain=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
await conn.execute(query, domain)
|
||||
|
||||
async def increment_failure_counter(self, domain: str) -> None:
|
||||
"""Increment the failure counter for a stream by 1.
|
||||
|
||||
:param domain: The stream domain.
|
||||
"""
|
||||
query = """UPDATE streams
|
||||
SET failure_counter = failure_counter + 1
|
||||
WHERE domain=$1"""
|
||||
async with self.db.acquire() as conn:
|
||||
await conn.execute(query, domain)
|
||||
|
||||
async def reset_failure_counter(self, domain: str) -> None:
|
||||
"""Reset the failure counter for a stream to 0.
|
||||
|
||||
:param domain: The stream domain.
|
||||
"""
|
||||
query = """UPDATE streams
|
||||
SET failure_counter = 0
|
||||
WHERE domain=$1 AND failure_counter != 0"""
|
||||
async with self.db.acquire() as conn:
|
||||
await conn.execute(query, domain)
|
||||
|
||||
|
||||
class SubscriptionRepository:
|
||||
"""Repository for managing stream subscriptions in the database."""
|
||||
|
||||
def __init__(self, database: Database) -> None:
|
||||
"""Initialize the subscription repository.
|
||||
|
||||
:param database: The maubot database instance.
|
||||
"""
|
||||
self.db: Any = database
|
||||
|
||||
async def add(self, domain: str, room_id: str) -> None:
|
||||
"""Add a subscription for a room to a stream.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param room_id: The Matrix room ID.
|
||||
:raises AlreadySubscribedError: If subscription already exists.
|
||||
"""
|
||||
query = """INSERT INTO subscriptions (stream_domain, room_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (room_id, stream_domain) DO NOTHING"""
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.execute(query, domain, room_id)
|
||||
if int(result.rowcount) == 0:
|
||||
raise AlreadySubscribedError(domain)
|
||||
|
||||
async def remove(self, domain: str, room_id: str) -> None:
|
||||
"""Remove a subscription for a room from a stream.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param room_id: The Matrix room ID.
|
||||
:raises NotSubscribedError: If no subscription exists.
|
||||
"""
|
||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.execute(query, domain, room_id)
|
||||
if int(result.rowcount) == 0:
|
||||
raise NotSubscribedError(domain)
|
||||
|
||||
async def delete_all_for_domain(self, domain: str) -> int:
|
||||
"""Delete all subscriptions for a given stream domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: Number of subscriptions deleted.
|
||||
"""
|
||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.execute(query, domain)
|
||||
return int(result.rowcount)
|
||||
|
||||
async def get_subscribed_rooms(self, domain: str) -> list[str]:
|
||||
"""Get all room IDs subscribed to a stream.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: List of room IDs.
|
||||
"""
|
||||
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query, domain)
|
||||
return [row["room_id"] for row in results]
|
||||
|
||||
async def get_subscribed_streams_for_room(self, room_id: str) -> list[str]:
|
||||
"""Get all stream domains that a room is subscribed to.
|
||||
|
||||
:param room_id: The Matrix room ID.
|
||||
:return: List of stream domains.
|
||||
"""
|
||||
query = "SELECT stream_domain FROM subscriptions WHERE room_id=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query, room_id)
|
||||
return [row["stream_domain"] for row in results]
|
||||
|
||||
async def has_room_subscriptions(self, room_id: str) -> bool:
|
||||
"""Check whether a room has any subscriptions."""
|
||||
query = "SELECT 1 FROM subscriptions WHERE room_id=$1 LIMIT 1"
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.fetchrow(query, room_id)
|
||||
return result is not None
|
||||
|
||||
async def get_room_subscriptions(self, room_id: str) -> list[RoomSubscription]:
|
||||
"""Get resolved stream subscriptions for a room ordered by domain.
|
||||
|
||||
:param room_id: The Matrix room ID.
|
||||
:return: Subscriptions with stream state attached.
|
||||
"""
|
||||
query = """SELECT streams.*
|
||||
FROM subscriptions
|
||||
JOIN streams ON streams.domain = subscriptions.stream_domain
|
||||
WHERE subscriptions.room_id=$1
|
||||
ORDER BY streams.domain"""
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query, room_id)
|
||||
return [
|
||||
RoomSubscription(
|
||||
domain=row["domain"],
|
||||
stream_state=StreamState.from_db_row(row),
|
||||
)
|
||||
for row in results
|
||||
]
|
||||
|
||||
async def get_live_room_subscriptions(self, room_id: str) -> list[RoomSubscription]:
|
||||
"""Get resolved live stream subscriptions for a room ordered by domain."""
|
||||
query = """SELECT streams.*
|
||||
FROM subscriptions
|
||||
JOIN streams ON streams.domain = subscriptions.stream_domain
|
||||
WHERE subscriptions.room_id=$1
|
||||
AND streams.online=true
|
||||
AND streams.failure_counter <= $2
|
||||
ORDER BY streams.domain"""
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query, room_id, UNKNOWN_STATUS_THRESHOLD)
|
||||
return [
|
||||
RoomSubscription(
|
||||
domain=row["domain"],
|
||||
stream_state=StreamState.from_db_row(row),
|
||||
)
|
||||
for row in results
|
||||
]
|
||||
|
||||
async def get_all_subscribed_domains(self) -> list[str]:
|
||||
"""Get all unique stream domains that have at least one subscription.
|
||||
|
||||
:return: List of stream domains.
|
||||
"""
|
||||
query = "SELECT DISTINCT stream_domain FROM subscriptions"
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query)
|
||||
return [row["stream_domain"] for row in results]
|
||||
|
||||
async def count_by_domain(self, domain: str) -> int:
|
||||
"""Count the number of subscriptions for a given stream domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: Number of subscriptions.
|
||||
"""
|
||||
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.fetchrow(query, domain)
|
||||
return int(result[0])
|
||||
|
||||
async def count_by_domains(self, domains: list[str]) -> dict[str, int]:
|
||||
"""Count subscriptions for each requested stream domain.
|
||||
|
||||
:param domains: The stream domains to count subscriptions for.
|
||||
:return: Mapping from each requested domain to its subscription count.
|
||||
"""
|
||||
if not domains:
|
||||
return {}
|
||||
|
||||
counts = dict.fromkeys(domains, 0)
|
||||
query = """SELECT stream_domain, COUNT(*) AS subscription_count
|
||||
FROM subscriptions
|
||||
GROUP BY stream_domain"""
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query)
|
||||
|
||||
for row in results:
|
||||
domain = row["stream_domain"]
|
||||
if domain in counts:
|
||||
counts[domain] = int(row["subscription_count"])
|
||||
return counts
|
||||
@@ -0,0 +1,443 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Stream monitoring service for OwncastSentry."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum, auto
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .types import (
|
||||
StreamConfigObservation,
|
||||
StreamState,
|
||||
StreamStateObservation,
|
||||
StreamStatus,
|
||||
UpdateResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import logging
|
||||
|
||||
from .metrics import MetricsService
|
||||
from .notification_service import NotificationService
|
||||
from .owncast_client import OwncastClient
|
||||
from .repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60
|
||||
|
||||
_CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60
|
||||
_CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60
|
||||
_QUERY_EVERY_MINUTE_FAILURE_LIMIT = 4
|
||||
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT = 9
|
||||
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT = 14
|
||||
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT = 29
|
||||
_QUERY_EVERY_2_MINUTES_INTERVAL = 2
|
||||
_QUERY_EVERY_3_MINUTES_INTERVAL = 3
|
||||
_QUERY_EVERY_5_MINUTES_INTERVAL = 5
|
||||
_QUERY_EVERY_15_MINUTES_INTERVAL = 15
|
||||
|
||||
|
||||
def _should_query_stream(failure_counter: int) -> bool:
|
||||
"""Determine if a stream should be queried based on failure count."""
|
||||
if failure_counter <= _QUERY_EVERY_MINUTE_FAILURE_LIMIT:
|
||||
return True
|
||||
if failure_counter <= _QUERY_EVERY_2_MINUTES_FAILURE_LIMIT:
|
||||
return failure_counter % _QUERY_EVERY_2_MINUTES_INTERVAL == 0
|
||||
if failure_counter <= _QUERY_EVERY_3_MINUTES_FAILURE_LIMIT:
|
||||
return failure_counter % _QUERY_EVERY_3_MINUTES_INTERVAL == 0
|
||||
if failure_counter <= _QUERY_EVERY_5_MINUTES_FAILURE_LIMIT:
|
||||
return failure_counter % _QUERY_EVERY_5_MINUTES_INTERVAL == 0
|
||||
return failure_counter % _QUERY_EVERY_15_MINUTES_INTERVAL == 0
|
||||
|
||||
|
||||
class _StreamTransitionKind(Enum):
|
||||
"""High-level status transition from stored state to fresh observation."""
|
||||
|
||||
FIRST_OBSERVATION = auto()
|
||||
WENT_LIVE = auto()
|
||||
WENT_OFFLINE = auto()
|
||||
TITLE_CHANGED = auto()
|
||||
STATUS_UNCHANGED = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _StreamTransition:
|
||||
"""Classified stream transition and timestamp metadata."""
|
||||
|
||||
kind: _StreamTransitionKind
|
||||
status_since: str | None
|
||||
|
||||
|
||||
class _NotificationKind(Enum):
|
||||
"""Live/title notification action for a transition."""
|
||||
|
||||
NONE = auto()
|
||||
LIVE = auto()
|
||||
TITLE_CHANGE = auto()
|
||||
|
||||
@property
|
||||
def should_notify(self) -> bool:
|
||||
"""Return whether a live/title-change notification should be attempted."""
|
||||
return self is not _NotificationKind.NONE
|
||||
|
||||
|
||||
def _classify_transition(
|
||||
old_state: StreamState, observation: StreamStateObservation
|
||||
) -> _StreamTransition:
|
||||
"""Classify the transition from the stored state to the latest observation."""
|
||||
if old_state.status_since is None:
|
||||
return _StreamTransition(
|
||||
kind=_StreamTransitionKind.FIRST_OBSERVATION,
|
||||
status_since=observation.observed_at,
|
||||
)
|
||||
|
||||
if old_state.online != observation.online:
|
||||
return _StreamTransition(
|
||||
kind=(
|
||||
_StreamTransitionKind.WENT_LIVE
|
||||
if observation.online
|
||||
else _StreamTransitionKind.WENT_OFFLINE
|
||||
),
|
||||
status_since=observation.observed_at,
|
||||
)
|
||||
|
||||
if old_state.online and old_state.title != observation.title:
|
||||
return _StreamTransition(
|
||||
kind=_StreamTransitionKind.TITLE_CHANGED,
|
||||
status_since=old_state.status_since,
|
||||
)
|
||||
|
||||
return _StreamTransition(
|
||||
kind=_StreamTransitionKind.STATUS_UNCHANGED,
|
||||
status_since=old_state.status_since,
|
||||
)
|
||||
|
||||
|
||||
def _classify_notification(
|
||||
transition: _StreamTransition,
|
||||
old_state: StreamState,
|
||||
observation: StreamStateObservation,
|
||||
*,
|
||||
offline_duration_seconds: int | None = None,
|
||||
) -> _NotificationKind:
|
||||
"""Classify the live/title notification policy for a transition.
|
||||
|
||||
The caller supplies offline duration because it comes from the monitor's
|
||||
monotonic offline cache, not the transition.
|
||||
"""
|
||||
match transition.kind:
|
||||
case _StreamTransitionKind.WENT_LIVE:
|
||||
if offline_duration_seconds is not None and (
|
||||
offline_duration_seconds < _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN
|
||||
):
|
||||
if old_state.title != observation.title:
|
||||
return _NotificationKind.TITLE_CHANGE
|
||||
return _NotificationKind.NONE
|
||||
|
||||
return _NotificationKind.LIVE
|
||||
|
||||
case _StreamTransitionKind.TITLE_CHANGED:
|
||||
return _NotificationKind.TITLE_CHANGE
|
||||
|
||||
case _:
|
||||
return _NotificationKind.NONE
|
||||
|
||||
|
||||
class StreamMonitor:
|
||||
"""Monitors Owncast streams and detects state changes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owncast_client: OwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
notification_service: NotificationService,
|
||||
logger: logging.Logger,
|
||||
metrics: MetricsService,
|
||||
) -> None:
|
||||
"""Initialize the stream monitor.
|
||||
|
||||
:param owncast_client: Client for making API calls to Owncast instances.
|
||||
:param stream_repo: Repository for stream data.
|
||||
:param subscription_repo: Repository for subscription data.
|
||||
:param notification_service: Service for sending notifications.
|
||||
:param logger: Logger instance for debugging.
|
||||
:param metrics: Metrics service for recording Prometheus metrics.
|
||||
"""
|
||||
self.owncast_client = owncast_client
|
||||
self.stream_repo = stream_repo
|
||||
self.subscription_repo = subscription_repo
|
||||
self.notification_service = notification_service
|
||||
self.log = logger
|
||||
self.metrics = metrics
|
||||
|
||||
# Cache for tracking when streams last went offline
|
||||
self.offline_timer_cache: dict[str, float] = {}
|
||||
|
||||
async def update_all_streams(self, subscribed_domains: list[str]) -> UpdateResult:
|
||||
"""Check the status of all streams with active subscriptions.
|
||||
|
||||
Updates for all streams are performed asynchronously, with the
|
||||
method returning when the slowest update completes.
|
||||
|
||||
:param subscribed_domains: List of stream domains to update.
|
||||
:return: UpdateResult with success/failure counts.
|
||||
"""
|
||||
self.log.debug("Updating all stream states...")
|
||||
|
||||
total_streams = len(subscribed_domains)
|
||||
|
||||
# Run all stream updates concurrently and collect results.
|
||||
results = await asyncio.gather(
|
||||
*(self.update_stream(domain) for domain in subscribed_domains),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Count successes and failures
|
||||
successful_checks = 0
|
||||
failed_checks = 0
|
||||
for domain, result in zip(subscribed_domains, results, strict=True):
|
||||
if isinstance(result, BaseException):
|
||||
self.log.exception(
|
||||
"[%s] Unhandled exception during stream update.",
|
||||
domain,
|
||||
exc_info=result,
|
||||
)
|
||||
failed_checks += 1
|
||||
elif result:
|
||||
successful_checks += 1
|
||||
else:
|
||||
failed_checks += 1
|
||||
|
||||
self.log.debug(
|
||||
"Update complete. %s/%s succeeded, %s failed.",
|
||||
successful_checks,
|
||||
total_streams,
|
||||
failed_checks,
|
||||
)
|
||||
|
||||
subscription_counts = await self.subscription_repo.count_by_domains(
|
||||
subscribed_domains
|
||||
)
|
||||
for domain in subscribed_domains:
|
||||
self.metrics.set_subscription_count(
|
||||
domain, subscription_counts.get(domain, 0)
|
||||
)
|
||||
|
||||
return UpdateResult(
|
||||
total_streams=total_streams,
|
||||
successful_checks=successful_checks,
|
||||
failed_checks=failed_checks,
|
||||
)
|
||||
|
||||
async def update_stream(self, domain: str) -> bool:
|
||||
"""Update the state of a stream and send notifications as needed.
|
||||
|
||||
Sends notifications to subscribed Matrix rooms when a stream goes
|
||||
live or changes title. Implements progressive backoff for connection
|
||||
failures and auto-cleanup for dead instances.
|
||||
|
||||
:param domain: The domain of the stream to update.
|
||||
:return: True if check succeeded or was skipped, False on failure.
|
||||
"""
|
||||
# Fetch the current stream state from database to check failure_counter
|
||||
old_state = await self.stream_repo.get_by_domain(domain)
|
||||
failure_counter = old_state.failure_counter if old_state else 0
|
||||
|
||||
if old_state is None:
|
||||
return True
|
||||
|
||||
# Check if we should query this stream based on backoff schedule
|
||||
if not _should_query_stream(failure_counter):
|
||||
# Skip this cycle, increment counter to track time passage
|
||||
await self.stream_repo.increment_failure_counter(domain)
|
||||
self.log.debug(
|
||||
"[%s] Skipping query due to backoff (counter=%s)",
|
||||
domain,
|
||||
failure_counter + 1,
|
||||
)
|
||||
# Check cleanup thresholds even when skipping query
|
||||
await self._check_cleanup_thresholds(domain, failure_counter + 1)
|
||||
updated_state = await self.stream_repo.get_by_domain(domain)
|
||||
if updated_state is not None:
|
||||
self.metrics.set_stream_status(domain, updated_state.status)
|
||||
self.metrics.set_check_failures(domain, failure_counter + 1)
|
||||
# Backoff is expected behavior, not a failure
|
||||
return True
|
||||
|
||||
# Fetch the latest stream state from the server
|
||||
observation = await self.owncast_client.get_stream_state(domain)
|
||||
|
||||
# If the fetch failed, increment failure counter and skip the update
|
||||
if observation is None:
|
||||
await self.stream_repo.increment_failure_counter(domain)
|
||||
self.log.warning(
|
||||
"[%s] Connection failure (counter=%s)",
|
||||
domain,
|
||||
failure_counter + 1,
|
||||
)
|
||||
# Check cleanup thresholds after connection failure
|
||||
await self._check_cleanup_thresholds(domain, failure_counter + 1)
|
||||
updated_state = await self.stream_repo.get_by_domain(domain)
|
||||
if updated_state is not None:
|
||||
self.metrics.set_stream_status(domain, updated_state.status)
|
||||
self.metrics.set_check_failures(domain, failure_counter + 1)
|
||||
# Actual connection failure
|
||||
return False
|
||||
|
||||
# Fetch succeeded! Reset failure counter
|
||||
await self.stream_repo.reset_failure_counter(domain)
|
||||
self.metrics.set_check_failures(domain, 0)
|
||||
|
||||
# Initialize timer cache entries to prevent KeyError on first access
|
||||
self.offline_timer_cache.setdefault(domain, 0)
|
||||
|
||||
transition = _classify_transition(old_state, observation)
|
||||
# Notification policy may need runtime timing context in addition to
|
||||
# the stored state and latest observation.
|
||||
offline_duration_seconds: int | None = None
|
||||
|
||||
match transition.kind:
|
||||
case _StreamTransitionKind.FIRST_OBSERVATION:
|
||||
self.log.info(
|
||||
"[%s] Not sending notifications. This is the first state "
|
||||
"update for this stream.",
|
||||
domain,
|
||||
)
|
||||
case _StreamTransitionKind.WENT_LIVE:
|
||||
self.log.info("[%s] Stream is now live!", domain)
|
||||
offline_duration_seconds = round(
|
||||
time.monotonic() - self.offline_timer_cache[domain]
|
||||
)
|
||||
# Brief reconnects with the same title are treated as transient
|
||||
# outages, not new live events.
|
||||
if (
|
||||
offline_duration_seconds < _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN
|
||||
and old_state.title == observation.title
|
||||
):
|
||||
self.log.info(
|
||||
"[%s] Not sending notifications. Stream was only offline for "
|
||||
"%s of %s seconds and did not change its title.",
|
||||
domain,
|
||||
offline_duration_seconds,
|
||||
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
||||
)
|
||||
case _StreamTransitionKind.TITLE_CHANGED:
|
||||
self.log.info("[%s] Stream title was changed!", domain)
|
||||
case _StreamTransitionKind.WENT_OFFLINE:
|
||||
self.offline_timer_cache[domain] = time.monotonic()
|
||||
self.log.info("[%s] Stream is now offline.", domain)
|
||||
case _StreamTransitionKind.STATUS_UNCHANGED:
|
||||
pass
|
||||
|
||||
notification_kind = _classify_notification(
|
||||
transition,
|
||||
old_state,
|
||||
observation,
|
||||
offline_duration_seconds=offline_duration_seconds,
|
||||
)
|
||||
|
||||
stream_config: StreamConfigObservation | None = None
|
||||
observed_at = datetime.fromisoformat(observation.observed_at)
|
||||
hourly_config_refresh_due = (
|
||||
observed_at.tzinfo is not None and observed_at.astimezone(UTC).minute == 0
|
||||
)
|
||||
should_fetch_config = (
|
||||
notification_kind.should_notify
|
||||
or transition.kind is _StreamTransitionKind.FIRST_OBSERVATION
|
||||
or hourly_config_refresh_due
|
||||
)
|
||||
if should_fetch_config:
|
||||
stream_config = await self.owncast_client.get_stream_config(domain)
|
||||
|
||||
if notification_kind.should_notify:
|
||||
stream_name = stream_config.name if stream_config else domain
|
||||
stream_tags = stream_config.tags if stream_config else ()
|
||||
is_title_change = notification_kind is _NotificationKind.TITLE_CHANGE
|
||||
|
||||
await self.notification_service.notify_stream_live(
|
||||
domain,
|
||||
stream_name,
|
||||
observation.title or "",
|
||||
stream_tags,
|
||||
title_change=is_title_change,
|
||||
)
|
||||
|
||||
self.log.debug("[%s] Saving stream state if changed...", domain)
|
||||
|
||||
if stream_config is not None:
|
||||
await self.stream_repo.update(
|
||||
domain,
|
||||
name=stream_config.name,
|
||||
title=observation.title,
|
||||
online=observation.online,
|
||||
status_since=transition.status_since,
|
||||
)
|
||||
else:
|
||||
await self.stream_repo.update(
|
||||
domain,
|
||||
title=observation.title,
|
||||
online=observation.online,
|
||||
status_since=transition.status_since,
|
||||
)
|
||||
|
||||
# All done.
|
||||
self.log.debug("[%s] State update completed.", domain)
|
||||
stream_status = (
|
||||
StreamStatus.ONLINE if observation.online else StreamStatus.OFFLINE
|
||||
)
|
||||
self.metrics.set_stream_status(domain, stream_status)
|
||||
return True
|
||||
|
||||
async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None:
|
||||
"""Check if a domain hit cleanup warning or deletion thresholds.
|
||||
|
||||
:param domain: The domain to check.
|
||||
:param counter: The current failure counter value.
|
||||
"""
|
||||
# Check for 83-day warning threshold
|
||||
if counter == _CLEANUP_WARNING_THRESHOLD:
|
||||
self.log.warning(
|
||||
"[%s] Reached 83-day warning threshold. Sending cleanup warning.",
|
||||
domain,
|
||||
)
|
||||
await self.notification_service.send_cleanup_warning(domain)
|
||||
|
||||
# Check for 90-day deletion threshold
|
||||
if counter >= _CLEANUP_DELETE_THRESHOLD:
|
||||
self.log.warning(
|
||||
"[%s] Reached 90-day deletion threshold. Removing all subscriptions.",
|
||||
domain,
|
||||
)
|
||||
# Send deletion notification
|
||||
await self.notification_service.send_cleanup_deletion(domain)
|
||||
|
||||
# Delete all subscriptions for this domain
|
||||
deleted_count = await self.subscription_repo.delete_all_for_domain(domain)
|
||||
|
||||
# Delete the stream record
|
||||
await self.stream_repo.delete(domain)
|
||||
self.offline_timer_cache.pop(domain, None)
|
||||
self.notification_service.clear_notification_state(domain)
|
||||
|
||||
self.log.info(
|
||||
"[%s] Cleanup complete. Deleted %s subscriptions and stream record.",
|
||||
domain,
|
||||
deleted_count,
|
||||
)
|
||||
self.metrics.remove_stream(domain)
|
||||
@@ -0,0 +1,119 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Business logic for managing room stream subscriptions."""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .types import (
|
||||
InvalidOwncastInstanceError,
|
||||
RoomSubscription,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import logging
|
||||
|
||||
from .owncast_client import OwncastClient
|
||||
from .repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
_DOMAIN_CLEANUP_RE = re.compile(r"[^a-z0-9.-]")
|
||||
|
||||
|
||||
def _domainify(url: str) -> str:
|
||||
"""Extract and sanitize a domain from user input."""
|
||||
url = url.strip()
|
||||
|
||||
if "@" in url:
|
||||
url = url.rsplit("@", 1)[1]
|
||||
|
||||
if not url.startswith(("http://", "https://", "//")):
|
||||
url = f"//{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
domain = (parsed.netloc or parsed.path).lower()
|
||||
domain = domain.partition(":")[0].partition("/")[0]
|
||||
return _DOMAIN_CLEANUP_RE.sub("", domain).strip(".-")
|
||||
|
||||
|
||||
class SubscriptionManager:
|
||||
"""Coordinates subscription use cases between handlers and repositories."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owncast_client: OwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
"""Initialize the subscription manager."""
|
||||
self.owncast_client = owncast_client
|
||||
self.stream_repo = stream_repo
|
||||
self.subscription_repo = subscription_repo
|
||||
self.log = logger
|
||||
|
||||
async def subscribe(self, room_id: str, url: str) -> str:
|
||||
"""Subscribe a room to stream notifications and return the stream domain.
|
||||
|
||||
:param room_id: Matrix room ID to subscribe.
|
||||
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||
:return: Normalized stream domain.
|
||||
:raises InvalidOwncastInstanceError: If first-time validation fails.
|
||||
:raises AlreadySubscribedError: If the room is already subscribed.
|
||||
"""
|
||||
stream_domain = _domainify(url)
|
||||
|
||||
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
|
||||
if subscription_count == 0:
|
||||
is_valid = await self.owncast_client.validate_instance(stream_domain)
|
||||
if not is_valid:
|
||||
raise InvalidOwncastInstanceError(stream_domain)
|
||||
|
||||
await self.subscription_repo.add(stream_domain, room_id)
|
||||
|
||||
if await self.stream_repo.create(stream_domain):
|
||||
self.log.info("[%s] Discovered new stream!", stream_domain)
|
||||
|
||||
self.log.info("[%s] Subscription added for room %s.", stream_domain, room_id)
|
||||
return stream_domain
|
||||
|
||||
async def unsubscribe(self, room_id: str, url: str) -> str:
|
||||
"""Remove a room subscription and return the stream domain.
|
||||
|
||||
:param room_id: Matrix room ID to unsubscribe.
|
||||
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||
:return: Normalized stream domain.
|
||||
:raises NotSubscribedError: If no subscription was removed.
|
||||
"""
|
||||
stream_domain = _domainify(url)
|
||||
await self.subscription_repo.remove(stream_domain, room_id)
|
||||
|
||||
self.log.info("[%s] Subscription removed for room %s.", stream_domain, room_id)
|
||||
return stream_domain
|
||||
|
||||
async def list_room_subscriptions(self, room_id: str) -> list[RoomSubscription]:
|
||||
"""Return stream subscriptions for a room with display state attached."""
|
||||
return await self.subscription_repo.get_room_subscriptions(room_id)
|
||||
|
||||
async def has_room_subscriptions(self, room_id: str) -> bool:
|
||||
"""Return whether the room has any stream subscriptions."""
|
||||
return await self.subscription_repo.has_room_subscriptions(room_id)
|
||||
|
||||
async def list_live_room_subscriptions(
|
||||
self, room_id: str
|
||||
) -> list[RoomSubscription]:
|
||||
"""Return only currently online stream subscriptions for a room."""
|
||||
return await self.subscription_repo.get_live_room_subscriptions(room_id)
|
||||
@@ -0,0 +1,257 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Data containers and domain errors for OwncastSentry."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
UNKNOWN_STATUS_THRESHOLD = 15
|
||||
|
||||
# Maximum field lengths based on Owncast's admin config constants.
|
||||
# Source: https://github.com/owncast/owncast/blob/master/web/utils/config-constants.tsx
|
||||
_MAX_INSTANCE_TITLE_LENGTH = 255 # TEXTFIELD_PROPS_SERVER_NAME maxLength
|
||||
_MAX_STREAM_TITLE_LENGTH = 100 # TEXTFIELD_PROPS_STREAM_TITLE maxLength
|
||||
_MAX_TAG_LENGTH = 24 # tag maxLength
|
||||
|
||||
|
||||
class InvalidApiResponseError(ValueError):
|
||||
"""The Owncast API response did not match the expected shape."""
|
||||
|
||||
|
||||
def _require_field(response: dict[str, Any], field: str) -> Any:
|
||||
"""Return a required API response field or raise if missing."""
|
||||
try:
|
||||
return response[field]
|
||||
except KeyError as e:
|
||||
raise InvalidApiResponseError(f"missing field: {field}") from e
|
||||
|
||||
|
||||
def _require_str(response: dict[str, Any], field: str) -> str:
|
||||
"""Return a required string API response field."""
|
||||
value = _require_field(response, field)
|
||||
if not isinstance(value, str):
|
||||
raise InvalidApiResponseError(f"{field} must be a string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_config_str(response: dict[str, Any], field: str) -> str:
|
||||
"""Return an optional config string, defaulting to empty when absent."""
|
||||
value = response.get(field, "")
|
||||
if not isinstance(value, str):
|
||||
raise InvalidApiResponseError(f"{field} must be a string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_tag_list(response: dict[str, Any]) -> list[str]:
|
||||
"""Return optional config tags, defaulting to an empty list when absent."""
|
||||
value = response.get("tags", [])
|
||||
if not isinstance(value, list):
|
||||
raise InvalidApiResponseError("tags must be a list")
|
||||
if not all(isinstance(tag, str) for tag in value):
|
||||
raise InvalidApiResponseError("tags must contain only strings")
|
||||
return value
|
||||
|
||||
|
||||
def _truncate(text: str, max_length: int) -> str:
|
||||
"""Truncate text to a maximum length."""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
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")
|
||||
|
||||
|
||||
def _current_observed_at() -> str:
|
||||
"""Return the current UTC time in the package's timestamp format."""
|
||||
return format_status_since(datetime.now(UTC))
|
||||
|
||||
|
||||
class StreamStatus(Enum):
|
||||
"""Represents the status of a stream."""
|
||||
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StreamStateObservation:
|
||||
"""Status API sample fetched from an Owncast instance.
|
||||
|
||||
This records what `/api/status` reported during one request. `observed_at`
|
||||
belongs to that fetch; the monitor decides whether that time starts a new
|
||||
persisted online/offline period in `StreamState`.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
title: str | None = None
|
||||
online: bool = False
|
||||
observed_at: str = field(default_factory=_current_observed_at)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(
|
||||
cls, response: dict[str, Any], domain: str
|
||||
) -> StreamStateObservation:
|
||||
"""Create a StreamStateObservation from a status API response.
|
||||
|
||||
:param response: API response as a dictionary (camelCase keys).
|
||||
:param domain: The stream domain.
|
||||
:return: StreamStateObservation instance.
|
||||
:raises InvalidApiResponseError: If the response shape is invalid.
|
||||
"""
|
||||
stream_title = _require_str(response, "streamTitle")
|
||||
online = _require_field(response, "online")
|
||||
if not isinstance(online, bool):
|
||||
raise InvalidApiResponseError("online must be a boolean")
|
||||
|
||||
return cls(
|
||||
domain=domain,
|
||||
title=_truncate(stream_title, _MAX_STREAM_TITLE_LENGTH),
|
||||
online=online,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StreamState:
|
||||
"""Stream record remembered by the repository.
|
||||
|
||||
States include persisted display data, the current online/offline period,
|
||||
and failure counters used to derive user-facing status. `status_since`
|
||||
records when the persisted online/offline period began.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
name: str | None = None
|
||||
title: str | None = None
|
||||
online: bool = False
|
||||
status_since: str | None = None
|
||||
failure_counter: int = 0
|
||||
|
||||
@property
|
||||
def status(self) -> StreamStatus:
|
||||
"""Derive stream status from failure count and online state.
|
||||
|
||||
Returns UNKNOWN if failures exceed the threshold, ONLINE if the
|
||||
stream is online, or OFFLINE otherwise.
|
||||
"""
|
||||
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
|
||||
return StreamStatus.UNKNOWN
|
||||
if self.online:
|
||||
return StreamStatus.ONLINE
|
||||
return StreamStatus.OFFLINE
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, row: dict[str, Any]) -> StreamState:
|
||||
"""Create a StreamState from a database row.
|
||||
|
||||
:param row: Database row as a dictionary.
|
||||
:return: StreamState instance.
|
||||
"""
|
||||
return cls(
|
||||
domain=row["domain"],
|
||||
name=row["name"],
|
||||
title=row["title"],
|
||||
online=bool(row["online"]),
|
||||
status_since=row["status_since"],
|
||||
failure_counter=row["failure_counter"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StreamConfigObservation:
|
||||
"""Config API sample fetched from an Owncast instance.
|
||||
|
||||
This records display metadata from one `/api/config` request. It is used for
|
||||
notification text and may refresh persisted display fields independently of
|
||||
the stream's online/offline state.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
tags: tuple[str, ...] = ()
|
||||
observed_at: str = field(default_factory=_current_observed_at)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, response: dict[str, Any]) -> StreamConfigObservation:
|
||||
"""Create a StreamConfigObservation from an API response.
|
||||
|
||||
:param response: API response as a dictionary.
|
||||
:return: StreamConfigObservation instance.
|
||||
:raises InvalidApiResponseError: If the response shape is invalid.
|
||||
"""
|
||||
# Apply Owncast's instance name limit.
|
||||
name = _truncate(
|
||||
_optional_config_str(response, "name"), _MAX_INSTANCE_TITLE_LENGTH
|
||||
)
|
||||
|
||||
# Apply Owncast's per-tag limit.
|
||||
raw_tags = _optional_tag_list(response)
|
||||
tags = tuple([_truncate(tag, _MAX_TAG_LENGTH) for tag in raw_tags])
|
||||
|
||||
return cls(name=name, tags=tags)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpdateResult:
|
||||
"""Result of a stream update cycle."""
|
||||
|
||||
total_streams: int
|
||||
successful_checks: int
|
||||
failed_checks: int
|
||||
|
||||
|
||||
class SubscriptionError(Exception):
|
||||
"""Base class for subscription domain errors."""
|
||||
|
||||
|
||||
class InvalidOwncastInstanceError(SubscriptionError):
|
||||
"""The requested domain is not a reachable Owncast instance."""
|
||||
|
||||
def __init__(self, domain: str) -> None:
|
||||
"""Initialize with the rejected stream domain."""
|
||||
self.domain = domain
|
||||
super().__init__(f"invalid Owncast instance: {domain}")
|
||||
|
||||
|
||||
class AlreadySubscribedError(SubscriptionError):
|
||||
"""The room is already subscribed to the stream."""
|
||||
|
||||
def __init__(self, domain: str) -> None:
|
||||
"""Initialize with the duplicate stream domain."""
|
||||
self.domain = domain
|
||||
super().__init__(f"already subscribed: {domain}")
|
||||
|
||||
|
||||
class NotSubscribedError(SubscriptionError):
|
||||
"""The room is not subscribed to the stream."""
|
||||
|
||||
def __init__(self, domain: str) -> None:
|
||||
"""Initialize with the missing stream domain."""
|
||||
self.domain = domain
|
||||
super().__init__(f"not subscribed: {domain}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoomSubscription:
|
||||
"""A stream subscription resolved with the stream state used for display."""
|
||||
|
||||
domain: str
|
||||
stream_state: StreamState
|
||||
@@ -0,0 +1,92 @@
|
||||
[project]
|
||||
name = "owncastsentry"
|
||||
dynamic = ["version"]
|
||||
description = "A maubot plugin that monitors Owncast streams and sends Matrix notifications"
|
||||
authors = [
|
||||
{ name = "Logan Fick" },
|
||||
]
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = ["prometheus_client>=0.25.0"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.logal.dev/LogalDeveloper/OwncastSentry"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.29.0", "hatch-vcs>=0.5.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"maubot[encryption]>=0.6.0",
|
||||
"aioresponses>=0.7.8",
|
||||
"codespell>=2.4.2",
|
||||
"hatch>=1.16.5",
|
||||
"mypy>=2.1.0",
|
||||
"pip-audit>=2.10.0",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"ruff>=0.15.13",
|
||||
"time-machine>=3.2.0",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "owncastsentry/_version.py"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
warn_unreachable = true
|
||||
explicit_package_bases = true
|
||||
# Maubot's pytest helpers are not fully typed, so strict mypy checks on
|
||||
# integration tests produce noise around fixture-provided bot/plugin objects.
|
||||
exclude = ["tests/"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
|
||||
"C901", # McCabe complexity: noisy and not actionable
|
||||
"COM812", # handled by the formatter
|
||||
"D203", # incompatible with D211 (no blank line before class docstring)
|
||||
"D213", # incompatible with D212 (summary on first line)
|
||||
"EM", # exception message style: inline literals are fine
|
||||
"PLR0911", # too many return statements: flat early-returns are clear
|
||||
"PLR0912", # too many branches: inherent in parsers, validators, CLI
|
||||
"PLR0913", # too many arguments: API surfaces and constructors need them
|
||||
"PLR0915", # too many statements: inherent in parsers, validators, CLI
|
||||
"TRY003", # inline exception messages are fine (complements EM ignore)
|
||||
"TRY301", # raise inside try: guard clauses don't need helper functions
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = [
|
||||
"S101", # assert is standard for pytest
|
||||
"S311", # pseudo-random generators are fine in tests
|
||||
"SLF001", # tests legitimately access private members for verification
|
||||
"ARG001", # unused args are normal for fixtures and handler stubs
|
||||
"PLR2004", # magic values are clear in test assertions
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["owncastsentry"]
|
||||
omit = ["owncastsentry/_version.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
|
||||
[tool.codespell]
|
||||
skip = "owncastsentry/_version.py,uv.lock"
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""OwncastSentry test suite."""
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Shared test fixtures and stubs for OwncastSentry tests."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from mautrix.util.async_db import Database
|
||||
from prometheus_client import generate_latest
|
||||
|
||||
from owncastsentry import OwncastSentry
|
||||
from owncastsentry.config import Config
|
||||
from owncastsentry.repository import (
|
||||
StreamRepository,
|
||||
SubscriptionRepository,
|
||||
get_upgrade_table,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
from maubot import Plugin
|
||||
from mautrix.util.async_db import UpgradeTable
|
||||
from mautrix.util.config import BaseProxyConfig
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.types import StreamConfigObservation, StreamStateObservation
|
||||
|
||||
|
||||
def generate_metrics_output(metrics: MetricsService) -> str:
|
||||
"""Generate Prometheus text format output from a MetricsService registry."""
|
||||
return generate_latest(metrics.registry).decode("utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def database(tmp_path: Path) -> AsyncIterator[Database]:
|
||||
"""Yield a real SQLite-backed mautrix Database with migrations applied."""
|
||||
db_path = tmp_path / "test.db"
|
||||
db = Database.create(
|
||||
f"sqlite:///{db_path}",
|
||||
upgrade_table=get_upgrade_table(),
|
||||
)
|
||||
await db.start()
|
||||
yield db
|
||||
await db.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stream_repo(database: Database) -> StreamRepository:
|
||||
"""Return a StreamRepository backed by the test database."""
|
||||
return StreamRepository(database)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subscription_repo(database: Database) -> SubscriptionRepository:
|
||||
"""Return a SubscriptionRepository backed by the test database."""
|
||||
return SubscriptionRepository(database)
|
||||
|
||||
|
||||
# These fixtures use names expected by the maubot.testing framework.
|
||||
# They look unused but are discovered automatically by maubot's pytest plugin.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maubot_plugin_class() -> type[Plugin]:
|
||||
"""Use OwncastSentry as the plugin class for maubot integration tests."""
|
||||
return OwncastSentry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maubot_plugin_config_class() -> type[BaseProxyConfig]:
|
||||
"""Use our Config class for maubot integration tests."""
|
||||
return Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maubot_upgrade_table() -> UpgradeTable:
|
||||
"""Provide the database upgrade table for maubot integration tests."""
|
||||
return get_upgrade_table()
|
||||
|
||||
|
||||
VALID_CONFIG_RESPONSE: dict[str, object] = {
|
||||
"appearanceVariables": {},
|
||||
"name": "LogalDeveloper's Live Stream",
|
||||
"customStyles": "",
|
||||
"streamTitle": "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!", # noqa: E501
|
||||
"offlineMessage": "<p>HTTP/1.1 204 No Content</p>\n<p>You've reached the right place, but I'm not live right now.</p>", # noqa: E501
|
||||
"logo": "/logo",
|
||||
"version": "Owncast v0.2.4-linux-64bit (8e89391309dd3aa4d0db4361a2ba1e144f42f8c8)",
|
||||
"extraPageContent": (
|
||||
"<h1>About</h1>\n<p>Hi there! I'm Logan, a cybersecurity professional "
|
||||
"with a background in Linux systems and network administration. This is my "
|
||||
"little corner of the internet where I exclusively run my live streams. While "
|
||||
"gaming is my most common stream topic, I occasionally stream other things "
|
||||
"which interest me, such as cybersecurity challenges. If you are looking for "
|
||||
"my main website, where my blog and project directory is hosted, please visit: "
|
||||
'<a href="https://logal.dev/">https://logal.dev/</a></p>\n<p>Any views and '
|
||||
"opinions expressed in my live streams and videos are my own and do not "
|
||||
"necessarily reflect those of my employer or any affiliated organizations."
|
||||
"</p>\n<h1>Following</h1>\n<p>I don\u2019t stick to a strict streaming "
|
||||
"schedule, but there are a few ways to stay updated so you know when I go "
|
||||
"live:</p>\n<ul>\n<li>\u2b50 <strong>Matrix</strong>: Send the message "
|
||||
"<code>!subscribe stream.logal.dev</code> to "
|
||||
'<a href="https://matrix.to/#/@owncastsentry:logal.dev">'
|
||||
"@owncastsentry:logal.dev</a>.</li>\n<li><strong>Fediverse</strong>: Follow "
|
||||
"<code>@notify@stream.logal.dev</code>.</li>\n</ul>\n<h1>Chat Commands</h1>"
|
||||
"\n<p>To make things more interactive, there are several commands you can "
|
||||
"send in chat to play sound effects live on stream:</p>\n<ul>\n"
|
||||
"<li>!boom</li>\n<li>!bluetooth</li>\n<li>!bruh</li>\n<li>!creeper</li>\n"
|
||||
"<li>!directed</li>\n<li>!fart</li>\n<li>!icetea</li>\n<li>!oof</li>\n"
|
||||
"<li>!perfect</li>\n<li>!spare</li>\n<li>!thatsit</li>\n<li>!usb</li>\n"
|
||||
"<li>!whocares</li>\n<li>!yoda</li>\n</ul>"
|
||||
),
|
||||
"summary": "Video games, cybersecurity, and more!",
|
||||
"tags": [
|
||||
"video games",
|
||||
"chatting",
|
||||
"casual",
|
||||
"english",
|
||||
"streaming",
|
||||
"owncast",
|
||||
"variety",
|
||||
],
|
||||
"socialHandles": None,
|
||||
"externalActions": [
|
||||
{
|
||||
"url": "https://ko-fi.com/logaldeveloper",
|
||||
"html": "",
|
||||
"title": "Tip",
|
||||
"description": "",
|
||||
"icon": "https://stream.logal.dev/img/platformlogos/ko-fi.svg",
|
||||
"color": "",
|
||||
"openExternally": True,
|
||||
},
|
||||
{
|
||||
"url": "https://tubefree.org/c/logaldeveloper_stream_archive",
|
||||
"html": "",
|
||||
"title": "Previous Live Stream Recordings",
|
||||
"description": "",
|
||||
"icon": "https://stream.logal.dev/img/platformlogos/fediverse.svg",
|
||||
"color": "",
|
||||
"openExternally": True,
|
||||
},
|
||||
],
|
||||
"notifications": {
|
||||
"browser": {
|
||||
"publicKey": "BI9BhIY6c7nfP6ZSIu7T53Lta5sGYqDqwSpiCabZ0XQxPQOmnUHRSjInaa3HX9XmYE-bV8SFmbFk4stZy2jnJ3M", # noqa: E501
|
||||
"enabled": True,
|
||||
},
|
||||
},
|
||||
"federation": {
|
||||
"account": "notify@stream.logal.dev",
|
||||
"followerCount": 74,
|
||||
"enabled": True,
|
||||
},
|
||||
"maxSocketPayloadSize": 2048,
|
||||
"hideViewerCount": False,
|
||||
"chatDisabled": False,
|
||||
"chatSpamProtectionDisabled": False,
|
||||
"nsfw": False,
|
||||
"authentication": {"indieAuthEnabled": True},
|
||||
}
|
||||
|
||||
VALID_STATUS_RESPONSE: dict[str, object] = {
|
||||
"serverTime": "2026-03-13T15:16:14.354962696-04:00",
|
||||
"lastConnectTime": None,
|
||||
"lastDisconnectTime": "2026-03-04T21:05:32-05:00",
|
||||
"versionNumber": "0.2.4",
|
||||
"streamTitle": "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!", # noqa: E501
|
||||
"online": False,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SentMessage:
|
||||
"""A message recorded by _StubMatrixClient."""
|
||||
|
||||
room_id: str
|
||||
content: Any
|
||||
|
||||
|
||||
class _StubMatrixClient:
|
||||
"""Recording stub for the Matrix client used in notification tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent_messages: list[_SentMessage] = []
|
||||
self.should_fail_for_rooms: set[str] = set()
|
||||
|
||||
async def send_message(self, room_id: str, content: Any) -> None:
|
||||
"""Record a sent message, or raise if room is in the fail set."""
|
||||
if room_id in self.should_fail_for_rooms:
|
||||
msg = f"Stubbed failure for room {room_id}"
|
||||
raise RuntimeError(msg)
|
||||
self.sent_messages.append(_SentMessage(room_id=room_id, content=content))
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubOwncastClient:
|
||||
"""Configurable stub for the Owncast HTTP client."""
|
||||
|
||||
stream_state: StreamStateObservation | None = None
|
||||
stream_config: StreamConfigObservation | None = None
|
||||
state_call_count: int = field(default=0, init=False)
|
||||
config_call_count: int = field(default=0, init=False)
|
||||
queried_domains: list[str] = field(default_factory=list, init=False)
|
||||
|
||||
async def get_stream_state(self, domain: str) -> StreamStateObservation | None:
|
||||
"""Return the configured stream observation."""
|
||||
self.state_call_count += 1
|
||||
self.queried_domains.append(domain)
|
||||
return self.stream_state
|
||||
|
||||
async def get_stream_config(self, _domain: str) -> StreamConfigObservation | None:
|
||||
"""Return the configured stream config."""
|
||||
self.config_call_count += 1
|
||||
return self.stream_config
|
||||
@@ -0,0 +1,581 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for bot command handlers."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.commands import (
|
||||
_SECONDS_PER_DAY,
|
||||
_SECONDS_PER_HOUR,
|
||||
_SECONDS_PER_MINUTE,
|
||||
_escape_markdown,
|
||||
_format_duration,
|
||||
_sanitize_for_markdown,
|
||||
)
|
||||
from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH
|
||||
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD
|
||||
from tests.conftest import VALID_STATUS_RESPONSE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from maubot.testing.bot import TestBot
|
||||
|
||||
from owncastsentry import OwncastSentry
|
||||
|
||||
|
||||
class TestEscapeMarkdown:
|
||||
"""Markdown special character escaping."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_text", "expected"),
|
||||
[
|
||||
pytest.param("hello", "hello", id="plain-text-unchanged"),
|
||||
pytest.param("*bold*", "\\*bold\\*", id="asterisks"),
|
||||
pytest.param("_italic_", "\\_italic\\_", id="underscores"),
|
||||
pytest.param("[link](url)", "\\[link\\]\\(url\\)", id="link-syntax"),
|
||||
pytest.param("`code`", "\\`code\\`", id="backticks"),
|
||||
pytest.param("# heading", "\\# heading", id="heading"),
|
||||
pytest.param("> quote", "\\> quote", id="blockquote"),
|
||||
pytest.param("<html>", "\\<html\\>", id="angle-brackets"),
|
||||
pytest.param("a & b", "a \\& b", id="ampersand"),
|
||||
pytest.param("a\\b", "a\\\\b", id="backslash"),
|
||||
pytest.param("", "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_escapes_special_chars(self, input_text: str, expected: str) -> None:
|
||||
"""Escape Markdown special characters in input text."""
|
||||
assert _escape_markdown(input_text) == expected
|
||||
|
||||
|
||||
class TestSanitizeForMarkdown:
|
||||
"""Markdown sanitization combining whitespace normalization and escaping."""
|
||||
|
||||
def test_removes_newlines_and_escapes(self) -> None:
|
||||
"""Normalize newlines and escape Markdown special characters."""
|
||||
result = _sanitize_for_markdown("*bold*\nnew line")
|
||||
assert result == "\\*bold\\* new line"
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
"""Return empty string unchanged."""
|
||||
assert _sanitize_for_markdown("") == ""
|
||||
|
||||
|
||||
class TestFormatDuration:
|
||||
"""Elapsed time calculation from ISO timestamps."""
|
||||
|
||||
_NOW = datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("seconds_ago", "expected"),
|
||||
[
|
||||
pytest.param(1, "1 second", id="singular-second"),
|
||||
pytest.param(30, "30 seconds", id="plural-seconds"),
|
||||
pytest.param(_SECONDS_PER_MINUTE, "1 minute", id="singular-minute"),
|
||||
pytest.param(2 * _SECONDS_PER_MINUTE, "2 minutes", id="plural-minutes"),
|
||||
pytest.param(_SECONDS_PER_HOUR, "1 hour", id="singular-hour"),
|
||||
pytest.param(2 * _SECONDS_PER_HOUR, "2 hours", id="plural-hours"),
|
||||
pytest.param(_SECONDS_PER_DAY, "1 day", id="singular-day"),
|
||||
pytest.param(2 * _SECONDS_PER_DAY, "2 days", id="plural-days"),
|
||||
],
|
||||
)
|
||||
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
|
||||
"""Format a timestamp into a human-readable duration."""
|
||||
timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat()
|
||||
result = _format_duration(timestamp, self._NOW)
|
||||
assert result == expected
|
||||
|
||||
def test_invalid_timestamp(self) -> None:
|
||||
"""Return 'unknown duration' for unparsable timestamps."""
|
||||
assert _format_duration("not-a-timestamp", self._NOW) == "unknown duration"
|
||||
|
||||
def test_naive_timestamp(self) -> None:
|
||||
"""Return 'unknown duration' for timestamps without timezone information."""
|
||||
assert _format_duration("2026-03-13T11:59:00", self._NOW) == "unknown duration"
|
||||
|
||||
def test_future_timestamp(self) -> None:
|
||||
"""Return 'unknown duration' for timestamps in the future."""
|
||||
timestamp = (self._NOW + timedelta(seconds=1)).isoformat()
|
||||
assert _format_duration(timestamp, self._NOW) == "unknown duration"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("maubot_plugin")
|
||||
class TestSubscribeCommand:
|
||||
"""Subscribe command end-to-end via maubot."""
|
||||
|
||||
async def test_subscribe_valid_stream(self, maubot_test_bot: TestBot) -> None:
|
||||
"""Subscribe to a valid Owncast stream."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"Subscription added! This room will receive notifications when "
|
||||
"stream.logal.dev goes live."
|
||||
)
|
||||
|
||||
async def test_subscribe_invalid_stream(self, maubot_test_bot: TestBot) -> None:
|
||||
"""Reject subscription to an invalid Owncast instance."""
|
||||
status_url = f"https://invalid.com{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(status_url, status=404)
|
||||
await maubot_test_bot.send("!subscribe invalid.com")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"The URL you supplied does not appear to "
|
||||
"be a valid Owncast instance. You may have "
|
||||
"specified an invalid domain, or the "
|
||||
"instance is offline."
|
||||
)
|
||||
|
||||
async def test_subscribe_already_subscribed(self, maubot_test_bot: TestBot) -> None:
|
||||
"""Reject duplicate subscription in the same room."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Second subscribe; stream already exists so validation is skipped
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"This room is already subscribed to notifications for stream.logal.dev."
|
||||
)
|
||||
|
||||
async def test_subscribe_existing_stream_new_room(
|
||||
self, maubot_test_bot: TestBot
|
||||
) -> None:
|
||||
"""Skip validation when the domain already has subscriptions."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Subscribe from a different room. The existing subscribed domain skips
|
||||
# validation, so an empty aioresponses context will raise ConnectionError
|
||||
# if any HTTP request is attempted.
|
||||
with aioresponses():
|
||||
await maubot_test_bot.send(
|
||||
"!subscribe stream.logal.dev", room_id="!otherroom:example.com"
|
||||
)
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"Subscription added! This room will receive notifications when "
|
||||
"stream.logal.dev goes live."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("maubot_plugin")
|
||||
class TestUnsubscribeCommand:
|
||||
"""Unsubscribe command end-to-end via maubot."""
|
||||
|
||||
async def test_unsubscribe_existing(self, maubot_test_bot: TestBot) -> None:
|
||||
"""Unsubscribe from a subscribed stream."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
await maubot_test_bot.send("!unsubscribe stream.logal.dev")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"Subscription removed! This room will no "
|
||||
"longer receive notifications for stream.logal.dev."
|
||||
)
|
||||
|
||||
async def test_unsubscribe_not_subscribed(self, maubot_test_bot: TestBot) -> None:
|
||||
"""Reject unsubscribe from a non-subscribed stream."""
|
||||
await maubot_test_bot.send("!unsubscribe unknown.com")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"This room is already not subscribed to notifications for unknown.com."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("maubot_plugin")
|
||||
class TestSubscriptionsCommand:
|
||||
"""Subscriptions listing command end-to-end via maubot."""
|
||||
|
||||
async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
|
||||
"""Show help text when no subscriptions exist."""
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"This room is not subscribed to any Owncast "
|
||||
"instances.\n\nTo subscribe to an Owncast "
|
||||
"instance, use `!subscribe <domain>`"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_online_stream(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""Show stream details including title and duration."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Update stream state to be online with a name
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
title="Playing Games",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Subscriptions for this room (1):**\n\n"
|
||||
"● **Test Stream** \n"
|
||||
" ○ Title: Playing Games\n"
|
||||
" ○ Status: Online for 71 days\n"
|
||||
" ○ Link: https://stream.logal.dev\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_escapes_markdown_in_stream_name_and_title(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""Render stream name and title as literal text in command output."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"stream.logal.dev",
|
||||
name="*Bold* [link](https://evil.example)\nName",
|
||||
title="`code` > quote #tag",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
content = maubot_test_bot.responded[1].content
|
||||
assert "● ***Bold* [link](https://evil.example) Name**" in content.body
|
||||
assert " ○ Title: `code` > quote #tag" in content.body
|
||||
assert content.formatted_body is not None
|
||||
assert '<a href="https://evil.example">' not in content.formatted_body
|
||||
assert (
|
||||
"<strong>*Bold* [link](https://evil.example) Name</strong>"
|
||||
in content.formatted_body
|
||||
)
|
||||
assert "Title: `code` > quote #tag" in content.formatted_body
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_offline_stream(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""Show offline status for non-live streams."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Update stream state to be offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Subscriptions for this room (1):**\n\n"
|
||||
"● **Test Stream** \n"
|
||||
" ○ Status: Offline for 71 days\n"
|
||||
" ○ Link: https://stream.logal.dev\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
async def test_shows_offline_stream_without_disconnect_time(
|
||||
self, maubot_test_bot: TestBot
|
||||
) -> None:
|
||||
"""Show offline status without duration before first poll completes."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Stream row exists with no state yet - query subscriptions immediately
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Subscriptions for this room (1):**\n\n"
|
||||
"● **stream.logal.dev** \n"
|
||||
" ○ Status: Offline\n"
|
||||
" ○ Link: https://stream.logal.dev\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
async def test_shows_unknown_stream(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""Show unknown status when instance has been unreachable."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Increment failure counter past the unknown threshold
|
||||
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||
await maubot_plugin.stream_repo.increment_failure_counter(
|
||||
"stream.logal.dev"
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Subscriptions for this room (1):**\n\n"
|
||||
"● **stream.logal.dev** \n"
|
||||
" ○ Status: Unknown (instance unreachable)\n"
|
||||
" ○ Link: https://stream.logal.dev\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_multiple_subscriptions(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""List subscriptions ordered by domain with mixed statuses."""
|
||||
# Subscribe in reverse domain order to verify domain-sorted output
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
f"https://beta.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
mocked.get(
|
||||
f"https://alpha.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe beta.com")
|
||||
await maubot_test_bot.send("!subscribe alpha.com")
|
||||
|
||||
# Set alpha online, beta offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Streaming Live",
|
||||
online=True,
|
||||
status_since="2026-03-13T10:00:00+00:00",
|
||||
)
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"beta.com",
|
||||
name="Beta Stream",
|
||||
status_since="2026-03-12T18:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 3
|
||||
assert maubot_test_bot.responded[2].content.body == (
|
||||
"**Subscriptions for this room (2):**\n\n"
|
||||
"● **Alpha Stream**\n"
|
||||
" \n"
|
||||
" ○ Title: Streaming Live\n"
|
||||
" ○ Status: Online for 2 hours\n"
|
||||
" ○ Link: https://alpha.com\n"
|
||||
"● **Beta Stream**\n"
|
||||
" \n"
|
||||
" ○ Status: Offline for 18 hours\n"
|
||||
" ○ Link: https://beta.com\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("maubot_plugin")
|
||||
class TestLiveCommand:
|
||||
"""Live streams listing command end-to-end via maubot."""
|
||||
|
||||
async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
|
||||
"""Show help text when no subscriptions exist."""
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"This room is not subscribed to any Owncast "
|
||||
"instances.\n\nTo subscribe to an Owncast "
|
||||
"instance, use `!subscribe <domain>`"
|
||||
)
|
||||
|
||||
async def test_no_live_streams(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""Show 'no live' message when all streams are offline."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Update stream state to offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"No subscribed Owncast instances are currently "
|
||||
"live.\n\nUse `!subscriptions` to list all "
|
||||
"subscriptions."
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_live_stream(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""Show live stream with title and duration."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Update stream state to online
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
title="Playing Games",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Live Owncast instances (1):**\n\n"
|
||||
"● **Test Stream** \n"
|
||||
" ○ Title: Playing Games\n"
|
||||
" ○ Online for 71 days\n"
|
||||
" ○ Link: https://stream.logal.dev"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_multiple_live_streams(
|
||||
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||
) -> None:
|
||||
"""List live streams ordered by domain with different durations."""
|
||||
# Subscribe in reverse domain order to verify domain-sorted output
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
f"https://beta.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
mocked.get(
|
||||
f"https://alpha.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe beta.com")
|
||||
await maubot_test_bot.send("!subscribe alpha.com")
|
||||
|
||||
# Set both streams online with different status timestamps
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Morning Show",
|
||||
online=True,
|
||||
status_since="2026-03-13T10:00:00+00:00",
|
||||
)
|
||||
await maubot_plugin.stream_repo.update(
|
||||
"beta.com",
|
||||
name="Beta Stream",
|
||||
title="Evening Vibes",
|
||||
online=True,
|
||||
status_since="2026-03-13T06:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 3
|
||||
assert maubot_test_bot.responded[2].content.body == (
|
||||
"**Live Owncast instances (2):**\n\n"
|
||||
"● **Alpha Stream**\n"
|
||||
" \n"
|
||||
" ○ Title: Morning Show\n"
|
||||
" ○ Online for 2 hours\n"
|
||||
" ○ Link: https://alpha.com\n"
|
||||
"● **Beta Stream**\n"
|
||||
" \n"
|
||||
" ○ Title: Evening Vibes\n"
|
||||
" ○ Online for 6 hours\n"
|
||||
" ○ Link: https://beta.com"
|
||||
)
|
||||
@@ -0,0 +1,314 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the Prometheus metrics service."""
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.metrics import ErrorSource, MetricsService, NotificationType
|
||||
from owncastsentry.types import StreamStatus
|
||||
from tests.conftest import generate_metrics_output
|
||||
|
||||
|
||||
class TestRecordDelivery:
|
||||
"""Notification delivery counter with type and result labels."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("notification_type", "successful", "failed", "expected_fragments"),
|
||||
[
|
||||
pytest.param(
|
||||
NotificationType.LIVE,
|
||||
3,
|
||||
0,
|
||||
['result="success",type="live"} 3.0'],
|
||||
id="live-success",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.LIVE,
|
||||
0,
|
||||
2,
|
||||
['result="failure",type="live"} 2.0'],
|
||||
id="live-failure",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.TITLE_CHANGE,
|
||||
1,
|
||||
0,
|
||||
['result="success",type="title_change"} 1.0'],
|
||||
id="title-change-success",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.CLEANUP_WARNING,
|
||||
2,
|
||||
0,
|
||||
['result="success",type="cleanup_warning"} 2.0'],
|
||||
id="cleanup-warning-success",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.CLEANUP_DELETION,
|
||||
1,
|
||||
1,
|
||||
[
|
||||
'result="success",type="cleanup_deletion"} 1.0',
|
||||
'result="failure",type="cleanup_deletion"} 1.0',
|
||||
],
|
||||
id="cleanup-deletion-mixed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_records_delivery(
|
||||
self,
|
||||
notification_type: NotificationType,
|
||||
successful: int,
|
||||
failed: int,
|
||||
expected_fragments: list[str],
|
||||
) -> None:
|
||||
"""Record delivery results with correct type and result labels."""
|
||||
service = MetricsService()
|
||||
service.record_delivery(notification_type, successful=successful, failed=failed)
|
||||
output = generate_metrics_output(service)
|
||||
for fragment in expected_fragments:
|
||||
assert fragment in output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("notification_type", "result"),
|
||||
[
|
||||
pytest.param(t, r, id=f"{t}-{r}")
|
||||
for t in NotificationType
|
||||
for r in ("success", "failure")
|
||||
],
|
||||
)
|
||||
def test_all_combinations_initialized(
|
||||
self, notification_type: NotificationType, result: str
|
||||
) -> None:
|
||||
"""All type/result label combinations exist at zero on init."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
expected = (
|
||||
f"owncastsentry_notification_delivery_total"
|
||||
f'{{result="{result}",type="{notification_type}"}} 0.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
|
||||
class TestSetStreamStatus:
|
||||
"""Per-stream status gauge."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected_value"),
|
||||
[
|
||||
pytest.param(StreamStatus.ONLINE, 1.0, id="online"),
|
||||
pytest.param(StreamStatus.OFFLINE, 0.0, id="offline"),
|
||||
pytest.param(StreamStatus.UNKNOWN, -1.0, id="unknown"),
|
||||
],
|
||||
)
|
||||
def test_sets_status(self, status: StreamStatus, expected_value: float) -> None:
|
||||
"""Set gauge to the correct value for each stream status."""
|
||||
service = MetricsService()
|
||||
service.set_stream_status("test.com", status)
|
||||
output = generate_metrics_output(service)
|
||||
expected = f'owncastsentry_stream_status{{domain="test.com"}} {expected_value}'
|
||||
assert expected in output
|
||||
|
||||
|
||||
class TestSetSubscriptionCount:
|
||||
"""Per-stream subscription count gauge."""
|
||||
|
||||
def test_sets_count(self) -> None:
|
||||
"""Set the subscription count for a domain."""
|
||||
service = MetricsService()
|
||||
service.set_subscription_count("test.com", 5)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_stream_subscriptions{domain="test.com"} 5.0' in output
|
||||
|
||||
def test_updates_count(self) -> None:
|
||||
"""Update the subscription count for a domain."""
|
||||
service = MetricsService()
|
||||
service.set_subscription_count("test.com", 5)
|
||||
service.set_subscription_count("test.com", 3)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_stream_subscriptions{domain="test.com"} 3.0' in output
|
||||
|
||||
|
||||
class TestSetCheckFailures:
|
||||
"""Consecutive check failure gauge per domain."""
|
||||
|
||||
def test_sets_count(self) -> None:
|
||||
"""Set the failure count for a domain."""
|
||||
service = MetricsService()
|
||||
service.set_check_failures("fail.com", 3)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_check_failures{domain="fail.com"} 3.0' in output
|
||||
|
||||
def test_resets_to_zero(self) -> None:
|
||||
"""Reset the failure count to zero."""
|
||||
service = MetricsService()
|
||||
service.set_check_failures("fail.com", 5)
|
||||
service.set_check_failures("fail.com", 0)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_check_failures{domain="fail.com"} 0.0' in output
|
||||
|
||||
|
||||
class TestResponseTimer:
|
||||
"""Response time gauge via context manager."""
|
||||
|
||||
def test_records_on_success(self) -> None:
|
||||
"""Record a response time when success() is called."""
|
||||
service = MetricsService()
|
||||
with service.response_timer("example.com") as timer:
|
||||
timer.success()
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
|
||||
def test_does_not_record_without_success(self) -> None:
|
||||
"""Do not record when success() is never called."""
|
||||
service = MetricsService()
|
||||
with service.response_timer("example.com"):
|
||||
pass
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
|
||||
def test_overwrites_previous_value(self) -> None:
|
||||
"""Overwrite previous value with the latest response time."""
|
||||
service = MetricsService()
|
||||
with service.response_timer("example.com") as timer:
|
||||
timer.success()
|
||||
with service.response_timer("example.com") as timer:
|
||||
timer.success()
|
||||
output = generate_metrics_output(service)
|
||||
# Gauge should have exactly one line for this domain, not accumulated
|
||||
matches = [
|
||||
line
|
||||
for line in output.splitlines()
|
||||
if line.startswith("owncastsentry_api_response_seconds{")
|
||||
]
|
||||
assert len(matches) == 1
|
||||
|
||||
def test_does_not_record_on_exception(self) -> None:
|
||||
"""Do not record when the block raises before success() is called."""
|
||||
service = MetricsService()
|
||||
with (
|
||||
pytest.raises(ValueError, match="boom"),
|
||||
service.response_timer("example.com"),
|
||||
):
|
||||
raise ValueError("boom")
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
|
||||
|
||||
class TestRemoveStream:
|
||||
"""Stale stream label cleanup."""
|
||||
|
||||
def test_removes_stream_label(self) -> None:
|
||||
"""Remove a stream's gauge labels after cleanup deletion."""
|
||||
service = MetricsService()
|
||||
service.set_stream_status("gone.com", StreamStatus.OFFLINE)
|
||||
service.set_subscription_count("gone.com", 2)
|
||||
assert 'domain="gone.com"' in generate_metrics_output(service)
|
||||
service.remove_stream("gone.com")
|
||||
assert 'domain="gone.com"' not in generate_metrics_output(service)
|
||||
|
||||
def test_remove_nonexistent_is_noop(self) -> None:
|
||||
"""Removing a nonexistent stream does not raise."""
|
||||
service = MetricsService()
|
||||
service.remove_stream("never.com")
|
||||
|
||||
|
||||
class TestRegisterOpenConnectionsGauge:
|
||||
"""Callback-based open connection gauge."""
|
||||
|
||||
def test_reads_value_from_callback(self) -> None:
|
||||
"""Read the open connection count from the callback at scrape time."""
|
||||
service = MetricsService()
|
||||
counter = [3]
|
||||
service.register_open_connections_gauge(lambda: counter[0])
|
||||
output = generate_metrics_output(service)
|
||||
assert "owncastsentry_http_connections_open 3.0" in output
|
||||
|
||||
def test_reflects_updated_value(self) -> None:
|
||||
"""Reflect changes in the callback value on subsequent scrapes."""
|
||||
service = MetricsService()
|
||||
counter = [1]
|
||||
service.register_open_connections_gauge(lambda: counter[0])
|
||||
counter[0] = 5
|
||||
output = generate_metrics_output(service)
|
||||
assert "owncastsentry_http_connections_open 5.0" in output
|
||||
|
||||
|
||||
class TestSetBuildInfo:
|
||||
"""Build version info metric."""
|
||||
|
||||
def test_sets_version(self) -> None:
|
||||
"""Set the build version info."""
|
||||
service = MetricsService()
|
||||
service.set_build_info("1.2.3")
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_info{version="1.2.3"} 1.0' in output
|
||||
|
||||
|
||||
class TestRecordError:
|
||||
"""Internal error counter."""
|
||||
|
||||
def test_increments_counter(self) -> None:
|
||||
"""Increment the error counter for a source."""
|
||||
service = MetricsService()
|
||||
service.record_error(ErrorSource.SCHEDULER_LOOP)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_errors_total{source="scheduler_loop"} 1.0' in output
|
||||
|
||||
def test_increments_multiple_sources(self) -> None:
|
||||
"""Increment error counters for different sources independently."""
|
||||
service = MetricsService()
|
||||
service.record_error(ErrorSource.SCHEDULER_LOOP)
|
||||
service.record_error(ErrorSource.COMMAND)
|
||||
service.record_error(ErrorSource.COMMAND)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_errors_total{source="scheduler_loop"} 1.0' in output
|
||||
assert 'owncastsentry_errors_total{source="command"} 2.0' in output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[pytest.param(s, id=s) for s in ErrorSource],
|
||||
)
|
||||
def test_all_sources_initialized(self, source: ErrorSource) -> None:
|
||||
"""All known source labels exist at zero on init."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
expected = f'owncastsentry_errors_total{{source="{source}"}} 0.0'
|
||||
assert expected in output
|
||||
|
||||
|
||||
class TestRegistryOutput:
|
||||
"""Prometheus registry output."""
|
||||
|
||||
def test_returns_string(self) -> None:
|
||||
"""Return a string (not bytes)."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
assert isinstance(output, str)
|
||||
|
||||
def test_contains_help_lines(self) -> None:
|
||||
"""Include HELP lines for registered metrics."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
assert "# HELP owncastsentry_notification_delivery_total" in output
|
||||
assert "# HELP owncastsentry_errors_total" in output
|
||||
assert "# HELP owncastsentry_info" in output
|
||||
|
||||
def test_uses_isolated_registry(self) -> None:
|
||||
"""Use a custom registry, not the global default."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
assert "python_gc" not in output
|
||||
assert "process_" not in output
|
||||
@@ -0,0 +1,582 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the notification service."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.notification_service import (
|
||||
_SECONDS_BETWEEN_NOTIFICATIONS,
|
||||
NotificationService,
|
||||
_sanitize_for_plain_text,
|
||||
)
|
||||
from tests.conftest import _StubMatrixClient, generate_metrics_output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
def _make_service(
|
||||
*,
|
||||
client: _StubMatrixClient,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
metrics: MetricsService | None = None,
|
||||
) -> NotificationService:
|
||||
"""Build a NotificationService with a stub client and real repo."""
|
||||
return NotificationService(
|
||||
client=client,
|
||||
subscription_repo=subscription_repo,
|
||||
logger=logging.getLogger("test"),
|
||||
metrics=metrics or MetricsService(),
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeForPlainText:
|
||||
"""Plain text sanitization for notifications."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_text", "expected"),
|
||||
[
|
||||
pytest.param("hello world", "hello world", id="plain-text"),
|
||||
pytest.param("line1\nline2", "line1 line2", id="newline-removed"),
|
||||
pytest.param("line1\rline2", "line1 line2", id="carriage-return"),
|
||||
pytest.param("line1\r\nline2", "line1 line2", id="crlf-removed"),
|
||||
pytest.param(
|
||||
"too many spaces", "too many spaces", id="spaces-collapsed"
|
||||
),
|
||||
pytest.param("", "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_sanitizes(self, input_text: str, expected: str) -> None:
|
||||
"""Sanitize the text for safe plain-text rendering."""
|
||||
assert _sanitize_for_plain_text(input_text) == expected
|
||||
|
||||
|
||||
class TestCanNotify:
|
||||
"""Rate-limiting logic for notification cooldowns."""
|
||||
|
||||
def test_first_notification_allowed(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Allow the first notification for an unseen domain."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
assert service._can_notify("example.com") is True
|
||||
|
||||
def test_within_cooldown_blocked(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Block notifications within the cooldown window."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
service.notification_timers_cache["example.com"] = time.monotonic()
|
||||
assert service._can_notify("example.com") is False
|
||||
|
||||
def test_after_cooldown_allowed(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Allow notifications after the cooldown window expires."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||
service.notification_timers_cache["example.com"] = (
|
||||
time.monotonic() - _SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
)
|
||||
assert service._can_notify("example.com") is True
|
||||
|
||||
|
||||
class TestGetLastNotificationTime:
|
||||
"""Last notification timestamp retrieval."""
|
||||
|
||||
def test_returns_cached_value(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return the cached timestamp for a known domain."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
service.notification_timers_cache["example.com"] = 12345.0
|
||||
assert service.get_last_notification_time("example.com") == 12345.0
|
||||
|
||||
def test_returns_zero_for_unknown(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 for a domain that has never been notified."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
assert service.get_last_notification_time("unknown.com") == 0
|
||||
|
||||
|
||||
class TestClearNotificationState:
|
||||
"""Notification cache cleanup for deleted domains."""
|
||||
|
||||
def test_clears_cached_notification_time(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Remove cached notification state for a domain."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
service.notification_timers_cache["example.com"] = 12345.0
|
||||
|
||||
service.clear_notification_state("example.com")
|
||||
|
||||
assert service.get_last_notification_time("example.com") == 0
|
||||
|
||||
def test_missing_domain_is_ignored(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Ignore cleanup for a domain with no cached state."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
|
||||
service.clear_notification_state("unknown.com")
|
||||
|
||||
assert service.get_last_notification_time("unknown.com") == 0
|
||||
|
||||
|
||||
class TestFormatMessage:
|
||||
"""Notification message formatting."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "title", "domain", "tags", "title_change", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"My Stream",
|
||||
"Playing Games",
|
||||
"example.com",
|
||||
[],
|
||||
False,
|
||||
"🎥 My Stream is now live!\n"
|
||||
"Stream Title: Playing Games\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="go-live-with-title",
|
||||
),
|
||||
pytest.param(
|
||||
"My Stream",
|
||||
"New Title",
|
||||
"example.com",
|
||||
[],
|
||||
True,
|
||||
"📝 My Stream has changed its stream title!\n"
|
||||
"Stream Title: New Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="title-change",
|
||||
),
|
||||
pytest.param(
|
||||
"My Stream",
|
||||
"",
|
||||
"example.com",
|
||||
[],
|
||||
False,
|
||||
"🎥 My Stream is now live!\n\nTo tune in, visit: https://example.com/",
|
||||
id="go-live-no-title",
|
||||
),
|
||||
pytest.param(
|
||||
"",
|
||||
"Title",
|
||||
"example.com",
|
||||
[],
|
||||
False,
|
||||
"🎥 example.com is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="name-fallback-to-domain",
|
||||
),
|
||||
pytest.param(
|
||||
"Stream",
|
||||
"Title",
|
||||
"example.com",
|
||||
["gaming", "fun"],
|
||||
False,
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/\n"
|
||||
"\n"
|
||||
"#gaming #fun",
|
||||
id="with-tags",
|
||||
),
|
||||
pytest.param(
|
||||
"Stream",
|
||||
"Title",
|
||||
"example.com",
|
||||
[".hidden", "visible"],
|
||||
False,
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/\n"
|
||||
"\n"
|
||||
"#visible",
|
||||
id="dot-prefix-tag-filtered",
|
||||
),
|
||||
pytest.param(
|
||||
"Stream",
|
||||
"Title",
|
||||
"example.com",
|
||||
[".secret"],
|
||||
False,
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="all-tags-dot-prefixed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_format_message(
|
||||
self,
|
||||
name: str,
|
||||
title: str,
|
||||
domain: str,
|
||||
tags: list[str],
|
||||
*,
|
||||
title_change: bool,
|
||||
expected: str,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Format the notification message with expected content."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
result = service._format_message(
|
||||
name, title, domain, tags, title_change=title_change
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestNotifyStreamLive:
|
||||
"""Live notification sending behavior."""
|
||||
|
||||
async def test_sends_to_all_subscribed_rooms(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send notifications to every room subscribed to the domain."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
await subscription_repo.add("example.com", "!room2:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", ["tag"])
|
||||
|
||||
assert len(client.sent_messages) == 2
|
||||
room_ids = {msg.room_id for msg in client.sent_messages}
|
||||
assert room_ids == {"!room1:matrix.org", "!room2:matrix.org"}
|
||||
expected_body = (
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/\n"
|
||||
"\n"
|
||||
"#tag"
|
||||
)
|
||||
for msg in client.sent_messages:
|
||||
assert msg.content.body == expected_body
|
||||
|
||||
async def test_records_cooldown_after_success(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a cooldown after at least one room receives a notification."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
|
||||
before_send = time.monotonic()
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert service.get_last_notification_time("example.com") >= before_send
|
||||
|
||||
async def test_no_cooldown_when_no_subscribed_rooms(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Do not record a cooldown if no room receives the notification."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert len(client.sent_messages) == 0
|
||||
assert service.get_last_notification_time("example.com") == 0
|
||||
|
||||
async def test_no_cooldown_when_all_deliveries_fail(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Do not record a cooldown if every room delivery fails."""
|
||||
client = _StubMatrixClient()
|
||||
client.should_fail_for_rooms.add("!bad:matrix.org")
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!bad:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert len(client.sent_messages) == 0
|
||||
assert service.get_last_notification_time("example.com") == 0
|
||||
|
||||
async def test_counts_cancelled_delivery_as_failure(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Count a cancelled delivery result as a failure."""
|
||||
client = _StubMatrixClient()
|
||||
|
||||
async def send_message(_room_id: str, _content: object) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
client.send_message = send_message
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert service.get_last_notification_time("example.com") == 0
|
||||
|
||||
async def test_skips_when_rate_limited(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Skip sending when the domain is within the rate-limit cooldown."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
service.notification_timers_cache["example.com"] = time.monotonic()
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert len(client.sent_messages) == 0
|
||||
|
||||
async def test_counts_failures(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Deliver to other subscribed rooms when one room delivery fails."""
|
||||
client = _StubMatrixClient()
|
||||
client.should_fail_for_rooms.add("!bad:matrix.org")
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!bad:matrix.org")
|
||||
await subscription_repo.add("example.com", "!good:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].room_id == "!good:matrix.org"
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/"
|
||||
)
|
||||
|
||||
|
||||
class TestSendCleanupWarning:
|
||||
"""Cleanup warning notification sending."""
|
||||
|
||||
async def test_sends_warning_to_all_rooms(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send cleanup warning to all subscribed rooms."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
|
||||
await service.send_cleanup_warning("example.com")
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"⚠️ Warning: Subscription Cleanup Scheduled\n"
|
||||
"\n"
|
||||
"The Owncast instance at example.com has been "
|
||||
"unreachable for 83 days. If it remains "
|
||||
"unreachable for 7 more days "
|
||||
"(90 days total), this subscription "
|
||||
"will be automatically removed."
|
||||
)
|
||||
|
||||
|
||||
class TestSendCleanupDeletion:
|
||||
"""Cleanup deletion notification sending."""
|
||||
|
||||
async def test_sends_deletion_to_all_rooms(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send cleanup deletion notice to all subscribed rooms."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
|
||||
await service.send_cleanup_deletion("example.com")
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🗑️ Subscription Automatically Removed\n"
|
||||
"\n"
|
||||
"The Owncast instance at example.com has been "
|
||||
"unreachable for 90 days and has been "
|
||||
"automatically removed from subscriptions in this "
|
||||
"room.\n"
|
||||
"\n"
|
||||
"If the instance comes online again and you want to "
|
||||
"resubscribe, run `!subscribe example.com`."
|
||||
)
|
||||
|
||||
|
||||
class TestNotificationMetrics:
|
||||
"""Notification metrics recording."""
|
||||
|
||||
async def test_records_live_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a live notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="live"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_records_title_change_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a title_change notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.notify_stream_live(
|
||||
"example.com", "Stream", "Title", [], title_change=True
|
||||
)
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="title_change"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_records_cleanup_warning_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a cleanup_warning notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.send_cleanup_warning("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="cleanup_warning"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_records_cleanup_deletion_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a cleanup_deletion notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.send_cleanup_deletion("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="cleanup_deletion"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_no_metric_when_rate_limited(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Do not record metric when notification is rate-limited."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
service.notification_timers_cache["example.com"] = time.monotonic()
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'result="success",type="live"} 0.0' in output
|
||||
@@ -0,0 +1,479 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the Owncast HTTP client."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.owncast_client import (
|
||||
_MAX_JSON_RESPONSE_BYTES,
|
||||
OwncastClient,
|
||||
_read_limited_response_body,
|
||||
_user_agent,
|
||||
)
|
||||
from tests.conftest import (
|
||||
VALID_CONFIG_RESPONSE,
|
||||
VALID_STATUS_RESPONSE,
|
||||
generate_metrics_output,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
class _ChunkedContent:
|
||||
"""Fake aiohttp response content that yields predefined chunks."""
|
||||
|
||||
def __init__(self, chunks: tuple[bytes, ...]) -> None:
|
||||
"""Store chunks to return from iter_chunked."""
|
||||
self._chunks = chunks
|
||||
|
||||
async def iter_chunked(self, _size: int) -> AsyncIterator[bytes]:
|
||||
"""Yield chunks using the interface aiohttp exposes."""
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class _ChunkedResponse:
|
||||
"""Fake aiohttp response with chunked content."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunks: tuple[bytes, ...],
|
||||
content_length: int | None = None,
|
||||
) -> None:
|
||||
"""Store the content stream and optional Content-Length value."""
|
||||
self.content = _ChunkedContent(chunks)
|
||||
self.content_length = content_length
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||
"""Create an OwncastClient and close it after the test."""
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=MetricsService(),
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
"""User-Agent header construction."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"1.2.3",
|
||||
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="semver",
|
||||
),
|
||||
pytest.param(
|
||||
"0.0.0",
|
||||
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="zeroed",
|
||||
),
|
||||
pytest.param(
|
||||
"1.1.1.dev10+gf0146d061.d20260313",
|
||||
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="dev-version",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_user_agent(self, version: str, expected: str) -> None:
|
||||
"""Build a correctly formatted User-Agent header."""
|
||||
assert _user_agent(version) == expected
|
||||
|
||||
|
||||
class TestReadLimitedResponseBody:
|
||||
"""Bounded response body reading."""
|
||||
|
||||
async def test_reads_all_chunks_before_returning(self) -> None:
|
||||
"""Return the full body when JSON arrives in multiple chunks."""
|
||||
response = _ChunkedResponse(
|
||||
(
|
||||
b'{"streamTitle":',
|
||||
b'"hello","online":true}',
|
||||
)
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result == bytearray(b'{"streamTitle":"hello","online":true}')
|
||||
|
||||
async def test_returns_none_when_content_length_is_too_large(self) -> None:
|
||||
"""Return None when Content-Length is already over the limit."""
|
||||
response = _ChunkedResponse(
|
||||
(),
|
||||
content_length=_MAX_JSON_RESPONSE_BYTES + 1,
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_when_streamed_body_is_too_large(self) -> None:
|
||||
"""Return None when chunked content grows past the limit."""
|
||||
response = _ChunkedResponse(
|
||||
(
|
||||
b"x" * _MAX_JSON_RESPONSE_BYTES,
|
||||
b"x",
|
||||
)
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetStreamState:
|
||||
"""Stream observation retrieval from the status API."""
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_returns_state_on_success(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return a StreamStateObservation with correct fields on a valid response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is not None
|
||||
assert result.domain == "stream.logal.dev"
|
||||
assert (
|
||||
result.title
|
||||
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
|
||||
)
|
||||
assert result.online is False
|
||||
assert result.observed_at == "2026-03-13T12:00:00+00:00"
|
||||
|
||||
async def test_returns_none_on_missing_field(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response is missing required fields."""
|
||||
incomplete = {"streamTitle": "Test Stream"}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps(incomplete).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_field_type(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response has malformed field types."""
|
||||
malformed = {
|
||||
**VALID_STATUS_RESPONSE,
|
||||
"streamTitle": 123,
|
||||
}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps(malformed).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is not valid JSON."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=b"not json",
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_object_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response JSON is not an object."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps([]).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_oversized_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is too large."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
status=404,
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_connection_error(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when a connection error occurs."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetStreamConfig:
|
||||
"""Stream configuration retrieval from the config API."""
|
||||
|
||||
async def test_returns_config_on_success(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return a StreamConfigObservation with fields from a valid response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps(VALID_CONFIG_RESPONSE).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "LogalDeveloper's Live Stream"
|
||||
assert result.tags == (
|
||||
"video games",
|
||||
"chatting",
|
||||
"casual",
|
||||
"english",
|
||||
"streaming",
|
||||
"owncast",
|
||||
"variety",
|
||||
)
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is not valid JSON."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=b"not json",
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_object_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response JSON is not an object."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps([]).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_field_type(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response has malformed field types."""
|
||||
malformed = {
|
||||
**VALID_CONFIG_RESPONSE,
|
||||
"tags": "gaming",
|
||||
}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps(malformed).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_oversized_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is too large."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
status=500,
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_connection_error(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when a connection error occurs."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestValidateInstance:
|
||||
"""Owncast instance validation via the status API."""
|
||||
|
||||
async def test_returns_true_for_valid_instance(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return True when the status endpoint returns a valid response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://valid.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
result = await owncast_client.validate_instance("valid.com")
|
||||
|
||||
assert result is True
|
||||
|
||||
async def test_returns_false_for_invalid_instance(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return False when the status endpoint returns a non-200 response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://invalid.com/api/status",
|
||||
status=404,
|
||||
)
|
||||
result = await owncast_client.validate_instance("invalid.com")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestResponseTimeMetrics:
|
||||
"""Response time gauge recording."""
|
||||
|
||||
async def test_records_on_success(self) -> None:
|
||||
"""Record response time on a successful request."""
|
||||
metrics = MetricsService()
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_failure(self) -> None:
|
||||
"""Do not record response time when request fails."""
|
||||
metrics = MetricsService()
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
status=500,
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_connection_error(self) -> None:
|
||||
"""Do not record response time on connection error."""
|
||||
metrics = MetricsService()
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestOpenConnectionCount:
|
||||
"""Open connection count."""
|
||||
|
||||
async def test_zero_with_no_requests(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return zero when no requests have been made."""
|
||||
assert owncast_client.open_connection_count == 0
|
||||
@@ -0,0 +1,483 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for database repository classes."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.repository import _normalize_legacy_status_since
|
||||
from owncastsentry.types import (
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
NotSubscribedError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
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:
|
||||
"""Stream existence checks."""
|
||||
|
||||
async def test_returns_true_for_existing_stream(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return True when the stream exists in the database."""
|
||||
await stream_repo.create("example.com")
|
||||
assert await stream_repo.exists("example.com") is True
|
||||
|
||||
async def test_returns_false_for_missing_stream(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return False when the stream does not exist in the database."""
|
||||
assert await stream_repo.exists("missing.com") is False
|
||||
|
||||
|
||||
class TestStreamCreate:
|
||||
"""Stream creation behavior."""
|
||||
|
||||
async def test_returns_true_when_created(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return True when a stream row is inserted."""
|
||||
assert await stream_repo.create("example.com") is True
|
||||
|
||||
async def test_returns_false_when_existing(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return False when a stream row already exists."""
|
||||
await stream_repo.create("example.com")
|
||||
assert await stream_repo.create("example.com") is False
|
||||
|
||||
|
||||
class TestStreamDelete:
|
||||
"""Stream record deletion."""
|
||||
|
||||
async def test_removes_stream_record(self, stream_repo: StreamRepository) -> None:
|
||||
"""Remove the stream record so get_by_domain returns None."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.delete("example.com")
|
||||
assert await stream_repo.get_by_domain("example.com") is None
|
||||
|
||||
|
||||
class TestStreamUpdate:
|
||||
"""Partial stream state updates."""
|
||||
|
||||
async def test_updates_only_supplied_fields(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Update supplied fields while leaving omitted fields unchanged."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.update(
|
||||
"example.com",
|
||||
name="Original Name",
|
||||
title="Original Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await stream_repo.update("example.com", title="Updated Title")
|
||||
|
||||
state = await stream_repo.get_by_domain("example.com")
|
||||
assert state is not None
|
||||
assert state.name == "Original Name"
|
||||
assert state.title == "Updated Title"
|
||||
assert state.online is True
|
||||
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||
|
||||
async def test_can_set_nullable_field_to_null(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Treat None as a supplied NULL value, not an omitted field."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.update(
|
||||
"example.com",
|
||||
name="Original Name",
|
||||
title="Original Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await stream_repo.update("example.com", title=None)
|
||||
|
||||
state = await stream_repo.get_by_domain("example.com")
|
||||
assert state is not None
|
||||
assert state.name == "Original Name"
|
||||
assert state.title is None
|
||||
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||
|
||||
async def test_accepts_no_supplied_fields(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Leave the row unchanged when no partial fields are supplied."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.update(
|
||||
"example.com",
|
||||
title="Original Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await stream_repo.update("example.com")
|
||||
|
||||
state = await stream_repo.get_by_domain("example.com")
|
||||
assert state is not None
|
||||
assert state.title == "Original Title"
|
||||
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||
|
||||
|
||||
class TestGetSubscribedStreamsForRoom:
|
||||
"""Subscribed stream lookup by room."""
|
||||
|
||||
async def test_returns_all_domains_for_room(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return all domains a room is subscribed to."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await stream_repo.create("beta.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("beta.com", "!room1:example.com")
|
||||
|
||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||
"!room1:example.com"
|
||||
)
|
||||
assert sorted(result) == ["alpha.com", "beta.com"]
|
||||
|
||||
async def test_returns_empty_list_for_unsubscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when the room has no subscriptions."""
|
||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||
"!nobody:example.com"
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestHasRoomSubscriptions:
|
||||
"""Room subscription existence checks."""
|
||||
|
||||
async def test_returns_true_for_subscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return True when the room has at least one subscription."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
|
||||
assert await subscription_repo.has_room_subscriptions("!room1:example.com")
|
||||
|
||||
async def test_returns_false_for_unsubscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return False when the room has no subscriptions."""
|
||||
assert not await subscription_repo.has_room_subscriptions("!nobody:example.com")
|
||||
|
||||
|
||||
class TestGetRoomSubscriptions:
|
||||
"""Resolved room subscription lookup."""
|
||||
|
||||
async def test_returns_sorted_stream_states_and_skips_missing_rows(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return sorted resolved subscriptions and skip missing stream rows."""
|
||||
await stream_repo.create("beta.example")
|
||||
await stream_repo.update("beta.example", name="Beta")
|
||||
await stream_repo.create("alpha.example")
|
||||
await stream_repo.update("alpha.example", name="Alpha")
|
||||
await subscription_repo.add("beta.example", "!room:example.com")
|
||||
await subscription_repo.add("missing.example", "!room:example.com")
|
||||
await subscription_repo.add("alpha.example", "!room:example.com")
|
||||
|
||||
subscriptions = await subscription_repo.get_room_subscriptions(
|
||||
"!room:example.com"
|
||||
)
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"alpha.example",
|
||||
"beta.example",
|
||||
]
|
||||
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
||||
"Alpha",
|
||||
"Beta",
|
||||
]
|
||||
|
||||
async def test_returns_empty_list_for_unsubscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when the room has no resolved subscriptions."""
|
||||
subscriptions = await subscription_repo.get_room_subscriptions(
|
||||
"!nobody:example.com"
|
||||
)
|
||||
assert subscriptions == []
|
||||
|
||||
|
||||
class TestGetLiveRoomSubscriptions:
|
||||
"""Resolved live room subscription lookup."""
|
||||
|
||||
async def test_returns_online_streams_and_skips_inactive_states(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return only streams whose derived status is ONLINE."""
|
||||
await stream_repo.create("offline.example")
|
||||
await stream_repo.update(
|
||||
"offline.example",
|
||||
name="Offline",
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("online.example")
|
||||
await stream_repo.update(
|
||||
"online.example",
|
||||
name="Online",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("unknown.example")
|
||||
await stream_repo.update(
|
||||
"unknown.example",
|
||||
name="Unknown",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||
await stream_repo.increment_failure_counter("unknown.example")
|
||||
|
||||
await subscription_repo.add("offline.example", "!room:example.com")
|
||||
await subscription_repo.add("online.example", "!room:example.com")
|
||||
await subscription_repo.add("unknown.example", "!room:example.com")
|
||||
|
||||
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
||||
"!room:example.com"
|
||||
)
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"online.example"
|
||||
]
|
||||
|
||||
async def test_returns_empty_list_for_room_with_no_live_streams(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return an empty list when no subscribed streams are live."""
|
||||
await stream_repo.create("offline.example")
|
||||
await subscription_repo.add("offline.example", "!room:example.com")
|
||||
|
||||
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
||||
"!room:example.com"
|
||||
)
|
||||
|
||||
assert subscriptions == []
|
||||
|
||||
|
||||
class TestAddSubscription:
|
||||
"""Subscription creation behavior."""
|
||||
|
||||
async def test_adds_subscription(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Add a subscription row."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
assert await subscription_repo.get_subscribed_rooms("alpha.com") == [
|
||||
"!room1:example.com"
|
||||
]
|
||||
|
||||
async def test_raises_when_existing(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Raise AlreadySubscribedError when a subscription already exists."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
with pytest.raises(AlreadySubscribedError):
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
|
||||
|
||||
class TestRemoveSubscription:
|
||||
"""Subscription removal behavior."""
|
||||
|
||||
async def test_removes_subscription(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Remove an existing subscription row."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
||||
assert await subscription_repo.get_subscribed_rooms("alpha.com") == []
|
||||
|
||||
async def test_raises_when_missing(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Raise NotSubscribedError when no subscription exists."""
|
||||
with pytest.raises(NotSubscribedError):
|
||||
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
||||
|
||||
|
||||
class TestGetAllSubscribedDomains:
|
||||
"""Unique subscribed domain retrieval."""
|
||||
|
||||
async def test_returns_each_domain_once(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return each domain once even with multiple subscriptions."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
result = await subscription_repo.get_all_subscribed_domains()
|
||||
assert result == ["alpha.com"]
|
||||
|
||||
async def test_returns_empty_list_with_no_subscriptions(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when there are no subscriptions."""
|
||||
result = await subscription_repo.get_all_subscribed_domains()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestCountByDomain:
|
||||
"""Subscription count by domain."""
|
||||
|
||||
async def test_returns_correct_count(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return the correct subscription count for a domain."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
assert await subscription_repo.count_by_domain("alpha.com") == 2
|
||||
|
||||
async def test_returns_zero_for_unknown_domain(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 for a domain with no subscriptions."""
|
||||
assert await subscription_repo.count_by_domain("unknown.com") == 0
|
||||
|
||||
|
||||
class TestCountByDomains:
|
||||
"""Bulk subscription counts by domain."""
|
||||
|
||||
async def test_returns_counts_for_requested_domains(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return counts for requested domains and zero for missing domains."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await stream_repo.create("beta.com")
|
||||
await stream_repo.create("ignored.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
await subscription_repo.add("beta.com", "!room3:example.com")
|
||||
await subscription_repo.add("ignored.com", "!room4:example.com")
|
||||
|
||||
assert await subscription_repo.count_by_domains(
|
||||
["beta.com", "missing.com", "alpha.com"]
|
||||
) == {
|
||||
"beta.com": 1,
|
||||
"missing.com": 0,
|
||||
"alpha.com": 2,
|
||||
}
|
||||
|
||||
async def test_returns_empty_dict_for_empty_domain_list(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty mapping when no domains are requested."""
|
||||
assert await subscription_repo.count_by_domains([]) == {}
|
||||
|
||||
|
||||
class TestDeleteAllForDomain:
|
||||
"""Bulk subscription deletion by domain."""
|
||||
|
||||
async def test_deletes_all_subscriptions_and_returns_count(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Delete all subscriptions for the domain and return the count."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
deleted = await subscription_repo.delete_all_for_domain("alpha.com")
|
||||
assert deleted == 2
|
||||
|
||||
rooms = await subscription_repo.get_subscribed_rooms("alpha.com")
|
||||
assert rooms == []
|
||||
|
||||
async def test_returns_zero_for_unknown_domain(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 when deleting subscriptions for an unknown domain."""
|
||||
assert await subscription_repo.delete_all_for_domain("unknown.com") == 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for subscription manager business logic."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.subscription_manager import SubscriptionManager, _domainify
|
||||
from owncastsentry.types import (
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
InvalidOwncastInstanceError,
|
||||
NotSubscribedError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
class _StubOwncastClient:
|
||||
"""Owncast client stub for validation-only manager tests."""
|
||||
|
||||
def __init__(self, *, valid: bool = True) -> None:
|
||||
"""Initialize the stub with a fixed validation result."""
|
||||
self.valid = valid
|
||||
self.validated_domains: list[str] = []
|
||||
|
||||
async def validate_instance(self, domain: str) -> bool:
|
||||
"""Record the domain and return the configured validation result."""
|
||||
self.validated_domains.append(domain)
|
||||
return self.valid
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def owncast_client() -> _StubOwncastClient:
|
||||
"""Return a validation-only Owncast client stub."""
|
||||
return _StubOwncastClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> SubscriptionManager:
|
||||
"""SubscriptionManager built directly for unit tests."""
|
||||
return SubscriptionManager(
|
||||
owncast_client=owncast_client, # type: ignore[arg-type]
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
class TestDomainify:
|
||||
"""Domain extraction and sanitization from user input."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_url", "expected"),
|
||||
[
|
||||
pytest.param("example.com", "example.com", id="bare-domain"),
|
||||
pytest.param(" example.com ", "example.com", id="surrounding-whitespace"),
|
||||
pytest.param("https://example.com", "example.com", id="https-url"),
|
||||
pytest.param("http://example.com", "example.com", id="http-url"),
|
||||
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
|
||||
pytest.param(
|
||||
"https://example.com/path/to/page",
|
||||
"example.com",
|
||||
id="url-with-path",
|
||||
),
|
||||
pytest.param(
|
||||
"user@stream.logal.dev",
|
||||
"stream.logal.dev",
|
||||
id="email-style",
|
||||
),
|
||||
pytest.param(
|
||||
"matrix@notify@stream.logal.dev",
|
||||
"stream.logal.dev",
|
||||
id="last-at-sign-wins",
|
||||
),
|
||||
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
|
||||
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
|
||||
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
|
||||
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
|
||||
pytest.param(
|
||||
"sub.domain.example.com",
|
||||
"sub.domain.example.com",
|
||||
id="subdomain",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extracts_domain(self, input_url: str, expected: str) -> None:
|
||||
"""Extract and sanitize the domain from various input formats."""
|
||||
assert _domainify(input_url) == expected
|
||||
|
||||
|
||||
class TestManagerSubscribe:
|
||||
"""SubscriptionManager subscribe workflow."""
|
||||
|
||||
async def test_first_subscription_validates_and_creates_stream(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""First subscription validates the instance and creates stream state."""
|
||||
domain = await manager.subscribe(
|
||||
"!room:example.com", "https://Stream.Example/foo"
|
||||
)
|
||||
|
||||
assert domain == "stream.example"
|
||||
assert owncast_client.validated_domains == ["stream.example"]
|
||||
assert await stream_repo.exists("stream.example") is True
|
||||
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||
"!room:example.com"
|
||||
]
|
||||
|
||||
async def test_invalid_first_subscription_raises(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Invalid first-time Owncast validation raises a domain error."""
|
||||
owncast_client.valid = False
|
||||
|
||||
with pytest.raises(InvalidOwncastInstanceError) as exc_info:
|
||||
await manager.subscribe("!room:example.com", "bad.example")
|
||||
|
||||
assert exc_info.value.domain == "bad.example"
|
||||
assert owncast_client.validated_domains == ["bad.example"]
|
||||
assert await stream_repo.exists("bad.example") is False
|
||||
assert await subscription_repo.get_subscribed_rooms("bad.example") == []
|
||||
|
||||
async def test_duplicate_subscription_raises(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Duplicate room subscription raises AlreadySubscribedError."""
|
||||
await manager.subscribe("!room:example.com", "stream.example")
|
||||
|
||||
with pytest.raises(AlreadySubscribedError) as exc_info:
|
||||
await manager.subscribe("!room:example.com", "stream.example")
|
||||
|
||||
assert exc_info.value.domain == "stream.example"
|
||||
assert owncast_client.validated_domains == ["stream.example"]
|
||||
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||
"!room:example.com"
|
||||
]
|
||||
|
||||
async def test_existing_stream_new_room_skips_validation(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Domains with existing subscriptions skip remote validation."""
|
||||
await manager.subscribe("!room1:example.com", "stream.example")
|
||||
owncast_client.valid = False
|
||||
|
||||
domain = await manager.subscribe("!room2:example.com", "stream.example")
|
||||
|
||||
assert domain == "stream.example"
|
||||
assert owncast_client.validated_domains == ["stream.example"]
|
||||
rooms = await subscription_repo.get_subscribed_rooms("stream.example")
|
||||
assert sorted(rooms) == [
|
||||
"!room1:example.com",
|
||||
"!room2:example.com",
|
||||
]
|
||||
|
||||
|
||||
class TestManagerUnsubscribe:
|
||||
"""SubscriptionManager unsubscribe workflow."""
|
||||
|
||||
async def test_removes_existing_subscription(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Existing room subscription is removed and its domain is returned."""
|
||||
await manager.subscribe("!room:example.com", "stream.example")
|
||||
|
||||
domain = await manager.unsubscribe("!room:example.com", "stream.example")
|
||||
|
||||
assert domain == "stream.example"
|
||||
assert await subscription_repo.get_subscribed_rooms("stream.example") == []
|
||||
|
||||
async def test_missing_subscription_raises(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
) -> None:
|
||||
"""Removing a non-existent subscription raises NotSubscribedError."""
|
||||
with pytest.raises(NotSubscribedError) as exc_info:
|
||||
await manager.unsubscribe("!room:example.com", "missing.example")
|
||||
|
||||
assert exc_info.value.domain == "missing.example"
|
||||
|
||||
|
||||
class TestManagerListings:
|
||||
"""SubscriptionManager room listing behavior."""
|
||||
|
||||
async def test_list_room_subscriptions_returns_sorted_stream_states(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return sorted room subscriptions and skip missing stream rows."""
|
||||
await stream_repo.create("beta.example")
|
||||
await stream_repo.update("beta.example", name="Beta")
|
||||
await stream_repo.create("alpha.example")
|
||||
await stream_repo.update("alpha.example", name="Alpha")
|
||||
await subscription_repo.add("beta.example", "!room:example.com")
|
||||
await subscription_repo.add("missing.example", "!room:example.com")
|
||||
await subscription_repo.add("alpha.example", "!room:example.com")
|
||||
|
||||
subscriptions = await manager.list_room_subscriptions("!room:example.com")
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"alpha.example",
|
||||
"beta.example",
|
||||
]
|
||||
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
||||
"Alpha",
|
||||
"Beta",
|
||||
]
|
||||
|
||||
async def test_list_live_room_subscriptions_filters_online_streams(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Live listing includes only subscriptions with ONLINE stream status."""
|
||||
await stream_repo.create("offline.example")
|
||||
await stream_repo.update(
|
||||
"offline.example",
|
||||
name="Offline",
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("online.example")
|
||||
await stream_repo.update(
|
||||
"online.example",
|
||||
name="Online",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("unknown.example")
|
||||
await stream_repo.update(
|
||||
"unknown.example",
|
||||
name="Unknown",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||
await stream_repo.increment_failure_counter("unknown.example")
|
||||
|
||||
await subscription_repo.add("offline.example", "!room:example.com")
|
||||
await subscription_repo.add("online.example", "!room:example.com")
|
||||
await subscription_repo.add("unknown.example", "!room:example.com")
|
||||
|
||||
subscriptions = await manager.list_live_room_subscriptions("!room:example.com")
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"online.example"
|
||||
]
|
||||
@@ -0,0 +1,388 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for data models."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
|
||||
from owncastsentry.types import (
|
||||
_MAX_INSTANCE_TITLE_LENGTH,
|
||||
_MAX_STREAM_TITLE_LENGTH,
|
||||
_MAX_TAG_LENGTH,
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
InvalidApiResponseError,
|
||||
InvalidOwncastInstanceError,
|
||||
NotSubscribedError,
|
||||
RoomSubscription,
|
||||
StreamConfigObservation,
|
||||
StreamState,
|
||||
StreamStateObservation,
|
||||
StreamStatus,
|
||||
SubscriptionError,
|
||||
UpdateResult,
|
||||
_truncate,
|
||||
format_status_since,
|
||||
)
|
||||
|
||||
|
||||
class TestTruncate:
|
||||
"""Text truncation to a maximum length."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "max_length", "expected"),
|
||||
[
|
||||
pytest.param("hello", 10, "hello", id="under-limit"),
|
||||
pytest.param("hello", 5, "hello", id="exact-limit"),
|
||||
pytest.param("hello world", 5, "hello", id="over-limit"),
|
||||
pytest.param("", 5, "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
|
||||
"""Return text unchanged within the limit and truncate over-limit text."""
|
||||
assert _truncate(text, max_length) == expected
|
||||
|
||||
|
||||
class TestStreamStateStatus:
|
||||
"""Stream status derivation from state fields."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_counter", "online", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
False,
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-offline-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
True,
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-online-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
True,
|
||||
StreamStatus.ONLINE,
|
||||
id="zero-failures-online-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
False,
|
||||
StreamStatus.OFFLINE,
|
||||
id="zero-failures-offline-returns-offline",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
True,
|
||||
StreamStatus.ONLINE,
|
||||
id="at-threshold-online-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
False,
|
||||
StreamStatus.OFFLINE,
|
||||
id="at-threshold-offline-returns-offline",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_status(
|
||||
self,
|
||||
failure_counter: int,
|
||||
online: object,
|
||||
expected: StreamStatus,
|
||||
) -> None:
|
||||
"""Return the correct status based on failure counter and online state."""
|
||||
state = StreamState(
|
||||
domain="example.com",
|
||||
failure_counter=failure_counter,
|
||||
online=online is True,
|
||||
)
|
||||
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 TestStreamStateObservationFromApiResponse:
|
||||
"""StreamStateObservation construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate API-derived fields from a complete status response."""
|
||||
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||
response = {
|
||||
"streamTitle": "My Stream",
|
||||
"online": True,
|
||||
}
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
observation = StreamStateObservation.from_api_response(
|
||||
response, "example.com"
|
||||
)
|
||||
assert observation.domain == "example.com"
|
||||
assert observation.title == "My Stream"
|
||||
assert observation.online is True
|
||||
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||
|
||||
def test_missing_required_field_raises(self) -> None:
|
||||
"""Reject API responses without required stream state fields."""
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamStateObservation.from_api_response({}, "bare.example.com")
|
||||
|
||||
def test_offline_response(self) -> None:
|
||||
"""Populate offline state from a status response."""
|
||||
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||
response = {
|
||||
"streamTitle": "Offline Stream",
|
||||
"online": False,
|
||||
}
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
observation = StreamStateObservation.from_api_response(
|
||||
response, "example.com"
|
||||
)
|
||||
assert observation.online is False
|
||||
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||
|
||||
def test_title_truncation(self) -> None:
|
||||
"""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)
|
||||
response = {
|
||||
"streamTitle": long_title,
|
||||
"online": True,
|
||||
}
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
observation = StreamStateObservation.from_api_response(
|
||||
response, "example.com"
|
||||
)
|
||||
assert len(observation.title) == _MAX_STREAM_TITLE_LENGTH
|
||||
assert observation.title == "A" * _MAX_STREAM_TITLE_LENGTH
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
pytest.param("streamTitle", 123, id="title-not-string"),
|
||||
pytest.param("online", "true", id="online-not-bool"),
|
||||
],
|
||||
)
|
||||
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||
"""Reject stream state responses with malformed field types."""
|
||||
response: dict[str, object] = {
|
||||
"streamTitle": "My Stream",
|
||||
"online": True,
|
||||
}
|
||||
response[field] = value
|
||||
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamStateObservation.from_api_response(response, "example.com")
|
||||
|
||||
|
||||
class TestStreamStateFromDbRow:
|
||||
"""StreamState construction from a database row dictionary."""
|
||||
|
||||
def test_typical_row(self) -> None:
|
||||
"""Populate all fields from a complete database row."""
|
||||
row = {
|
||||
"domain": "example.com",
|
||||
"name": "Test Instance",
|
||||
"title": "Live Now",
|
||||
"online": True,
|
||||
"status_since": "2026-01-01T00:00:00+00:00",
|
||||
"failure_counter": 3,
|
||||
}
|
||||
state = StreamState.from_db_row(row)
|
||||
assert state.domain == "example.com"
|
||||
assert state.name == "Test Instance"
|
||||
assert state.title == "Live Now"
|
||||
assert state.online is True
|
||||
assert state.status_since == "2026-01-01T00:00:00+00:00"
|
||||
assert state.failure_counter == 3
|
||||
|
||||
def test_row_with_none_optional_fields(self) -> None:
|
||||
"""Accept None for optional fields in a database row."""
|
||||
row = {
|
||||
"domain": "example.com",
|
||||
"name": None,
|
||||
"title": None,
|
||||
"online": False,
|
||||
"status_since": None,
|
||||
"failure_counter": 0,
|
||||
}
|
||||
state = StreamState.from_db_row(row)
|
||||
assert state.domain == "example.com"
|
||||
assert state.name is None
|
||||
assert state.title is None
|
||||
assert state.online is False
|
||||
assert state.status_since is None
|
||||
assert state.failure_counter == 0
|
||||
|
||||
|
||||
class TestStreamConfigObservationFromApiResponse:
|
||||
"""StreamConfigObservation construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate name and tags from a complete API response."""
|
||||
response = {"name": "My Instance", "tags": ["gaming", "music"]}
|
||||
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
config = StreamConfigObservation.from_api_response(response)
|
||||
assert config.name == "My Instance"
|
||||
assert config.tags == ("gaming", "music")
|
||||
assert config.observed_at == "2026-01-01T00:00:01+00:00"
|
||||
|
||||
def test_missing_keys_defaults(self) -> None:
|
||||
"""Use defaults when name and tags keys are missing."""
|
||||
config = StreamConfigObservation.from_api_response({})
|
||||
assert config.name == ""
|
||||
assert config.tags == ()
|
||||
|
||||
def test_name_truncation(self) -> None:
|
||||
"""Truncate the instance name to _MAX_INSTANCE_TITLE_LENGTH."""
|
||||
long_name = "B" * (_MAX_INSTANCE_TITLE_LENGTH + 50)
|
||||
response = {"name": long_name, "tags": []}
|
||||
config = StreamConfigObservation.from_api_response(response)
|
||||
assert len(config.name) == _MAX_INSTANCE_TITLE_LENGTH
|
||||
assert config.name == "B" * _MAX_INSTANCE_TITLE_LENGTH
|
||||
|
||||
def test_tag_truncation(self) -> None:
|
||||
"""Truncate each tag to _MAX_TAG_LENGTH."""
|
||||
long_tag = "C" * (_MAX_TAG_LENGTH + 10)
|
||||
response = {"name": "", "tags": [long_tag, "short"]}
|
||||
config = StreamConfigObservation.from_api_response(response)
|
||||
assert len(config.tags[0]) == _MAX_TAG_LENGTH
|
||||
assert config.tags[0] == "C" * _MAX_TAG_LENGTH
|
||||
assert config.tags[1] == "short"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
pytest.param("name", None, id="name-not-string"),
|
||||
pytest.param("tags", "gaming", id="tags-not-list"),
|
||||
pytest.param("tags", ["gaming", 123], id="tag-not-string"),
|
||||
],
|
||||
)
|
||||
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||
"""Reject stream config responses with malformed field types."""
|
||||
response: dict[str, object] = {"name": "My Instance", "tags": ["gaming"]}
|
||||
response[field] = value
|
||||
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamConfigObservation.from_api_response(response)
|
||||
|
||||
|
||||
class TestValueTypeImmutability:
|
||||
"""Dataclass value containers are immutable snapshots."""
|
||||
|
||||
def test_stream_state_is_immutable(self) -> None:
|
||||
"""StreamState cannot be mutated in place."""
|
||||
state = StreamState(domain="stream.example")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.title = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_stream_observation_is_immutable(self) -> None:
|
||||
"""StreamStateObservation cannot be mutated in place."""
|
||||
observation = StreamStateObservation(
|
||||
domain="stream.example",
|
||||
)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
observation.title = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_stream_config_is_immutable(self) -> None:
|
||||
"""StreamConfigObservation cannot be mutated in place."""
|
||||
config = StreamConfigObservation(name="Stream")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
config.name = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_stream_config_tags_are_immutable(self) -> None:
|
||||
"""StreamConfigObservation tags are stored in an immutable tuple."""
|
||||
config = StreamConfigObservation(name="Stream", tags=("gaming",))
|
||||
assert config.tags == ("gaming",)
|
||||
|
||||
def test_update_result_is_immutable(self) -> None:
|
||||
"""UpdateResult cannot be mutated in place."""
|
||||
result = UpdateResult(total_streams=1, successful_checks=1, failed_checks=0)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
result.failed_checks = 1 # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSubscriptionTypes:
|
||||
"""Subscription display containers and domain error hierarchy."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_cls",
|
||||
[
|
||||
pytest.param(InvalidOwncastInstanceError, id="invalid-instance"),
|
||||
pytest.param(AlreadySubscribedError, id="already-subscribed"),
|
||||
pytest.param(NotSubscribedError, id="not-subscribed"),
|
||||
],
|
||||
)
|
||||
def test_errors_subclass_subscription_error(
|
||||
self, error_cls: type[Exception]
|
||||
) -> None:
|
||||
"""Every subscription domain error subclasses SubscriptionError."""
|
||||
assert issubclass(error_cls, SubscriptionError)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
pytest.param(
|
||||
InvalidOwncastInstanceError("bad.example"),
|
||||
id="invalid-instance",
|
||||
),
|
||||
pytest.param(
|
||||
AlreadySubscribedError("dupe.example"),
|
||||
id="already-subscribed",
|
||||
),
|
||||
pytest.param(
|
||||
NotSubscribedError("missing.example"),
|
||||
id="not-subscribed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_errors_store_domain(self, error: SubscriptionError) -> None:
|
||||
"""Subscription domain errors expose the stream domain that failed."""
|
||||
assert error.domain in str(error)
|
||||
|
||||
def test_room_subscription_is_immutable(self) -> None:
|
||||
"""RoomSubscription is an immutable stream display snapshot."""
|
||||
state = StreamState(domain="stream.example")
|
||||
subscription = RoomSubscription(domain="stream.example", stream_state=state)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
subscription.domain = "other.example" # type: ignore[misc]
|
||||
Reference in New Issue
Block a user