28 Commits
Author SHA1 Message Date
LogalDeveloper d02202295c Updated package README.
CI / Formatting (push) Failing after 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 36s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
2026-05-24 14:12:05 -04:00
LogalDeveloper 58ad476a94 Simplified title-change notification handling. 2026-05-24 14:11:21 -04:00
LogalDeveloper 38e91add32 Refactored stream observations and config refresh handling.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 27s
CI / Type Checking (push) Successful in 23s
CI / Spelling (push) Successful in 19s
2026-05-21 19:44:29 -04:00
LogalDeveloper c837b916c0 Switched stream status tracking to explicit online state.
CI / Formatting (push) Failing after 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 28s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 6s
2026-05-21 16:09:28 -04:00
LogalDeveloper 4d0ae3d1ea Clarified comments and docstrings.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 29s
CI / Type Checking (push) Successful in 23s
CI / Spelling (push) Successful in 40s
2026-05-18 14:37:36 -04:00
LogalDeveloper 2f6d0bc1ed Reordered OwncastClient methods.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 28s
CI / Type Checking (push) Successful in 16s
CI / Spelling (push) Successful in 19s
2026-05-18 11:35:27 -04:00
LogalDeveloper d905740755 Reordered OwncastSentry plugin methods.
CI / Formatting (push) Successful in 8s
CI / Linting (push) Successful in 9s
CI / Tests (push) Successful in 38s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 11s
2026-05-18 11:15:11 -04:00
LogalDeveloper 795184cd17 Enabled all Ruff lint rules and resolved findings.
CI / Formatting (push) Successful in 27s
CI / Linting (push) Successful in 9s
CI / Tests (push) Successful in 34s
CI / Type Checking (push) Successful in 7s
CI / Spelling (push) Successful in 7s
2026-05-18 11:10:46 -04:00
LogalDeveloper 48a4fb9bbf Fixed formatting (again).
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 27s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 5s
2026-05-17 15:12:57 -04:00
LogalDeveloper ec42266af3 Scheduled stream updates at the top of each minute.
CI / Formatting (push) Failing after 6s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 27s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 12s
2026-05-17 15:11:08 -04:00
LogalDeveloper 33cc434354 Fixed formatting.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 27s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 5s
2026-05-17 14:49:37 -04:00
LogalDeveloper 0620c675d9 Refactored OwncastSentry internals and API validation.
CI / Formatting (push) Failing after 7s
CI / Linting (push) Successful in 12s
CI / Tests (push) Successful in 47s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 7s
2026-05-17 14:46:44 -04:00
LogalDeveloper 179d087e33 Updated dependencies.
Audit / Dependencies (push) Successful in 17s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 7s
2026-05-15 21:27:23 -04:00
LogalDeveloper a60de6dec7 Added SBOM generation to CD workflow.
Audit / Dependencies (push) Failing after 8s
CD / Publish (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 11s
2026-05-15 21:04:16 -04:00
LogalDeveloper 60f747725b Updated Gitea Actions workflows to use new CI image.
CD / Build (push) Successful in 18s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 14s
2026-05-15 20:58:00 -04:00
LogalDeveloper c42f16416f Updated dependencies.
Audit / Dependencies (push) Successful in 18s
CD / Build (push) Successful in 7s
CI / Formatting (push) Successful in 7s
CI / Linting (push) Successful in 7s
CI / Tests (push) Successful in 25s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 10s
2026-04-29 10:47:53 -04:00
LogalDeveloper 89e60c5955 Hardened Gitea Actions workflows and updated action pins.
CD / Build (push) Successful in 7s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 6s
2026-04-29 10:42:11 -04:00
LogalDeveloper 4464595e7b Fixed plugin upload by swapping back to gitea-upload-artifact fork with v7 branch.
CD / Build (push) Successful in 12s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 9s
CI / Tests (push) Successful in 20s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 10s
2026-04-22 12:46:52 -04:00
LogalDeveloper 33d5a8a0da Fixed upload-artifact isGHES rejection by spoofing GITHUB_SERVER_URL.
CD / Build (push) Failing after 8s
CI / Formatting (push) Successful in 14s
CI / Linting (push) Successful in 9s
CI / Tests (push) Successful in 20s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
2026-04-22 12:42:24 -04:00
LogalDeveloper def15b22f9 Switched to official actions/upload-artifact with archive disabled to avoid redundant re-zipping.
CD / Build (push) Failing after 16s
CI / Formatting (push) Successful in 7s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 12s
2026-04-22 12:37:07 -04:00
LogalDeveloper 69714e59fa Scoped GITEA_TOKEN to least privilege in Gitea Actions workflows.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 8s
2026-04-22 12:28:58 -04:00
LogalDeveloper 8c209e2bb3 Updated dependencies.
Audit / Dependencies (push) Successful in 17s
CD / Build (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-04-21 09:20:31 -04:00
LogalDeveloper 0dfe602d3f Updated dependencies. (Closes #7)
CD / Build (push) Successful in 6s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 12s
Audit / Dependencies (push) Failing after 8s
2026-03-29 20:31:47 -04:00
LogalDeveloper 47c7e97ac0 Fixed single stream update exception aborting all parallel updates.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
Audit / Dependencies (push) Failing after 9s
2026-03-25 09:54:33 -04:00
LogalDeveloper c690702113 Fixed rate-limit log message reporting wall-clock time instead of elapsed seconds. 2026-03-25 09:54:06 -04:00
LogalDeveloper 9be932c0c7 Replaced health checker with Prometheus metrics service.
Audit / Dependencies (push) Failing after 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 14s
CI / Spelling (push) Successful in 13s
2026-03-25 08:54:38 -04:00
LogalDeveloper 295a8c90a9 Improved code quality with more idiomatic Python patterns.
CD / Build (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 20s
CI / Spelling (push) Successful in 32s
2026-03-24 11:02:13 -04:00
LogalDeveloper 653c27c97c Updated dependencies.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 22s
CI / Type Checking (push) Successful in 13s
CI / Spelling (push) Successful in 9s
Audit / Dependencies (push) Successful in 9s
2026-03-19 09:25:30 -04:00
36 changed files with 6023 additions and 2930 deletions
+9 -3
View File
@@ -8,22 +8,28 @@ on:
pull_request: pull_request:
paths: [uv.lock] paths: [uv.lock]
permissions:
contents: read
env:
UV_PYTHON_DOWNLOADS: never
jobs: jobs:
audit: audit:
name: Dependencies name: Dependencies
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Audit dependencies with pip-audit - name: Audit dependencies with pip-audit
run: uv run pip-audit --skip-editable run: uv run pip-audit --skip-editable
+47 -24
View File
@@ -2,51 +2,74 @@ name: CD
on: on:
push: push:
branches: [master]
tags: ["v*"] tags: ["v*"]
permissions:
contents: read
# packages: write # not yet supported by Gitea
env:
UV_PYTHON_DOWNLOADS: never
jobs: jobs:
build: publish:
name: Build name: Publish
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked --no-dev
- name: Compute version - name: Generate package metadata
id: version id: metadata
run: | run: |
version=$(uv run hatch version) version="${{ gitea.ref_name }}"
echo "version=$version" version="${version#v}"
echo "version=$version" >> "$GITEA_OUTPUT" printf 'version=%s\n' "$version" | tee -a "$GITEA_OUTPUT"
- name: Write version into maubot.yaml - name: Write version into maubot.yaml
run: | run: |
sed -i "s/^version: .*/version: ${{ steps.version.outputs.version }}/" maubot.yaml sed -i "s/^version: .*/version: ${{ steps.metadata.outputs.version }}/" maubot.yaml
grep "^version:" maubot.yaml grep "^version:" maubot.yaml
- name: Build plugin - name: Build plugin
run: 7z a -tzip -mx=9 -xr!__pycache__ owncastsentry-v${{ steps.version.outputs.version }}.mbp owncastsentry/ maubot.yaml base-config.yaml LICENSE.txt 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 - name: Print artifact checksums
run: | run: |
echo "MD5: $(md5sum owncastsentry-v${{ steps.version.outputs.version }}.mbp)" echo "MD5: $(md5sum owncastsentry-v${{ steps.metadata.outputs.version }}.mbp)"
echo "SHA1: $(sha1sum owncastsentry-v${{ steps.version.outputs.version }}.mbp)" echo "SHA1: $(sha1sum owncastsentry-v${{ steps.metadata.outputs.version }}.mbp)"
echo "SHA256: $(sha256sum owncastsentry-v${{ steps.version.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 - name: Upload plugin artifact
uses: https://github.com/christopherhx/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2 # v4 uses: https://github.com/christopherhx/gitea-upload-artifact@8818363695ca2d5782c64f6453273341374767b7 # v7
with: with:
name: owncastsentry-v${{ steps.version.outputs.version }} path: owncastsentry-v${{ steps.metadata.outputs.version }}.mbp
path: owncastsentry-v${{ steps.version.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"
+21 -15
View File
@@ -4,60 +4,66 @@ on:
push: push:
pull_request: pull_request:
permissions:
contents: read
env:
UV_PYTHON_DOWNLOADS: never
jobs: jobs:
formatting: formatting:
name: Formatting name: Formatting
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check formatting with Ruff - name: Check formatting with Ruff
run: uv run ruff format --check --diff . run: uv run ruff format --check --diff .
linting: linting:
name: Linting name: Linting
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check linting with Ruff - name: Check linting with Ruff
run: uv run ruff check . run: uv run ruff check .
tests: tests:
name: Tests name: Tests
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Run unit tests with Pytest - name: Run unit tests with Pytest
run: uv run pytest -v --cov --cov-report= run: uv run pytest -v --cov --cov-report=
@@ -67,38 +73,38 @@ jobs:
type-checking: type-checking:
name: Type Checking name: Type Checking
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check types with Mypy - name: Check types with Mypy
run: uv run mypy . run: uv run mypy .
spelling: spelling:
name: Spelling name: Spelling
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check spelling with codespell - name: Check spelling with codespell
run: uv run codespell run: uv run codespell
+4 -4
View File
@@ -1,4 +1,4 @@
# Health check endpoint URL. # Prometheus metrics endpoint.
# If configured, a GET request will be sent to this URL after each successful update cycle. # When enabled, a /metrics endpoint is exposed via the maubot webapp.
# Leave empty to disable health check reporting. # Disabled by default.
health_check_endpoint: "" metrics_enabled: false
+3
View File
@@ -8,5 +8,8 @@ main_class: OwncastSentry
database: true database: true
database_type: asyncpg database_type: asyncpg
config: true config: true
webapp: true
dependencies:
- prometheus_client>=0.24.1
extra_files: extra_files:
- base-config.yaml - base-config.yaml
+317
View File
@@ -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.
+91 -36
View File
@@ -14,19 +14,23 @@
"""OwncastSentry maubot plugin.""" """OwncastSentry maubot plugin."""
import asyncio
from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aiohttp import web
from maubot import MessageEvent, Plugin # type: ignore[attr-defined] from maubot import MessageEvent, Plugin # type: ignore[attr-defined]
from maubot.handlers import command from maubot.handlers import command
from prometheus_client.exposition import choose_encoder
from .commands import CommandHandler from .commands import CommandHandler
from .config import Config from .config import Config
from .database import StreamRepository, SubscriptionRepository from .metrics import ErrorSource, MetricsService
from .health_checker import HealthChecker
from .migrations import get_upgrade_table
from .notification_service import NotificationService from .notification_service import NotificationService
from .owncast_client import OwncastClient from .owncast_client import OwncastClient
from .repository import StreamRepository, SubscriptionRepository, get_upgrade_table
from .stream_monitor import StreamMonitor from .stream_monitor import StreamMonitor
from .subscription_manager import SubscriptionManager
if TYPE_CHECKING: if TYPE_CHECKING:
from mautrix.util.async_db import Database, UpgradeTable from mautrix.util.async_db import Database, UpgradeTable
@@ -47,18 +51,31 @@ class OwncastSentry(Plugin):
return Config return Config
async def start(self) -> None: async def start(self) -> None:
"""Initialize all services and register recurring tasks. """Initialize services and schedule stream state updates.
Registers a recurring task every minute to update the state of Starts a scheduler coroutine that updates subscribed streams at the top
all subscribed streams. of each wall-clock minute.
""" """
# Load configuration # Load configuration
config: Config = self.config # type: ignore[assignment] config: Config = self.config # type: ignore[assignment]
config.load_and_update() config.load_and_update()
db: Database = self.database # type: ignore[assignment] 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 # Initialize the Owncast API client
self.owncast_client = OwncastClient(self.log, str(self.loader.meta.version)) 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 # Initialize repositories
self.stream_repo = StreamRepository(db) self.stream_repo = StreamRepository(db)
@@ -66,7 +83,10 @@ class OwncastSentry(Plugin):
# Initialize notification service # Initialize notification service
self.notification_service = NotificationService( self.notification_service = NotificationService(
self.client, self.subscription_repo, self.log self.client,
self.subscription_repo,
self.log,
metrics=self.metrics_service,
) )
# Initialize stream monitor # Initialize stream monitor
@@ -76,63 +96,98 @@ class OwncastSentry(Plugin):
self.subscription_repo, self.subscription_repo,
self.notification_service, self.notification_service,
self.log, self.log,
metrics=self.metrics_service,
) )
# Initialize health checker # Initialize subscription manager
self.health_checker = HealthChecker( self.subscription_manager = SubscriptionManager(
db,
self.owncast_client,
self.log,
)
# Initialize command handler
self.command_handler = CommandHandler(
self.owncast_client, self.owncast_client,
self.stream_repo, self.stream_repo,
self.subscription_repo, self.subscription_repo,
self.log, self.log,
) )
# Schedule periodic stream state updates every 60 seconds # Initialize command handler
self.sched.run_periodically(60, self._update_all_stream_states) self.command_handler = CommandHandler(
self.subscription_manager,
async def _update_all_stream_states(self) -> None:
"""Update all stream states and perform health check."""
# Get list of all stream domains with active subscriptions
subscribed_domains = await self.subscription_repo.get_all_subscribed_domains()
# Delegate to stream monitor and get results
update_result = await self.stream_monitor.update_all_streams(subscribed_domains)
# Perform health check
config: Config = self.config # type: ignore[assignment]
await self.health_checker.perform_health_check(
update_result,
config.health_check_endpoint,
) )
# 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.new(help="Subscribes to a new Owncast stream.")
@command.argument("url") @command.argument("url")
async def subscribe(self, evt: MessageEvent, url: str) -> None: async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""Delegate subscribe command to CommandHandler.""" """Delegate subscribe command to CommandHandler."""
try:
await self.command_handler.subscribe(evt, url) 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.new(help="Unsubscribes from an Owncast stream.")
@command.argument("url") @command.argument("url")
async def unsubscribe(self, evt: MessageEvent, url: str) -> None: async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
"""Delegate unsubscribe command to CommandHandler.""" """Delegate unsubscribe command to CommandHandler."""
try:
await self.command_handler.unsubscribe(evt, url) 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.") @command.new(help="Lists all stream subscriptions in this room.")
async def subscriptions(self, evt: MessageEvent) -> None: async def subscriptions(self, evt: MessageEvent) -> None:
"""Delegate subscriptions command to CommandHandler.""" """Delegate subscriptions command to CommandHandler."""
try:
await self.command_handler.subscriptions(evt) 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.") @command.new(help="Lists currently live streams in this room.")
async def live(self, evt: MessageEvent) -> None: async def live(self, evt: MessageEvent) -> None:
"""Delegate live command to CommandHandler.""" """Delegate live command to CommandHandler."""
try:
await self.command_handler.live(evt) 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 stop(self) -> None: async def _run_stream_updates_every_minute(self) -> None:
"""Clean up resources by closing the HTTP session.""" """Run stream state updates at the top of each wall-clock minute."""
await self.owncast_client.close() 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
+138 -166
View File
@@ -14,20 +14,75 @@
"""Command handlers for OwncastSentry bot commands.""" """Command handlers for OwncastSentry bot commands."""
import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .models import StreamStatus from .types import (
from .utils import domainify, sanitize_for_markdown AlreadySubscribedError,
InvalidOwncastInstanceError,
NotSubscribedError,
StreamStatus,
)
if TYPE_CHECKING: if TYPE_CHECKING:
import logging
from maubot import MessageEvent # type: ignore[attr-defined] from maubot import MessageEvent # type: ignore[attr-defined]
from .database import StreamRepository, SubscriptionRepository from .subscription_manager import SubscriptionManager
from .owncast_client import OwncastClient
_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: class CommandHandler:
@@ -35,40 +90,23 @@ class CommandHandler:
def __init__( def __init__(
self, self,
owncast_client: OwncastClient, subscription_manager: SubscriptionManager,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
logger: logging.Logger,
) -> None: ) -> None:
"""Initialize the command handler. """Initialize the command handler.
:param owncast_client: Client for making API calls to Owncast instances. :param subscription_manager: Subscription domain workflow coordinator.
:param stream_repo: Repository for stream data.
:param subscription_repo: Repository for subscription data.
:param logger: Logger instance for debugging.
""" """
self.owncast_client = owncast_client self.subscription_manager = subscription_manager
self.stream_repo = stream_repo
self.subscription_repo = subscription_repo
self.log = logger
async def subscribe(self, evt: MessageEvent, url: str) -> None: async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""Subscribe a room to a stream's notifications. """Subscribe a room to a stream's notifications.
:param evt: MessageEvent of the message calling the command. :param evt: MessageEvent of the message calling the command.
:param url: User supplied URL to a stream to subscribe to. :param url: User-supplied Owncast URL, domain, or Fediverse-style address.
""" """
# Convert the user input to only a domain try:
stream_domain = domainify(url) stream_domain = await self.subscription_manager.subscribe(evt.room_id, url)
except InvalidOwncastInstanceError:
# How many subscriptions already exist for this domain?
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
if subscription_count == 0:
# No subscriptions; validate this is an Owncast stream.
is_valid = await self.owncast_client.validate_instance(stream_domain)
if not is_valid:
# Fetch returned nothing. Probably not Owncast.
await evt.reply( await evt.reply(
"The URL you supplied does not appear to " "The URL you supplied does not appear to "
"be a valid Owncast instance. You may have " "be a valid Owncast instance. You may have "
@@ -76,103 +114,48 @@ class CommandHandler:
"instance is offline." "instance is offline."
) )
return return
except AlreadySubscribedError as e:
# Try to add a new subscription for this stream in this room
try:
await self.subscription_repo.add(stream_domain, evt.room_id)
except sqlite3.IntegrityError:
# Room is already subscribed.
await evt.reply( await evt.reply(
"This room is already subscribed to notifications for " f"This room is already subscribed to notifications for {e.domain}."
+ stream_domain
+ "."
) )
return return
# Try to add a placeholder row for the stream's state.
try:
await self.stream_repo.create(stream_domain)
# First time seeing this stream. Log it.
self.log.info(f"[{stream_domain}] Discovered new stream!")
except sqlite3.IntegrityError:
# Adding rows for known streams is expected.
pass
# All went well! Tell the user.
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
await evt.reply( await evt.reply(
"Subscription added! This room will receive notifications when " f"Subscription added! This room will receive "
+ stream_domain f"notifications when {stream_domain} goes live."
+ " goes live."
) )
async def unsubscribe(self, evt: MessageEvent, url: str) -> None: async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
"""Unsubscribe a room from a stream's notifications. """Unsubscribe a room from a stream's notifications.
:param evt: MessageEvent of the message calling the command. :param evt: MessageEvent of the message calling the command.
:param url: User supplied URL to a stream to unsubscribe from. :param url: User-supplied Owncast URL, domain, or Fediverse-style address.
"""
# Convert the user input to only a domain
stream_domain = domainify(url)
# Attempt to delete the requested subscription
result = await self.subscription_repo.remove(stream_domain, evt.room_id)
# Did it work?
if result == 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 + "."
)
else:
# No, nothing changed. Tell the user.
await evt.reply(
"This room is already not subscribed to notifications for "
+ stream_domain
+ "."
)
def _format_duration(self, timestamp_str: str) -> str:
"""Calculate and format the duration from a timestamp to now.
:param timestamp_str: ISO 8601 timestamp string.
:return: Formatted duration string (e.g., "1 hour", "2 days").
""" """
try: try:
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) stream_domain = await self.subscription_manager.unsubscribe(
now = datetime.now(UTC) evt.room_id, url
delta = now - timestamp )
except NotSubscribedError as e:
await evt.reply(
f"This room is already not subscribed to notifications for {e.domain}."
)
return
seconds = int(delta.total_seconds()) await evt.reply(
if seconds < 60: f"Subscription removed! This room will no "
return f"{seconds} second{'s' if seconds != 1 else ''}" f"longer receive notifications for {stream_domain}."
if seconds < 3600: )
minutes = seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''}"
if seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''}"
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
except Exception:
return "unknown duration"
async def subscriptions(self, evt: MessageEvent) -> None: async def subscriptions(self, evt: MessageEvent) -> None:
"""List all stream subscriptions in the current room. """List all stream subscriptions in the current room.
:param evt: MessageEvent of the message calling the command. :param evt: MessageEvent of the message calling the command.
""" """
# Get all stream domains this room is subscribed to subscriptions = await self.subscription_manager.list_room_subscriptions(
subscribed_domains = ( evt.room_id
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
) )
# Check if there are no subscriptions if not subscriptions:
if not subscribed_domains:
await evt.reply( await evt.reply(
"This room is not subscribed to any Owncast " "This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast " "instances.\n\nTo subscribe to an Owncast "
@@ -182,68 +165,62 @@ class CommandHandler:
return return
# Build the response message body as Markdown # Build the response message body as Markdown
count = len(subscribed_domains) count = len(subscriptions)
body_text = f"**Subscriptions for this room ({count}):**\n\n" parts = [f"**Subscriptions for this room ({count}):**\n\n"]
now = datetime.now(UTC)
for domain in subscribed_domains: for subscription in subscriptions:
# Get the stream state from the database domain = subscription.domain
stream_state = await self.stream_repo.get_by_domain(domain) stream_state = subscription.stream_state
if stream_state is None: stream_name = stream_state.name or domain
continue safe_stream_name = _sanitize_for_markdown(stream_name)
# Determine stream name (use domain as fallback)
stream_name = stream_state.name if stream_state.name else domain
safe_stream_name = sanitize_for_markdown(stream_name)
# Start building this stream's entry with stream name as main bullet # Start building this stream's entry with stream name as main bullet
body_text += f"- **{safe_stream_name}** \n" parts.append(f"- **{safe_stream_name}** \n")
# Add title if stream is online (as a sub-bullet) # Add title if stream is online (as a sub-bullet)
if stream_state.status == StreamStatus.ONLINE and stream_state.title: if stream_state.status == StreamStatus.ONLINE and stream_state.title:
safe_title = sanitize_for_markdown(stream_state.title) safe_title = _sanitize_for_markdown(stream_state.title)
body_text += f" - Title: {safe_title} \n" parts.append(f" - Title: {safe_title} \n")
# Determine status and duration (as a sub-bullet) # Determine status and duration (as a sub-bullet)
if stream_state.status == StreamStatus.ONLINE: match stream_state.status:
# Stream is online - use last_connect_time case StreamStatus.ONLINE if stream_state.status_since:
if stream_state.last_connect_time: duration = _format_duration(stream_state.status_since, now)
duration = self._format_duration(stream_state.last_connect_time) parts.append(f" - Status: Online for {duration} \n")
body_text += f" - Status: Online for {duration} \n" case StreamStatus.ONLINE:
elif stream_state.status == StreamStatus.UNKNOWN: parts.append(" - Status: Online \n")
# Stream status is unknown - instance unreachable case StreamStatus.UNKNOWN:
body_text += " - Status: Unknown (instance unreachable) \n" parts.append(" - Status: Unknown (instance unreachable) \n")
else: case StreamStatus.OFFLINE if stream_state.status_since:
# Stream is offline - use last_disconnect_time duration = _format_duration(stream_state.status_since, now)
if stream_state.last_disconnect_time: parts.append(f" - Status: Offline for {duration} \n")
duration = self._format_duration(stream_state.last_disconnect_time) case StreamStatus.OFFLINE:
body_text += f" - Status: Offline for {duration} \n" parts.append(" - Status: Offline \n")
else:
body_text += " - Status: Offline \n"
# Add stream link (as a sub-bullet) # Add stream link (as a sub-bullet)
body_text += f" - Link: https://{domain}\n\n" parts.append(f" - Link: https://{domain}\n\n")
# Add help text for unsubscribing # Add help text for unsubscribing
body_text += ( parts.append(
"\nTo unsubscribe from any of these Owncast " "\nTo unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`" "instances, use `!unsubscribe <domain>`"
) )
# Send the response as Markdown # Send the response as Markdown
await evt.reply(body_text, markdown=True) await evt.reply("".join(parts), markdown=True)
async def live(self, evt: MessageEvent) -> None: async def live(self, evt: MessageEvent) -> None:
"""List currently live streams in the current room. """List currently live streams in the current room.
:param evt: MessageEvent of the message calling the command. :param evt: MessageEvent of the message calling the command.
""" """
# Get all stream domains this room is subscribed to live_streams = await self.subscription_manager.list_live_room_subscriptions(
subscribed_domains = ( evt.room_id
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
) )
# Check if there are no subscriptions if not live_streams:
if not subscribed_domains: if not await self.subscription_manager.has_room_subscriptions(evt.room_id):
await evt.reply( await evt.reply(
"This room is not subscribed to any Owncast " "This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast " "instances.\n\nTo subscribe to an Owncast "
@@ -252,15 +229,6 @@ class CommandHandler:
) )
return return
# Filter for only live streams (exclude unknown status)
live_streams = []
for domain in subscribed_domains:
stream_state = await self.stream_repo.get_by_domain(domain)
if stream_state and stream_state.status == StreamStatus.ONLINE:
live_streams.append((domain, stream_state))
# Check if there are no live streams
if not live_streams:
await evt.reply( await evt.reply(
"No subscribed Owncast instances are currently " "No subscribed Owncast instances are currently "
"live.\n\nUse `!subscriptions` to list all " "live.\n\nUse `!subscriptions` to list all "
@@ -271,28 +239,32 @@ class CommandHandler:
# Build the response message body as Markdown # Build the response message body as Markdown
count = len(live_streams) count = len(live_streams)
body_text = f"**Live Owncast instances ({count}):**\n\n" parts = [f"**Live Owncast instances ({count}):**\n\n"]
now = datetime.now(UTC)
for domain, stream_state in live_streams: for subscription in live_streams:
# Determine stream name (use domain as fallback) domain = subscription.domain
stream_name = stream_state.name if stream_state.name else domain stream_state = subscription.stream_state
safe_stream_name = sanitize_for_markdown(stream_name) 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 # Start building this stream's entry with stream name as main bullet
body_text += f"- **{safe_stream_name}** \n" parts.append(f"- **{safe_stream_name}** \n")
# Add title (should be present for live streams) # Add title when available
if stream_state.title: if stream_state.title:
safe_title = sanitize_for_markdown(stream_state.title) safe_title = _sanitize_for_markdown(stream_state.title)
body_text += f" - Title: {safe_title} \n" parts.append(f" - Title: {safe_title} \n")
# Add status with duration # Add status with duration
if stream_state.last_connect_time: if stream_state.status_since:
duration = self._format_duration(stream_state.last_connect_time) duration = _format_duration(stream_state.status_since, now)
body_text += f" - Online for {duration} \n" parts.append(f" - Online for {duration} \n")
else:
parts.append(" - Online \n")
# Add stream link # Add stream link
body_text += f" - Link: https://{domain}\n\n" parts.append(f" - Link: https://{domain}\n\n")
# Send the response as Markdown # Send the response as Markdown
await evt.reply(body_text.rstrip(), markdown=True) await evt.reply("".join(parts).rstrip(), markdown=True)
+4 -4
View File
@@ -25,9 +25,9 @@ class Config(BaseProxyConfig):
:param helper: ConfigUpdateHelper for copying values. :param helper: ConfigUpdateHelper for copying values.
""" """
helper.copy("health_check_endpoint") helper.copy("metrics_enabled")
@property @property
def health_check_endpoint(self) -> str: def metrics_enabled(self) -> bool:
"""Return the configured health check endpoint URL.""" """Return whether the Prometheus metrics endpoint is enabled."""
return self["health_check_endpoint"] # type: ignore[no-any-return] return self["metrics_enabled"] # type: ignore[no-any-return]
-199
View File
@@ -1,199 +0,0 @@
# 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.
"""Database repository classes for OwncastSentry."""
from typing import TYPE_CHECKING
from .models import StreamState
if TYPE_CHECKING:
from mautrix.util.async_db import Database
class StreamRepository:
"""Repository for managing stream data in the database."""
def __init__(self, database: Database):
"""Initialize the stream repository.
:param database: The maubot database instance.
"""
self.db = database
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: # type: ignore[var-annotated]
row = await conn.fetchrow(query, domain)
return StreamState.from_db_row(row) if row else None
async def create(self, domain: str) -> None:
"""Create a new stream entry in the database.
:param domain: The stream domain.
"""
query = "INSERT INTO streams (domain) VALUES ($1)"
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain)
async def update(self, state: StreamState) -> None:
"""Update a stream's state in the database.
:param state: The StreamState to save.
"""
query = """UPDATE streams
SET name=$1, title=$2, last_connect_time=$3, last_disconnect_time=$4
WHERE domain=$5"""
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(
query,
state.name,
state.title,
state.last_connect_time,
state.last_disconnect_time,
state.domain,
)
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 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: # type: ignore[var-annotated]
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"""
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, 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: # type: ignore[var-annotated]
await conn.execute(query, domain)
class SubscriptionRepository:
"""Repository for managing stream subscriptions in the database."""
def __init__(self, database: Database):
"""Initialize the subscription repository.
:param database: The maubot database instance.
"""
self.db = 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 sqlite3.IntegrityError: If subscription already exists.
"""
query = "INSERT INTO subscriptions (stream_domain, room_id) VALUES ($1, $2)"
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain, room_id)
async def remove(self, domain: str, room_id: str) -> int:
"""Remove a subscription for a room from a stream.
:param domain: The stream domain.
:param room_id: The Matrix room ID.
:return: Number of rows deleted (0 or 1).
"""
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
async with self.db.acquire() as conn: # type: ignore[var-annotated]
result = await conn.execute(query, domain, room_id)
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: # type: ignore[var-annotated]
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: # type: ignore[var-annotated]
results = await conn.fetch(query, room_id)
return [row["stream_domain"] 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: # type: ignore[var-annotated]
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: # type: ignore[var-annotated]
result = await conn.fetchrow(query, domain)
return int(result[0])
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: # type: ignore[var-annotated]
result = await conn.execute(query, domain)
return int(result.rowcount)
-163
View File
@@ -1,163 +0,0 @@
# 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.
"""Health checking service for OwncastSentry."""
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import logging
from mautrix.util.async_db import Database
from .owncast_client import OwncastClient
@dataclass
class UpdateResult:
"""Result of a stream update cycle."""
total_streams: int
successful_checks: int
failed_checks: int
@property
def http_healthy(self) -> bool:
"""Determine HTTP health based on update results.
HTTP is considered healthy if:
- No streams are subscribed (nothing to check), OR
- At least one stream check succeeded
:return: True if HTTP is considered healthy.
"""
if self.total_streams == 0:
return True
return self.successful_checks > 0
@dataclass
class HealthStatus:
"""Represents the health status of the plugin."""
database_healthy: bool
http_healthy: bool
@property
def is_healthy(self) -> bool:
"""Check if all health components are healthy.
:return: True if all checks pass.
"""
return self.database_healthy and self.http_healthy
class HealthChecker:
"""Service for performing health checks on the plugin."""
def __init__(
self,
database: Database,
owncast_client: OwncastClient,
logger: logging.Logger,
):
"""Initialize the health checker.
:param database: The maubot database instance.
:param owncast_client: Client for making HTTP requests.
:param logger: Logger instance for debugging.
"""
self.db = database
self.owncast_client = owncast_client
self.log = logger
async def check_database(self) -> bool:
"""Check if the database is functioning by executing a simple query.
:return: True if database is healthy, False otherwise.
"""
try:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.fetchval("SELECT 1")
return True
except Exception as e:
self.log.warning(f"Database health check failed: {e}")
return False
async def perform_health_check(
self,
update_result: UpdateResult,
endpoint: str,
) -> None:
"""Perform health check and report to configured endpoint if all healthy.
:param update_result: Result of the stream update cycle.
:param endpoint: Health check endpoint URL (empty string to skip reporting).
"""
# Check database health
database_healthy = await self.check_database()
# Evaluate HTTP health from update results
http_healthy = update_result.http_healthy
# Create health status
status = HealthStatus(
database_healthy=database_healthy,
http_healthy=http_healthy,
)
self.log.debug(
f"Health check: database={database_healthy}, http={http_healthy}, "
f"streams={update_result.total_streams}, "
f"succeeded={update_result.successful_checks}, "
f"failed={update_result.failed_checks}"
)
# Skip endpoint notification if not configured
if not endpoint or not endpoint.strip():
self.log.debug("Health check endpoint not configured, skipping report.")
return
# Only send to endpoint if ALL checks pass
if not status.is_healthy:
self.log.warning(
f"Health check failed, not reporting to endpoint. "
f"database={database_healthy}, http={http_healthy}"
)
return
# Send GET request to health endpoint
await self._send_health_report(endpoint)
async def _send_health_report(self, endpoint: str) -> None:
"""Send a GET request to the health check endpoint.
:param endpoint: The endpoint URL.
"""
try:
async with self.owncast_client.session.get(
endpoint, allow_redirects=True
) as response:
if 200 <= response.status < 300:
self.log.debug(
f"Health check reported successfully (status={response.status})"
)
else:
self.log.warning(
"Health check endpoint returned "
f"non-success status: {response.status}"
)
except Exception as e:
self.log.warning(f"Failed to report health check to endpoint: {e}")
+236
View File
@@ -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)
-100
View File
@@ -1,100 +0,0 @@
# 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.
"""Database migration definitions for OwncastSentry."""
from mautrix.util.async_db import Connection, UpgradeTable
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial revision") # type: ignore[arg-type, call-arg, untyped-decorator]
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.
"""
# Create new subscriptions table with correct schema
await conn.execute(
"""CREATE TABLE "subscriptions_new" (
"stream_domain" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
UNIQUE("room_id","stream_domain")
)"""
)
# Copy all existing data from old table to new table
await conn.execute(
"""INSERT INTO subscriptions_new (stream_domain, room_id)
SELECT stream_domain, room_id FROM subscriptions"""
)
# Drop the old table and rename new table to original name
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.
"""
# Add failure_counter column with default value of 0
await conn.execute(
"""ALTER TABLE streams ADD COLUMN failure_counter INTEGER DEFAULT 0"""
)
def get_upgrade_table() -> UpgradeTable:
"""Return the upgrade table with registered migrations."""
return upgrade_table
-115
View File
@@ -1,115 +0,0 @@
# 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 models for OwncastSentry."""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .utils import (
MAX_INSTANCE_TITLE_LENGTH,
MAX_STREAM_TITLE_LENGTH,
MAX_TAG_LENGTH,
UNKNOWN_STATUS_THRESHOLD,
truncate,
)
class StreamStatus(Enum):
"""Represents the status of a stream."""
ONLINE = "online"
OFFLINE = "offline"
UNKNOWN = "unknown"
@dataclass
class StreamState:
"""Represents the state of an Owncast stream."""
domain: str
name: str | None = None
title: str | None = None
last_connect_time: str | None = None
last_disconnect_time: str | None = None
failure_counter: int = 0
@property
def status(self) -> StreamStatus:
"""Derive stream status from failure count and connect times.
Returns UNKNOWN if failures exceed the threshold, ONLINE if a
connect time is present, or OFFLINE otherwise.
"""
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
return StreamStatus.UNKNOWN
if self.last_connect_time is not None:
return StreamStatus.ONLINE
return StreamStatus.OFFLINE
@classmethod
def from_api_response(cls, response: dict[str, Any], domain: str) -> StreamState:
"""Create a StreamState from an API response.
:param response: API response as a dictionary (camelCase keys).
:param domain: The stream domain.
:return: StreamState instance.
"""
return cls(
domain=domain,
title=truncate(response.get("streamTitle", ""), MAX_STREAM_TITLE_LENGTH),
last_connect_time=response.get("lastConnectTime"),
last_disconnect_time=response.get("lastDisconnectTime"),
)
@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"],
last_connect_time=row["last_connect_time"],
last_disconnect_time=row["last_disconnect_time"],
failure_counter=row["failure_counter"],
)
@dataclass
class StreamConfig:
"""Represents the configuration of an Owncast stream."""
name: str = ""
tags: list[str] = field(default_factory=list)
@classmethod
def from_api_response(cls, response: dict[str, Any]) -> StreamConfig:
"""Create a StreamConfig from an API response.
:param response: API response as a dictionary.
:return: StreamConfig instance.
"""
# Truncate instance name to max length
name = truncate(response.get("name", ""), MAX_INSTANCE_TITLE_LENGTH)
# Truncate each tag to max length
raw_tags = response.get("tags", [])
tags = [truncate(tag, MAX_TAG_LENGTH) for tag in raw_tags]
return cls(name=name, tags=tags)
+159 -136
View File
@@ -20,17 +20,28 @@ from typing import TYPE_CHECKING, Any
from mautrix.types import MessageType, TextMessageEventContent from mautrix.types import MessageType, TextMessageEventContent
from .utils import ( from .metrics import NotificationType
CLEANUP_DELETE_THRESHOLD,
CLEANUP_WARNING_THRESHOLD,
SECONDS_BETWEEN_NOTIFICATIONS,
sanitize_for_plain_text,
)
if TYPE_CHECKING: if TYPE_CHECKING:
import logging import logging
from collections.abc import Sequence
from .database import SubscriptionRepository 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: class NotificationService:
@@ -41,16 +52,19 @@ class NotificationService:
client: Any, client: Any,
subscription_repo: SubscriptionRepository, subscription_repo: SubscriptionRepository,
logger: logging.Logger, logger: logging.Logger,
metrics: MetricsService,
) -> None: ) -> None:
"""Initialize the notification service. """Initialize the notification service.
:param client: The Matrix client for sending messages. :param client: The Matrix client for sending messages.
:param subscription_repo: Repository for managing subscriptions. :param subscription_repo: Repository for managing subscriptions.
:param logger: Logger instance for debugging. :param logger: Logger instance for debugging.
:param metrics: Metrics service for recording counters.
""" """
self.client = client self.client = client
self.subscription_repo = subscription_repo self.subscription_repo = subscription_repo
self.log = logger self.log = logger
self.metrics = metrics
# Cache for tracking when notifications were last sent # Cache for tracking when notifications were last sent
self.notification_timers_cache: dict[str, float] = {} self.notification_timers_cache: dict[str, float] = {}
@@ -60,7 +74,7 @@ class NotificationService:
domain: str, domain: str,
name: str, name: str,
title: str, title: str,
tags: list[str], tags: Sequence[str],
*, *,
title_change: bool = False, title_change: bool = False,
) -> None: ) -> None:
@@ -75,51 +89,115 @@ class NotificationService:
# Has enough time passed since the last notification was sent? # Has enough time passed since the last notification was sent?
if not self._can_notify(domain): if not self._can_notify(domain):
seconds_since_last = round( seconds_since_last = round(
time.time() - self.notification_timers_cache[domain] time.monotonic() - self.notification_timers_cache[domain]
) )
self.log.info( self.log.info(
f"[{domain}] Not sending notifications. Only " "[%s] Not sending notifications. Only %s of required "
f"{seconds_since_last} of required " "%s seconds have passed since last notification.",
f"{SECONDS_BETWEEN_NOTIFICATIONS} seconds have " domain,
f"passed since last notification." seconds_since_last,
_SECONDS_BETWEEN_NOTIFICATIONS,
) )
return return
# Record that we're sending a notification now
self._record_notification(domain)
# Get a list of room IDs with active subscriptions to the stream domain
room_ids = await self.subscription_repo.get_subscribed_rooms(domain)
# Build the notification message # Build the notification message
body_text = self._format_message(name, title, domain, tags, title_change) body_text = self._format_message(
name, title, domain, tags, title_change=title_change
# Set up counters for statistics )
successful_notifications = 0
failed_notifications = 0
# Send notifications to all subscribed rooms in parallel # Send notifications to all subscribed rooms in parallel
tasks = [ successful, failed = await self._broadcast_to_rooms(domain, body_text)
self._send_notification(room_id, body_text, domain) for room_id in room_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True) # Record that a notification was sent if at least one room received it.
if successful > 0:
# Count successes and failures self._record_notification(domain)
for result in results:
if isinstance(result, Exception):
failed_notifications += 1
else:
successful_notifications += 1
# Log completion # Log completion
notification_type = "title change" if title_change else "going live" notification_type = "title change" if title_change else "going live"
self.log.info( self.log.info(
f"[{domain}] Completed sending {notification_type} " "[%s] Completed sending %s notifications! %s succeeded, %s failed.",
f"notifications! {successful_notifications} succeeded, " domain,
f"{failed_notifications} failed." 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( async def _send_notification(
self, room_id: str, body_text: str, domain: str self, room_id: str, body_text: str, domain: str
) -> None: ) -> None:
@@ -128,20 +206,28 @@ class NotificationService:
:param room_id: The Matrix room ID to send to. :param room_id: The Matrix room ID to send to.
:param body_text: The message body text. :param body_text: The message body text.
:param domain: The stream domain (for logging). :param domain: The stream domain (for logging).
:raises Exception: If sending fails. Propagates send errors so broadcast delivery accounting can count failures.
""" """
try: try:
content = TextMessageEventContent(msgtype=MessageType.TEXT, body=body_text) content = TextMessageEventContent(msgtype=MessageType.TEXT, body=body_text)
await self.client.send_message(room_id, content) await self.client.send_message(room_id, content)
except Exception as exception: except Exception as exception:
self.log.warning( self.log.warning(
f"[{domain}] Failed to send notification " "[%s] Failed to send notification message to room [%s]: %s",
f"message to room [{room_id}]: {exception}" domain,
room_id,
exception,
) )
raise raise
def _format_message( def _format_message(
self, name: str, title: str, domain: str, tags: list[str], title_change: bool self,
name: str,
title: str,
domain: str,
tags: Sequence[str],
*,
title_change: bool,
) -> str: ) -> str:
"""Format the notification message body. """Format the notification message body.
@@ -153,44 +239,36 @@ class NotificationService:
:return: Formatted message body. :return: Formatted message body.
""" """
# Use name if available, fallback to domain # Use name if available, fallback to domain
stream_name = name if name else domain stream_name = name or domain
safe_stream_name = sanitize_for_plain_text(stream_name) safe_stream_name = _sanitize_for_plain_text(stream_name)
# Choose message based on notification type # Choose message based on notification type
if title_change: if title_change:
body_text = "📝 " + safe_stream_name + " has changed its stream title!" parts = [f"📝 {safe_stream_name} has changed its stream title!"]
else: else:
body_text = "🎥 " + safe_stream_name + " is now live!" parts = [f"🎥 {safe_stream_name} is now live!"]
# Add title if present # Add title if present
if title != "": if title:
safe_title = sanitize_for_plain_text(title) safe_title = _sanitize_for_plain_text(title)
body_text += "\nStream Title: " + safe_title parts.append(f"\nStream Title: {safe_title}")
# Add stream URL # Add stream URL
body_text += "\n\nTo tune in, visit: https://" + domain + "/" parts.append(f"\n\nTo tune in, visit: https://{domain}/")
# Add tags if present # Add tags if present
if tags: if tags:
safe_tags = [] tag_text = " ".join(
for tag in tags: f"#{safe_tag}"
safe_tag = sanitize_for_plain_text(tag) for tag in tags
if safe_tag and not safe_tag.startswith("."): if (safe_tag := _sanitize_for_plain_text(tag))
safe_tags.append(safe_tag) and not safe_tag.startswith(".")
)
if safe_tags: if tag_text:
body_text += "\n\n" parts.append(f"\n\n{tag_text}")
body_text += " ".join("#" + tag for tag in safe_tags)
return body_text return "".join(parts)
def get_last_notification_time(self, domain: str) -> float:
"""Get the timestamp of the last notification sent for a domain.
:param domain: The stream domain.
:return: Unix timestamp of last notification, or 0 if never notified.
"""
return self.notification_timers_cache.get(domain, 0)
def _can_notify(self, domain: str) -> bool: def _can_notify(self, domain: str) -> bool:
"""Check if enough time has passed to send another notification. """Check if enough time has passed to send another notification.
@@ -198,87 +276,32 @@ class NotificationService:
:param domain: The stream domain. :param domain: The stream domain.
:return: True if notification can be sent, False otherwise. :return: True if notification can be sent, False otherwise.
""" """
if domain not in self.notification_timers_cache: last_notification_time = self.notification_timers_cache.get(domain)
return True return (
last_notification_time is None
seconds_since_last = round(time.time() - self.notification_timers_cache[domain]) or time.monotonic() - last_notification_time
return bool(seconds_since_last >= SECONDS_BETWEEN_NOTIFICATIONS) >= _SECONDS_BETWEEN_NOTIFICATIONS
)
def _record_notification(self, domain: str) -> None: def _record_notification(self, domain: str) -> None:
"""Record that a notification was sent at the current time. """Record that a notification was sent at the current time.
:param domain: The stream domain. :param domain: The stream domain.
""" """
self.notification_timers_cache[domain] = time.time() self.notification_timers_cache[domain] = time.monotonic()
async def send_cleanup_warning(self, domain: str) -> None: async def _broadcast_to_rooms(self, domain: str, body_text: str) -> tuple[int, int]:
"""Send cleanup warning notification to all subscribed rooms. """Send a message to all rooms subscribed to a domain.
:param domain: The stream domain. :param domain: The stream domain.
:param body_text: The message body text.
:return: Tuple of (successful, failed) counts.
""" """
# Get all subscribed rooms
room_ids = await self.subscription_repo.get_subscribed_rooms(domain) room_ids = await self.subscription_repo.get_subscribed_rooms(domain)
# Build the warning message
warning_days = CLEANUP_WARNING_THRESHOLD // (24 * 60)
delete_days = CLEANUP_DELETE_THRESHOLD // (24 * 60)
remaining_days = delete_days - warning_days
body_text = (
"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
f"The Owncast instance at {domain} has been "
f"unreachable for {warning_days} days. If it remains "
f"unreachable for {remaining_days} more days "
f"({delete_days} days total), this subscription "
f"will be automatically removed."
)
# Send to all rooms in parallel
tasks = [ tasks = [
self._send_notification(room_id, body_text, domain) for room_id in room_ids self._send_notification(room_id, body_text, domain) for room_id in room_ids
] ]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
failed = sum(1 for r in results if isinstance(r, BaseException))
# Count successes and failures successful = len(results) - failed
successful = sum(1 for r in results if not isinstance(r, Exception)) return successful, failed
failed = sum(1 for r in results if isinstance(r, Exception))
self.log.info(
f"[{domain}] Sent cleanup warning to {successful} rooms ({failed} failed)."
)
async def send_cleanup_deletion(self, domain: str) -> None:
"""Send cleanup deletion notification to all subscribed rooms.
:param domain: The stream domain.
"""
# Get all subscribed rooms
room_ids = await self.subscription_repo.get_subscribed_rooms(domain)
# Build the deletion message
delete_days = CLEANUP_DELETE_THRESHOLD // (24 * 60)
body_text = (
"🗑️ Subscription Automatically Removed\n\n"
f"The Owncast instance at {domain} has been "
f"unreachable for {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}`."
)
# Send to all rooms in parallel
tasks = [
self._send_notification(room_id, body_text, domain) for room_id in room_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Count successes and failures
successful = sum(1 for r in results if not isinstance(r, Exception))
failed = sum(1 for r in results if isinstance(r, Exception))
self.log.info(
f"[{domain}] Sent cleanup deletion notice to "
f"{successful} rooms ({failed} failed)."
)
+169 -86
View File
@@ -15,38 +15,96 @@
"""HTTP client for querying Owncast instance APIs.""" """HTTP client for querying Owncast instance APIs."""
import json import json
from typing import TYPE_CHECKING from http import HTTPStatus
from typing import TYPE_CHECKING, Any
import aiohttp import aiohttp
from .types import (
InvalidApiResponseError,
StreamConfigObservation,
StreamStateObservation,
)
if TYPE_CHECKING: if TYPE_CHECKING:
import logging import logging
from .models import StreamConfig, StreamState from .metrics import MetricsService
from .utils import OWNCAST_CONFIG_PATH, OWNCAST_STATUS_PATH, user_agent
_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: class OwncastClient:
"""HTTP client for communicating with Owncast instances.""" """HTTP client for communicating with Owncast instances."""
def __init__(self, logger: logging.Logger, version: str) -> None: def __init__(
self,
logger: logging.Logger,
version: str,
metrics: MetricsService,
) -> None:
"""Initialize the Owncast client with an HTTP session. """Initialize the Owncast client with an HTTP session.
:param logger: Logger instance for debugging :param logger: Logger instance for debugging
:param version: Plugin version string for the User-Agent header :param version: Plugin version string for the User-Agent header
:param metrics: Metrics service for recording response times.
""" """
self.log = logger self.log = logger
self.metrics = metrics
# Set up HTTP session configuration # Set up HTTP session configuration
headers = {"User-Agent": user_agent(version)} headers = {"User-Agent": _user_agent(version)}
cookie_jar = aiohttp.DummyCookieJar() cookie_jar = aiohttp.DummyCookieJar()
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
use_dns_cache=False, use_dns_cache=False,
limit=1000, limit=_HTTP_CONNECTION_LIMIT,
limit_per_host=1, limit_per_host=_HTTP_CONNECTION_LIMIT_PER_HOST,
keepalive_timeout=120, keepalive_timeout=_HTTP_KEEPALIVE_TIMEOUT_SECONDS,
)
timeout = aiohttp.ClientTimeout(
sock_connect=_HTTP_CONNECT_TIMEOUT_SECONDS,
sock_read=_HTTP_READ_TIMEOUT_SECONDS,
) )
timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
self.session = aiohttp.ClientSession( self.session = aiohttp.ClientSession(
headers=headers, headers=headers,
@@ -55,110 +113,68 @@ class OwncastClient:
connector=connector, connector=connector,
) )
async def get_stream_state(self, domain: str) -> StreamState | None: async def get_stream_state(self, domain: str) -> StreamStateObservation | None:
"""Get the current stream state for a given domain. """Get the current stream state for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports HTTPS on port 443 is assumed, no other protocols or ports
are supported. are supported.
:param domain: The domain (not URL) where the stream is hosted. :param domain: The domain (not URL) where the stream is hosted.
:return: A StreamState if available, None on error. :return: A StreamStateObservation if available, None on error.
""" """
self.log.debug(f"[{domain}] Fetching current stream state...") self.log.debug("[%s] Fetching current stream state...", domain)
status_url = "https://" + domain + OWNCAST_STATUS_PATH 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
# Make a request to the endpoint
try: try:
async with self.session.request( stream_observation = StreamStateObservation.from_api_response(
"GET", status_url, allow_redirects=False new_state, domain
) as response: )
# Check the response code is success except InvalidApiResponseError as e:
if response.status != 200:
self.log.warning( self.log.warning(
f"[{domain}] Response to request on " "[%s] Rejecting response to request on %s as response "
f"{OWNCAST_STATUS_PATH} was not 200, " "shape is invalid: %s",
f"got {response.status} instead." domain,
_OWNCAST_STATUS_PATH,
e,
) )
return None return None
# Try to interpret the response as JSON timer.success()
try: return stream_observation
new_state = json.loads(await response.read())
except Exception as e:
self.log.warning(
f"[{domain}] Rejecting response to request on "
f"{OWNCAST_STATUS_PATH} as could not be "
f"interpreted as JSON: {e}"
)
return None
except Exception as e:
self.log.warning(
f"[{domain}] Error making GET request to {OWNCAST_STATUS_PATH}: {e}"
)
return None
# Validate the response contains all basic info needed async def get_stream_config(self, domain: str) -> StreamConfigObservation | None:
required_fields = [
"lastConnectTime",
"lastDisconnectTime",
"streamTitle",
"online",
]
for field in required_fields:
if field not in new_state:
self.log.warning(
f"[{domain}] Rejecting response to request "
f"on {OWNCAST_STATUS_PATH} as it does not "
f"have {field} field."
)
return None
return StreamState.from_api_response(new_state, domain)
async def get_stream_config(self, domain: str) -> StreamConfig | None:
"""Get the current stream config for a given domain. """Get the current stream config for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports HTTPS on port 443 is assumed, no other protocols or ports
are supported. are supported.
:param domain: The domain (not URL) where the stream is hosted. :param domain: The domain (not URL) where the stream is hosted.
:return: A StreamConfig, or None if fetch failed. :return: A StreamConfigObservation, or None if fetch failed.
""" """
self.log.debug(f"[{domain}] Fetching current stream config...") self.log.debug("[%s] Fetching current stream config...", domain)
config_url = "https://" + domain + OWNCAST_CONFIG_PATH with self.metrics.response_timer(domain) as timer:
config = await self._fetch_json(domain, _OWNCAST_CONFIG_PATH)
if config is None:
return None
# Make a request to the endpoint
try: try:
async with self.session.request( stream_config = StreamConfigObservation.from_api_response(config)
"GET", config_url, allow_redirects=False except InvalidApiResponseError as e:
) as response:
# Check the response code is success
if response.status != 200:
self.log.warning( self.log.warning(
f"[{domain}] Response to request on " "[%s] Rejecting response to request on %s as response "
f"{OWNCAST_CONFIG_PATH} was not 200, " "shape is invalid: %s",
f"got {response.status} instead." domain,
_OWNCAST_CONFIG_PATH,
e,
) )
return None return None
# Try to interpret the response as JSON timer.success()
try: return stream_config
config = json.loads(await response.read())
except Exception as e:
self.log.warning(
f"[{domain}] Rejecting response to request on "
f"{OWNCAST_CONFIG_PATH} as could not be "
f"interpreted as JSON: {e}"
)
return None
except Exception as e:
self.log.warning(
f"[{domain}] Error making GET request to {OWNCAST_CONFIG_PATH}: {e}"
)
return None
# Create StreamConfig from response (fields are truncated to max lengths)
return StreamConfig.from_api_response(config)
async def validate_instance(self, domain: str) -> bool: async def validate_instance(self, domain: str) -> bool:
"""Validate that a domain is a valid Owncast instance. """Validate that a domain is a valid Owncast instance.
@@ -169,6 +185,73 @@ class OwncastClient:
state = await self.get_stream_state(domain) state = await self.get_stream_state(domain)
return state is not None 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: async def close(self) -> None:
"""Close the HTTP session.""" """Close the HTTP session."""
await self.session.close() 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
+493
View File
@@ -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
+273 -172
View File
@@ -16,23 +16,146 @@
import asyncio import asyncio
import time import time
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum, auto
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .health_checker import UpdateResult from .types import (
from .models import StreamState StreamConfigObservation,
from .utils import ( StreamState,
CLEANUP_DELETE_THRESHOLD, StreamStateObservation,
CLEANUP_WARNING_THRESHOLD, StreamStatus,
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN, UpdateResult,
should_query_stream,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
import logging import logging
from .database import StreamRepository, SubscriptionRepository from .metrics import MetricsService
from .notification_service import NotificationService from .notification_service import NotificationService
from .owncast_client import OwncastClient 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: class StreamMonitor:
@@ -45,6 +168,7 @@ class StreamMonitor:
subscription_repo: SubscriptionRepository, subscription_repo: SubscriptionRepository,
notification_service: NotificationService, notification_service: NotificationService,
logger: logging.Logger, logger: logging.Logger,
metrics: MetricsService,
) -> None: ) -> None:
"""Initialize the stream monitor. """Initialize the stream monitor.
@@ -53,12 +177,14 @@ class StreamMonitor:
:param subscription_repo: Repository for subscription data. :param subscription_repo: Repository for subscription data.
:param notification_service: Service for sending notifications. :param notification_service: Service for sending notifications.
:param logger: Logger instance for debugging. :param logger: Logger instance for debugging.
:param metrics: Metrics service for recording Prometheus metrics.
""" """
self.owncast_client = owncast_client self.owncast_client = owncast_client
self.stream_repo = stream_repo self.stream_repo = stream_repo
self.subscription_repo = subscription_repo self.subscription_repo = subscription_repo
self.notification_service = notification_service self.notification_service = notification_service
self.log = logger self.log = logger
self.metrics = metrics
# Cache for tracking when streams last went offline # Cache for tracking when streams last went offline
self.offline_timer_cache: dict[str, float] = {} self.offline_timer_cache: dict[str, float] = {}
@@ -76,22 +202,41 @@ class StreamMonitor:
total_streams = len(subscribed_domains) total_streams = len(subscribed_domains)
# Build a list of async tasks for each stream domain # Run all stream updates concurrently and collect results.
tasks = [ results = await asyncio.gather(
asyncio.create_task(self.update_stream(domain)) *(self.update_stream(domain) for domain in subscribed_domains),
for domain in subscribed_domains return_exceptions=True,
] )
# Run the tasks in parallel and collect results
results = await asyncio.gather(*tasks)
# Count successes and failures # Count successes and failures
successful_checks = sum(1 for result in results if result is True) successful_checks = 0
failed_checks = sum(1 for result in results if result is False) 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( self.log.debug(
f"Update complete. {successful_checks}/{total_streams} succeeded, " "Update complete. %s/%s succeeded, %s failed.",
f"{failed_checks} 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( return UpdateResult(
@@ -103,9 +248,9 @@ class StreamMonitor:
async def update_stream(self, domain: str) -> bool: async def update_stream(self, domain: str) -> bool:
"""Update the state of a stream and send notifications as needed. """Update the state of a stream and send notifications as needed.
Sends notifications to subscribed Matrix rooms if a stream goes Sends notifications to subscribed Matrix rooms when a stream goes
live. Implements progressive backoff for connection failures and live or changes title. Implements progressive backoff for connection
auto-cleanup for dead instances. failures and auto-cleanup for dead instances.
:param domain: The domain of the stream to update. :param domain: The domain of the stream to update.
:return: True if check succeeded or was skipped, False on failure. :return: True if check succeeded or was skipped, False on failure.
@@ -118,193 +263,145 @@ class StreamMonitor:
return True return True
# Check if we should query this stream based on backoff schedule # Check if we should query this stream based on backoff schedule
if not should_query_stream(failure_counter): if not _should_query_stream(failure_counter):
# Skip this cycle, increment counter to track time passage # Skip this cycle, increment counter to track time passage
await self.stream_repo.increment_failure_counter(domain) await self.stream_repo.increment_failure_counter(domain)
self.log.debug( self.log.debug(
f"[{domain}] Skipping query due to backoff " "[%s] Skipping query due to backoff (counter=%s)",
f"(counter={failure_counter + 1})" domain,
failure_counter + 1,
) )
# Check cleanup thresholds even when skipping query # Check cleanup thresholds even when skipping query
await self._check_cleanup_thresholds(domain, failure_counter + 1) 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 # Backoff is expected behavior, not a failure
return True return True
# Flag: first state update of a brand-new stream to avoid
# sending notifications if it's already live.
first_update = False
# Flag: whether to update the stream's state in the database.
# Used to avoid writes when state hasn't changed at all.
update_database = False
# The stream's latest configuration, if fetched during update.
stream_config = None
# Fetch the latest stream state from the server # Fetch the latest stream state from the server
new_state = await self.owncast_client.get_stream_state(domain) observation = await self.owncast_client.get_stream_state(domain)
# If the fetch failed, increment failure counter and skip the update # If the fetch failed, increment failure counter and skip the update
if new_state is None: if observation is None:
await self.stream_repo.increment_failure_counter(domain) await self.stream_repo.increment_failure_counter(domain)
self.log.warning( self.log.warning(
f"[{domain}] Connection failure (counter={failure_counter + 1})" "[%s] Connection failure (counter=%s)",
domain,
failure_counter + 1,
) )
# Check cleanup thresholds after connection failure # Check cleanup thresholds after connection failure
await self._check_cleanup_thresholds(domain, failure_counter + 1) 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 # Actual connection failure
return False return False
# Fetch succeeded! Reset failure counter # Fetch succeeded! Reset failure counter
await self.stream_repo.reset_failure_counter(domain) 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 # Initialize timer cache entries to prevent KeyError on first access
if domain not in self.offline_timer_cache: self.offline_timer_cache.setdefault(domain, 0)
self.offline_timer_cache[domain] = 0
# Does the last known stream state lack connect/disconnect? transition = _classify_transition(old_state, observation)
if ( # Notification policy may need runtime timing context in addition to
old_state.last_connect_time is None # the stored state and latest observation.
and old_state.last_disconnect_time is None offline_duration_seconds: int | None = None
):
# Yes, this is the first update. Don't send any notifications.
update_database = True
first_update = True
# Does the new state have a connect time but the old one not? match transition.kind:
if ( case _StreamTransitionKind.FIRST_OBSERVATION:
new_state.last_connect_time is not None
and old_state.last_connect_time is None
):
# Yes! This stream is now live.
update_database = True
stream_config = await self.owncast_client.get_stream_config(domain)
self.log.info(f"[{domain}] Stream is now live!")
# Calculate seconds since the stream last went offline
seconds_since_last_offline = round(
time.time() - self.offline_timer_cache[domain]
)
# Have we queried this stream before?
if not first_update:
# Use fallback values if config fetch failed
stream_name = stream_config.name if stream_config else domain
stream_tags = stream_config.tags if stream_config else []
# Has this stream been offline for a short time?
if seconds_since_last_offline < TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN:
# Did the stream title change?
if old_state.title != new_state.title:
# Stream was briefly down; send title
# change notification.
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title or "",
stream_tags,
title_change=True,
)
else:
# Briefly offline, no title change. Skip.
self.log.info( self.log.info(
f"[{domain}] Not sending " "[%s] Not sending notifications. This is the first state "
f"notifications. Stream was only " "update for this stream.",
f"offline for "
f"{seconds_since_last_offline} of "
f"{TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN}"
f" seconds and did not change its "
f"title."
)
else:
# Offline for a while. Send a normal notification.
await self.notification_service.notify_stream_live(
domain, domain,
stream_name,
new_state.title or "",
stream_tags,
title_change=False,
) )
else: case _StreamTransitionKind.WENT_LIVE:
# No, this is the first time we're querying self.log.info("[%s] Stream is now live!", domain)
self.log.info( offline_duration_seconds = round(
f"[{domain}] Not sending notifications. " time.monotonic() - self.offline_timer_cache[domain]
f"This is the first state update for "
f"this stream."
) )
# Brief reconnects with the same title are treated as transient
# outages, not new live events.
if ( if (
new_state.last_connect_time is not None offline_duration_seconds < _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN
and old_state.last_connect_time is not None and old_state.title == observation.title
): ):
# Did the stream title change mid-session? self.log.info(
if old_state.title != new_state.title: "[%s] Not sending notifications. Stream was only offline for "
self.log.info(f"[{domain}] Stream title was changed!") "%s of %s seconds and did not change its title.",
update_database = True 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) stream_config = await self.owncast_client.get_stream_config(domain)
# Use fallback values if config fetch failed if notification_kind.should_notify:
stream_name = stream_config.name if stream_config else domain stream_name = stream_config.name if stream_config else domain
stream_tags = stream_config.tags if stream_config else [] stream_tags = stream_config.tags if stream_config else ()
is_title_change = notification_kind is _NotificationKind.TITLE_CHANGE
# Was the last notification sent before the stream
# last went offline? If so, send a regular go-live
# instead of a title change to avoid confusion.
if self.offline_timer_cache[
domain
] > self.notification_service.get_last_notification_time(domain):
await self.notification_service.notify_stream_live( await self.notification_service.notify_stream_live(
domain, domain,
stream_name, stream_name,
new_state.title or "", observation.title or "",
stream_tags, stream_tags,
title_change=False, 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: else:
# No. Send a normal title change notification. await self.stream_repo.update(
await self.notification_service.notify_stream_live(
domain, domain,
stream_name, title=observation.title,
new_state.title or "", online=observation.online,
stream_tags, status_since=transition.status_since,
title_change=True,
) )
# Did the stream go offline (old had connect, new doesn't)?
elif (
new_state.last_connect_time is None
and old_state.last_connect_time is not None
):
# Yep. This stream is now offline. Log it.
update_database = True
self.offline_timer_cache[domain] = time.time()
self.log.info(f"[{domain}] Stream is now offline.")
# Update the database with current stream state, if needed.
if update_database:
# Ensure we have the stream config before updating
if stream_config is None:
stream_config = await self.owncast_client.get_stream_config(domain)
# Use fallback value if config fetch failed
stream_name = stream_config.name if stream_config else ""
self.log.debug(f"[{domain}] Updating stream state in database...")
# Create updated state object (title already truncated in new_state)
updated_state = StreamState(
domain=domain,
name=stream_name,
title=new_state.title,
last_connect_time=new_state.last_connect_time,
last_disconnect_time=new_state.last_disconnect_time,
)
await self.stream_repo.update(updated_state)
# All done. # All done.
self.log.debug(f"[{domain}] State update completed.") 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 return True
async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None: async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None:
@@ -314,17 +411,18 @@ class StreamMonitor:
:param counter: The current failure counter value. :param counter: The current failure counter value.
""" """
# Check for 83-day warning threshold # Check for 83-day warning threshold
if counter == CLEANUP_WARNING_THRESHOLD: if counter == _CLEANUP_WARNING_THRESHOLD:
self.log.warning( self.log.warning(
f"[{domain}] Reached 83-day warning threshold. Sending cleanup warning." "[%s] Reached 83-day warning threshold. Sending cleanup warning.",
domain,
) )
await self.notification_service.send_cleanup_warning(domain) await self.notification_service.send_cleanup_warning(domain)
# Check for 90-day deletion threshold # Check for 90-day deletion threshold
if counter >= CLEANUP_DELETE_THRESHOLD: if counter >= _CLEANUP_DELETE_THRESHOLD:
self.log.warning( self.log.warning(
f"[{domain}] Reached 90-day deletion threshold." "[%s] Reached 90-day deletion threshold. Removing all subscriptions.",
f" Removing all subscriptions." domain,
) )
# Send deletion notification # Send deletion notification
await self.notification_service.send_cleanup_deletion(domain) await self.notification_service.send_cleanup_deletion(domain)
@@ -334,9 +432,12 @@ class StreamMonitor:
# Delete the stream record # Delete the stream record
await self.stream_repo.delete(domain) await self.stream_repo.delete(domain)
self.offline_timer_cache.pop(domain, None)
self.notification_service.clear_notification_state(domain)
self.log.info( self.log.info(
f"[{domain}] Cleanup complete. " "[%s] Cleanup complete. Deleted %s subscriptions and stream record.",
f"Deleted {deleted_count} subscriptions " domain,
f"and stream record." deleted_count,
) )
self.metrics.remove_stream(domain)
+119
View File
@@ -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)
+257
View File
@@ -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
-226
View File
@@ -1,226 +0,0 @@
# 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.
"""Utility functions and constants for OwncastSentry."""
import re
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"
def user_agent(version: str) -> str:
"""Build the User-Agent header string for HTTP requests.
:param version: The plugin version string.
:return: A formatted User-Agent string.
"""
return (
f"OwncastSentry/{version}"
" (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
)
# Hard minimum amount of time between when notifications can be sent
# for a stream. Prevents spamming notifications for glitchy or
# malicious streams.
SECONDS_BETWEEN_NOTIFICATIONS = 20 * 60 # 20 minutes in seconds
# After a stream goes offline, a timer is started. Then, ...
# - If a stream comes back online with the same title within this
# time, no notification is sent.
# - If a stream comes back online with a different title, a rename
# notification is sent.
# - If this time period passes entirely and a stream comes back
# online after, it's treated as regular going live.
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60 # 7 min in seconds
# Counter thresholds for auto-cleanup (60-second polling intervals)
CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60 # 119,520 cycles = 83 days
CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60 # 129,600 cycles = 90 days
# Failure counter threshold for treating stream status as "unknown"
UNKNOWN_STATUS_THRESHOLD = 15
# Maximum field lengths based on Owncast's configuration
# Source: https://github.com/owncast/owncast/blob/master/
# web/utils/config-constants.tsx
MAX_INSTANCE_TITLE_LENGTH = 255 # Server Name (line 81)
MAX_STREAM_TITLE_LENGTH = 100 # Stream Title (line 91)
MAX_TAG_LENGTH = 24 # Per tag (line 208)
def should_query_stream(failure_counter: int) -> bool:
"""Determine if a stream should be queried based on failure count.
Implements progressive backoff with increasing intervals:
- Counters 0-4: every 60s (first 5 minutes)
- Counters 5-9: every 2min (next 5 minutes)
- Counters 10-14: every 3min (next 5 minutes)
- Counters 15-29: every 5min (next 15 minutes)
- Counters 30+: every 15min
:param failure_counter: The current failure counter value.
:return: True if the stream should be queried this cycle.
"""
if failure_counter <= 4:
# Query every cycle for first 5 minutes (counters 0-4)
return True
if failure_counter <= 9:
# Query every 2nd cycle for next 5 minutes (counters 5-9)
return failure_counter % 2 == 0
if failure_counter <= 14:
# Query every 3rd cycle for next 5 minutes (counters 10-14)
return failure_counter % 3 == 0
if failure_counter <= 29:
# Query every 5th cycle for next 15 minutes (counters 15-29)
return failure_counter % 5 == 0
# Query every 15th cycle after 30 minutes (counter 30+)
return failure_counter % 15 == 0
def domainify(url: str) -> str:
"""Extract and sanitize a domain from user input.
Handles URLs, bare domains, and email-style input (user@domain).
Only allows valid domain characters (alphanumeric, hyphens, periods).
:param url: URL, domain, or email-style string
:return: Sanitized domain
"""
# Handle email-style format first (e.g., "notify@stream.logal.dev")
if "@" in url:
url = url.split("@")[-1]
# Prepend // if no scheme so urlparse treats input as netloc
if not url.startswith(("http://", "https://", "//")):
url = "//" + url
parsed = urlparse(url)
domain = (parsed.netloc or parsed.path).lower()
# Strip port and path
domain = domain.split(":")[0].split("/")[0]
# Allow only valid domain characters
return re.sub(r"[^a-z0-9.-]", "", domain).strip(".-")
def truncate(text: str, max_length: int) -> str:
"""Truncate text to a maximum length.
:param text: The text to truncate
:param max_length: Maximum allowed length
:return: Truncated text, or original if within limit
"""
if not text or len(text) <= max_length:
return text
return text[:max_length]
def escape_markdown(text: str) -> str:
"""Escape Markdown special characters to prevent injection attacks.
This function sanitizes untrusted external input (like stream names and titles)
before embedding them in Markdown-formatted messages. It prevents malicious
actors from injecting arbitrary Markdown/HTML content.
:param text: The text to escape
:return: The escaped text safe for Markdown rendering
"""
if not text:
return text
# Escape Markdown special characters by prefixing with backslash
# Covers: formatting (*_~`), links ([]()), headings (#), lists (-+),
# blockquotes (>), code blocks (```), and other special characters
special_chars = {
"\\": "\\\\", # Backslash must be first to avoid double-escaping
"*": "\\*",
"_": "\\_",
"[": "\\[",
"]": "\\]",
"(": "\\(",
")": "\\)",
"~": "\\~",
"`": "\\`",
"#": "\\#",
"+": "\\+",
"-": "\\-",
"=": "\\=",
"|": "\\|",
"{": "\\{",
"}": "\\}",
".": "\\.",
"!": "\\!",
"<": "\\<",
">": "\\>",
"&": "\\&",
}
escaped_text = text
for char, replacement in special_chars.items():
escaped_text = escaped_text.replace(char, replacement)
return escaped_text
def sanitize_for_plain_text(text: str) -> str:
"""Sanitize text for plain text rendering.
Remove newlines and normalize whitespace without escaping
special characters. Use this for plain text notifications where
escaping would show literal backslashes.
:param text: The text to sanitize
:return: Sanitized text
"""
if not text:
return text
# Remove newlines and carriage returns to prevent multi-line injection
sanitized = text.replace("\n", " ").replace("\r", " ")
# Collapse multiple spaces into single space
return " ".join(sanitized.split())
def sanitize_for_markdown(text: str) -> str:
"""Sanitize text for safe Markdown rendering.
Remove newlines, normalize whitespace, and escape Markdown special
characters. Use this for any untrusted external content before
embedding in Markdown messages.
Note: This function does not truncate. Size limits should be
enforced at the model layer (e.g., in from_api_response methods).
:param text: The text to sanitize
:return: Sanitized and escaped text safe for Markdown rendering
"""
if not text:
return text
# Remove newlines and carriage returns to prevent multi-line injection
sanitized = text.replace("\n", " ").replace("\r", " ")
# Collapse multiple spaces into single space
sanitized = " ".join(sanitized.split())
# Escape Markdown special characters
return escape_markdown(sanitized)
+29 -54
View File
@@ -7,13 +7,13 @@ authors = [
] ]
license = "Apache-2.0" license = "Apache-2.0"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [] dependencies = ["prometheus_client>=0.25.0"]
[project.urls] [project.urls]
Repository = "https://git.logal.dev/LogalDeveloper/OwncastSentry" Repository = "https://git.logal.dev/LogalDeveloper/OwncastSentry"
[build-system] [build-system]
requires = ["hatchling>=1.28.0", "hatch-vcs>=0.5.0"] requires = ["hatchling>=1.29.0", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[dependency-groups] [dependency-groups]
@@ -22,12 +22,12 @@ dev = [
"aioresponses>=0.7.8", "aioresponses>=0.7.8",
"codespell>=2.4.2", "codespell>=2.4.2",
"hatch>=1.16.5", "hatch>=1.16.5",
"mypy>=1.19.1", "mypy>=2.1.0",
"pip-audit>=2.10.0", "pip-audit>=2.10.0",
"pytest>=9.0.2", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0", "pytest-cov>=7.1.0",
"ruff>=0.15.5", "ruff>=0.15.13",
"time-machine>=3.2.0", "time-machine>=3.2.0",
] ]
@@ -42,68 +42,43 @@ python_version = "3.14"
strict = true strict = true
warn_unreachable = true warn_unreachable = true
explicit_package_bases = true explicit_package_bases = true
exclude = ["owncastsentry/_version\\.py", "tests/"] # 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] [tool.ruff]
target-version = "py314" target-version = "py314"
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = ["ALL"]
# Core
"F", # Pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"N", # pep8-naming
"D", # pydocstyle
"I", # isort
"ICN", # flake8-import-conventions
# Correctness & bugs
"B", # flake8-bugbear
"ASYNC", # flake8-async
"DTZ", # flake8-datetimez
"RSE", # flake8-raise
"RET", # flake8-return
"A", # flake8-builtins
"PIE", # flake8-pie
# Modernization & simplification
"UP", # pyupgrade
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"FLY", # flynt (f-string conversion)
"PTH", # flake8-use-pathlib
# Performance
"PERF", # Perflint
# Security
"S", # flake8-bandit
# Code hygiene
"T10", # flake8-debugger
"T20", # flake8-print
"ERA", # eradicate
"PGH", # pygrep-hooks
"TC", # flake8-type-checking
# Testing
"PT", # flake8-pytest-style
# Ruff-specific
"RUF", # Ruff-specific rules
]
ignore = [ 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) "D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line) "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] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"] "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] [tool.coverage.run]
source = ["owncastsentry"] source = ["owncastsentry"]
@@ -114,4 +89,4 @@ show_missing = true
skip_empty = true skip_empty = true
[tool.codespell] [tool.codespell]
skip = "uv.lock" skip = "owncastsentry/_version.py,uv.lock"
+25 -13
View File
@@ -14,24 +14,36 @@
"""Shared test fixtures and stubs for OwncastSentry tests.""" """Shared test fixtures and stubs for OwncastSentry tests."""
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import pytest import pytest
from mautrix.util.async_db import Database from mautrix.util.async_db import Database
from prometheus_client import generate_latest
from owncastsentry import OwncastSentry from owncastsentry import OwncastSentry
from owncastsentry.config import Config from owncastsentry.config import Config
from owncastsentry.database import StreamRepository, SubscriptionRepository from owncastsentry.repository import (
from owncastsentry.migrations import get_upgrade_table StreamRepository,
SubscriptionRepository,
get_upgrade_table,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
from owncastsentry.models import StreamConfig, StreamState 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 @pytest.fixture
@@ -64,19 +76,19 @@ def subscription_repo(database: Database) -> SubscriptionRepository:
@pytest.fixture @pytest.fixture
def maubot_plugin_class(): def maubot_plugin_class() -> type[Plugin]:
"""Use OwncastSentry as the plugin class for maubot integration tests.""" """Use OwncastSentry as the plugin class for maubot integration tests."""
return OwncastSentry return OwncastSentry
@pytest.fixture @pytest.fixture
def maubot_plugin_config_class(): def maubot_plugin_config_class() -> type[BaseProxyConfig]:
"""Use our Config class for maubot integration tests.""" """Use our Config class for maubot integration tests."""
return Config return Config
@pytest.fixture @pytest.fixture
def maubot_upgrade_table(): def maubot_upgrade_table() -> UpgradeTable:
"""Provide the database upgrade table for maubot integration tests.""" """Provide the database upgrade table for maubot integration tests."""
return get_upgrade_table() return get_upgrade_table()
@@ -200,19 +212,19 @@ class _StubMatrixClient:
class _StubOwncastClient: class _StubOwncastClient:
"""Configurable stub for the Owncast HTTP client.""" """Configurable stub for the Owncast HTTP client."""
stream_state: StreamState | None = None stream_state: StreamStateObservation | None = None
stream_config: StreamConfig | None = None stream_config: StreamConfigObservation | None = None
state_call_count: int = field(default=0, init=False) state_call_count: int = field(default=0, init=False)
config_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) queried_domains: list[str] = field(default_factory=list, init=False)
async def get_stream_state(self, domain: str) -> StreamState | None: async def get_stream_state(self, domain: str) -> StreamStateObservation | None:
"""Return the configured stream state.""" """Return the configured stream observation."""
self.state_call_count += 1 self.state_call_count += 1
self.queried_domains.append(domain) self.queried_domains.append(domain)
return self.stream_state return self.stream_state
async def get_stream_config(self, domain: str) -> StreamConfig | None: async def get_stream_config(self, _domain: str) -> StreamConfigObservation | None:
"""Return the configured stream config.""" """Return the configured stream config."""
self.config_call_count += 1 self.config_call_count += 1
return self.stream_config return self.stream_config
+176 -103
View File
@@ -14,31 +14,67 @@
"""Tests for bot command handlers.""" """Tests for bot command handlers."""
from __future__ import annotations
import json import json
import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock from typing import TYPE_CHECKING
import pytest import pytest
import time_machine import time_machine
from aioresponses import aioresponses from aioresponses import aioresponses
from owncastsentry.commands import CommandHandler from owncastsentry.commands import (
from owncastsentry.models import StreamState _SECONDS_PER_DAY,
from owncastsentry.utils import OWNCAST_STATUS_PATH, UNKNOWN_STATUS_THRESHOLD _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 from tests.conftest import VALID_STATUS_RESPONSE
if TYPE_CHECKING:
from maubot.testing.bot import TestBot
def _make_command_handler() -> CommandHandler: from owncastsentry import OwncastSentry
"""Build a CommandHandler with dummy dependencies for pure logic tests."""
return CommandHandler(
owncast_client=MagicMock(), class TestEscapeMarkdown:
stream_repo=MagicMock(), """Markdown special character escaping."""
subscription_repo=MagicMock(),
logger=logging.getLogger("test"), @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: class TestFormatDuration:
@@ -51,34 +87,41 @@ class TestFormatDuration:
[ [
pytest.param(1, "1 second", id="singular-second"), pytest.param(1, "1 second", id="singular-second"),
pytest.param(30, "30 seconds", id="plural-seconds"), pytest.param(30, "30 seconds", id="plural-seconds"),
pytest.param(60, "1 minute", id="singular-minute"), pytest.param(_SECONDS_PER_MINUTE, "1 minute", id="singular-minute"),
pytest.param(120, "2 minutes", id="plural-minutes"), pytest.param(2 * _SECONDS_PER_MINUTE, "2 minutes", id="plural-minutes"),
pytest.param(3600, "1 hour", id="singular-hour"), pytest.param(_SECONDS_PER_HOUR, "1 hour", id="singular-hour"),
pytest.param(7200, "2 hours", id="plural-hours"), pytest.param(2 * _SECONDS_PER_HOUR, "2 hours", id="plural-hours"),
pytest.param(86400, "1 day", id="singular-day"), pytest.param(_SECONDS_PER_DAY, "1 day", id="singular-day"),
pytest.param(172800, "2 days", id="plural-days"), pytest.param(2 * _SECONDS_PER_DAY, "2 days", id="plural-days"),
], ],
) )
@time_machine.travel(_NOW)
def test_formats_duration(self, seconds_ago: int, expected: str) -> None: def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
"""Format a timestamp into a human-readable duration.""" """Format a timestamp into a human-readable duration."""
handler = _make_command_handler()
timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat() timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat()
result = handler._format_duration(timestamp) result = _format_duration(timestamp, self._NOW)
assert result == expected assert result == expected
def test_invalid_timestamp(self) -> None: def test_invalid_timestamp(self) -> None:
"""Return 'unknown duration' for unparsable timestamps.""" """Return 'unknown duration' for unparsable timestamps."""
handler = _make_command_handler() assert _format_duration("not-a-timestamp", self._NOW) == "unknown duration"
assert handler._format_duration("not-a-timestamp") == "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: class TestSubscribeCommand:
"""Subscribe command end-to-end via maubot.""" """Subscribe command end-to-end via maubot."""
async def test_subscribe_valid_stream(self, maubot_test_bot, maubot_plugin) -> None: async def test_subscribe_valid_stream(self, maubot_test_bot: TestBot) -> None:
"""Subscribe to a valid Owncast stream.""" """Subscribe to a valid Owncast stream."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -92,11 +135,9 @@ class TestSubscribeCommand:
"stream.logal.dev goes live." "stream.logal.dev goes live."
) )
async def test_subscribe_invalid_stream( async def test_subscribe_invalid_stream(self, maubot_test_bot: TestBot) -> None:
self, maubot_test_bot, maubot_plugin
) -> None:
"""Reject subscription to an invalid Owncast instance.""" """Reject subscription to an invalid Owncast instance."""
status_url = f"https://invalid.com{OWNCAST_STATUS_PATH}" status_url = f"https://invalid.com{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get(status_url, status=404) mocked.get(status_url, status=404)
await maubot_test_bot.send("!subscribe invalid.com") await maubot_test_bot.send("!subscribe invalid.com")
@@ -109,11 +150,9 @@ class TestSubscribeCommand:
"instance is offline." "instance is offline."
) )
async def test_subscribe_already_subscribed( async def test_subscribe_already_subscribed(self, maubot_test_bot: TestBot) -> None:
self, maubot_test_bot, maubot_plugin
) -> None:
"""Reject duplicate subscription in the same room.""" """Reject duplicate subscription in the same room."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -130,10 +169,10 @@ class TestSubscribeCommand:
) )
async def test_subscribe_existing_stream_new_room( async def test_subscribe_existing_stream_new_room(
self, maubot_test_bot, maubot_plugin self, maubot_test_bot: TestBot
) -> None: ) -> None:
"""Skip instance validation when subscribing from a new room.""" """Skip validation when the domain already has subscriptions."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -141,9 +180,9 @@ class TestSubscribeCommand:
) )
await maubot_test_bot.send("!subscribe stream.logal.dev") await maubot_test_bot.send("!subscribe stream.logal.dev")
# Subscribe from a different room; skips validation and should not # Subscribe from a different room. The existing subscribed domain skips
# query the remote instance. An empty aioresponses context will raise # validation, so an empty aioresponses context will raise ConnectionError
# ConnectionError if any HTTP request is attempted. # if any HTTP request is attempted.
with aioresponses(): with aioresponses():
await maubot_test_bot.send( await maubot_test_bot.send(
"!subscribe stream.logal.dev", room_id="!otherroom:example.com" "!subscribe stream.logal.dev", room_id="!otherroom:example.com"
@@ -156,12 +195,13 @@ class TestSubscribeCommand:
) )
@pytest.mark.usefixtures("maubot_plugin")
class TestUnsubscribeCommand: class TestUnsubscribeCommand:
"""Unsubscribe command end-to-end via maubot.""" """Unsubscribe command end-to-end via maubot."""
async def test_unsubscribe_existing(self, maubot_test_bot, maubot_plugin) -> None: async def test_unsubscribe_existing(self, maubot_test_bot: TestBot) -> None:
"""Unsubscribe from a subscribed stream.""" """Unsubscribe from a subscribed stream."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -177,9 +217,7 @@ class TestUnsubscribeCommand:
"longer receive notifications for stream.logal.dev." "longer receive notifications for stream.logal.dev."
) )
async def test_unsubscribe_not_subscribed( async def test_unsubscribe_not_subscribed(self, maubot_test_bot: TestBot) -> None:
self, maubot_test_bot, maubot_plugin
) -> None:
"""Reject unsubscribe from a non-subscribed stream.""" """Reject unsubscribe from a non-subscribed stream."""
await maubot_test_bot.send("!unsubscribe unknown.com") await maubot_test_bot.send("!unsubscribe unknown.com")
@@ -189,10 +227,11 @@ class TestUnsubscribeCommand:
) )
@pytest.mark.usefixtures("maubot_plugin")
class TestSubscriptionsCommand: class TestSubscriptionsCommand:
"""Subscriptions listing command end-to-end via maubot.""" """Subscriptions listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None: async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
"""Show help text when no subscriptions exist.""" """Show help text when no subscriptions exist."""
await maubot_test_bot.send("!subscriptions") await maubot_test_bot.send("!subscriptions")
@@ -204,10 +243,12 @@ class TestSubscriptionsCommand:
) )
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_online_stream(self, maubot_test_bot, maubot_plugin) -> None: async def test_shows_online_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show stream details including title and duration.""" """Show stream details including title and duration."""
# Subscribe first # Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -217,12 +258,11 @@ class TestSubscriptionsCommand:
# Update stream state to be online with a name # Update stream state to be online with a name
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "stream.logal.dev",
domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
title="Playing Games", title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z", online=True,
) status_since="2026-01-01T12:00:00+00:00",
) )
await maubot_test_bot.send("!subscriptions") await maubot_test_bot.send("!subscriptions")
@@ -239,10 +279,46 @@ class TestSubscriptionsCommand:
) )
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_offline_stream(self, maubot_test_bot, maubot_plugin) -> None: 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` &gt; 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.""" """Show offline status for non-live streams."""
# Subscribe first # Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -252,11 +328,9 @@ class TestSubscriptionsCommand:
# Update stream state to be offline # Update stream state to be offline
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "stream.logal.dev",
domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T10:00:00+00:00",
)
) )
await maubot_test_bot.send("!subscriptions") await maubot_test_bot.send("!subscriptions")
@@ -272,10 +346,10 @@ class TestSubscriptionsCommand:
) )
async def test_shows_offline_stream_without_disconnect_time( async def test_shows_offline_stream_without_disconnect_time(
self, maubot_test_bot, maubot_plugin self, maubot_test_bot: TestBot
) -> None: ) -> None:
"""Show offline status without duration before first poll completes.""" """Show offline status without duration before first poll completes."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -296,9 +370,11 @@ class TestSubscriptionsCommand:
"instances, use `!unsubscribe <domain>`" "instances, use `!unsubscribe <domain>`"
) )
async def test_shows_unknown_stream(self, maubot_test_bot, maubot_plugin) -> None: async def test_shows_unknown_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show unknown status when instance has been unreachable.""" """Show unknown status when instance has been unreachable."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -326,17 +402,17 @@ class TestSubscriptionsCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_subscriptions( async def test_shows_multiple_subscriptions(
self, maubot_test_bot, maubot_plugin self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None: ) -> None:
"""List subscriptions alphabetically with mixed statuses.""" """List subscriptions ordered by domain with mixed statuses."""
# Subscribe in reverse alphabetical order to verify sorted output # Subscribe in reverse domain order to verify domain-sorted output
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
f"https://beta.com{OWNCAST_STATUS_PATH}", f"https://beta.com{_OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(), body=json.dumps(VALID_STATUS_RESPONSE).encode(),
) )
mocked.get( mocked.get(
f"https://alpha.com{OWNCAST_STATUS_PATH}", f"https://alpha.com{_OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(), body=json.dumps(VALID_STATUS_RESPONSE).encode(),
) )
await maubot_test_bot.send("!subscribe beta.com") await maubot_test_bot.send("!subscribe beta.com")
@@ -344,19 +420,16 @@ class TestSubscriptionsCommand:
# Set alpha online, beta offline # Set alpha online, beta offline
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "alpha.com",
domain="alpha.com",
name="Alpha Stream", name="Alpha Stream",
title="Streaming Live", title="Streaming Live",
last_connect_time="2026-03-13T10:00:00Z", online=True,
) status_since="2026-03-13T10:00:00+00:00",
) )
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "beta.com",
domain="beta.com",
name="Beta Stream", name="Beta Stream",
last_disconnect_time="2026-03-12T18:00:00Z", status_since="2026-03-12T18:00:00+00:00",
)
) )
await maubot_test_bot.send("!subscriptions") await maubot_test_bot.send("!subscriptions")
@@ -378,10 +451,11 @@ class TestSubscriptionsCommand:
) )
@pytest.mark.usefixtures("maubot_plugin")
class TestLiveCommand: class TestLiveCommand:
"""Live streams listing command end-to-end via maubot.""" """Live streams listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None: async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
"""Show help text when no subscriptions exist.""" """Show help text when no subscriptions exist."""
await maubot_test_bot.send("!live") await maubot_test_bot.send("!live")
@@ -392,10 +466,12 @@ class TestLiveCommand:
"instance, use `!subscribe <domain>`" "instance, use `!subscribe <domain>`"
) )
async def test_no_live_streams(self, maubot_test_bot, maubot_plugin) -> None: async def test_no_live_streams(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show 'no live' message when all streams are offline.""" """Show 'no live' message when all streams are offline."""
# Subscribe first # Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -405,11 +481,9 @@ class TestLiveCommand:
# Update stream state to offline # Update stream state to offline
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "stream.logal.dev",
domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z", status_since="2026-01-01T10:00:00+00:00",
)
) )
await maubot_test_bot.send("!live") await maubot_test_bot.send("!live")
@@ -422,10 +496,12 @@ class TestLiveCommand:
) )
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_live_stream(self, maubot_test_bot, maubot_plugin) -> None: async def test_shows_live_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show live stream with title and duration.""" """Show live stream with title and duration."""
# Subscribe first # Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}" status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
status_url, status_url,
@@ -435,12 +511,11 @@ class TestLiveCommand:
# Update stream state to online # Update stream state to online
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "stream.logal.dev",
domain="stream.logal.dev",
name="Test Stream", name="Test Stream",
title="Playing Games", title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z", online=True,
) status_since="2026-01-01T12:00:00+00:00",
) )
await maubot_test_bot.send("!live") await maubot_test_bot.send("!live")
@@ -456,38 +531,36 @@ class TestLiveCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_live_streams( async def test_shows_multiple_live_streams(
self, maubot_test_bot, maubot_plugin self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None: ) -> None:
"""List live streams alphabetically with different durations.""" """List live streams ordered by domain with different durations."""
# Subscribe in reverse alphabetical order to verify sorted output # Subscribe in reverse domain order to verify domain-sorted output
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
f"https://beta.com{OWNCAST_STATUS_PATH}", f"https://beta.com{_OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(), body=json.dumps(VALID_STATUS_RESPONSE).encode(),
) )
mocked.get( mocked.get(
f"https://alpha.com{OWNCAST_STATUS_PATH}", f"https://alpha.com{_OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(), body=json.dumps(VALID_STATUS_RESPONSE).encode(),
) )
await maubot_test_bot.send("!subscribe beta.com") await maubot_test_bot.send("!subscribe beta.com")
await maubot_test_bot.send("!subscribe alpha.com") await maubot_test_bot.send("!subscribe alpha.com")
# Set both streams online with different connect times # Set both streams online with different status timestamps
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "alpha.com",
domain="alpha.com",
name="Alpha Stream", name="Alpha Stream",
title="Morning Show", title="Morning Show",
last_connect_time="2026-03-13T10:00:00Z", online=True,
) status_since="2026-03-13T10:00:00+00:00",
) )
await maubot_plugin.stream_repo.update( await maubot_plugin.stream_repo.update(
StreamState( "beta.com",
domain="beta.com",
name="Beta Stream", name="Beta Stream",
title="Evening Vibes", title="Evening Vibes",
last_connect_time="2026-03-13T06:00:00Z", online=True,
) status_since="2026-03-13T06:00:00+00:00",
) )
await maubot_test_bot.send("!live") await maubot_test_bot.send("!live")
-150
View File
@@ -1,150 +0,0 @@
# 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 __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from owncastsentry.database import StreamRepository, SubscriptionRepository
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 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 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 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 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
-171
View File
@@ -1,171 +0,0 @@
# 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 health checking service."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pytest
from aioresponses import aioresponses
from owncastsentry.health_checker import HealthChecker, HealthStatus, UpdateResult
from owncastsentry.owncast_client import OwncastClient
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from mautrix.util.async_db import Database
@pytest.fixture
async def health_checker(database: Database) -> AsyncIterator[HealthChecker]:
"""Yield a HealthChecker backed by a real OwncastClient."""
client = OwncastClient(logger=logging.getLogger("test"), version="0.0.0")
yield HealthChecker(database, client, logging.getLogger("test"))
await client.close()
class TestUpdateResultHttpHealthy:
"""HTTP health derivation from update results."""
@pytest.mark.parametrize(
("total", "successful", "failed", "expected"),
[
pytest.param(0, 0, 0, True, id="no-streams-is-healthy"),
pytest.param(3, 2, 1, True, id="some-successes-is-healthy"),
pytest.param(3, 0, 3, False, id="all-failures-is-unhealthy"),
],
)
def test_http_healthy(
self, total: int, successful: int, failed: int, expected: bool
) -> None:
"""Derive HTTP health from stream check results."""
result = UpdateResult(
total_streams=total,
successful_checks=successful,
failed_checks=failed,
)
assert result.http_healthy is expected
class TestHealthStatusIsHealthy:
"""Overall health status derivation."""
@pytest.mark.parametrize(
("db_healthy", "http_healthy", "expected"),
[
pytest.param(True, True, True, id="all-healthy"),
pytest.param(False, True, False, id="db-unhealthy"),
pytest.param(True, False, False, id="http-unhealthy"),
pytest.param(False, False, False, id="both-unhealthy"),
],
)
def test_is_healthy(
self, db_healthy: bool, http_healthy: bool, expected: bool
) -> None:
"""Derive overall health from component health."""
status = HealthStatus(database_healthy=db_healthy, http_healthy=http_healthy)
assert status.is_healthy is expected
class TestCheckDatabase:
"""Database health check."""
async def test_returns_true_for_healthy_db(
self, health_checker: HealthChecker
) -> None:
"""Return True when the database responds to queries."""
assert await health_checker.check_database() is True
async def test_returns_false_for_stopped_db(
self, health_checker: HealthChecker, database: Database
) -> None:
"""Return False when the database connection is closed."""
await database.stop()
assert await health_checker.check_database() is False
class TestPerformHealthCheck:
"""Health check orchestration and endpoint reporting."""
async def test_skips_report_when_no_endpoint(
self, health_checker: HealthChecker
) -> None:
"""Skip reporting when endpoint is empty."""
result = UpdateResult(total_streams=0, successful_checks=0, failed_checks=0)
with aioresponses():
await health_checker.perform_health_check(result, "")
async def test_skips_report_when_unhealthy(
self, health_checker: HealthChecker
) -> None:
"""Skip health report when all stream checks failed."""
result = UpdateResult(total_streams=3, successful_checks=0, failed_checks=3)
with aioresponses():
await health_checker.perform_health_check(
result, "https://health.example.com/ping"
)
async def test_sends_report_when_healthy(
self, health_checker: HealthChecker
) -> None:
"""Send GET to endpoint when all checks pass."""
result = UpdateResult(total_streams=1, successful_checks=1, failed_checks=0)
with aioresponses() as mocked:
mocked.get("https://health.example.com/ping", status=200)
await health_checker.perform_health_check(
result, "https://health.example.com/ping"
)
async def test_skips_report_for_whitespace_endpoint(
self, health_checker: HealthChecker
) -> None:
"""Skip reporting when endpoint is whitespace."""
result = UpdateResult(total_streams=0, successful_checks=0, failed_checks=0)
with aioresponses():
await health_checker.perform_health_check(result, " ")
class TestSendHealthReport:
"""Health report HTTP delivery."""
async def test_handles_success_response(
self, health_checker: HealthChecker
) -> None:
"""Complete without error on a 2xx response."""
with aioresponses() as mocked:
mocked.get("https://health.example.com/ping", status=200)
await health_checker._send_health_report("https://health.example.com/ping")
async def test_handles_non_success_response(
self, health_checker: HealthChecker
) -> None:
"""Complete without error on a non-2xx response."""
with aioresponses() as mocked:
mocked.get("https://health.example.com/ping", status=500)
await health_checker._send_health_report("https://health.example.com/ping")
async def test_handles_connection_error(
self, health_checker: HealthChecker
) -> None:
"""Complete without error on a connection failure."""
with aioresponses() as mocked:
mocked.get(
"https://health.example.com/ping",
exception=ConnectionError(),
)
await health_checker._send_health_report("https://health.example.com/ping")
+314
View File
@@ -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
-195
View File
@@ -1,195 +0,0 @@
# 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 __future__ import annotations
import pytest
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
from owncastsentry.utils import (
MAX_INSTANCE_TITLE_LENGTH,
MAX_STREAM_TITLE_LENGTH,
MAX_TAG_LENGTH,
UNKNOWN_STATUS_THRESHOLD,
)
class TestStreamStateStatus:
"""Stream status derivation from state fields."""
@pytest.mark.parametrize(
("failure_counter", "last_connect_time", "expected"),
[
pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1,
None,
StreamStatus.UNKNOWN,
id="above-threshold-offline-returns-unknown",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1,
"2026-01-01T00:00:00Z",
StreamStatus.UNKNOWN,
id="above-threshold-online-returns-unknown",
),
pytest.param(
0,
"2026-01-01T00:00:00Z",
StreamStatus.ONLINE,
id="zero-failures-with-connect-time-returns-online",
),
pytest.param(
0,
None,
StreamStatus.OFFLINE,
id="zero-failures-no-connect-time-returns-offline",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD,
"2026-01-01T00:00:00Z",
StreamStatus.ONLINE,
id="at-threshold-with-connect-time-returns-online",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD,
None,
StreamStatus.OFFLINE,
id="at-threshold-no-connect-time-returns-offline",
),
],
)
def test_status(
self,
failure_counter: int,
last_connect_time: str | None,
expected: StreamStatus,
) -> None:
"""Return the correct status based on failure counter and connect time."""
state = StreamState(
domain="example.com",
failure_counter=failure_counter,
last_connect_time=last_connect_time,
)
assert state.status is expected
class TestStreamStateFromApiResponse:
"""StreamState construction from an API response dictionary."""
def test_typical_response(self) -> None:
"""Populate all fields from a complete API response."""
response = {
"streamTitle": "My Stream",
"lastConnectTime": "2026-01-01T00:00:00Z",
"lastDisconnectTime": "2025-12-31T23:00:00Z",
}
state = StreamState.from_api_response(response, "example.com")
assert state.domain == "example.com"
assert state.title == "My Stream"
assert state.last_connect_time == "2026-01-01T00:00:00Z"
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
assert state.name is None
assert state.failure_counter == 0
def test_empty_response_defaults(self) -> None:
"""Use defaults when optional fields are missing."""
state = StreamState.from_api_response({}, "bare.example.com")
assert state.domain == "bare.example.com"
assert state.title == ""
assert state.last_connect_time is None
assert state.last_disconnect_time is None
def test_title_truncation(self) -> None:
"""Truncate the stream title to MAX_STREAM_TITLE_LENGTH."""
long_title = "A" * (MAX_STREAM_TITLE_LENGTH + 50)
response = {"streamTitle": long_title}
state = StreamState.from_api_response(response, "example.com")
assert len(state.title) == MAX_STREAM_TITLE_LENGTH
assert state.title == "A" * MAX_STREAM_TITLE_LENGTH
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",
"last_connect_time": "2026-01-01T00:00:00Z",
"last_disconnect_time": "2025-12-31T23:00:00Z",
"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.last_connect_time == "2026-01-01T00:00:00Z"
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
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,
"last_connect_time": None,
"last_disconnect_time": 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.last_connect_time is None
assert state.last_disconnect_time is None
assert state.failure_counter == 0
class TestStreamConfigFromApiResponse:
"""StreamConfig 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"]}
config = StreamConfig.from_api_response(response)
assert config.name == "My Instance"
assert config.tags == ["gaming", "music"]
def test_missing_keys_defaults(self) -> None:
"""Use defaults when name and tags keys are missing."""
config = StreamConfig.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 = StreamConfig.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 = StreamConfig.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"
+246 -12
View File
@@ -14,35 +14,61 @@
"""Tests for the notification service.""" """Tests for the notification service."""
from __future__ import annotations import asyncio
import logging import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
from owncastsentry.notification_service import NotificationService from owncastsentry.metrics import MetricsService
from owncastsentry.utils import SECONDS_BETWEEN_NOTIFICATIONS from owncastsentry.notification_service import (
from tests.conftest import _StubMatrixClient _SECONDS_BETWEEN_NOTIFICATIONS,
NotificationService,
_sanitize_for_plain_text,
)
from tests.conftest import _StubMatrixClient, generate_metrics_output
if TYPE_CHECKING: if TYPE_CHECKING:
from owncastsentry.database import StreamRepository, SubscriptionRepository from owncastsentry.repository import StreamRepository, SubscriptionRepository
def _make_service( def _make_service(
*, *,
client: _StubMatrixClient, client: _StubMatrixClient,
subscription_repo: SubscriptionRepository, subscription_repo: SubscriptionRepository,
metrics: MetricsService | None = None,
) -> NotificationService: ) -> NotificationService:
"""Build a NotificationService with a stub client and real repo.""" """Build a NotificationService with a stub client and real repo."""
return NotificationService( return NotificationService(
client=client, client=client,
subscription_repo=subscription_repo, subscription_repo=subscription_repo,
logger=logging.getLogger("test"), 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: class TestCanNotify:
"""Rate-limiting logic for notification cooldowns.""" """Rate-limiting logic for notification cooldowns."""
@@ -62,7 +88,7 @@ class TestCanNotify:
service = _make_service( service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo client=_StubMatrixClient(), subscription_repo=subscription_repo
) )
service.notification_timers_cache["example.com"] = time.time() service.notification_timers_cache["example.com"] = time.monotonic()
assert service._can_notify("example.com") is False assert service._can_notify("example.com") is False
def test_after_cooldown_allowed( def test_after_cooldown_allowed(
@@ -74,7 +100,7 @@ class TestCanNotify:
) )
# Subtract an extra second to ensure the cooldown has fully elapsed # Subtract an extra second to ensure the cooldown has fully elapsed
service.notification_timers_cache["example.com"] = ( service.notification_timers_cache["example.com"] = (
time.time() - SECONDS_BETWEEN_NOTIFICATIONS - 1 time.monotonic() - _SECONDS_BETWEEN_NOTIFICATIONS - 1
) )
assert service._can_notify("example.com") is True assert service._can_notify("example.com") is True
@@ -102,6 +128,35 @@ class TestGetLastNotificationTime:
assert service.get_last_notification_time("unknown.com") == 0 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: class TestFormatMessage:
"""Notification message formatting.""" """Notification message formatting."""
@@ -201,6 +256,7 @@ class TestFormatMessage:
title: str, title: str,
domain: str, domain: str,
tags: list[str], tags: list[str],
*,
title_change: bool, title_change: bool,
expected: str, expected: str,
subscription_repo: SubscriptionRepository, subscription_repo: SubscriptionRepository,
@@ -209,12 +265,14 @@ class TestFormatMessage:
service = _make_service( service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo client=_StubMatrixClient(), subscription_repo=subscription_repo
) )
result = service._format_message(name, title, domain, tags, title_change) result = service._format_message(
name, title, domain, tags, title_change=title_change
)
assert result == expected assert result == expected
class TestNotifyStreamLive: class TestNotifyStreamLive:
"""End-to-end notification sending.""" """Live notification sending behavior."""
async def test_sends_to_all_subscribed_rooms( async def test_sends_to_all_subscribed_rooms(
self, self,
@@ -245,6 +303,74 @@ class TestNotifyStreamLive:
for msg in client.sent_messages: for msg in client.sent_messages:
assert msg.content.body == expected_body 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( async def test_skips_when_rate_limited(
self, self,
stream_repo: StreamRepository, stream_repo: StreamRepository,
@@ -253,7 +379,7 @@ class TestNotifyStreamLive:
"""Skip sending when the domain is within the rate-limit cooldown.""" """Skip sending when the domain is within the rate-limit cooldown."""
client = _StubMatrixClient() client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo) service = _make_service(client=client, subscription_repo=subscription_repo)
service.notification_timers_cache["example.com"] = time.time() service.notification_timers_cache["example.com"] = time.monotonic()
await stream_repo.create("example.com") await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org") await subscription_repo.add("example.com", "!room1:matrix.org")
@@ -267,7 +393,7 @@ class TestNotifyStreamLive:
stream_repo: StreamRepository, stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository, subscription_repo: SubscriptionRepository,
) -> None: ) -> None:
"""Send to remaining rooms after a delivery failure.""" """Deliver to other subscribed rooms when one room delivery fails."""
client = _StubMatrixClient() client = _StubMatrixClient()
client.should_fail_for_rooms.add("!bad:matrix.org") client.should_fail_for_rooms.add("!bad:matrix.org")
service = _make_service(client=client, subscription_repo=subscription_repo) service = _make_service(client=client, subscription_repo=subscription_repo)
@@ -346,3 +472,111 @@ class TestSendCleanupDeletion:
"If the instance comes online again and you want to " "If the instance comes online again and you want to "
"resubscribe, run `!subscribe example.com`." "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
+286 -14
View File
@@ -14,37 +14,147 @@
"""Tests for the Owncast HTTP client.""" """Tests for the Owncast HTTP client."""
from __future__ import annotations
import json import json
import logging import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
import time_machine
from aioresponses import aioresponses from aioresponses import aioresponses
from owncastsentry.owncast_client import OwncastClient from owncastsentry.metrics import MetricsService
from tests.conftest import VALID_CONFIG_RESPONSE, VALID_STATUS_RESPONSE 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: if TYPE_CHECKING:
from collections.abc import AsyncIterator 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 @pytest.fixture
async def owncast_client() -> AsyncIterator[OwncastClient]: async def owncast_client() -> AsyncIterator[OwncastClient]:
"""Create an OwncastClient and close it after the test.""" """Create an OwncastClient and close it after the test."""
client = OwncastClient(logger=logging.getLogger("test"), version="0.0.0") client = OwncastClient(
logger=logging.getLogger("test"),
version="0.0.0",
metrics=MetricsService(),
)
yield client yield client
await client.close() await client.close()
class TestGetStreamState: class TestUserAgent:
"""Stream state retrieval from the status API.""" """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( async def test_returns_state_on_success(
self, owncast_client: OwncastClient self, owncast_client: OwncastClient
) -> None: ) -> None:
"""Return a StreamState with correct fields on a valid 200 response.""" """Return a StreamStateObservation with correct fields on a valid response."""
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
"https://stream.logal.dev/api/status", "https://stream.logal.dev/api/status",
@@ -58,14 +168,14 @@ class TestGetStreamState:
result.title result.title
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501 == "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
) )
assert result.last_connect_time is None assert result.online is False
assert result.last_disconnect_time == "2026-03-04T21:05:32-05:00" assert result.observed_at == "2026-03-13T12:00:00+00:00"
async def test_returns_none_on_missing_field( async def test_returns_none_on_missing_field(
self, owncast_client: OwncastClient self, owncast_client: OwncastClient
) -> None: ) -> None:
"""Return None when the response is missing required fields.""" """Return None when the response is missing required fields."""
incomplete = {"streamTitle": "Test Stream", "online": True} incomplete = {"streamTitle": "Test Stream"}
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
"https://stream.logal.dev/api/status", "https://stream.logal.dev/api/status",
@@ -75,6 +185,23 @@ class TestGetStreamState:
assert result is None 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( async def test_returns_none_on_invalid_json(
self, owncast_client: OwncastClient self, owncast_client: OwncastClient
) -> None: ) -> None:
@@ -88,6 +215,32 @@ class TestGetStreamState:
assert result is None 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: async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
"""Return None when the response status is not 200.""" """Return None when the response status is not 200."""
with aioresponses() as mocked: with aioresponses() as mocked:
@@ -119,7 +272,7 @@ class TestGetStreamConfig:
async def test_returns_config_on_success( async def test_returns_config_on_success(
self, owncast_client: OwncastClient self, owncast_client: OwncastClient
) -> None: ) -> None:
"""Return a StreamConfig with correct fields on a valid 200 response.""" """Return a StreamConfigObservation with fields from a valid response."""
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(
"https://stream.logal.dev/api/config", "https://stream.logal.dev/api/config",
@@ -129,7 +282,7 @@ class TestGetStreamConfig:
assert result is not None assert result is not None
assert result.name == "LogalDeveloper's Live Stream" assert result.name == "LogalDeveloper's Live Stream"
assert result.tags == [ assert result.tags == (
"video games", "video games",
"chatting", "chatting",
"casual", "casual",
@@ -137,7 +290,7 @@ class TestGetStreamConfig:
"streaming", "streaming",
"owncast", "owncast",
"variety", "variety",
] )
async def test_returns_none_on_invalid_json( async def test_returns_none_on_invalid_json(
self, owncast_client: OwncastClient self, owncast_client: OwncastClient
@@ -152,6 +305,49 @@ class TestGetStreamConfig:
assert result is None 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: async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
"""Return None when the response status is not 200.""" """Return None when the response status is not 200."""
with aioresponses() as mocked: with aioresponses() as mocked:
@@ -205,3 +401,79 @@ class TestValidateInstance:
result = await owncast_client.validate_instance("invalid.com") result = await owncast_client.validate_instance("invalid.com")
assert result is False 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
+483
View File
@@ -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
+284
View File
@@ -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"
]
+388
View File
@@ -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]
-199
View File
@@ -1,199 +0,0 @@
# 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 utility functions and constants."""
from __future__ import annotations
import pytest
from owncastsentry.utils import (
domainify,
escape_markdown,
sanitize_for_markdown,
sanitize_for_plain_text,
should_query_stream,
truncate,
user_agent,
)
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 TestShouldQueryStream:
"""Progressive backoff logic for stream polling."""
@pytest.mark.parametrize(
("counter", "expected"),
[
pytest.param(0, True, id="counter-0-always-query"),
pytest.param(1, True, id="counter-1-always-query"),
pytest.param(4, True, id="counter-4-always-query"),
pytest.param(5, False, id="counter-5-skip-odd"),
pytest.param(6, True, id="counter-6-query-even"),
pytest.param(9, False, id="counter-9-skip-odd"),
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
pytest.param(12, True, id="counter-12-query-mod-3"),
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
pytest.param(15, True, id="counter-15-query-mod-5"),
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
pytest.param(20, True, id="counter-20-query-mod-5"),
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
pytest.param(30, True, id="counter-30-query-mod-15"),
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
pytest.param(45, True, id="counter-45-query-mod-15"),
pytest.param(100, False, id="counter-100-skip-not-mod-15"),
pytest.param(105, True, id="counter-105-query-mod-15"),
],
)
def test_backoff_tiers(self, counter: int, expected: bool) -> None:
"""Return the expected query decision for each backoff tier."""
assert should_query_stream(counter) == expected
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("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("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 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:
"""Truncate text that exceeds the maximum length."""
assert truncate(text, max_length) == expected
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 the given Markdown special character."""
assert escape_markdown(input_text) == expected
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 TestSanitizeForMarkdown:
"""Markdown sanitization combining newline removal and escaping."""
def test_removes_newlines_and_escapes(self) -> None:
"""Remove 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("") == ""
Generated
+419 -334
View File
File diff suppressed because it is too large Load Diff