Compare commits
28
Commits
v1.1.1
..
d02202295c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02202295c
|
||
|
|
58ad476a94
|
||
|
|
38e91add32
|
||
|
|
c837b916c0
|
||
|
|
4d0ae3d1ea
|
||
|
|
2f6d0bc1ed
|
||
|
|
d905740755
|
||
|
|
795184cd17
|
||
|
|
48a4fb9bbf
|
||
|
|
ec42266af3
|
||
|
|
33cc434354
|
||
|
|
0620c675d9
|
||
|
|
179d087e33
|
||
|
|
a60de6dec7
|
||
|
|
60f747725b
|
||
|
|
c42f16416f
|
||
|
|
89e60c5955
|
||
|
|
4464595e7b
|
||
|
|
33d5a8a0da
|
||
|
|
def15b22f9
|
||
|
|
69714e59fa
|
||
|
|
8c209e2bb3
|
||
|
|
0dfe602d3f
|
||
|
|
47c7e97ac0
|
||
|
|
c690702113
|
||
|
|
9be932c0c7
|
||
|
|
295a8c90a9
|
||
|
|
653c27c97c
|
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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)
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Business logic for managing room stream subscriptions."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
InvalidOwncastInstanceError,
|
||||||
|
RoomSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .owncast_client import OwncastClient
|
||||||
|
from .repository import StreamRepository, SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
|
_DOMAIN_CLEANUP_RE = re.compile(r"[^a-z0-9.-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _domainify(url: str) -> str:
|
||||||
|
"""Extract and sanitize a domain from user input."""
|
||||||
|
url = url.strip()
|
||||||
|
|
||||||
|
if "@" in url:
|
||||||
|
url = url.rsplit("@", 1)[1]
|
||||||
|
|
||||||
|
if not url.startswith(("http://", "https://", "//")):
|
||||||
|
url = f"//{url}"
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
domain = (parsed.netloc or parsed.path).lower()
|
||||||
|
domain = domain.partition(":")[0].partition("/")[0]
|
||||||
|
return _DOMAIN_CLEANUP_RE.sub("", domain).strip(".-")
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionManager:
|
||||||
|
"""Coordinates subscription use cases between handlers and repositories."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
owncast_client: OwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the subscription manager."""
|
||||||
|
self.owncast_client = owncast_client
|
||||||
|
self.stream_repo = stream_repo
|
||||||
|
self.subscription_repo = subscription_repo
|
||||||
|
self.log = logger
|
||||||
|
|
||||||
|
async def subscribe(self, room_id: str, url: str) -> str:
|
||||||
|
"""Subscribe a room to stream notifications and return the stream domain.
|
||||||
|
|
||||||
|
:param room_id: Matrix room ID to subscribe.
|
||||||
|
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||||
|
:return: Normalized stream domain.
|
||||||
|
:raises InvalidOwncastInstanceError: If first-time validation fails.
|
||||||
|
:raises AlreadySubscribedError: If the room is already subscribed.
|
||||||
|
"""
|
||||||
|
stream_domain = _domainify(url)
|
||||||
|
|
||||||
|
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
|
||||||
|
if subscription_count == 0:
|
||||||
|
is_valid = await self.owncast_client.validate_instance(stream_domain)
|
||||||
|
if not is_valid:
|
||||||
|
raise InvalidOwncastInstanceError(stream_domain)
|
||||||
|
|
||||||
|
await self.subscription_repo.add(stream_domain, room_id)
|
||||||
|
|
||||||
|
if await self.stream_repo.create(stream_domain):
|
||||||
|
self.log.info("[%s] Discovered new stream!", stream_domain)
|
||||||
|
|
||||||
|
self.log.info("[%s] Subscription added for room %s.", stream_domain, room_id)
|
||||||
|
return stream_domain
|
||||||
|
|
||||||
|
async def unsubscribe(self, room_id: str, url: str) -> str:
|
||||||
|
"""Remove a room subscription and return the stream domain.
|
||||||
|
|
||||||
|
:param room_id: Matrix room ID to unsubscribe.
|
||||||
|
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||||
|
:return: Normalized stream domain.
|
||||||
|
:raises NotSubscribedError: If no subscription was removed.
|
||||||
|
"""
|
||||||
|
stream_domain = _domainify(url)
|
||||||
|
await self.subscription_repo.remove(stream_domain, room_id)
|
||||||
|
|
||||||
|
self.log.info("[%s] Subscription removed for room %s.", stream_domain, room_id)
|
||||||
|
return stream_domain
|
||||||
|
|
||||||
|
async def list_room_subscriptions(self, room_id: str) -> list[RoomSubscription]:
|
||||||
|
"""Return stream subscriptions for a room with display state attached."""
|
||||||
|
return await self.subscription_repo.get_room_subscriptions(room_id)
|
||||||
|
|
||||||
|
async def has_room_subscriptions(self, room_id: str) -> bool:
|
||||||
|
"""Return whether the room has any stream subscriptions."""
|
||||||
|
return await self.subscription_repo.has_room_subscriptions(room_id)
|
||||||
|
|
||||||
|
async def list_live_room_subscriptions(
|
||||||
|
self, room_id: str
|
||||||
|
) -> list[RoomSubscription]:
|
||||||
|
"""Return only currently online stream subscriptions for a room."""
|
||||||
|
return await self.subscription_repo.get_live_room_subscriptions(room_id)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Data containers and domain errors for OwncastSentry."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
UNKNOWN_STATUS_THRESHOLD = 15
|
||||||
|
|
||||||
|
# Maximum field lengths based on Owncast's admin config constants.
|
||||||
|
# Source: https://github.com/owncast/owncast/blob/master/web/utils/config-constants.tsx
|
||||||
|
_MAX_INSTANCE_TITLE_LENGTH = 255 # TEXTFIELD_PROPS_SERVER_NAME maxLength
|
||||||
|
_MAX_STREAM_TITLE_LENGTH = 100 # TEXTFIELD_PROPS_STREAM_TITLE maxLength
|
||||||
|
_MAX_TAG_LENGTH = 24 # tag maxLength
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidApiResponseError(ValueError):
|
||||||
|
"""The Owncast API response did not match the expected shape."""
|
||||||
|
|
||||||
|
|
||||||
|
def _require_field(response: dict[str, Any], field: str) -> Any:
|
||||||
|
"""Return a required API response field or raise if missing."""
|
||||||
|
try:
|
||||||
|
return response[field]
|
||||||
|
except KeyError as e:
|
||||||
|
raise InvalidApiResponseError(f"missing field: {field}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _require_str(response: dict[str, Any], field: str) -> str:
|
||||||
|
"""Return a required string API response field."""
|
||||||
|
value = _require_field(response, field)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise InvalidApiResponseError(f"{field} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_config_str(response: dict[str, Any], field: str) -> str:
|
||||||
|
"""Return an optional config string, defaulting to empty when absent."""
|
||||||
|
value = response.get(field, "")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise InvalidApiResponseError(f"{field} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_tag_list(response: dict[str, Any]) -> list[str]:
|
||||||
|
"""Return optional config tags, defaulting to an empty list when absent."""
|
||||||
|
value = response.get("tags", [])
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise InvalidApiResponseError("tags must be a list")
|
||||||
|
if not all(isinstance(tag, str) for tag in value):
|
||||||
|
raise InvalidApiResponseError("tags must contain only strings")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, max_length: int) -> str:
|
||||||
|
"""Truncate text to a maximum length."""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
return text[:max_length]
|
||||||
|
|
||||||
|
|
||||||
|
def format_status_since(timestamp: datetime) -> str:
|
||||||
|
"""Format a status timestamp using the package's canonical UTC format."""
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=UTC)
|
||||||
|
return timestamp.astimezone(UTC).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _current_observed_at() -> str:
|
||||||
|
"""Return the current UTC time in the package's timestamp format."""
|
||||||
|
return format_status_since(datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
class StreamStatus(Enum):
|
||||||
|
"""Represents the status of a stream."""
|
||||||
|
|
||||||
|
ONLINE = "online"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamStateObservation:
|
||||||
|
"""Status API sample fetched from an Owncast instance.
|
||||||
|
|
||||||
|
This records what `/api/status` reported during one request. `observed_at`
|
||||||
|
belongs to that fetch; the monitor decides whether that time starts a new
|
||||||
|
persisted online/offline period in `StreamState`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
title: str | None = None
|
||||||
|
online: bool = False
|
||||||
|
observed_at: str = field(default_factory=_current_observed_at)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(
|
||||||
|
cls, response: dict[str, Any], domain: str
|
||||||
|
) -> StreamStateObservation:
|
||||||
|
"""Create a StreamStateObservation from a status API response.
|
||||||
|
|
||||||
|
:param response: API response as a dictionary (camelCase keys).
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: StreamStateObservation instance.
|
||||||
|
:raises InvalidApiResponseError: If the response shape is invalid.
|
||||||
|
"""
|
||||||
|
stream_title = _require_str(response, "streamTitle")
|
||||||
|
online = _require_field(response, "online")
|
||||||
|
if not isinstance(online, bool):
|
||||||
|
raise InvalidApiResponseError("online must be a boolean")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
domain=domain,
|
||||||
|
title=_truncate(stream_title, _MAX_STREAM_TITLE_LENGTH),
|
||||||
|
online=online,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamState:
|
||||||
|
"""Stream record remembered by the repository.
|
||||||
|
|
||||||
|
States include persisted display data, the current online/offline period,
|
||||||
|
and failure counters used to derive user-facing status. `status_since`
|
||||||
|
records when the persisted online/offline period began.
|
||||||
|
"""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
name: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
online: bool = False
|
||||||
|
status_since: str | None = None
|
||||||
|
failure_counter: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> StreamStatus:
|
||||||
|
"""Derive stream status from failure count and online state.
|
||||||
|
|
||||||
|
Returns UNKNOWN if failures exceed the threshold, ONLINE if the
|
||||||
|
stream is online, or OFFLINE otherwise.
|
||||||
|
"""
|
||||||
|
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
|
||||||
|
return StreamStatus.UNKNOWN
|
||||||
|
if self.online:
|
||||||
|
return StreamStatus.ONLINE
|
||||||
|
return StreamStatus.OFFLINE
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db_row(cls, row: dict[str, Any]) -> StreamState:
|
||||||
|
"""Create a StreamState from a database row.
|
||||||
|
|
||||||
|
:param row: Database row as a dictionary.
|
||||||
|
:return: StreamState instance.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
domain=row["domain"],
|
||||||
|
name=row["name"],
|
||||||
|
title=row["title"],
|
||||||
|
online=bool(row["online"]),
|
||||||
|
status_since=row["status_since"],
|
||||||
|
failure_counter=row["failure_counter"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamConfigObservation:
|
||||||
|
"""Config API sample fetched from an Owncast instance.
|
||||||
|
|
||||||
|
This records display metadata from one `/api/config` request. It is used for
|
||||||
|
notification text and may refresh persisted display fields independently of
|
||||||
|
the stream's online/offline state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
tags: tuple[str, ...] = ()
|
||||||
|
observed_at: str = field(default_factory=_current_observed_at)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, response: dict[str, Any]) -> StreamConfigObservation:
|
||||||
|
"""Create a StreamConfigObservation from an API response.
|
||||||
|
|
||||||
|
:param response: API response as a dictionary.
|
||||||
|
:return: StreamConfigObservation instance.
|
||||||
|
:raises InvalidApiResponseError: If the response shape is invalid.
|
||||||
|
"""
|
||||||
|
# Apply Owncast's instance name limit.
|
||||||
|
name = _truncate(
|
||||||
|
_optional_config_str(response, "name"), _MAX_INSTANCE_TITLE_LENGTH
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply Owncast's per-tag limit.
|
||||||
|
raw_tags = _optional_tag_list(response)
|
||||||
|
tags = tuple([_truncate(tag, _MAX_TAG_LENGTH) for tag in raw_tags])
|
||||||
|
|
||||||
|
return cls(name=name, tags=tags)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UpdateResult:
|
||||||
|
"""Result of a stream update cycle."""
|
||||||
|
|
||||||
|
total_streams: int
|
||||||
|
successful_checks: int
|
||||||
|
failed_checks: int
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionError(Exception):
|
||||||
|
"""Base class for subscription domain errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidOwncastInstanceError(SubscriptionError):
|
||||||
|
"""The requested domain is not a reachable Owncast instance."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str) -> None:
|
||||||
|
"""Initialize with the rejected stream domain."""
|
||||||
|
self.domain = domain
|
||||||
|
super().__init__(f"invalid Owncast instance: {domain}")
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadySubscribedError(SubscriptionError):
|
||||||
|
"""The room is already subscribed to the stream."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str) -> None:
|
||||||
|
"""Initialize with the duplicate stream domain."""
|
||||||
|
self.domain = domain
|
||||||
|
super().__init__(f"already subscribed: {domain}")
|
||||||
|
|
||||||
|
|
||||||
|
class NotSubscribedError(SubscriptionError):
|
||||||
|
"""The room is not subscribed to the stream."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str) -> None:
|
||||||
|
"""Initialize with the missing stream domain."""
|
||||||
|
self.domain = domain
|
||||||
|
super().__init__(f"not subscribed: {domain}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RoomSubscription:
|
||||||
|
"""A stream subscription resolved with the stream state used for display."""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
stream_state: StreamState
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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` > 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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
||||||
@@ -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"
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+974
-177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for subscription manager business logic."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from owncastsentry.subscription_manager import SubscriptionManager, _domainify
|
||||||
|
from owncastsentry.types import (
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
AlreadySubscribedError,
|
||||||
|
InvalidOwncastInstanceError,
|
||||||
|
NotSubscribedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
|
class _StubOwncastClient:
|
||||||
|
"""Owncast client stub for validation-only manager tests."""
|
||||||
|
|
||||||
|
def __init__(self, *, valid: bool = True) -> None:
|
||||||
|
"""Initialize the stub with a fixed validation result."""
|
||||||
|
self.valid = valid
|
||||||
|
self.validated_domains: list[str] = []
|
||||||
|
|
||||||
|
async def validate_instance(self, domain: str) -> bool:
|
||||||
|
"""Record the domain and return the configured validation result."""
|
||||||
|
self.validated_domains.append(domain)
|
||||||
|
return self.valid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owncast_client() -> _StubOwncastClient:
|
||||||
|
"""Return a validation-only Owncast client stub."""
|
||||||
|
return _StubOwncastClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> SubscriptionManager:
|
||||||
|
"""SubscriptionManager built directly for unit tests."""
|
||||||
|
return SubscriptionManager(
|
||||||
|
owncast_client=owncast_client, # type: ignore[arg-type]
|
||||||
|
stream_repo=stream_repo,
|
||||||
|
subscription_repo=subscription_repo,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainify:
|
||||||
|
"""Domain extraction and sanitization from user input."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("input_url", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("example.com", "example.com", id="bare-domain"),
|
||||||
|
pytest.param(" example.com ", "example.com", id="surrounding-whitespace"),
|
||||||
|
pytest.param("https://example.com", "example.com", id="https-url"),
|
||||||
|
pytest.param("http://example.com", "example.com", id="http-url"),
|
||||||
|
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
|
||||||
|
pytest.param(
|
||||||
|
"https://example.com/path/to/page",
|
||||||
|
"example.com",
|
||||||
|
id="url-with-path",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"user@stream.logal.dev",
|
||||||
|
"stream.logal.dev",
|
||||||
|
id="email-style",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"matrix@notify@stream.logal.dev",
|
||||||
|
"stream.logal.dev",
|
||||||
|
id="last-at-sign-wins",
|
||||||
|
),
|
||||||
|
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
|
||||||
|
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
|
||||||
|
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
|
||||||
|
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
|
||||||
|
pytest.param(
|
||||||
|
"sub.domain.example.com",
|
||||||
|
"sub.domain.example.com",
|
||||||
|
id="subdomain",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extracts_domain(self, input_url: str, expected: str) -> None:
|
||||||
|
"""Extract and sanitize the domain from various input formats."""
|
||||||
|
assert _domainify(input_url) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerSubscribe:
|
||||||
|
"""SubscriptionManager subscribe workflow."""
|
||||||
|
|
||||||
|
async def test_first_subscription_validates_and_creates_stream(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""First subscription validates the instance and creates stream state."""
|
||||||
|
domain = await manager.subscribe(
|
||||||
|
"!room:example.com", "https://Stream.Example/foo"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert domain == "stream.example"
|
||||||
|
assert owncast_client.validated_domains == ["stream.example"]
|
||||||
|
assert await stream_repo.exists("stream.example") is True
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||||
|
"!room:example.com"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_invalid_first_subscription_raises(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Invalid first-time Owncast validation raises a domain error."""
|
||||||
|
owncast_client.valid = False
|
||||||
|
|
||||||
|
with pytest.raises(InvalidOwncastInstanceError) as exc_info:
|
||||||
|
await manager.subscribe("!room:example.com", "bad.example")
|
||||||
|
|
||||||
|
assert exc_info.value.domain == "bad.example"
|
||||||
|
assert owncast_client.validated_domains == ["bad.example"]
|
||||||
|
assert await stream_repo.exists("bad.example") is False
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("bad.example") == []
|
||||||
|
|
||||||
|
async def test_duplicate_subscription_raises(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Duplicate room subscription raises AlreadySubscribedError."""
|
||||||
|
await manager.subscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
with pytest.raises(AlreadySubscribedError) as exc_info:
|
||||||
|
await manager.subscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
assert exc_info.value.domain == "stream.example"
|
||||||
|
assert owncast_client.validated_domains == ["stream.example"]
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||||
|
"!room:example.com"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_existing_stream_new_room_skips_validation(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Domains with existing subscriptions skip remote validation."""
|
||||||
|
await manager.subscribe("!room1:example.com", "stream.example")
|
||||||
|
owncast_client.valid = False
|
||||||
|
|
||||||
|
domain = await manager.subscribe("!room2:example.com", "stream.example")
|
||||||
|
|
||||||
|
assert domain == "stream.example"
|
||||||
|
assert owncast_client.validated_domains == ["stream.example"]
|
||||||
|
rooms = await subscription_repo.get_subscribed_rooms("stream.example")
|
||||||
|
assert sorted(rooms) == [
|
||||||
|
"!room1:example.com",
|
||||||
|
"!room2:example.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerUnsubscribe:
|
||||||
|
"""SubscriptionManager unsubscribe workflow."""
|
||||||
|
|
||||||
|
async def test_removes_existing_subscription(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Existing room subscription is removed and its domain is returned."""
|
||||||
|
await manager.subscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
domain = await manager.unsubscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
assert domain == "stream.example"
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("stream.example") == []
|
||||||
|
|
||||||
|
async def test_missing_subscription_raises(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
) -> None:
|
||||||
|
"""Removing a non-existent subscription raises NotSubscribedError."""
|
||||||
|
with pytest.raises(NotSubscribedError) as exc_info:
|
||||||
|
await manager.unsubscribe("!room:example.com", "missing.example")
|
||||||
|
|
||||||
|
assert exc_info.value.domain == "missing.example"
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerListings:
|
||||||
|
"""SubscriptionManager room listing behavior."""
|
||||||
|
|
||||||
|
async def test_list_room_subscriptions_returns_sorted_stream_states(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return sorted room subscriptions and skip missing stream rows."""
|
||||||
|
await stream_repo.create("beta.example")
|
||||||
|
await stream_repo.update("beta.example", name="Beta")
|
||||||
|
await stream_repo.create("alpha.example")
|
||||||
|
await stream_repo.update("alpha.example", name="Alpha")
|
||||||
|
await subscription_repo.add("beta.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("missing.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("alpha.example", "!room:example.com")
|
||||||
|
|
||||||
|
subscriptions = await manager.list_room_subscriptions("!room:example.com")
|
||||||
|
|
||||||
|
assert [subscription.domain for subscription in subscriptions] == [
|
||||||
|
"alpha.example",
|
||||||
|
"beta.example",
|
||||||
|
]
|
||||||
|
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
||||||
|
"Alpha",
|
||||||
|
"Beta",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_list_live_room_subscriptions_filters_online_streams(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Live listing includes only subscriptions with ONLINE stream status."""
|
||||||
|
await stream_repo.create("offline.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"offline.example",
|
||||||
|
name="Offline",
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await stream_repo.create("online.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"online.example",
|
||||||
|
name="Online",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await stream_repo.create("unknown.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"unknown.example",
|
||||||
|
name="Unknown",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||||
|
await stream_repo.increment_failure_counter("unknown.example")
|
||||||
|
|
||||||
|
await subscription_repo.add("offline.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("online.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("unknown.example", "!room:example.com")
|
||||||
|
|
||||||
|
subscriptions = await manager.list_live_room_subscriptions("!room:example.com")
|
||||||
|
|
||||||
|
assert [subscription.domain for subscription in subscriptions] == [
|
||||||
|
"online.example"
|
||||||
|
]
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for data models."""
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
from datetime import UTC, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time_machine
|
||||||
|
|
||||||
|
from owncastsentry.types import (
|
||||||
|
_MAX_INSTANCE_TITLE_LENGTH,
|
||||||
|
_MAX_STREAM_TITLE_LENGTH,
|
||||||
|
_MAX_TAG_LENGTH,
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
AlreadySubscribedError,
|
||||||
|
InvalidApiResponseError,
|
||||||
|
InvalidOwncastInstanceError,
|
||||||
|
NotSubscribedError,
|
||||||
|
RoomSubscription,
|
||||||
|
StreamConfigObservation,
|
||||||
|
StreamState,
|
||||||
|
StreamStateObservation,
|
||||||
|
StreamStatus,
|
||||||
|
SubscriptionError,
|
||||||
|
UpdateResult,
|
||||||
|
_truncate,
|
||||||
|
format_status_since,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncate:
|
||||||
|
"""Text truncation to a maximum length."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("text", "max_length", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("hello", 10, "hello", id="under-limit"),
|
||||||
|
pytest.param("hello", 5, "hello", id="exact-limit"),
|
||||||
|
pytest.param("hello world", 5, "hello", id="over-limit"),
|
||||||
|
pytest.param("", 5, "", id="empty-string"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
|
||||||
|
"""Return text unchanged within the limit and truncate over-limit text."""
|
||||||
|
assert _truncate(text, max_length) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamStateStatus:
|
||||||
|
"""Stream status derivation from state fields."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("failure_counter", "online", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||||
|
False,
|
||||||
|
StreamStatus.UNKNOWN,
|
||||||
|
id="above-threshold-offline-returns-unknown",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||||
|
True,
|
||||||
|
StreamStatus.UNKNOWN,
|
||||||
|
id="above-threshold-online-returns-unknown",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
0,
|
||||||
|
True,
|
||||||
|
StreamStatus.ONLINE,
|
||||||
|
id="zero-failures-online-returns-online",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
0,
|
||||||
|
False,
|
||||||
|
StreamStatus.OFFLINE,
|
||||||
|
id="zero-failures-offline-returns-offline",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
True,
|
||||||
|
StreamStatus.ONLINE,
|
||||||
|
id="at-threshold-online-returns-online",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
False,
|
||||||
|
StreamStatus.OFFLINE,
|
||||||
|
id="at-threshold-offline-returns-offline",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_status(
|
||||||
|
self,
|
||||||
|
failure_counter: int,
|
||||||
|
online: object,
|
||||||
|
expected: StreamStatus,
|
||||||
|
) -> None:
|
||||||
|
"""Return the correct status based on failure counter and online state."""
|
||||||
|
state = StreamState(
|
||||||
|
domain="example.com",
|
||||||
|
failure_counter=failure_counter,
|
||||||
|
online=online is True,
|
||||||
|
)
|
||||||
|
assert state.status is expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatStatusSince:
|
||||||
|
"""Status timestamp formatting."""
|
||||||
|
|
||||||
|
def test_formats_as_utc_iso_seconds(self) -> None:
|
||||||
|
"""Normalize aware datetimes to UTC with second precision."""
|
||||||
|
timestamp = datetime(
|
||||||
|
2026,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
34,
|
||||||
|
56,
|
||||||
|
123456,
|
||||||
|
tzinfo=timezone(timedelta(hours=-5)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert format_status_since(timestamp) == "2026-01-01T17:34:56+00:00"
|
||||||
|
|
||||||
|
def test_treats_naive_as_utc(self) -> None:
|
||||||
|
"""Format naive datetimes as UTC."""
|
||||||
|
timestamp = datetime.fromisoformat("2026-01-01T12:34:56.123456")
|
||||||
|
|
||||||
|
assert format_status_since(timestamp) == "2026-01-01T12:34:56+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamStateObservationFromApiResponse:
|
||||||
|
"""StreamStateObservation construction from an API response dictionary."""
|
||||||
|
|
||||||
|
def test_typical_response(self) -> None:
|
||||||
|
"""Populate API-derived fields from a complete status response."""
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
response = {
|
||||||
|
"streamTitle": "My Stream",
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
observation = StreamStateObservation.from_api_response(
|
||||||
|
response, "example.com"
|
||||||
|
)
|
||||||
|
assert observation.domain == "example.com"
|
||||||
|
assert observation.title == "My Stream"
|
||||||
|
assert observation.online is True
|
||||||
|
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||||
|
|
||||||
|
def test_missing_required_field_raises(self) -> None:
|
||||||
|
"""Reject API responses without required stream state fields."""
|
||||||
|
with pytest.raises(InvalidApiResponseError):
|
||||||
|
StreamStateObservation.from_api_response({}, "bare.example.com")
|
||||||
|
|
||||||
|
def test_offline_response(self) -> None:
|
||||||
|
"""Populate offline state from a status response."""
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
response = {
|
||||||
|
"streamTitle": "Offline Stream",
|
||||||
|
"online": False,
|
||||||
|
}
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
observation = StreamStateObservation.from_api_response(
|
||||||
|
response, "example.com"
|
||||||
|
)
|
||||||
|
assert observation.online is False
|
||||||
|
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||||
|
|
||||||
|
def test_title_truncation(self) -> None:
|
||||||
|
"""Truncate the stream title to _MAX_STREAM_TITLE_LENGTH."""
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
long_title = "A" * (_MAX_STREAM_TITLE_LENGTH + 50)
|
||||||
|
response = {
|
||||||
|
"streamTitle": long_title,
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
observation = StreamStateObservation.from_api_response(
|
||||||
|
response, "example.com"
|
||||||
|
)
|
||||||
|
assert len(observation.title) == _MAX_STREAM_TITLE_LENGTH
|
||||||
|
assert observation.title == "A" * _MAX_STREAM_TITLE_LENGTH
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[
|
||||||
|
pytest.param("streamTitle", 123, id="title-not-string"),
|
||||||
|
pytest.param("online", "true", id="online-not-bool"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||||
|
"""Reject stream state responses with malformed field types."""
|
||||||
|
response: dict[str, object] = {
|
||||||
|
"streamTitle": "My Stream",
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
response[field] = value
|
||||||
|
|
||||||
|
with pytest.raises(InvalidApiResponseError):
|
||||||
|
StreamStateObservation.from_api_response(response, "example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamStateFromDbRow:
|
||||||
|
"""StreamState construction from a database row dictionary."""
|
||||||
|
|
||||||
|
def test_typical_row(self) -> None:
|
||||||
|
"""Populate all fields from a complete database row."""
|
||||||
|
row = {
|
||||||
|
"domain": "example.com",
|
||||||
|
"name": "Test Instance",
|
||||||
|
"title": "Live Now",
|
||||||
|
"online": True,
|
||||||
|
"status_since": "2026-01-01T00:00:00+00:00",
|
||||||
|
"failure_counter": 3,
|
||||||
|
}
|
||||||
|
state = StreamState.from_db_row(row)
|
||||||
|
assert state.domain == "example.com"
|
||||||
|
assert state.name == "Test Instance"
|
||||||
|
assert state.title == "Live Now"
|
||||||
|
assert state.online is True
|
||||||
|
assert state.status_since == "2026-01-01T00:00:00+00:00"
|
||||||
|
assert state.failure_counter == 3
|
||||||
|
|
||||||
|
def test_row_with_none_optional_fields(self) -> None:
|
||||||
|
"""Accept None for optional fields in a database row."""
|
||||||
|
row = {
|
||||||
|
"domain": "example.com",
|
||||||
|
"name": None,
|
||||||
|
"title": None,
|
||||||
|
"online": False,
|
||||||
|
"status_since": None,
|
||||||
|
"failure_counter": 0,
|
||||||
|
}
|
||||||
|
state = StreamState.from_db_row(row)
|
||||||
|
assert state.domain == "example.com"
|
||||||
|
assert state.name is None
|
||||||
|
assert state.title is None
|
||||||
|
assert state.online is False
|
||||||
|
assert state.status_since is None
|
||||||
|
assert state.failure_counter == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamConfigObservationFromApiResponse:
|
||||||
|
"""StreamConfigObservation construction from an API response dictionary."""
|
||||||
|
|
||||||
|
def test_typical_response(self) -> None:
|
||||||
|
"""Populate name and tags from a complete API response."""
|
||||||
|
response = {"name": "My Instance", "tags": ["gaming", "music"]}
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
config = StreamConfigObservation.from_api_response(response)
|
||||||
|
assert config.name == "My Instance"
|
||||||
|
assert config.tags == ("gaming", "music")
|
||||||
|
assert config.observed_at == "2026-01-01T00:00:01+00:00"
|
||||||
|
|
||||||
|
def test_missing_keys_defaults(self) -> None:
|
||||||
|
"""Use defaults when name and tags keys are missing."""
|
||||||
|
config = StreamConfigObservation.from_api_response({})
|
||||||
|
assert config.name == ""
|
||||||
|
assert config.tags == ()
|
||||||
|
|
||||||
|
def test_name_truncation(self) -> None:
|
||||||
|
"""Truncate the instance name to _MAX_INSTANCE_TITLE_LENGTH."""
|
||||||
|
long_name = "B" * (_MAX_INSTANCE_TITLE_LENGTH + 50)
|
||||||
|
response = {"name": long_name, "tags": []}
|
||||||
|
config = StreamConfigObservation.from_api_response(response)
|
||||||
|
assert len(config.name) == _MAX_INSTANCE_TITLE_LENGTH
|
||||||
|
assert config.name == "B" * _MAX_INSTANCE_TITLE_LENGTH
|
||||||
|
|
||||||
|
def test_tag_truncation(self) -> None:
|
||||||
|
"""Truncate each tag to _MAX_TAG_LENGTH."""
|
||||||
|
long_tag = "C" * (_MAX_TAG_LENGTH + 10)
|
||||||
|
response = {"name": "", "tags": [long_tag, "short"]}
|
||||||
|
config = StreamConfigObservation.from_api_response(response)
|
||||||
|
assert len(config.tags[0]) == _MAX_TAG_LENGTH
|
||||||
|
assert config.tags[0] == "C" * _MAX_TAG_LENGTH
|
||||||
|
assert config.tags[1] == "short"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[
|
||||||
|
pytest.param("name", None, id="name-not-string"),
|
||||||
|
pytest.param("tags", "gaming", id="tags-not-list"),
|
||||||
|
pytest.param("tags", ["gaming", 123], id="tag-not-string"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||||
|
"""Reject stream config responses with malformed field types."""
|
||||||
|
response: dict[str, object] = {"name": "My Instance", "tags": ["gaming"]}
|
||||||
|
response[field] = value
|
||||||
|
|
||||||
|
with pytest.raises(InvalidApiResponseError):
|
||||||
|
StreamConfigObservation.from_api_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValueTypeImmutability:
|
||||||
|
"""Dataclass value containers are immutable snapshots."""
|
||||||
|
|
||||||
|
def test_stream_state_is_immutable(self) -> None:
|
||||||
|
"""StreamState cannot be mutated in place."""
|
||||||
|
state = StreamState(domain="stream.example")
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
state.title = "Changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_stream_observation_is_immutable(self) -> None:
|
||||||
|
"""StreamStateObservation cannot be mutated in place."""
|
||||||
|
observation = StreamStateObservation(
|
||||||
|
domain="stream.example",
|
||||||
|
)
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
observation.title = "Changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_stream_config_is_immutable(self) -> None:
|
||||||
|
"""StreamConfigObservation cannot be mutated in place."""
|
||||||
|
config = StreamConfigObservation(name="Stream")
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
config.name = "Changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_stream_config_tags_are_immutable(self) -> None:
|
||||||
|
"""StreamConfigObservation tags are stored in an immutable tuple."""
|
||||||
|
config = StreamConfigObservation(name="Stream", tags=("gaming",))
|
||||||
|
assert config.tags == ("gaming",)
|
||||||
|
|
||||||
|
def test_update_result_is_immutable(self) -> None:
|
||||||
|
"""UpdateResult cannot be mutated in place."""
|
||||||
|
result = UpdateResult(total_streams=1, successful_checks=1, failed_checks=0)
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
result.failed_checks = 1 # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubscriptionTypes:
|
||||||
|
"""Subscription display containers and domain error hierarchy."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error_cls",
|
||||||
|
[
|
||||||
|
pytest.param(InvalidOwncastInstanceError, id="invalid-instance"),
|
||||||
|
pytest.param(AlreadySubscribedError, id="already-subscribed"),
|
||||||
|
pytest.param(NotSubscribedError, id="not-subscribed"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_errors_subclass_subscription_error(
|
||||||
|
self, error_cls: type[Exception]
|
||||||
|
) -> None:
|
||||||
|
"""Every subscription domain error subclasses SubscriptionError."""
|
||||||
|
assert issubclass(error_cls, SubscriptionError)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
InvalidOwncastInstanceError("bad.example"),
|
||||||
|
id="invalid-instance",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
AlreadySubscribedError("dupe.example"),
|
||||||
|
id="already-subscribed",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
NotSubscribedError("missing.example"),
|
||||||
|
id="not-subscribed",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_errors_store_domain(self, error: SubscriptionError) -> None:
|
||||||
|
"""Subscription domain errors expose the stream domain that failed."""
|
||||||
|
assert error.domain in str(error)
|
||||||
|
|
||||||
|
def test_room_subscription_is_immutable(self) -> None:
|
||||||
|
"""RoomSubscription is an immutable stream display snapshot."""
|
||||||
|
state = StreamState(domain="stream.example")
|
||||||
|
subscription = RoomSubscription(domain="stream.example", stream_state=state)
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
subscription.domain = "other.example" # type: ignore[misc]
|
||||||
@@ -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("") == ""
|
|
||||||
Reference in New Issue
Block a user