Modernized codebase with tooling configuration and CI/CD workflows.
- Replaced legacy typing (Optional, List, Type, Union, Tuple) with PEP 604/585 equivalents. - Added pyproject.toml with configurations for hatch-vcs, mypy, ruff, and codespell. - Added CI workflows for formatting, linting, type checking, and spelling. - Added CD workflow for building and uploading plugin artifacts on push to master and version tags. - Added dependency auditing workflow with pip-audit. - Added comprehensive docstrings and inline comments across all modules. - Fixed User-Agent header using hardcoded version instead of actual plugin version. - Fixed grammar and terminology in log messages and comments. - Removed unreachable error handling branch in unsubscribe command.
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
name: Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1"
|
||||||
|
push:
|
||||||
|
paths: [uv.lock]
|
||||||
|
pull_request:
|
||||||
|
paths: [uv.lock]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
name: Dependencies
|
||||||
|
runs-on: logaldeveloper-archlinux
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Audit dependencies with pip-audit
|
||||||
|
run: uv run pip-audit --skip-editable
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: logaldeveloper-archlinux
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
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
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
version=$(uv run hatch version)
|
||||||
|
echo "version=$version"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Write version into maubot.yaml
|
||||||
|
run: |
|
||||||
|
sed -i "s/^version: .*/version: ${{ steps.version.outputs.version }}/" maubot.yaml
|
||||||
|
grep "^version:" maubot.yaml
|
||||||
|
|
||||||
|
- name: Build plugin
|
||||||
|
run: uv run mbc build -o owncastsentry-v${{ steps.version.outputs.version }}.mbp
|
||||||
|
|
||||||
|
- name: Upload plugin artifact
|
||||||
|
uses: https://github.com/christopherhx/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2 # v4
|
||||||
|
with:
|
||||||
|
name: owncastsentry-v${{ steps.version.outputs.version }}
|
||||||
|
path: owncastsentry-v${{ steps.version.outputs.version }}.mbp
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
formatting:
|
||||||
|
name: Formatting
|
||||||
|
runs-on: logaldeveloper-archlinux
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Check formatting with Ruff
|
||||||
|
run: uv run ruff format --check --diff .
|
||||||
|
|
||||||
|
linting:
|
||||||
|
name: Linting
|
||||||
|
runs-on: logaldeveloper-archlinux
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Check linting with Ruff
|
||||||
|
run: uv run ruff check .
|
||||||
|
|
||||||
|
type-checking:
|
||||||
|
name: Type Checking
|
||||||
|
runs-on: logaldeveloper-archlinux
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Check types with Mypy
|
||||||
|
run: uv run mypy owncastsentry/
|
||||||
|
|
||||||
|
spelling:
|
||||||
|
name: Spelling
|
||||||
|
runs-on: logaldeveloper-archlinux
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Check spelling with codespell
|
||||||
|
run: uv run codespell
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
owncastsentry/_version.py
|
||||||
|
|||||||
+27
-1
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
Apache License
|
Apache License
|
||||||
Version 2.0, January 2004
|
Version 2.0, January 2004
|
||||||
https://www.apache.org/licenses/
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
@@ -174,3 +175,28 @@
|
|||||||
of your accepting any such warranty or additional liability.
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|||||||
+44
-70
@@ -1,24 +1,36 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://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.
|
||||||
|
|
||||||
from typing import Type
|
"""OwncastSentry maubot plugin."""
|
||||||
|
|
||||||
from maubot import Plugin, MessageEvent
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from maubot import MessageEvent, Plugin # type: ignore[attr-defined]
|
||||||
from maubot.handlers import command
|
from maubot.handlers import command
|
||||||
from mautrix.util.async_db import UpgradeTable
|
|
||||||
from mautrix.util.config import BaseProxyConfig
|
|
||||||
|
|
||||||
from .migrations import get_upgrade_table
|
|
||||||
from .owncast_client import OwncastClient
|
|
||||||
from .database import StreamRepository, SubscriptionRepository
|
|
||||||
from .notification_service import NotificationService
|
|
||||||
from .stream_monitor import StreamMonitor
|
|
||||||
from .commands import CommandHandler
|
from .commands import CommandHandler
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .database import StreamRepository, SubscriptionRepository
|
||||||
from .health_checker import HealthChecker
|
from .health_checker import HealthChecker
|
||||||
|
from .migrations import get_upgrade_table
|
||||||
|
from .notification_service import NotificationService
|
||||||
|
from .owncast_client import OwncastClient
|
||||||
|
from .stream_monitor import StreamMonitor
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mautrix.util.async_db import Database, UpgradeTable
|
||||||
|
from mautrix.util.config import BaseProxyConfig
|
||||||
|
|
||||||
|
|
||||||
class OwncastSentry(Plugin):
|
class OwncastSentry(Plugin):
|
||||||
@@ -26,38 +38,31 @@ class OwncastSentry(Plugin):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_db_upgrade_table(cls) -> UpgradeTable | None:
|
def get_db_upgrade_table(cls) -> UpgradeTable | None:
|
||||||
"""
|
"""Return the database upgrade table for Maubot."""
|
||||||
Helper method for telling Maubot about our database migrations.
|
|
||||||
|
|
||||||
:return: An UpgradeTable with our registered migrations.
|
|
||||||
"""
|
|
||||||
return get_upgrade_table()
|
return get_upgrade_table()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_class(cls) -> Type[BaseProxyConfig]:
|
def get_config_class(cls) -> type[BaseProxyConfig]:
|
||||||
"""
|
"""Return the configuration class for Maubot."""
|
||||||
Helper method for telling Maubot about our configuration class.
|
|
||||||
|
|
||||||
:return: The Config class.
|
|
||||||
"""
|
|
||||||
return Config
|
return Config
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""
|
"""Initialize all services and register recurring tasks.
|
||||||
Method called by Maubot upon startup of the instance.
|
|
||||||
Initializes all services and registers a recurring task every minute to update the state of all subscribed streams.
|
|
||||||
|
|
||||||
:return: Nothing.
|
Registers a recurring task every minute to update the state of
|
||||||
|
all subscribed streams.
|
||||||
"""
|
"""
|
||||||
# Load configuration
|
# Load configuration
|
||||||
self.config.load_and_update()
|
config: Config = self.config # type: ignore[assignment]
|
||||||
|
config.load_and_update()
|
||||||
|
db: Database = self.database # type: ignore[assignment]
|
||||||
|
|
||||||
# Initialize the Owncast API client
|
# Initialize the Owncast API client
|
||||||
self.owncast_client = OwncastClient(self.log)
|
self.owncast_client = OwncastClient(self.log, str(self.loader.meta.version))
|
||||||
|
|
||||||
# Initialize repositories
|
# Initialize repositories
|
||||||
self.stream_repo = StreamRepository(self.database)
|
self.stream_repo = StreamRepository(db)
|
||||||
self.subscription_repo = SubscriptionRepository(self.database)
|
self.subscription_repo = SubscriptionRepository(db)
|
||||||
|
|
||||||
# Initialize notification service
|
# Initialize notification service
|
||||||
self.notification_service = NotificationService(
|
self.notification_service = NotificationService(
|
||||||
@@ -74,7 +79,7 @@ class OwncastSentry(Plugin):
|
|||||||
|
|
||||||
# Initialize health checker
|
# Initialize health checker
|
||||||
self.health_checker = HealthChecker(
|
self.health_checker = HealthChecker(
|
||||||
self.database,
|
db,
|
||||||
self.owncast_client,
|
self.owncast_client,
|
||||||
self.log,
|
self.log,
|
||||||
)
|
)
|
||||||
@@ -91,12 +96,7 @@ class OwncastSentry(Plugin):
|
|||||||
self.sched.run_periodically(60, self._update_all_stream_states)
|
self.sched.run_periodically(60, self._update_all_stream_states)
|
||||||
|
|
||||||
async def _update_all_stream_states(self) -> None:
|
async def _update_all_stream_states(self) -> None:
|
||||||
"""
|
"""Update all stream states and perform health check."""
|
||||||
Wrapper method for updating all stream states.
|
|
||||||
Fetches list of subscribed domains, delegates to StreamMonitor, and performs health check.
|
|
||||||
|
|
||||||
:return: Nothing.
|
|
||||||
"""
|
|
||||||
# Get list of all stream domains with active subscriptions
|
# Get list of all stream domains with active subscriptions
|
||||||
subscribed_domains = await self.subscription_repo.get_all_subscribed_domains()
|
subscribed_domains = await self.subscription_repo.get_all_subscribed_domains()
|
||||||
|
|
||||||
@@ -104,60 +104,34 @@ class OwncastSentry(Plugin):
|
|||||||
update_result = await self.stream_monitor.update_all_streams(subscribed_domains)
|
update_result = await self.stream_monitor.update_all_streams(subscribed_domains)
|
||||||
|
|
||||||
# Perform health check
|
# Perform health check
|
||||||
|
config: Config = self.config # type: ignore[assignment]
|
||||||
await self.health_checker.perform_health_check(
|
await self.health_checker.perform_health_check(
|
||||||
update_result,
|
update_result,
|
||||||
self.config.health_check_endpoint,
|
config.health_check_endpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
@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."""
|
||||||
Command handler that delegates to CommandHandler.
|
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
|
||||||
:param url: A string containing the user supplied URL to a stream to try and subscribe to.
|
|
||||||
:return: Nothing.
|
|
||||||
"""
|
|
||||||
await self.command_handler.subscribe(evt, url)
|
await self.command_handler.subscribe(evt, url)
|
||||||
|
|
||||||
@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."""
|
||||||
Command handler that delegates to CommandHandler.
|
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
|
||||||
:param url: A string containing the user supplied URL to a stream to try and unsubscribe from.
|
|
||||||
:return: Nothing.
|
|
||||||
"""
|
|
||||||
await self.command_handler.unsubscribe(evt, url)
|
await self.command_handler.unsubscribe(evt, url)
|
||||||
|
|
||||||
@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."""
|
||||||
Command handler that delegates to CommandHandler.
|
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
|
||||||
:return: Nothing.
|
|
||||||
"""
|
|
||||||
await self.command_handler.subscriptions(evt)
|
await self.command_handler.subscriptions(evt)
|
||||||
|
|
||||||
@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."""
|
||||||
Command handler that delegates to CommandHandler.
|
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
|
||||||
:return: Nothing.
|
|
||||||
"""
|
|
||||||
await self.command_handler.live(evt)
|
await self.command_handler.live(evt)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""
|
"""Clean up resources by closing the HTTP session."""
|
||||||
Method called by Maubot upon shutdown of the instance.
|
|
||||||
Closes the HTTP session.
|
|
||||||
|
|
||||||
:return: Nothing.
|
|
||||||
"""
|
|
||||||
await self.owncast_client.close()
|
await self.owncast_client.close()
|
||||||
|
|||||||
+101
-79
@@ -1,37 +1,51 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Command handlers for OwncastSentry bot commands."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from maubot import MessageEvent
|
from typing import TYPE_CHECKING
|
||||||
from mautrix.types import TextMessageEventContent, MessageType
|
|
||||||
|
|
||||||
from .owncast_client import OwncastClient
|
|
||||||
from .database import StreamRepository, SubscriptionRepository
|
|
||||||
from .models import StreamStatus
|
from .models import StreamStatus
|
||||||
from .utils import domainify, sanitize_for_markdown
|
from .utils import domainify, sanitize_for_markdown
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from maubot import MessageEvent # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
from .database import StreamRepository, SubscriptionRepository
|
||||||
|
from .owncast_client import OwncastClient
|
||||||
|
|
||||||
|
|
||||||
class CommandHandler:
|
class CommandHandler:
|
||||||
"""Handles bot commands for subscribing to streams."""
|
"""Handles bot commands for managing stream subscriptions."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
owncast_client: OwncastClient,
|
owncast_client: OwncastClient,
|
||||||
stream_repo: StreamRepository,
|
stream_repo: StreamRepository,
|
||||||
subscription_repo: SubscriptionRepository,
|
subscription_repo: SubscriptionRepository,
|
||||||
logger,
|
logger: logging.Logger,
|
||||||
):
|
) -> None:
|
||||||
"""
|
"""Initialize the command handler.
|
||||||
Initialize the command handler.
|
|
||||||
|
|
||||||
:param owncast_client: Client for making API calls to Owncast instances
|
:param owncast_client: Client for making API calls to Owncast instances.
|
||||||
:param stream_repo: Repository for stream data
|
:param stream_repo: Repository for stream data.
|
||||||
:param subscription_repo: Repository for subscription data
|
:param subscription_repo: Repository for subscription data.
|
||||||
:param logger: Logger instance
|
:param logger: Logger instance for debugging.
|
||||||
"""
|
"""
|
||||||
self.owncast_client = owncast_client
|
self.owncast_client = owncast_client
|
||||||
self.stream_repo = stream_repo
|
self.stream_repo = stream_repo
|
||||||
@@ -39,12 +53,10 @@ class CommandHandler:
|
|||||||
self.log = logger
|
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" command handler for users to subscribe a room to a given stream's notifications.
|
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
:param evt: MessageEvent of the message calling the command.
|
||||||
:param url: A string containing the user supplied URL to a stream to try and subscribe to.
|
:param url: User supplied URL to a stream to subscribe to.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# Convert the user input to only a domain
|
# Convert the user input to only a domain
|
||||||
stream_domain = domainify(url)
|
stream_domain = domainify(url)
|
||||||
@@ -53,50 +65,56 @@ class CommandHandler:
|
|||||||
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
|
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
|
||||||
|
|
||||||
if subscription_count == 0:
|
if subscription_count == 0:
|
||||||
# There are 0 subscriptions, we need to validate this domain is an Owncast stream.
|
# No subscriptions; validate this is an Owncast stream.
|
||||||
is_valid = await self.owncast_client.validate_instance(stream_domain)
|
is_valid = await self.owncast_client.validate_instance(stream_domain)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
# The stream state fetch returned nothing. Probably not an Owncast stream.
|
# Fetch returned nothing. Probably not Owncast.
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"The URL you supplied does not appear to be a valid Owncast instance. You may have specified an invalid domain, or the instance is offline."
|
"The URL you supplied does not appear to "
|
||||||
|
"be a valid Owncast instance. You may have "
|
||||||
|
"specified an invalid domain, or the "
|
||||||
|
"instance is offline."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try to add a new subscription for the requested stream domain in the room the command was executed in
|
# Try to add a new subscription for this stream in this room
|
||||||
try:
|
try:
|
||||||
await self.subscription_repo.add(stream_domain, evt.room_id)
|
await self.subscription_repo.add(stream_domain, evt.room_id)
|
||||||
except sqlite3.IntegrityError as exception:
|
except sqlite3.IntegrityError as exception:
|
||||||
# Something weird happened... Was it due to attempting to insert a duplicate row?
|
# Was it a duplicate row?
|
||||||
if "UNIQUE constraint failed" in exception.args[0]:
|
if "UNIQUE constraint failed" in exception.args[0]:
|
||||||
# Yes, this is an expected condition. Tell the user the room is already subscribed and give up.
|
# Expected: room is already subscribed.
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"This room is already subscribed to notifications for "
|
"This room is already subscribed to notifications for "
|
||||||
+ stream_domain
|
+ stream_domain
|
||||||
+ "."
|
+ "."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
else:
|
# Something unexpected happened. Give up.
|
||||||
# Nope... Something unexpected happened. Give up.
|
|
||||||
self.log.error(
|
self.log.error(
|
||||||
f"[{stream_domain}] An error occurred while attempting to add subscription in room {evt.room_id}: {exception}"
|
f"[{stream_domain}] An error occurred while "
|
||||||
|
f"attempting to add subscription in room "
|
||||||
|
f"{evt.room_id}: {exception}"
|
||||||
)
|
)
|
||||||
raise exception
|
raise exception
|
||||||
|
|
||||||
# The subscription was successfully added! Try to add a placeholder row for the stream's state in the streams table.
|
# Try to add a placeholder row for the stream's state.
|
||||||
try:
|
try:
|
||||||
await self.stream_repo.create(stream_domain)
|
await self.stream_repo.create(stream_domain)
|
||||||
# The insert was successful, so this is the first time we're seeing this stream. Log it.
|
# First time seeing this stream. Log it.
|
||||||
self.log.info(f"[{stream_domain}] Discovered new stream!")
|
self.log.info(f"[{stream_domain}] Discovered new stream!")
|
||||||
except sqlite3.IntegrityError as exception:
|
except sqlite3.IntegrityError as exception:
|
||||||
# Attempts to add rows for streams already known is an expected condition. What is anything except that?
|
# Adding rows for known streams is expected.
|
||||||
if "UNIQUE constraint failed" not in exception.args[0]:
|
if "UNIQUE constraint failed" not in exception.args[0]:
|
||||||
# Something unexpected happened. Give up.
|
# Something unexpected happened. Give up.
|
||||||
self.log.error(
|
self.log.error(
|
||||||
f"[{stream_domain}] An error occurred while attempting to add stream information after adding subscription: {exception}"
|
f"[{stream_domain}] An error occurred while "
|
||||||
|
f"attempting to add stream information "
|
||||||
|
f"after adding subscription: {exception}"
|
||||||
)
|
)
|
||||||
raise exception
|
raise exception
|
||||||
|
|
||||||
# All went well! We added a new subscription and (at least tried) to add a row for the stream state. Tell the user.
|
# All went well! Tell the user.
|
||||||
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
|
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 "
|
"Subscription added! This room will receive notifications when "
|
||||||
@@ -105,17 +123,15 @@ class CommandHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
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" command handler for users to unsubscribe a room from a given stream's notifications.
|
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
:param evt: MessageEvent of the message calling the command.
|
||||||
:param url: A string containing the user supplied URL to a stream to try and unsubscribe from.
|
:param url: User supplied URL to a stream to unsubscribe from.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# Convert the user input to only a domain
|
# Convert the user input to only a domain
|
||||||
stream_domain = domainify(url)
|
stream_domain = domainify(url)
|
||||||
|
|
||||||
# Attempt to delete the requested subscription from the database
|
# Attempt to delete the requested subscription
|
||||||
result = await self.subscription_repo.remove(stream_domain, evt.room_id)
|
result = await self.subscription_repo.remove(stream_domain, evt.room_id)
|
||||||
|
|
||||||
# Did it work?
|
# Did it work?
|
||||||
@@ -125,80 +141,74 @@ class CommandHandler:
|
|||||||
f"[{stream_domain}] Subscription removed for room {evt.room_id}."
|
f"[{stream_domain}] Subscription removed for room {evt.room_id}."
|
||||||
)
|
)
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"Subscription removed! This room will no longer receive notifications for "
|
"Subscription removed! This room will no "
|
||||||
+ stream_domain
|
"longer receive notifications for " + stream_domain + "."
|
||||||
+ "."
|
|
||||||
)
|
)
|
||||||
elif result == 0:
|
else:
|
||||||
# No, nothing changed. Tell the user.
|
# No, nothing changed. Tell the user.
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"This room is already not subscribed to notifications for "
|
"This room is already not subscribed to notifications for "
|
||||||
+ stream_domain
|
+ stream_domain
|
||||||
+ "."
|
+ "."
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Somehow more than 1 (or even less than 0 ???) rows were changed... Log it!
|
|
||||||
self.log.error(
|
|
||||||
"Encountered strange situation! Expected 0 or 1 rows on DELETE query for removing subscription; got "
|
|
||||||
+ str(result)
|
|
||||||
+ " instead. Something very bad may have happened!!!!"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _format_duration(self, timestamp_str: str) -> str:
|
def _format_duration(self, timestamp_str: str) -> str:
|
||||||
"""
|
"""Calculate and format the duration from a timestamp to now.
|
||||||
Calculate and format the duration from a timestamp to now.
|
|
||||||
|
|
||||||
:param timestamp_str: ISO 8601 timestamp string
|
:param timestamp_str: ISO 8601 timestamp string.
|
||||||
:return: Formatted duration string (e.g., "1 hour", "2 days")
|
:return: Formatted duration string (e.g., "1 hour", "2 days").
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
delta = now - timestamp
|
delta = now - timestamp
|
||||||
|
|
||||||
seconds = int(delta.total_seconds())
|
seconds = int(delta.total_seconds())
|
||||||
if seconds < 60:
|
if seconds < 60:
|
||||||
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
||||||
elif seconds < 3600:
|
if seconds < 3600:
|
||||||
minutes = seconds // 60
|
minutes = seconds // 60
|
||||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||||
elif seconds < 86400:
|
if seconds < 86400:
|
||||||
hours = seconds // 3600
|
hours = seconds // 3600
|
||||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||||
else:
|
|
||||||
days = seconds // 86400
|
days = seconds // 86400
|
||||||
return f"{days} day{'s' if days != 1 else ''}"
|
return f"{days} day{'s' if days != 1 else ''}"
|
||||||
except Exception:
|
except Exception:
|
||||||
return "unknown duration"
|
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.
|
||||||
"!subscriptions" command handler for listing 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.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# Get all stream domains this room is subscribed to
|
# Get all stream domains this room is subscribed to
|
||||||
subscribed_domains = await self.subscription_repo.get_subscribed_streams_for_room(
|
subscribed_domains = (
|
||||||
evt.room_id
|
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are no subscriptions
|
# Check if there are no subscriptions
|
||||||
if not subscribed_domains:
|
if not subscribed_domains:
|
||||||
await evt.reply("This room is not subscribed to any Owncast instances.\n\nTo subscribe to an Owncast instance, use `!subscribe <domain>`", markdown=True)
|
await evt.reply(
|
||||||
|
"This room is not subscribed to any Owncast "
|
||||||
|
"instances.\n\nTo subscribe to an Owncast "
|
||||||
|
"instance, use `!subscribe <domain>`",
|
||||||
|
markdown=True,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build the response message body as Markdown
|
# Build the response message body as Markdown
|
||||||
body_text = f"**Subscriptions for this room ({len(subscribed_domains)}):**\n\n"
|
count = len(subscribed_domains)
|
||||||
|
body_text = f"**Subscriptions for this room ({count}):**\n\n"
|
||||||
|
|
||||||
for domain in subscribed_domains:
|
for domain in subscribed_domains:
|
||||||
# Get the stream state from the database
|
# Get the stream state from the database
|
||||||
stream_state = await self.stream_repo.get_by_domain(domain)
|
stream_state = await self.stream_repo.get_by_domain(domain)
|
||||||
|
|
||||||
if stream_state is None:
|
if stream_state is None:
|
||||||
# Stream exists in subscriptions but not in streams table (shouldn't happen)
|
# Stream in subscriptions but not streams table
|
||||||
body_text += f"- **{domain}** \n"
|
body_text += f"- **{domain}** \n"
|
||||||
body_text += f" - Status: Unknown \n"
|
body_text += " - Status: Unknown \n"
|
||||||
body_text += f" - Link: https://{domain}\n\n"
|
body_text += f" - Link: https://{domain}\n\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -221,42 +231,48 @@ class CommandHandler:
|
|||||||
duration = self._format_duration(stream_state.last_connect_time)
|
duration = self._format_duration(stream_state.last_connect_time)
|
||||||
body_text += f" - Status: Online for {duration} \n"
|
body_text += f" - Status: Online for {duration} \n"
|
||||||
else:
|
else:
|
||||||
body_text += f" - Status: Online \n"
|
body_text += " - Status: Online \n"
|
||||||
elif stream_state.status == StreamStatus.UNKNOWN:
|
elif stream_state.status == StreamStatus.UNKNOWN:
|
||||||
# Stream status is unknown - instance unreachable
|
# Stream status is unknown - instance unreachable
|
||||||
body_text += f" - Status: Unknown (instance unreachable) \n"
|
body_text += " - Status: Unknown (instance unreachable) \n"
|
||||||
else:
|
else:
|
||||||
# Stream is offline - use last_disconnect_time
|
# Stream is offline - use last_disconnect_time
|
||||||
if stream_state.last_disconnect_time:
|
if stream_state.last_disconnect_time:
|
||||||
duration = self._format_duration(stream_state.last_disconnect_time)
|
duration = self._format_duration(stream_state.last_disconnect_time)
|
||||||
body_text += f" - Status: Offline for {duration} \n"
|
body_text += f" - Status: Offline for {duration} \n"
|
||||||
else:
|
else:
|
||||||
body_text += f" - Status: Offline \n"
|
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"
|
body_text += f" - Link: https://{domain}\n\n"
|
||||||
|
|
||||||
# Add help text for unsubscribing
|
# Add help text for unsubscribing
|
||||||
body_text += "\nTo unsubscribe from any of these Owncast instances, use `!unsubscribe <domain>`"
|
body_text += (
|
||||||
|
"\nTo unsubscribe from any of these Owncast "
|
||||||
|
"instances, use `!unsubscribe <domain>`"
|
||||||
|
)
|
||||||
|
|
||||||
# Send the response as Markdown
|
# Send the response as Markdown
|
||||||
await evt.reply(body_text, markdown=True)
|
await evt.reply(body_text, markdown=True)
|
||||||
|
|
||||||
async def live(self, evt: MessageEvent) -> None:
|
async def live(self, evt: MessageEvent) -> None:
|
||||||
"""
|
"""List currently live streams in the current room.
|
||||||
"!live" command handler for listing only 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.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# Get all stream domains this room is subscribed to
|
# Get all stream domains this room is subscribed to
|
||||||
subscribed_domains = await self.subscription_repo.get_subscribed_streams_for_room(
|
subscribed_domains = (
|
||||||
evt.room_id
|
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are no subscriptions
|
# Check if there are no subscriptions
|
||||||
if not subscribed_domains:
|
if not subscribed_domains:
|
||||||
await evt.reply("This room is not subscribed to any Owncast instances.\n\nTo subscribe to an Owncast instance, use `!subscribe <domain>`", markdown=True)
|
await evt.reply(
|
||||||
|
"This room is not subscribed to any Owncast "
|
||||||
|
"instances.\n\nTo subscribe to an Owncast "
|
||||||
|
"instance, use `!subscribe <domain>`",
|
||||||
|
markdown=True,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Filter for only live streams (exclude unknown status)
|
# Filter for only live streams (exclude unknown status)
|
||||||
@@ -268,11 +284,17 @@ class CommandHandler:
|
|||||||
|
|
||||||
# Check if there are no live streams
|
# Check if there are no live streams
|
||||||
if not live_streams:
|
if not live_streams:
|
||||||
await evt.reply("No subscribed Owncast instances are currently live.\n\nUse `!subscriptions` to list all subscriptions.", markdown=True)
|
await evt.reply(
|
||||||
|
"No subscribed Owncast instances are currently "
|
||||||
|
"live.\n\nUse `!subscriptions` to list all "
|
||||||
|
"subscriptions.",
|
||||||
|
markdown=True,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build the response message body as Markdown
|
# Build the response message body as Markdown
|
||||||
body_text = f"**Live Owncast instances ({len(live_streams)}):**\n\n"
|
count = len(live_streams)
|
||||||
|
body_text = f"**Live Owncast instances ({count}):**\n\n"
|
||||||
|
|
||||||
for domain, stream_state in live_streams:
|
for domain, stream_state in live_streams:
|
||||||
# Determine stream name (use domain as fallback)
|
# Determine stream name (use domain as fallback)
|
||||||
|
|||||||
+15
-11
@@ -1,8 +1,18 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Configuration module for the OwncastSentry plugin."""
|
||||||
|
|
||||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||||
|
|
||||||
@@ -11,19 +21,13 @@ class Config(BaseProxyConfig):
|
|||||||
"""Configuration class for OwncastSentry plugin."""
|
"""Configuration class for OwncastSentry plugin."""
|
||||||
|
|
||||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||||
"""
|
"""Update configuration with user-provided values.
|
||||||
Update configuration with user-provided values.
|
|
||||||
|
|
||||||
:param helper: ConfigUpdateHelper for copying values.
|
:param helper: ConfigUpdateHelper for copying values.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
helper.copy("health_check_endpoint")
|
helper.copy("health_check_endpoint")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health_check_endpoint(self) -> str:
|
def health_check_endpoint(self) -> str:
|
||||||
"""
|
"""Return the configured health check endpoint URL."""
|
||||||
Get the health check endpoint URL.
|
return self["health_check_endpoint"] # type: ignore[no-any-return]
|
||||||
|
|
||||||
:return: The configured endpoint URL or empty string if not set.
|
|
||||||
"""
|
|
||||||
return self["health_check_endpoint"]
|
|
||||||
|
|||||||
+78
-88
@@ -1,60 +1,66 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://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.
|
||||||
|
|
||||||
from typing import Optional, List
|
"""Database repository classes for OwncastSentry."""
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .models import StreamState
|
from .models import StreamState
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mautrix.util.async_db import Database
|
||||||
|
|
||||||
|
|
||||||
class StreamRepository:
|
class StreamRepository:
|
||||||
"""Repository for managing stream data in the database."""
|
"""Repository for managing stream data in the database."""
|
||||||
|
|
||||||
def __init__(self, database: Database):
|
def __init__(self, database: Database):
|
||||||
"""
|
"""Initialize the stream repository.
|
||||||
Initialize the stream repository.
|
|
||||||
|
|
||||||
:param database: The maubot database instance
|
:param database: The maubot database instance.
|
||||||
"""
|
"""
|
||||||
self.db = database
|
self.db = database
|
||||||
|
|
||||||
async def get_by_domain(self, domain: str) -> Optional[StreamState]:
|
async def get_by_domain(self, domain: str) -> StreamState | None:
|
||||||
"""
|
"""Get a stream's state by domain.
|
||||||
Get a stream's state by domain.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: StreamState if found, None otherwise
|
:return: StreamState if found, None otherwise.
|
||||||
"""
|
"""
|
||||||
query = "SELECT * FROM streams WHERE domain=$1"
|
query = "SELECT * FROM streams WHERE domain=$1"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
row = await conn.fetchrow(query, domain)
|
row = await conn.fetchrow(query, domain)
|
||||||
return StreamState.from_db_row(row) if row else None
|
return StreamState.from_db_row(row) if row else None
|
||||||
|
|
||||||
async def create(self, domain: str) -> None:
|
async def create(self, domain: str) -> None:
|
||||||
"""
|
"""Create a new stream entry in the database.
|
||||||
Create a new stream entry in the database.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
query = "INSERT INTO streams (domain) VALUES ($1)"
|
query = "INSERT INTO streams (domain) VALUES ($1)"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
await conn.execute(query, domain)
|
await conn.execute(query, domain)
|
||||||
|
|
||||||
async def update(self, state: StreamState) -> None:
|
async def update(self, state: StreamState) -> None:
|
||||||
"""
|
"""Update a stream's state in the database.
|
||||||
Update a stream's state in the database.
|
|
||||||
|
|
||||||
:param state: The StreamState to save
|
:param state: The StreamState to save.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
query = """UPDATE streams
|
query = """UPDATE streams
|
||||||
SET name=$1, title=$2, last_connect_time=$3, last_disconnect_time=$4
|
SET name=$1, title=$2, last_connect_time=$3, last_disconnect_time=$4
|
||||||
WHERE domain=$5"""
|
WHERE domain=$5"""
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
query,
|
query,
|
||||||
state.name,
|
state.name,
|
||||||
@@ -65,50 +71,43 @@ class StreamRepository:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def exists(self, domain: str) -> bool:
|
async def exists(self, domain: str) -> bool:
|
||||||
"""
|
"""Check if a stream exists in the database.
|
||||||
Check if a stream exists in the database.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: True if exists, False otherwise
|
:return: True if exists, False otherwise.
|
||||||
"""
|
"""
|
||||||
result = await self.get_by_domain(domain)
|
result = await self.get_by_domain(domain)
|
||||||
return result is not None
|
return result is not None
|
||||||
|
|
||||||
async def increment_failure_counter(self, domain: str) -> None:
|
async def increment_failure_counter(self, domain: str) -> None:
|
||||||
"""
|
"""Increment the failure counter for a stream by 1.
|
||||||
Increment the failure counter for a stream by 1.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
query = """UPDATE streams
|
query = """UPDATE streams
|
||||||
SET failure_counter = failure_counter + 1
|
SET failure_counter = failure_counter + 1
|
||||||
WHERE domain=$1"""
|
WHERE domain=$1"""
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
await conn.execute(query, domain)
|
await conn.execute(query, domain)
|
||||||
|
|
||||||
async def reset_failure_counter(self, domain: str) -> None:
|
async def reset_failure_counter(self, domain: str) -> None:
|
||||||
"""
|
"""Reset the failure counter for a stream to 0.
|
||||||
Reset the failure counter for a stream to 0.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
query = """UPDATE streams
|
query = """UPDATE streams
|
||||||
SET failure_counter = 0
|
SET failure_counter = 0
|
||||||
WHERE domain=$1"""
|
WHERE domain=$1"""
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
await conn.execute(query, domain)
|
await conn.execute(query, domain)
|
||||||
|
|
||||||
async def delete(self, domain: str) -> None:
|
async def delete(self, domain: str) -> None:
|
||||||
"""
|
"""Delete a stream record from the database.
|
||||||
Delete a stream record from the database.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
query = "DELETE FROM streams WHERE domain=$1"
|
query = "DELETE FROM streams WHERE domain=$1"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
await conn.execute(query, domain)
|
await conn.execute(query, domain)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,94 +115,85 @@ class SubscriptionRepository:
|
|||||||
"""Repository for managing stream subscriptions in the database."""
|
"""Repository for managing stream subscriptions in the database."""
|
||||||
|
|
||||||
def __init__(self, database: Database):
|
def __init__(self, database: Database):
|
||||||
"""
|
"""Initialize the subscription repository.
|
||||||
Initialize the subscription repository.
|
|
||||||
|
|
||||||
:param database: The maubot database instance
|
:param database: The maubot database instance.
|
||||||
"""
|
"""
|
||||||
self.db = database
|
self.db = database
|
||||||
|
|
||||||
async def add(self, domain: str, room_id: str) -> None:
|
async def add(self, domain: str, room_id: str) -> None:
|
||||||
"""
|
"""Add a subscription for a room to a stream.
|
||||||
Add a subscription for a room to a stream.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:param room_id: The Matrix room ID
|
:param room_id: The Matrix room ID.
|
||||||
:return: Nothing
|
:raises sqlite3.IntegrityError: If subscription already exists.
|
||||||
:raises: sqlite3.IntegrityError if subscription already exists
|
|
||||||
"""
|
"""
|
||||||
query = "INSERT INTO subscriptions (stream_domain, room_id) VALUES ($1, $2)"
|
query = "INSERT INTO subscriptions (stream_domain, room_id) VALUES ($1, $2)"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
await conn.execute(query, domain, room_id)
|
await conn.execute(query, domain, room_id)
|
||||||
|
|
||||||
async def remove(self, domain: str, room_id: str) -> int:
|
async def remove(self, domain: str, room_id: str) -> int:
|
||||||
"""
|
"""Remove a subscription for a room from a stream.
|
||||||
Remove a subscription for a room from a stream.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:param room_id: The Matrix room ID
|
:param room_id: The Matrix room ID.
|
||||||
:return: Number of rows deleted (0 or 1)
|
:return: Number of rows deleted (0 or 1).
|
||||||
"""
|
"""
|
||||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
|
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
result = await conn.execute(query, domain, room_id)
|
result = await conn.execute(query, domain, room_id)
|
||||||
return result.rowcount
|
return int(result.rowcount)
|
||||||
|
|
||||||
async def get_subscribed_rooms(self, domain: str) -> List[str]:
|
async def get_subscribed_rooms(self, domain: str) -> list[str]:
|
||||||
"""
|
"""Get all room IDs subscribed to a stream.
|
||||||
Get all room IDs subscribed to a stream.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: List of room IDs
|
:return: List of room IDs.
|
||||||
"""
|
"""
|
||||||
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
|
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
results = await conn.fetch(query, domain)
|
results = await conn.fetch(query, domain)
|
||||||
return [row["room_id"] for row in results]
|
return [row["room_id"] for row in results]
|
||||||
|
|
||||||
async def get_subscribed_streams_for_room(self, room_id: str) -> List[str]:
|
async def get_subscribed_streams_for_room(self, room_id: str) -> list[str]:
|
||||||
"""
|
"""Get all stream domains that a room is subscribed to.
|
||||||
Get all stream domains that a room is subscribed to.
|
|
||||||
|
|
||||||
:param room_id: The Matrix room ID
|
:param room_id: The Matrix room ID.
|
||||||
:return: List of stream domains
|
:return: List of stream domains.
|
||||||
"""
|
"""
|
||||||
query = "SELECT stream_domain FROM subscriptions WHERE room_id=$1"
|
query = "SELECT stream_domain FROM subscriptions WHERE room_id=$1"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
results = await conn.fetch(query, room_id)
|
results = await conn.fetch(query, room_id)
|
||||||
return [row["stream_domain"] for row in results]
|
return [row["stream_domain"] for row in results]
|
||||||
|
|
||||||
async def get_all_subscribed_domains(self) -> List[str]:
|
async def get_all_subscribed_domains(self) -> list[str]:
|
||||||
"""
|
"""Get all unique stream domains that have at least one subscription.
|
||||||
Get all unique stream domains that have at least one subscription.
|
|
||||||
|
|
||||||
:return: List of stream domains
|
:return: List of stream domains.
|
||||||
"""
|
"""
|
||||||
query = "SELECT DISTINCT stream_domain FROM subscriptions"
|
query = "SELECT DISTINCT stream_domain FROM subscriptions"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
results = await conn.fetch(query)
|
results = await conn.fetch(query)
|
||||||
return [row["stream_domain"] for row in results]
|
return [row["stream_domain"] for row in results]
|
||||||
|
|
||||||
async def count_by_domain(self, domain: str) -> int:
|
async def count_by_domain(self, domain: str) -> int:
|
||||||
"""
|
"""Count the number of subscriptions for a given stream domain.
|
||||||
Count the number of subscriptions for a given stream domain.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Number of subscriptions
|
:return: Number of subscriptions.
|
||||||
"""
|
"""
|
||||||
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
|
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
result = await conn.fetchrow(query, domain)
|
result = await conn.fetchrow(query, domain)
|
||||||
return result[0]
|
return int(result[0])
|
||||||
|
|
||||||
async def delete_all_for_domain(self, domain: str) -> int:
|
async def delete_all_for_domain(self, domain: str) -> int:
|
||||||
"""
|
"""Delete all subscriptions for a given stream domain.
|
||||||
Delete all subscriptions for a given stream domain.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Number of subscriptions deleted
|
:return: Number of subscriptions deleted.
|
||||||
"""
|
"""
|
||||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1"
|
query = "DELETE FROM subscriptions WHERE stream_domain=$1"
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
result = await conn.execute(query, domain)
|
result = await conn.execute(query, domain)
|
||||||
return result.rowcount
|
return int(result.rowcount)
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://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."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
from mautrix.util.async_db import Database
|
from mautrix.util.async_db import Database
|
||||||
|
|
||||||
@@ -22,8 +35,7 @@ class UpdateResult:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def http_healthy(self) -> bool:
|
def http_healthy(self) -> bool:
|
||||||
"""
|
"""Determine HTTP health based on update results.
|
||||||
Determine HTTP health based on update results.
|
|
||||||
|
|
||||||
HTTP is considered healthy if:
|
HTTP is considered healthy if:
|
||||||
- No streams are subscribed (nothing to check), OR
|
- No streams are subscribed (nothing to check), OR
|
||||||
@@ -45,8 +57,7 @@ class HealthStatus:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_healthy(self) -> bool:
|
def is_healthy(self) -> bool:
|
||||||
"""
|
"""Check if all health components are healthy.
|
||||||
Check if all health components are healthy.
|
|
||||||
|
|
||||||
:return: True if all checks pass.
|
:return: True if all checks pass.
|
||||||
"""
|
"""
|
||||||
@@ -62,25 +73,23 @@ class HealthChecker:
|
|||||||
owncast_client: OwncastClient,
|
owncast_client: OwncastClient,
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
):
|
):
|
||||||
"""
|
"""Initialize the health checker.
|
||||||
Initialize the health checker.
|
|
||||||
|
|
||||||
:param database: The maubot database instance.
|
:param database: The maubot database instance.
|
||||||
:param owncast_client: Client for making HTTP requests.
|
:param owncast_client: Client for making HTTP requests.
|
||||||
:param logger: Logger instance.
|
:param logger: Logger instance for debugging.
|
||||||
"""
|
"""
|
||||||
self.db = database
|
self.db = database
|
||||||
self.owncast_client = owncast_client
|
self.owncast_client = owncast_client
|
||||||
self.log = logger
|
self.log = logger
|
||||||
|
|
||||||
async def check_database(self) -> bool:
|
async def check_database(self) -> bool:
|
||||||
"""
|
"""Check if the database is functioning by executing a simple query.
|
||||||
Check if the database is functioning by executing a simple query.
|
|
||||||
|
|
||||||
:return: True if database is healthy, False otherwise.
|
:return: True if database is healthy, False otherwise.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with self.db.acquire() as conn:
|
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
||||||
await conn.fetchval("SELECT 1")
|
await conn.fetchval("SELECT 1")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -92,12 +101,10 @@ class HealthChecker:
|
|||||||
update_result: UpdateResult,
|
update_result: UpdateResult,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Perform health check and report to configured endpoint if all healthy.
|
||||||
Perform health check and report to configured endpoint if all healthy.
|
|
||||||
|
|
||||||
:param update_result: Result of the stream update cycle.
|
:param update_result: Result of the stream update cycle.
|
||||||
:param endpoint: Health check endpoint URL (empty string to skip reporting).
|
:param endpoint: Health check endpoint URL (empty string to skip reporting).
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# Check database health
|
# Check database health
|
||||||
database_healthy = await self.check_database()
|
database_healthy = await self.check_database()
|
||||||
@@ -135,11 +142,9 @@ class HealthChecker:
|
|||||||
await self._send_health_report(endpoint)
|
await self._send_health_report(endpoint)
|
||||||
|
|
||||||
async def _send_health_report(self, endpoint: str) -> None:
|
async def _send_health_report(self, endpoint: str) -> None:
|
||||||
"""
|
"""Send a GET request to the health check endpoint.
|
||||||
Send a GET request to the health check endpoint.
|
|
||||||
|
|
||||||
:param endpoint: The endpoint URL.
|
:param endpoint: The endpoint URL.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with self.owncast_client.session.get(
|
async with self.owncast_client.session.get(
|
||||||
@@ -151,7 +156,8 @@ class HealthChecker:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"Health check endpoint returned non-success status: {response.status}"
|
"Health check endpoint returned "
|
||||||
|
f"non-success status: {response.status}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f"Failed to report health check to endpoint: {e}")
|
self.log.warning(f"Failed to report health check to endpoint: {e}")
|
||||||
|
|||||||
+33
-26
@@ -1,22 +1,31 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://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.
|
||||||
|
|
||||||
from mautrix.util.async_db import UpgradeTable, Connection
|
"""Database migration definitions for OwncastSentry."""
|
||||||
|
|
||||||
|
from mautrix.util.async_db import Connection, UpgradeTable
|
||||||
|
|
||||||
upgrade_table = UpgradeTable()
|
upgrade_table = UpgradeTable()
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Initial revision")
|
@upgrade_table.register(description="Initial revision") # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||||
async def upgrade_v1(conn: Connection) -> None:
|
async def upgrade_v1(conn: Connection) -> None:
|
||||||
"""
|
"""Create the initial database schema.
|
||||||
Runs migrations to upgrade database schema to verison 1 format.
|
|
||||||
Version 1 is the initial format of the database.
|
Creates the streams and subscriptions tables.
|
||||||
|
|
||||||
:param conn: A connection to run the v1 database migration on.
|
:param conn: A connection to run the v1 database migration on.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""CREATE TABLE "streams" (
|
"""CREATE TABLE "streams" (
|
||||||
@@ -38,14 +47,16 @@ async def upgrade_v1(conn: Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Fix stream_domain column type from INTEGER to TEXT")
|
@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:
|
async def upgrade_v2(conn: Connection) -> None:
|
||||||
"""
|
"""Upgrade database schema to version 2 format.
|
||||||
Runs migrations to upgrade database schema to version 2 format.
|
|
||||||
Version 2 fixes the stream_domain column type in subscriptions table from INTEGER to TEXT.
|
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.
|
:param conn: A connection to run the v2 database migration on.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# Create new subscriptions table with correct schema
|
# Create new subscriptions table with correct schema
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@@ -62,21 +73,21 @@ async def upgrade_v2(conn: Connection) -> None:
|
|||||||
SELECT stream_domain, room_id FROM subscriptions"""
|
SELECT stream_domain, room_id FROM subscriptions"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Drop the old table
|
# Drop the old table and rename new table to original name
|
||||||
await conn.execute("DROP TABLE subscriptions")
|
await conn.execute("DROP TABLE subscriptions")
|
||||||
|
|
||||||
# Rename new table to original name
|
|
||||||
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
|
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add failure_counter column for backoff and auto-cleanup")
|
@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:
|
async def upgrade_v3(conn: Connection) -> None:
|
||||||
"""
|
"""Upgrade database schema to version 3 format.
|
||||||
Runs migrations to upgrade database schema to version 3 format.
|
|
||||||
Version 3 adds the failure_counter column to track connection failures for backoff and auto-cleanup.
|
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.
|
:param conn: A connection to run the v3 database migration on.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# Add failure_counter column with default value of 0
|
# Add failure_counter column with default value of 0
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@@ -85,9 +96,5 @@ async def upgrade_v3(conn: Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_upgrade_table() -> UpgradeTable:
|
def get_upgrade_table() -> UpgradeTable:
|
||||||
"""
|
"""Return the upgrade table with registered migrations."""
|
||||||
Helper function for retrieving the upgrade table.
|
|
||||||
|
|
||||||
:return: The upgrade table with registered migrations.
|
|
||||||
"""
|
|
||||||
return upgrade_table
|
return upgrade_table
|
||||||
|
|||||||
+38
-33
@@ -1,12 +1,22 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://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.
|
||||||
|
|
||||||
from dataclasses import dataclass
|
"""Data models for OwncastSentry."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, List
|
from typing import Any
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
MAX_INSTANCE_TITLE_LENGTH,
|
MAX_INSTANCE_TITLE_LENGTH,
|
||||||
@@ -30,30 +40,32 @@ class StreamState:
|
|||||||
"""Represents the state of an Owncast stream."""
|
"""Represents the state of an Owncast stream."""
|
||||||
|
|
||||||
domain: str
|
domain: str
|
||||||
name: Optional[str] = None
|
name: str | None = None
|
||||||
title: Optional[str] = None
|
title: str | None = None
|
||||||
last_connect_time: Optional[str] = None
|
last_connect_time: str | None = None
|
||||||
last_disconnect_time: Optional[str] = None
|
last_disconnect_time: str | None = None
|
||||||
failure_counter: int = 0
|
failure_counter: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> StreamStatus:
|
def status(self) -> StreamStatus:
|
||||||
"""Returns the stream status considering failure_counter."""
|
"""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:
|
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
|
||||||
return StreamStatus.UNKNOWN
|
return StreamStatus.UNKNOWN
|
||||||
elif self.last_connect_time is not None:
|
if self.last_connect_time is not None:
|
||||||
return StreamStatus.ONLINE
|
return StreamStatus.ONLINE
|
||||||
else:
|
|
||||||
return StreamStatus.OFFLINE
|
return StreamStatus.OFFLINE
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_response(cls, response: dict, domain: str) -> "StreamState":
|
def from_api_response(cls, response: dict[str, Any], domain: str) -> StreamState:
|
||||||
"""
|
"""Create a StreamState from an API response.
|
||||||
Creates a StreamState from an API response.
|
|
||||||
|
|
||||||
:param response: API response as a dictionary (camelCase keys)
|
:param response: API response as a dictionary (camelCase keys).
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: StreamState instance
|
:return: StreamState instance.
|
||||||
"""
|
"""
|
||||||
return cls(
|
return cls(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
@@ -63,12 +75,11 @@ class StreamState:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(cls, row: dict) -> "StreamState":
|
def from_db_row(cls, row: dict[str, Any]) -> StreamState:
|
||||||
"""
|
"""Create a StreamState from a database row.
|
||||||
Creates a StreamState from a database row.
|
|
||||||
|
|
||||||
:param row: Database row as a dictionary
|
:param row: Database row as a dictionary.
|
||||||
:return: StreamState instance
|
:return: StreamState instance.
|
||||||
"""
|
"""
|
||||||
return cls(
|
return cls(
|
||||||
domain=row["domain"],
|
domain=row["domain"],
|
||||||
@@ -85,20 +96,14 @@ class StreamConfig:
|
|||||||
"""Represents the configuration of an Owncast stream."""
|
"""Represents the configuration of an Owncast stream."""
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
tags: List[str] = None
|
tags: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Initialize default values after dataclass initialization."""
|
|
||||||
if self.tags is None:
|
|
||||||
self.tags = []
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_response(cls, response: dict) -> "StreamConfig":
|
def from_api_response(cls, response: dict[str, Any]) -> StreamConfig:
|
||||||
"""
|
"""Create a StreamConfig from an API response.
|
||||||
Creates a StreamConfig from an API response.
|
|
||||||
|
|
||||||
:param response: API response as a dictionary
|
:param response: API response as a dictionary.
|
||||||
:return: StreamConfig instance
|
:return: StreamConfig instance.
|
||||||
"""
|
"""
|
||||||
# Truncate instance name to max length
|
# Truncate instance name to max length
|
||||||
name = truncate(response.get("name", ""), MAX_INSTANCE_TITLE_LENGTH)
|
name = truncate(response.get("name", ""), MAX_INSTANCE_TITLE_LENGTH)
|
||||||
|
|||||||
@@ -1,54 +1,71 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Notification service for sending Matrix messages."""
|
||||||
|
|
||||||
import time
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from mautrix.types import TextMessageEventContent, MessageType
|
from mautrix.types import MessageType, TextMessageEventContent
|
||||||
|
|
||||||
|
from .utils import SECONDS_BETWEEN_NOTIFICATIONS, sanitize_for_plain_text
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
from .database import SubscriptionRepository
|
from .database import SubscriptionRepository
|
||||||
from .utils import SECONDS_BETWEEN_NOTIFICATIONS, sanitize_for_plain_text
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
"""Service for sending Matrix notifications about stream events."""
|
"""Service for sending Matrix notifications about stream events."""
|
||||||
|
|
||||||
def __init__(self, client, subscription_repo: SubscriptionRepository, logger):
|
def __init__(
|
||||||
"""
|
self,
|
||||||
Initialize the notification service.
|
client: Any,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""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
|
:param logger: Logger instance for debugging.
|
||||||
"""
|
"""
|
||||||
self.client = client
|
self.client = client
|
||||||
self.subscription_repo = subscription_repo
|
self.subscription_repo = subscription_repo
|
||||||
self.log = logger
|
self.log = logger
|
||||||
|
|
||||||
# Cache for tracking when notifications were last sent
|
# Cache for tracking when notifications were last sent
|
||||||
self.notification_timers_cache = {}
|
self.notification_timers_cache: dict[str, float] = {}
|
||||||
|
|
||||||
async def notify_stream_live(
|
async def notify_stream_live(
|
||||||
self,
|
self,
|
||||||
domain: str,
|
domain: str,
|
||||||
name: str,
|
name: str,
|
||||||
title: str,
|
title: str,
|
||||||
tags: List[str],
|
tags: list[str],
|
||||||
|
*,
|
||||||
title_change: bool = False,
|
title_change: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Send notifications to rooms subscribed to a stream.
|
||||||
Sends notifications to rooms with subscriptions to the provided stream domain.
|
|
||||||
|
|
||||||
:param domain: The domain of the stream to send notifications for.
|
:param domain: The stream domain to send notifications for.
|
||||||
:param name: The name of the stream to include in the message.
|
:param name: The stream name to include in the message.
|
||||||
:param title: The title of the stream to include in the message.
|
:param title: The stream title to include in the message.
|
||||||
:param tags: List of stream tags to include in the message.
|
:param tags: List of stream tags to include in the message.
|
||||||
:param title_change: Whether or not this is for a stream changing its title rather than going live.
|
:param title_change: Whether this is a title change notification.
|
||||||
:return: Nothing.
|
|
||||||
"""
|
"""
|
||||||
# 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):
|
||||||
@@ -56,7 +73,10 @@ class NotificationService:
|
|||||||
time.time() - self.notification_timers_cache[domain]
|
time.time() - self.notification_timers_cache[domain]
|
||||||
)
|
)
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Not sending notifications. Only {seconds_since_last} of required {SECONDS_BETWEEN_NOTIFICATIONS} seconds has passed since last notification."
|
f"[{domain}] Not sending notifications. Only "
|
||||||
|
f"{seconds_since_last} of required "
|
||||||
|
f"{SECONDS_BETWEEN_NOTIFICATIONS} seconds have "
|
||||||
|
f"passed since last notification."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -74,10 +94,9 @@ class NotificationService:
|
|||||||
failed_notifications = 0
|
failed_notifications = 0
|
||||||
|
|
||||||
# Send notifications to all subscribed rooms in parallel
|
# Send notifications to all subscribed rooms in parallel
|
||||||
# IMPROVEMENT: Parallel notification delivery with asyncio.gather (was a TODO in original code)
|
tasks = [
|
||||||
tasks = []
|
self._send_notification(room_id, body_text, domain) for room_id in room_ids
|
||||||
for room_id in room_ids:
|
]
|
||||||
tasks.append(self._send_notification(room_id, body_text, domain))
|
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -91,42 +110,42 @@ class NotificationService:
|
|||||||
# 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} notifications! {successful_notifications} succeeded, {failed_notifications} failed."
|
f"[{domain}] Completed sending {notification_type} "
|
||||||
|
f"notifications! {successful_notifications} succeeded, "
|
||||||
|
f"{failed_notifications} failed."
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
||||||
"""
|
"""Send a notification to a single room.
|
||||||
Send a notification to a single room.
|
|
||||||
|
|
||||||
: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).
|
||||||
:return: Nothing
|
:raises Exception: If sending fails.
|
||||||
:raises: Exception if sending fails
|
|
||||||
"""
|
"""
|
||||||
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 message to room [{room_id}]: {exception}"
|
f"[{domain}] Failed to send notification "
|
||||||
|
f"message to room [{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: list[str], title_change: bool
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Format the notification message body.
|
||||||
Format the notification message body.
|
|
||||||
|
|
||||||
:param name: The stream name
|
:param name: The stream name.
|
||||||
:param title: The stream title
|
:param title: The stream title.
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:param tags: List of stream tags
|
:param tags: List of stream tags.
|
||||||
:param title_change: Whether this is a title change notification
|
:param title_change: Whether this is a title change notification.
|
||||||
: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 if name else domain
|
||||||
@@ -151,7 +170,7 @@ class NotificationService:
|
|||||||
safe_tags = []
|
safe_tags = []
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
safe_tag = sanitize_for_plain_text(tag)
|
safe_tag = sanitize_for_plain_text(tag)
|
||||||
if safe_tag and not safe_tag.startswith('.'):
|
if safe_tag and not safe_tag.startswith("."):
|
||||||
safe_tags.append(safe_tag)
|
safe_tags.append(safe_tag)
|
||||||
|
|
||||||
if safe_tags:
|
if safe_tags:
|
||||||
@@ -161,49 +180,45 @@ class NotificationService:
|
|||||||
return body_text
|
return body_text
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
: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:
|
if domain not in self.notification_timers_cache:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
seconds_since_last = round(time.time() - self.notification_timers_cache[domain])
|
seconds_since_last = round(time.time() - self.notification_timers_cache[domain])
|
||||||
return seconds_since_last >= SECONDS_BETWEEN_NOTIFICATIONS
|
return bool(seconds_since_last >= 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.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
self.notification_timers_cache[domain] = time.time()
|
self.notification_timers_cache[domain] = time.time()
|
||||||
|
|
||||||
async def send_cleanup_warning(self, domain: str) -> None:
|
async def send_cleanup_warning(self, domain: str) -> None:
|
||||||
"""
|
"""Send cleanup warning notification to all subscribed rooms.
|
||||||
Send 83-day warning notification to all subscribed rooms.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
# Get all subscribed rooms
|
# 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
|
# Build the warning message
|
||||||
body_text = (
|
body_text = (
|
||||||
f"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
|
"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
|
||||||
f"The Owncast instance at {domain} has been unreachable for 83 days. "
|
f"The Owncast instance at {domain} has been "
|
||||||
f"If it remains unreachable for 7 more days (90 days total), this "
|
f"unreachable for 83 days. If it remains unreachable "
|
||||||
f"subscription will be automatically removed."
|
f"for 7 more days (90 days total), this subscription "
|
||||||
|
f"will be automatically removed."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to all rooms in parallel
|
# Send to all rooms in parallel
|
||||||
tasks = []
|
tasks = [
|
||||||
for room_id in room_ids:
|
self._send_notification(room_id, body_text, domain) for room_id in room_ids
|
||||||
tasks.append(self._send_notification(room_id, body_text, domain))
|
]
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -216,28 +231,27 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def send_cleanup_deletion(self, domain: str) -> None:
|
async def send_cleanup_deletion(self, domain: str) -> None:
|
||||||
"""
|
"""Send cleanup deletion notification to all subscribed rooms.
|
||||||
Send 90-day deletion notification to all subscribed rooms.
|
|
||||||
|
|
||||||
:param domain: The stream domain
|
:param domain: The stream domain.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
# Get all subscribed rooms
|
# 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 deletion message
|
# Build the deletion message
|
||||||
body_text = (
|
body_text = (
|
||||||
f"🗑️ Subscription Automatically Removed\n\n"
|
"🗑️ Subscription Automatically Removed\n\n"
|
||||||
f"The Owncast instance at {domain} has been unreachable for 90 days "
|
f"The Owncast instance at {domain} has been "
|
||||||
f"and has been automatically removed from subscriptions in this room.\n\n"
|
f"unreachable for 90 days and has been automatically "
|
||||||
f"If the instance comes online again and you want to resubscribe, "
|
f"removed from subscriptions in this room.\n\n"
|
||||||
f"run `!subscribe {domain}`."
|
f"If the instance comes online again and you want to "
|
||||||
|
f"resubscribe, run `!subscribe {domain}`."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to all rooms in parallel
|
# Send to all rooms in parallel
|
||||||
tasks = []
|
tasks = [
|
||||||
for room_id in room_ids:
|
self._send_notification(room_id, body_text, domain) for room_id in room_ids
|
||||||
tasks.append(self._send_notification(room_id, body_text, domain))
|
]
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -246,5 +260,6 @@ class NotificationService:
|
|||||||
failed = sum(1 for r in results if isinstance(r, Exception))
|
failed = sum(1 for r in results if isinstance(r, Exception))
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Sent cleanup deletion notice to {successful} rooms ({failed} failed)."
|
f"[{domain}] Sent cleanup deletion notice to "
|
||||||
|
f"{successful} rooms ({failed} failed)."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,47 +1,68 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""HTTP client for querying Owncast instance APIs."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import json
|
|
||||||
from typing import Optional
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
from .models import StreamConfig, StreamState
|
from .models import StreamConfig, StreamState
|
||||||
from .utils import OWNCAST_STATUS_PATH, OWNCAST_CONFIG_PATH, USER_AGENT
|
from .utils import OWNCAST_CONFIG_PATH, OWNCAST_STATUS_PATH, user_agent
|
||||||
|
|
||||||
|
|
||||||
class OwncastClient:
|
class OwncastClient:
|
||||||
"""HTTP client for communicating with Owncast instances."""
|
"""HTTP client for communicating with Owncast instances."""
|
||||||
|
|
||||||
def __init__(self, logger):
|
def __init__(self, logger: logging.Logger, version: str) -> 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
|
||||||
"""
|
"""
|
||||||
self.log = logger
|
self.log = logger
|
||||||
|
|
||||||
# Set up HTTP session configuration
|
# Set up HTTP session configuration
|
||||||
headers = {"User-Agent": USER_AGENT}
|
headers = {"User-Agent": user_agent(version)}
|
||||||
cookie_jar = aiohttp.DummyCookieJar()
|
cookie_jar = aiohttp.DummyCookieJar()
|
||||||
connector = aiohttp.TCPConnector(
|
connector = aiohttp.TCPConnector(
|
||||||
use_dns_cache=False, limit=1000, limit_per_host=1, keepalive_timeout=120
|
use_dns_cache=False,
|
||||||
|
limit=1000,
|
||||||
|
limit_per_host=1,
|
||||||
|
keepalive_timeout=120,
|
||||||
)
|
)
|
||||||
timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
|
timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
|
||||||
|
|
||||||
self.session = aiohttp.ClientSession(
|
self.session = aiohttp.ClientSession(
|
||||||
headers=headers, cookie_jar=cookie_jar, timeout=timeout, connector=connector
|
headers=headers,
|
||||||
|
cookie_jar=cookie_jar,
|
||||||
|
timeout=timeout,
|
||||||
|
connector=connector,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_stream_state(self, domain: str) -> Optional[StreamState]:
|
async def get_stream_state(self, domain: str) -> StreamState | 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 are supported.
|
HTTPS on port 443 is assumed, no other protocols or ports
|
||||||
|
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 with stream state if available, None if an error occurred.
|
:return: A StreamState if available, None on error.
|
||||||
"""
|
"""
|
||||||
self.log.debug(f"[{domain}] Fetching current stream state...")
|
self.log.debug(f"[{domain}] Fetching current stream state...")
|
||||||
status_url = "https://" + domain + OWNCAST_STATUS_PATH
|
status_url = "https://" + domain + OWNCAST_STATUS_PATH
|
||||||
@@ -60,20 +81,24 @@ class OwncastClient:
|
|||||||
# Check the response code is success
|
# Check the response code is success
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Response to request on {OWNCAST_STATUS_PATH} was not 200, got {response.status} instead."
|
f"[{domain}] Response to request on "
|
||||||
|
f"{OWNCAST_STATUS_PATH} was not 200, "
|
||||||
|
f"got {response.status} instead."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try and interpret the response as JSON
|
# Try to interpret the response as JSON
|
||||||
try:
|
try:
|
||||||
new_state = json.loads(await response.read())
|
new_state = json.loads(await response.read())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as could not be interpreted as JSON: {e}"
|
f"[{domain}] Rejecting response to request on "
|
||||||
|
f"{OWNCAST_STATUS_PATH} as could not be "
|
||||||
|
f"interpreted as JSON: {e}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Validate the response to ensure it contains all the basic info needed to function
|
# Validate the response contains all basic info needed
|
||||||
required_fields = [
|
required_fields = [
|
||||||
"lastConnectTime",
|
"lastConnectTime",
|
||||||
"lastDisconnectTime",
|
"lastDisconnectTime",
|
||||||
@@ -83,19 +108,22 @@ class OwncastClient:
|
|||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if field not in new_state:
|
if field not in new_state:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have {field} parameter."
|
f"[{domain}] Rejecting response to request "
|
||||||
|
f"on {OWNCAST_STATUS_PATH} as it does not "
|
||||||
|
f"have {field} field."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return StreamState.from_api_response(new_state, domain)
|
return StreamState.from_api_response(new_state, domain)
|
||||||
|
|
||||||
async def get_stream_config(self, domain: str) -> Optional[StreamConfig]:
|
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 are supported.
|
HTTPS on port 443 is assumed, no other protocols or ports
|
||||||
|
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 with the stream's configuration, or None if fetch failed.
|
:return: A StreamConfig, or None if fetch failed.
|
||||||
"""
|
"""
|
||||||
self.log.debug(f"[{domain}] Fetching current stream config...")
|
self.log.debug(f"[{domain}] Fetching current stream config...")
|
||||||
config_url = "https://" + domain + OWNCAST_CONFIG_PATH
|
config_url = "https://" + domain + OWNCAST_CONFIG_PATH
|
||||||
@@ -114,25 +142,28 @@ class OwncastClient:
|
|||||||
# Check the response code is success
|
# Check the response code is success
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Response to request on {OWNCAST_CONFIG_PATH} was not 200, got {response.status} instead."
|
f"[{domain}] Response to request on "
|
||||||
|
f"{OWNCAST_CONFIG_PATH} was not 200, "
|
||||||
|
f"got {response.status} instead."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try and interpret the response as JSON
|
# Try to interpret the response as JSON
|
||||||
try:
|
try:
|
||||||
config = json.loads(await response.read())
|
config = json.loads(await response.read())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Rejecting response to request on {OWNCAST_CONFIG_PATH} as could not be interpreted as JSON: {e}"
|
f"[{domain}] Rejecting response to request on "
|
||||||
|
f"{OWNCAST_CONFIG_PATH} as could not be "
|
||||||
|
f"interpreted as JSON: {e}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create StreamConfig with validated fields
|
# Create StreamConfig from response (fields are truncated to max lengths)
|
||||||
return StreamConfig.from_api_response(config)
|
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.
|
|
||||||
|
|
||||||
:param domain: The domain to validate
|
:param domain: The domain to validate
|
||||||
:return: True if valid Owncast instance, False otherwise
|
:return: True if valid Owncast instance, False otherwise
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Stream monitoring service for OwncastSentry."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .owncast_client import OwncastClient
|
from .database import SubscriptionRepository
|
||||||
from .database import StreamRepository, SubscriptionRepository
|
from .health_checker import UpdateResult
|
||||||
from .notification_service import NotificationService
|
|
||||||
from .models import StreamState
|
from .models import StreamState
|
||||||
from .utils import (
|
from .utils import (
|
||||||
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
|
||||||
CLEANUP_WARNING_THRESHOLD,
|
|
||||||
CLEANUP_DELETE_THRESHOLD,
|
CLEANUP_DELETE_THRESHOLD,
|
||||||
|
CLEANUP_WARNING_THRESHOLD,
|
||||||
|
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
||||||
should_query_stream,
|
should_query_stream,
|
||||||
)
|
)
|
||||||
from .health_checker import UpdateResult
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .database import StreamRepository
|
||||||
|
from .notification_service import NotificationService
|
||||||
|
from .owncast_client import OwncastClient
|
||||||
|
|
||||||
|
|
||||||
class StreamMonitor:
|
class StreamMonitor:
|
||||||
@@ -28,15 +44,14 @@ class StreamMonitor:
|
|||||||
owncast_client: OwncastClient,
|
owncast_client: OwncastClient,
|
||||||
stream_repo: StreamRepository,
|
stream_repo: StreamRepository,
|
||||||
notification_service: NotificationService,
|
notification_service: NotificationService,
|
||||||
logger,
|
logger: logging.Logger,
|
||||||
):
|
) -> None:
|
||||||
"""
|
"""Initialize the stream monitor.
|
||||||
Initialize the stream monitor.
|
|
||||||
|
|
||||||
:param owncast_client: Client for making API calls to Owncast instances
|
:param owncast_client: Client for making API calls to Owncast instances.
|
||||||
:param stream_repo: Repository for stream data
|
:param stream_repo: Repository for stream data.
|
||||||
:param notification_service: Service for sending notifications
|
:param notification_service: Service for sending notifications.
|
||||||
:param logger: Logger instance
|
:param logger: Logger instance for debugging.
|
||||||
"""
|
"""
|
||||||
self.owncast_client = owncast_client
|
self.owncast_client = owncast_client
|
||||||
self.stream_repo = stream_repo
|
self.stream_repo = stream_repo
|
||||||
@@ -44,12 +59,13 @@ class StreamMonitor:
|
|||||||
self.log = logger
|
self.log = logger
|
||||||
|
|
||||||
# Cache for tracking when streams last went offline
|
# Cache for tracking when streams last went offline
|
||||||
self.offline_timer_cache = {}
|
self.offline_timer_cache: dict[str, float] = {}
|
||||||
|
|
||||||
async def update_all_streams(self, subscribed_domains: list[str]) -> UpdateResult:
|
async def update_all_streams(self, subscribed_domains: list[str]) -> UpdateResult:
|
||||||
"""
|
"""Check the status of all streams with active subscriptions.
|
||||||
Checks the status of all streams with active subscriptions.
|
|
||||||
Updates for all streams are performed asynchronously, with the method returning when the slowest update completes.
|
Updates for all streams are performed asynchronously, with the
|
||||||
|
method returning when the slowest update completes.
|
||||||
|
|
||||||
:param subscribed_domains: List of stream domains to update.
|
:param subscribed_domains: List of stream domains to update.
|
||||||
:return: UpdateResult with success/failure counts.
|
:return: UpdateResult with success/failure counts.
|
||||||
@@ -58,10 +74,11 @@ class StreamMonitor:
|
|||||||
|
|
||||||
total_streams = len(subscribed_domains)
|
total_streams = len(subscribed_domains)
|
||||||
|
|
||||||
# Build a list of async tasks which update the state for each stream domain
|
# Build a list of async tasks for each stream domain
|
||||||
tasks = []
|
tasks = [
|
||||||
for domain in subscribed_domains:
|
asyncio.create_task(self.update_stream(domain))
|
||||||
tasks.append(asyncio.create_task(self.update_stream(domain)))
|
for domain in subscribed_domains
|
||||||
|
]
|
||||||
|
|
||||||
# Run the tasks in parallel and collect results
|
# Run the tasks in parallel and collect results
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
@@ -82,12 +99,14 @@ 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.
|
||||||
Updates the state of a given stream domain and sends notifications to subscribed Matrix rooms if it goes live.
|
|
||||||
Implements progressive backoff for connection failures and auto-cleanup for dead instances.
|
Sends notifications to subscribed Matrix rooms if a stream goes
|
||||||
|
live. Implements progressive backoff for connection 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 the stream check succeeded (or was skipped due to backoff), False on connection failure.
|
:return: True if check succeeded or was skipped, False on failure.
|
||||||
"""
|
"""
|
||||||
# Fetch the current stream state from database to check failure_counter
|
# Fetch the current stream state from database to check failure_counter
|
||||||
old_state = await self.stream_repo.get_by_domain(domain)
|
old_state = await self.stream_repo.get_by_domain(domain)
|
||||||
@@ -98,21 +117,28 @@ class StreamMonitor:
|
|||||||
# 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 (counter={failure_counter + 1})"
|
f"[{domain}] Skipping query due to backoff "
|
||||||
|
f"(counter={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)
|
||||||
# Backoff is expected behavior, not a failure
|
# Backoff is expected behavior, not a failure
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# A flag indicating whether this is the first state update of a brand-new stream to avoid sending notifications if its already live.
|
# Defensive check: old_state should always exist here since
|
||||||
|
# the stream is in the DB
|
||||||
|
if old_state is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Flag: first state update of a brand-new stream to avoid
|
||||||
|
# sending notifications if it's already live.
|
||||||
first_update = False
|
first_update = False
|
||||||
|
|
||||||
# A flag indicating whether to update the stream's state in the database.
|
# Flag: whether to update the stream's state in the database.
|
||||||
# Used to avoid writing to the database when a stream's state hasn't changed at all.
|
# Used to avoid writes when state hasn't changed at all.
|
||||||
update_database = False
|
update_database = False
|
||||||
|
|
||||||
# Holds the stream's latest configuration, if fetched and deemed necessary during the update process.
|
# The stream's latest configuration, if fetched during update.
|
||||||
stream_config = None
|
stream_config = None
|
||||||
|
|
||||||
# Fetch the latest stream state from the server
|
# Fetch the latest stream state from the server
|
||||||
@@ -132,13 +158,13 @@ class StreamMonitor:
|
|||||||
# 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)
|
||||||
|
|
||||||
# Fix possible race conditions with timers
|
# Initialize timer cache entries to prevent KeyError on first access
|
||||||
if domain not in self.offline_timer_cache:
|
if domain not in self.offline_timer_cache:
|
||||||
self.offline_timer_cache[domain] = 0
|
self.offline_timer_cache[domain] = 0
|
||||||
if domain not in self.notification_service.notification_timers_cache:
|
if domain not in self.notification_service.notification_timers_cache:
|
||||||
self.notification_service.notification_timers_cache[domain] = 0
|
self.notification_service.notification_timers_cache[domain] = 0
|
||||||
|
|
||||||
# Does the last known stream state not have a value for the last connect and disconnect time?
|
# Does the last known stream state lack connect/disconnect?
|
||||||
if (
|
if (
|
||||||
old_state.last_connect_time is None
|
old_state.last_connect_time is None
|
||||||
and old_state.last_disconnect_time is None
|
and old_state.last_disconnect_time is None
|
||||||
@@ -147,7 +173,7 @@ class StreamMonitor:
|
|||||||
update_database = True
|
update_database = True
|
||||||
first_update = True
|
first_update = True
|
||||||
|
|
||||||
# Does the latest stream state have a last connect time and the old state not have one?
|
# Does the new state have a connect time but the old one not?
|
||||||
if (
|
if (
|
||||||
new_state.last_connect_time is not None
|
new_state.last_connect_time is not None
|
||||||
and old_state.last_connect_time is None
|
and old_state.last_connect_time is None
|
||||||
@@ -158,47 +184,56 @@ class StreamMonitor:
|
|||||||
|
|
||||||
self.log.info(f"[{domain}] Stream is now live!")
|
self.log.info(f"[{domain}] Stream is now live!")
|
||||||
|
|
||||||
# Calculate how many seconds since the stream last went offline
|
# Calculate seconds since the stream last went offline
|
||||||
seconds_since_last_offline = round(
|
seconds_since_last_offline = round(
|
||||||
time.time() - self.offline_timer_cache[domain]
|
time.time() - self.offline_timer_cache[domain]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Have we queried this stream before? (In other words, is this not the first state update ever?)
|
# Have we queried this stream before?
|
||||||
if not first_update:
|
if not first_update:
|
||||||
# Use fallback values if config fetch failed
|
# Use fallback values if config fetch failed
|
||||||
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 []
|
||||||
|
|
||||||
# Yes. Has this stream been offline for a short amount of time?
|
# Has this stream been offline for a short time?
|
||||||
if seconds_since_last_offline < TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN:
|
if seconds_since_last_offline < TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN:
|
||||||
# Yes. Did the stream title change?
|
# Did the stream title change?
|
||||||
if old_state.title != new_state.title:
|
if old_state.title != new_state.title:
|
||||||
# Yes. The stream was only down for a short time, send a special notification indicating the stream changed its name.
|
# Stream was briefly down; send title
|
||||||
|
# change notification.
|
||||||
await self.notification_service.notify_stream_live(
|
await self.notification_service.notify_stream_live(
|
||||||
domain,
|
domain,
|
||||||
stream_name,
|
stream_name,
|
||||||
new_state.title,
|
new_state.title or "",
|
||||||
stream_tags,
|
stream_tags,
|
||||||
title_change=True,
|
title_change=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No. The stream was only down for a short time and didn't change its title. Don't send a notification.
|
# Briefly offline, no title change. Skip.
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Not sending notifications. Stream was only offline for {seconds_since_last_offline} of {TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN} seconds and did not change its title."
|
f"[{domain}] Not sending "
|
||||||
|
f"notifications. Stream was only "
|
||||||
|
f"offline for "
|
||||||
|
f"{seconds_since_last_offline} of "
|
||||||
|
f"{TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN}"
|
||||||
|
f" seconds and did not change its "
|
||||||
|
f"title."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# This stream has been offline for a while. Send a normal notification.
|
# Offline for a while. Send a normal notification.
|
||||||
await self.notification_service.notify_stream_live(
|
await self.notification_service.notify_stream_live(
|
||||||
domain,
|
domain,
|
||||||
stream_name,
|
stream_name,
|
||||||
new_state.title,
|
new_state.title or "",
|
||||||
stream_tags,
|
stream_tags,
|
||||||
title_change=False,
|
title_change=False,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No, this is the first time we're querying
|
# No, this is the first time we're querying
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Not sending notifications. This is the first state update for this stream."
|
f"[{domain}] Not sending notifications. "
|
||||||
|
f"This is the first state update for "
|
||||||
|
f"this stream."
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -215,19 +250,17 @@ class StreamMonitor:
|
|||||||
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 []
|
||||||
|
|
||||||
# This is a fun case to account for... Let's try and explain this.
|
# Was the last notification sent before the stream
|
||||||
# Was the last time this stream sent a notification before it last went offline?
|
# last went offline? If so, send a regular go-live
|
||||||
|
# instead of a title change to avoid confusion.
|
||||||
if (
|
if (
|
||||||
self.offline_timer_cache[domain]
|
self.offline_timer_cache[domain]
|
||||||
> self.notification_service.notification_timers_cache[domain]
|
> self.notification_service.notification_timers_cache[domain]
|
||||||
):
|
):
|
||||||
# Yes. Send a regular go live notification.
|
|
||||||
# Why? A title change notification could be confusing to users in this case.
|
|
||||||
# How? If a stream goes offline before its next allowed notification, it'll get rate limited. If it then changes its title, this part of the code will send a title change notification. This can be a little confusing, so override to a normal go live notification in this case.
|
|
||||||
await self.notification_service.notify_stream_live(
|
await self.notification_service.notify_stream_live(
|
||||||
domain,
|
domain,
|
||||||
stream_name,
|
stream_name,
|
||||||
new_state.title,
|
new_state.title or "",
|
||||||
stream_tags,
|
stream_tags,
|
||||||
title_change=False,
|
title_change=False,
|
||||||
)
|
)
|
||||||
@@ -236,12 +269,12 @@ class StreamMonitor:
|
|||||||
await self.notification_service.notify_stream_live(
|
await self.notification_service.notify_stream_live(
|
||||||
domain,
|
domain,
|
||||||
stream_name,
|
stream_name,
|
||||||
new_state.title,
|
new_state.title or "",
|
||||||
stream_tags,
|
stream_tags,
|
||||||
title_change=True,
|
title_change=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Does the latest stream state no longer have a last connect time but the old state does?
|
# Did the stream go offline (old had connect, new doesn't)?
|
||||||
elif (
|
elif (
|
||||||
new_state.last_connect_time is None
|
new_state.last_connect_time is None
|
||||||
and old_state.last_connect_time is not None
|
and old_state.last_connect_time is not None
|
||||||
@@ -254,9 +287,9 @@ class StreamMonitor:
|
|||||||
else:
|
else:
|
||||||
self.log.info(f"[{domain}] Stream is now offline.")
|
self.log.info(f"[{domain}] Stream is now offline.")
|
||||||
|
|
||||||
# Update the database with the current stream state, if needed.
|
# Update the database with current stream state, if needed.
|
||||||
if update_database:
|
if update_database:
|
||||||
# Ensure we have the stream config before updating the database
|
# Ensure we have the stream config before updating
|
||||||
if stream_config is None:
|
if stream_config is None:
|
||||||
stream_config = await self.owncast_client.get_stream_config(domain)
|
stream_config = await self.owncast_client.get_stream_config(domain)
|
||||||
|
|
||||||
@@ -281,12 +314,10 @@ class StreamMonitor:
|
|||||||
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:
|
||||||
"""
|
"""Check if a domain hit cleanup warning or deletion thresholds.
|
||||||
Check if a domain has hit cleanup warning or deletion thresholds.
|
|
||||||
|
|
||||||
:param domain: The domain to check
|
:param domain: The domain to check.
|
||||||
:param counter: The current failure counter value
|
:param counter: The current failure counter value.
|
||||||
:return: Nothing
|
|
||||||
"""
|
"""
|
||||||
# Check for 83-day warning threshold
|
# Check for 83-day warning threshold
|
||||||
if counter == CLEANUP_WARNING_THRESHOLD:
|
if counter == CLEANUP_WARNING_THRESHOLD:
|
||||||
@@ -298,7 +329,8 @@ class StreamMonitor:
|
|||||||
# 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. Removing all subscriptions."
|
f"[{domain}] Reached 90-day deletion threshold."
|
||||||
|
f" Removing all subscriptions."
|
||||||
)
|
)
|
||||||
# Send deletion notification
|
# Send deletion notification
|
||||||
await self.notification_service.send_cleanup_deletion(domain)
|
await self.notification_service.send_cleanup_deletion(domain)
|
||||||
@@ -311,5 +343,7 @@ class StreamMonitor:
|
|||||||
await self.stream_repo.delete(domain)
|
await self.stream_repo.delete(domain)
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Cleanup complete. Deleted {deleted_count} subscriptions and stream record."
|
f"[{domain}] Cleanup complete. "
|
||||||
|
f"Deleted {deleted_count} subscriptions "
|
||||||
|
f"and stream record."
|
||||||
)
|
)
|
||||||
|
|||||||
+92
-73
@@ -1,8 +1,18 @@
|
|||||||
# Copyright 2026 Logan Fick
|
# 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: https://www.apache.org/licenses/LICENSE-2.0
|
# 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
|
||||||
#
|
#
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
# http://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
|
import re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -13,23 +23,34 @@ OWNCAST_STATUS_PATH = "/api/status"
|
|||||||
# Path to GetWebConfig API call on Owncast instances
|
# Path to GetWebConfig API call on Owncast instances
|
||||||
OWNCAST_CONFIG_PATH = "/api/config"
|
OWNCAST_CONFIG_PATH = "/api/config"
|
||||||
|
|
||||||
# User agent to send with all HTTP requests.
|
|
||||||
USER_AGENT = (
|
def user_agent(version: str) -> str:
|
||||||
"OwncastSentry/1.1.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
|
"""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.
|
|
||||||
|
# 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
|
SECONDS_BETWEEN_NOTIFICATIONS = 20 * 60 # 20 minutes in seconds
|
||||||
|
|
||||||
# I'm not sure the best way to name or explain this variable, so let's just say what uses it:
|
|
||||||
#
|
|
||||||
# After a stream goes offline, a timer is started. Then, ...
|
# 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 the same title within this
|
||||||
# - If a stream comes back online with a different title, a rename notification is sent.
|
# time, no notification is sent.
|
||||||
# - If this time period passes entirely and a stream comes back online after, it's treated as regular going live.
|
# - If a stream comes back online with a different title, a rename
|
||||||
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60 # 7 minutes in seconds
|
# 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 (based on 60-second polling intervals)
|
# Counter thresholds for auto-cleanup (60-second polling intervals)
|
||||||
CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60 # 119,520 cycles = 83 days
|
CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60 # 119,520 cycles = 83 days
|
||||||
CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60 # 129,600 cycles = 90 days
|
CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60 # 129,600 cycles = 90 days
|
||||||
|
|
||||||
@@ -37,40 +58,44 @@ CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60 # 129,600 cycles = 90 days
|
|||||||
UNKNOWN_STATUS_THRESHOLD = 15
|
UNKNOWN_STATUS_THRESHOLD = 15
|
||||||
|
|
||||||
# Maximum field lengths based on Owncast's configuration
|
# Maximum field lengths based on Owncast's configuration
|
||||||
# Source: https://github.com/owncast/owncast/blob/master/web/utils/config-constants.tsx
|
# Source: https://github.com/owncast/owncast/blob/master/
|
||||||
|
# web/utils/config-constants.tsx
|
||||||
MAX_INSTANCE_TITLE_LENGTH = 255 # Server Name (line 81)
|
MAX_INSTANCE_TITLE_LENGTH = 255 # Server Name (line 81)
|
||||||
MAX_STREAM_TITLE_LENGTH = 100 # Stream Title (line 91)
|
MAX_STREAM_TITLE_LENGTH = 100 # Stream Title (line 91)
|
||||||
MAX_TAG_LENGTH = 24 # Per tag (line 208)
|
MAX_TAG_LENGTH = 24 # Per tag (line 208)
|
||||||
|
|
||||||
|
|
||||||
def should_query_stream(failure_counter: int) -> bool:
|
def should_query_stream(failure_counter: int) -> bool:
|
||||||
"""
|
"""Determine if a stream should be queried based on failure count.
|
||||||
Determine if a stream should be queried based on its failure counter.
|
|
||||||
Implements progressive backoff: 60s (5min) -> 2min (5min) -> 3min (5min) -> 5min (15min) -> 15min.
|
|
||||||
|
|
||||||
:param failure_counter: The current failure counter value
|
Implements progressive backoff with increasing intervals:
|
||||||
:return: True if the stream should be queried this cycle, False otherwise
|
- 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:
|
if failure_counter <= 4:
|
||||||
# Query every 60s for first 5 minutes (counters 0-4)
|
# Query every 60s for first 5 minutes (counters 0-4)
|
||||||
return True
|
return True
|
||||||
elif failure_counter <= 9:
|
if failure_counter <= 9:
|
||||||
# Query every 2 minutes for next 5 minutes (counters 5-9)
|
# Query every 2 minutes for next 5 minutes (counters 5-9)
|
||||||
return (failure_counter * 60) % 120 == 0
|
return (failure_counter * 60) % 120 == 0
|
||||||
elif failure_counter <= 14:
|
if failure_counter <= 14:
|
||||||
# Query every 3 minutes for next 5 minutes (counters 10-14)
|
# Query every 3 minutes for next 5 minutes (counters 10-14)
|
||||||
return (failure_counter * 60) % 180 == 0
|
return (failure_counter * 60) % 180 == 0
|
||||||
elif failure_counter <= 29:
|
if failure_counter <= 29:
|
||||||
# Query every 5 minutes for next 15 minutes (counters 15-29)
|
# Query every 5 minutes for next 15 minutes (counters 15-29)
|
||||||
return (failure_counter * 60) % 300 == 0
|
return (failure_counter * 60) % 300 == 0
|
||||||
else:
|
|
||||||
# Query every 15 minutes after 30 minutes (counter 30+)
|
# Query every 15 minutes after 30 minutes (counter 30+)
|
||||||
return (failure_counter * 60) % 900 == 0
|
return (failure_counter * 60) % 900 == 0
|
||||||
|
|
||||||
|
|
||||||
def domainify(url: str) -> str:
|
def domainify(url: str) -> str:
|
||||||
"""
|
"""Extract and sanitize a domain from user input.
|
||||||
Extract and sanitize a domain from user input.
|
|
||||||
|
|
||||||
Handles URLs, bare domains, and email-style input (user@domain).
|
Handles URLs, bare domains, and email-style input (user@domain).
|
||||||
Only allows valid domain characters (alphanumeric, hyphens, periods).
|
Only allows valid domain characters (alphanumeric, hyphens, periods).
|
||||||
@@ -83,22 +108,21 @@ def domainify(url: str) -> str:
|
|||||||
url = url.split("@")[-1]
|
url = url.split("@")[-1]
|
||||||
|
|
||||||
# Prepend // if no scheme so urlparse treats input as netloc
|
# Prepend // if no scheme so urlparse treats input as netloc
|
||||||
if not url.startswith(('http://', 'https://', '//')):
|
if not url.startswith(("http://", "https://", "//")):
|
||||||
url = '//' + url
|
url = "//" + url
|
||||||
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
domain = (parsed.netloc or parsed.path).lower()
|
domain = (parsed.netloc or parsed.path).lower()
|
||||||
|
|
||||||
# Strip port and path
|
# Strip port and path
|
||||||
domain = domain.split(':')[0].split('/')[0]
|
domain = domain.split(":")[0].split("/")[0]
|
||||||
|
|
||||||
# Allow only valid domain characters
|
# Allow only valid domain characters
|
||||||
return re.sub(r'[^a-z0-9.-]', '', domain).strip('.-')
|
return re.sub(r"[^a-z0-9.-]", "", domain).strip(".-")
|
||||||
|
|
||||||
|
|
||||||
def truncate(text: str, max_length: int) -> str:
|
def truncate(text: str, max_length: int) -> str:
|
||||||
"""
|
"""Truncate text to a maximum length.
|
||||||
Truncate text to a maximum length.
|
|
||||||
|
|
||||||
:param text: The text to truncate
|
:param text: The text to truncate
|
||||||
:param max_length: Maximum allowed length
|
:param max_length: Maximum allowed length
|
||||||
@@ -110,8 +134,7 @@ def truncate(text: str, max_length: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def escape_markdown(text: str) -> str:
|
def escape_markdown(text: str) -> str:
|
||||||
"""
|
"""Escape Markdown special characters to prevent injection attacks.
|
||||||
Escape Markdown special characters to prevent injection attacks.
|
|
||||||
|
|
||||||
This function sanitizes untrusted external input (like stream names and titles)
|
This function sanitizes untrusted external input (like stream names and titles)
|
||||||
before embedding them in Markdown-formatted messages. It prevents malicious
|
before embedding them in Markdown-formatted messages. It prevents malicious
|
||||||
@@ -127,27 +150,27 @@ def escape_markdown(text: str) -> str:
|
|||||||
# Covers: formatting (*_~`), links ([]()), headings (#), lists (-+),
|
# Covers: formatting (*_~`), links ([]()), headings (#), lists (-+),
|
||||||
# blockquotes (>), code blocks (```), and other special characters
|
# blockquotes (>), code blocks (```), and other special characters
|
||||||
special_chars = {
|
special_chars = {
|
||||||
'\\': '\\\\', # Backslash must be first to avoid double-escaping
|
"\\": "\\\\", # Backslash must be first to avoid double-escaping
|
||||||
'*': '\\*',
|
"*": "\\*",
|
||||||
'_': '\\_',
|
"_": "\\_",
|
||||||
'[': '\\[',
|
"[": "\\[",
|
||||||
']': '\\]',
|
"]": "\\]",
|
||||||
'(': '\\(',
|
"(": "\\(",
|
||||||
')': '\\)',
|
")": "\\)",
|
||||||
'~': '\\~',
|
"~": "\\~",
|
||||||
'`': '\\`',
|
"`": "\\`",
|
||||||
'#': '\\#',
|
"#": "\\#",
|
||||||
'+': '\\+',
|
"+": "\\+",
|
||||||
'-': '\\-',
|
"-": "\\-",
|
||||||
'=': '\\=',
|
"=": "\\=",
|
||||||
'|': '\\|',
|
"|": "\\|",
|
||||||
'{': '\\{',
|
"{": "\\{",
|
||||||
'}': '\\}',
|
"}": "\\}",
|
||||||
'.': '\\.',
|
".": "\\.",
|
||||||
'!': '\\!',
|
"!": "\\!",
|
||||||
'<': '\\<',
|
"<": "\\<",
|
||||||
'>': '\\>',
|
">": "\\>",
|
||||||
'&': '\\&',
|
"&": "\\&",
|
||||||
}
|
}
|
||||||
|
|
||||||
escaped_text = text
|
escaped_text = text
|
||||||
@@ -158,11 +181,11 @@ def escape_markdown(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def sanitize_for_plain_text(text: str) -> str:
|
def sanitize_for_plain_text(text: str) -> str:
|
||||||
"""
|
"""Sanitize text for plain text rendering.
|
||||||
Sanitize text for plain text rendering.
|
|
||||||
|
|
||||||
Removes newlines and normalizes whitespace without escaping special characters.
|
Remove newlines and normalize whitespace without escaping
|
||||||
Use this for plain text notifications where escaping would show literal backslashes.
|
special characters. Use this for plain text notifications where
|
||||||
|
escaping would show literal backslashes.
|
||||||
|
|
||||||
:param text: The text to sanitize
|
:param text: The text to sanitize
|
||||||
:return: Sanitized text
|
:return: Sanitized text
|
||||||
@@ -171,23 +194,21 @@ def sanitize_for_plain_text(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
# Remove newlines and carriage returns to prevent multi-line injection
|
# Remove newlines and carriage returns to prevent multi-line injection
|
||||||
sanitized = text.replace('\n', ' ').replace('\r', ' ')
|
sanitized = text.replace("\n", " ").replace("\r", " ")
|
||||||
|
|
||||||
# Collapse multiple spaces into single space
|
# Collapse multiple spaces into single space
|
||||||
sanitized = ' '.join(sanitized.split())
|
return " ".join(sanitized.split())
|
||||||
|
|
||||||
return sanitized
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_for_markdown(text: str) -> str:
|
def sanitize_for_markdown(text: str) -> str:
|
||||||
"""
|
"""Sanitize text for safe Markdown rendering.
|
||||||
Sanitize text for safe Markdown rendering.
|
|
||||||
|
|
||||||
Removes newlines, normalizes whitespace, and escapes Markdown special characters.
|
Remove newlines, normalize whitespace, and escape Markdown special
|
||||||
Use this for any untrusted external content before embedding in Markdown messages.
|
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
|
Note: This function does not truncate. Size limits should be
|
||||||
model layer (e.g., in from_api_response methods).
|
enforced at the model layer (e.g., in from_api_response methods).
|
||||||
|
|
||||||
:param text: The text to sanitize
|
:param text: The text to sanitize
|
||||||
:return: Sanitized and escaped text safe for Markdown rendering
|
:return: Sanitized and escaped text safe for Markdown rendering
|
||||||
@@ -196,12 +217,10 @@ def sanitize_for_markdown(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
# Remove newlines and carriage returns to prevent multi-line injection
|
# Remove newlines and carriage returns to prevent multi-line injection
|
||||||
sanitized = text.replace('\n', ' ').replace('\r', ' ')
|
sanitized = text.replace("\n", " ").replace("\r", " ")
|
||||||
|
|
||||||
# Collapse multiple spaces into single space
|
# Collapse multiple spaces into single space
|
||||||
sanitized = ' '.join(sanitized.split())
|
sanitized = " ".join(sanitized.split())
|
||||||
|
|
||||||
# Escape Markdown special characters
|
# Escape Markdown special characters
|
||||||
sanitized = escape_markdown(sanitized)
|
return escape_markdown(sanitized)
|
||||||
|
|
||||||
return sanitized
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
[project]
|
||||||
|
name = "owncastsentry"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "A maubot plugin that monitors Owncast streams and sends Matrix notifications"
|
||||||
|
authors = [
|
||||||
|
{ name = "Logan Fick" },
|
||||||
|
]
|
||||||
|
license = "Apache-2.0"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"maubot[encryption]",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://git.logal.dev/LogalDeveloper/OwncastSentry"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.28.0", "hatch-vcs>=0.5.0"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"codespell>=2.4.2",
|
||||||
|
"hatch>=1.16.5",
|
||||||
|
"mypy>=1.19.1",
|
||||||
|
"pip-audit>=2.10.0",
|
||||||
|
"ruff>=0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
source = "vcs"
|
||||||
|
|
||||||
|
[tool.hatch.build.hooks.vcs]
|
||||||
|
version-file = "owncastsentry/_version.py"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.14"
|
||||||
|
strict = true
|
||||||
|
warn_unreachable = true
|
||||||
|
explicit_package_bases = true
|
||||||
|
exclude = ["owncastsentry/_version\\.py"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py314"
|
||||||
|
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Ruff-specific
|
||||||
|
"RUF", # Ruff-specific rules
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"D203", # incompatible with D211 (no blank line before class docstring)
|
||||||
|
"D213", # incompatible with D212 (summary on first line)
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.codespell]
|
||||||
|
skip = "uv.lock"
|
||||||
Reference in New Issue
Block a user