Added pydocstyle (D) rules to Ruff and fixed all violations.
CI / Formatting (push) Successful in 12s
CI / Linting (push) Successful in 13s
CI / Tests (Python 3.12) (push) Successful in 26s
CI / Tests (Python 3.13) (push) Successful in 25s
CI / Tests (Python 3.14) (push) Successful in 25s
CI / Type Checking (push) Successful in 26s

This commit is contained in:
2026-02-19 11:47:47 -05:00
parent 33dd49e20a
commit ca4adbcebf
30 changed files with 309 additions and 591 deletions
+1 -2
View File
@@ -83,8 +83,7 @@ def on_command(
requires_moderator: bool = False,
cooldown: int | float = 0,
) -> Callable[[CommandHandler], CommandHandler]:
"""
Decorator to register a command handler.
"""Register a command handler.
:param name: Primary command name (case-insensitive).
:param aliases: Optional list of alternative names.
+19 -35
View File
@@ -36,8 +36,7 @@ class Config:
config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None,
):
"""
Initialize the configuration manager.
"""Initialize the configuration manager.
:param config_path: Path to the YAML config file.
:param overrides: CLI overrides (keys match property names).
@@ -91,9 +90,9 @@ class Config:
default: Any = _UNSET,
type_fn: type = str,
) -> Any:
"""
Resolve a setting through the priority chain:
CLI arg > env var > config file > default.
"""Resolve a setting through the priority chain.
Priority order: CLI arg > env var > config file > default.
When *default* is a ``str``, the resolved value is coerced to ``str``
(the implicit *type_fn*). For non-string types, pass both *default*
@@ -123,8 +122,7 @@ class Config:
@property
def webhook_secret(self) -> str:
"""
Secret string for the webhook URL path.
"""Secret string for the webhook URL path.
The webhook endpoint is always /webhook/<secret>. A cryptographically
secure value is generated on first run if not explicitly configured.
@@ -139,8 +137,7 @@ class Config:
@property
def webhook_path(self) -> str:
"""
URL path where Owncast sends webhooks.
"""URL path where Owncast sends webhooks.
Always returns /webhook/<secret>. The secret is auto-generated
on first run if not configured.
@@ -207,8 +204,7 @@ class Config:
@property
def public_base_url(self) -> str:
"""
Public base URL for Owlbot's web server.
"""Public base URL for Owlbot's web server.
Used to construct URLs for module routes and the webhook endpoint.
Falls back to ``owncast.url`` if not explicitly set.
@@ -224,8 +220,7 @@ class Config:
@property
def storage_dir(self) -> Path:
"""
Directory for module database files.
"""Directory for module database files.
Each module gets its own database file named '<module_name>.db'.
Defaults to 'data/' in the working directory.
@@ -242,8 +237,7 @@ class Config:
@property
def modules_dir(self) -> Path:
"""
Directory containing user modules.
"""Directory containing user modules.
Built-in modules are loaded from the package regardless of this setting.
Defaults to 'modules/' in the working directory.
@@ -260,8 +254,7 @@ class Config:
@property
def log_dir(self) -> Path | None:
"""
Directory for the log file.
"""Directory for the log file.
When set, an ``owlbot.log`` file is written to this directory in
addition to stdout. Returns ``None`` when unset (stdout only).
@@ -405,8 +398,7 @@ class Config:
logger.info(f"Configuration saved to: {self.config_path.absolute()}")
def is_module_enabled(self, module_name: str) -> bool:
"""
Check if a module is enabled.
"""Check if a module is enabled.
Modules are enabled by default unless explicitly disabled with
``modules.<name>.enabled: false`` in the config file.
@@ -424,8 +416,7 @@ class Config:
return True
def get_module_config(self, module_name: str) -> dict[str, Any]:
"""
Get the configuration dict for a module.
"""Get the configuration dict for a module.
Returns merged defaults and config file values, with config file
values taking precedence.
@@ -443,8 +434,7 @@ class Config:
return {**defaults, **config}
def set_module_config(self, module_name: str, config: dict[str, Any]) -> None:
"""
Update the configuration for a module at runtime and persist to disk.
"""Update the configuration for a module at runtime and persist to disk.
:param module_name: The module name.
:param config: Dict of configuration values to set.
@@ -459,8 +449,7 @@ class Config:
def register_module_defaults(
self, module_name: str, defaults: dict[str, Any]
) -> None:
"""
Register default configuration values for a module.
"""Register default configuration values for a module.
Called by modules during setup to declare their expected config keys
and default values. Missing keys are backfilled into the config file
@@ -516,8 +505,7 @@ class ModuleConfig:
"""Pre-scoped configuration for a specific module."""
def __init__(self, config: Config, module_name: str):
"""
Initialize a module-scoped configuration.
"""Initialize a module-scoped configuration.
:param config: The parent Config object.
:param module_name: The name of the module this config
@@ -537,8 +525,7 @@ class ModuleConfig:
return self._config.public_base_url
def get(self, key: str, default: Any = None) -> Any:
"""
Get a config value by key.
"""Get a config value by key.
:param key: The configuration key.
:param default: Value to return if key is not found.
@@ -547,16 +534,14 @@ class ModuleConfig:
return self.as_dict().get(key, default)
def as_dict(self) -> dict[str, Any]:
"""
Get the full config dict for this module.
"""Get the full config dict for this module.
:return: Dict of all configuration values.
"""
return self._config.get_module_config(self._module_name)
def set(self, key: str, value: Any) -> None:
"""
Set a config value at runtime and persist to disk.
"""Set a config value at runtime and persist to disk.
:param key: The configuration key.
:param value: The value to set.
@@ -566,8 +551,7 @@ class ModuleConfig:
self._config.set_module_config(self._module_name, current)
def register_defaults(self, defaults: dict[str, Any]) -> None:
"""
Register default values for this module's config.
"""Register default values for this module's config.
Called during setup() to declare expected config keys and their
default values.
+7 -12
View File
@@ -44,8 +44,7 @@ if TYPE_CHECKING:
@dataclass
class ModuleContext:
"""
Shared services available to all module handlers.
"""Shared services available to all module handlers.
Created once per module during loading and reused for all handler
invocations. This bundles the common dependencies that every handler needs.
@@ -83,6 +82,7 @@ class ModuleContext:
logger: logging.Logger = field(init=False)
def __post_init__(self) -> None:
"""Derive the module-scoped logger from the module name."""
self.logger = logging.getLogger(f"owlbot.modules.{self.module_name}")
@@ -96,8 +96,7 @@ class PropagationState:
@dataclass
class EventContext[E]:
"""
Context passed to event handlers.
"""Context passed to event handlers.
Each handler invocation receives its own EventContext instance with the event
data and access to shared services via the module context.
@@ -165,16 +164,14 @@ class EventContext[E]:
@property
def propagation_stopped(self) -> bool:
"""
Check if event propagation has been stopped by a handler.
"""Check if event propagation has been stopped by a handler.
:return: True if stop_propagation() was called by any handler.
"""
return self._propagation.stopped
def stop_propagation(self, reason: str | None = None) -> None:
"""
Stop event from being dispatched to remaining handlers and commands.
"""Stop event from being dispatched to remaining handlers and commands.
Once called, no further handlers will be invoked for this event, and
command dispatch (for CHAT events) will be skipped.
@@ -189,8 +186,7 @@ class EventContext[E]:
@dataclass
class CommandContext:
"""
Context passed to command handlers.
"""Context passed to command handlers.
Provides access to the parsed command data, original chat event context,
and shared services. Like EventContext and RouteContext, all ModuleContext
@@ -289,8 +285,7 @@ class CommandContext:
@dataclass
class RouteContext:
"""
Context passed to HTTP route handlers.
"""Context passed to HTTP route handlers.
Similar to EventContext but includes the aiohttp request object
for accessing HTTP-specific data (body, headers, query params).
+14 -28
View File
@@ -43,8 +43,7 @@ class EventType(StrEnum):
def _parse_timestamp(ts: str | None) -> datetime | None:
"""
Parse an ISO 8601 timestamp string into a datetime.
"""Parse an ISO 8601 timestamp string into a datetime.
Handles timezone suffixes and the Go zero-value timestamp that Owncast
sends for missing/unset timestamps.
@@ -91,8 +90,7 @@ class User:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> User:
"""
Create a User from webhook JSON data.
"""Create a User from webhook JSON data.
:param data: The user data from the webhook payload.
:return: A populated User instance.
@@ -111,8 +109,7 @@ class User:
@property
def is_moderator(self) -> bool:
"""
Check if the user has moderator privileges.
"""Check if the user has moderator privileges.
:return: True if the user has the MODERATOR scope.
"""
@@ -133,8 +130,7 @@ class ChatEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ChatEvent:
"""
Create a ChatEvent from webhook JSON data.
"""Create a ChatEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated ChatEvent instance.
@@ -168,8 +164,7 @@ class UserJoinedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> UserJoinedEvent:
"""
Create a UserJoinedEvent from webhook JSON data.
"""Create a UserJoinedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated UserJoinedEvent instance.
@@ -193,8 +188,7 @@ class UserPartedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> UserPartedEvent:
"""
Create a UserPartedEvent from webhook JSON data.
"""Create a UserPartedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated UserPartedEvent instance.
@@ -219,8 +213,7 @@ class NameChangedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> NameChangedEvent:
"""
Create a NameChangedEvent from webhook JSON data.
"""Create a NameChangedEvent from webhook JSON data.
Note: The user object contains the OLD display name in user.display_name.
@@ -248,8 +241,7 @@ class StreamStartedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStartedEvent:
"""
Create a StreamStartedEvent from webhook JSON data.
"""Create a StreamStartedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamStartedEvent instance.
@@ -275,8 +267,7 @@ class StreamStoppedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStoppedEvent:
"""
Create a StreamStoppedEvent from webhook JSON data.
"""Create a StreamStoppedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamStoppedEvent instance.
@@ -305,8 +296,7 @@ class StreamStatus:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStatus:
"""
Create a StreamStatus from webhook JSON data.
"""Create a StreamStatus from webhook JSON data.
:param data: The status data from the webhook payload.
:return: A populated StreamStatus instance.
@@ -336,8 +326,7 @@ class StreamTitleUpdatedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamTitleUpdatedEvent:
"""
Create a StreamTitleUpdatedEvent from webhook JSON data.
"""Create a StreamTitleUpdatedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamTitleUpdatedEvent instance.
@@ -364,8 +353,7 @@ class VisibilityUpdateEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> VisibilityUpdateEvent:
"""
Create a VisibilityUpdateEvent from webhook JSON data.
"""Create a VisibilityUpdateEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated VisibilityUpdateEvent instance.
@@ -407,8 +395,7 @@ _EVENT_TYPE_MAP: dict[EventType, type[Event]] = {
def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
"""
Parse a raw webhook payload into a typed event.
"""Parse a raw webhook payload into a typed event.
:param data: The JSON payload from Owncast's webhook.
:return: Tuple of (event_type, event_instance), or None if the event type
@@ -431,8 +418,7 @@ def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
def log_event(event_type: EventType, event: Event) -> None:
"""
Log event details for debugging and monitoring.
"""Log event details for debugging and monitoring.
:param event_type: The type of event being logged.
:param event: The parsed event instance.
+2 -4
View File
@@ -43,8 +43,7 @@ type EventHandler = "Callable[[EventContext[Any]], Awaitable[None]]"
class Priority(IntEnum):
"""
Standard priority levels for event handlers.
"""Standard priority levels for event handlers.
Higher values run first. Handlers at the same priority level run in
registration order. Custom numeric values can be used for fine-grained control.
@@ -61,8 +60,7 @@ def on_event(
*event_types: EventType,
priority: int = Priority.NORMAL,
) -> Callable[[EventHandler], EventHandler]:
"""
Decorator to register a function as a handler for one or more event types.
"""Register a function as a handler for one or more event types.
The decorated function will be called whenever an event of the specified
type(s) is received. Handlers are executed sequentially in priority order
+2 -4
View File
@@ -31,8 +31,7 @@ type LifecycleHandler = "Callable[[ModuleContext], Awaitable[None]]"
def on_setup(func: LifecycleHandler) -> LifecycleHandler:
"""
Decorator to mark a function as a module setup hook.
"""Mark a function as a module setup hook.
The decorated function will be called during module loading with a
``ModuleContext``. Setup hooks run inside a storage transaction that
@@ -52,8 +51,7 @@ def on_setup(func: LifecycleHandler) -> LifecycleHandler:
def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
"""
Decorator to mark a function as a module teardown hook.
"""Mark a function as a module teardown hook.
The decorated function will be called during module unload or bot
shutdown with the same ``ModuleContext`` from setup.
+77 -154
View File
@@ -186,8 +186,7 @@ class OwncastAdminClient(OwncastClient):
def __init__(
self, base_url: str, username: str, password: str, http_client: HttpClient
):
"""
Initialize the admin client.
"""Initialize the admin client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
:param username: Admin username.
@@ -203,24 +202,21 @@ class OwncastAdminClient(OwncastClient):
)
async def get_status(self) -> dict[str, Any]:
"""
Get the current server status including stream info and viewer count.
"""Get the current server status including stream info and viewer count.
:return: Server status dict.
"""
return dict(await self._get("/api/admin/status"))
async def get_active_viewers(self) -> list[dict[str, Any]]:
"""
Get a list of currently active viewers.
"""Get a list of currently active viewers.
:return: Viewer list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/viewers"))
async def get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]:
"""
Get viewer count data over time for charting.
"""Get viewer count data over time for charting.
:param window_start: Unix timestamp (seconds since epoch)
for the start of the window.
@@ -233,24 +229,21 @@ class OwncastAdminClient(OwncastClient):
)
async def get_hardware_stats(self) -> dict[str, Any]:
"""
Get server hardware statistics (CPU, memory, disk).
"""Get server hardware statistics (CPU, memory, disk).
:return: Hardware stats dict.
"""
return dict(await self._get("/api/admin/hardwarestats"))
async def get_server_config(self) -> dict[str, Any]:
"""
Get the full server configuration.
"""Get the full server configuration.
:return: Server configuration dict.
"""
return dict(await self._get("/api/admin/serverconfig"))
async def disconnect_stream(self) -> dict[str, Any]:
"""
Disconnect the current inbound stream.
"""Disconnect the current inbound stream.
:return: API response confirming the disconnect.
"""
@@ -258,16 +251,14 @@ class OwncastAdminClient(OwncastClient):
return dict(await self._get("/api/admin/disconnect"))
async def get_chat_messages(self) -> list[dict[str, Any]]:
"""
Get chat messages from the admin perspective.
"""Get chat messages from the admin perspective.
:return: Chat message list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/messages"))
async def get_connected_chat_clients(self) -> list[dict[str, Any]]:
"""
Get currently connected chat clients.
"""Get currently connected chat clients.
:return: Client list as returned by the Owncast API.
"""
@@ -276,8 +267,7 @@ class OwncastAdminClient(OwncastClient):
async def set_message_visibility(
self, message_ids: list[str], visible: bool
) -> str:
"""
Hide or show chat messages.
"""Hide or show chat messages.
:param message_ids: List of message IDs to modify.
:param visible: True to show messages, False to hide them.
@@ -292,8 +282,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_user_enabled(self, user_id: str, enabled: bool) -> str:
"""
Enable or disable a chat user.
"""Enable or disable a chat user.
:param user_id: The user ID to modify.
:param enabled: True to enable, False to disable the user.
@@ -307,16 +296,14 @@ class OwncastAdminClient(OwncastClient):
)
async def get_disabled_users(self) -> list[dict[str, Any]]:
"""
Get a list of disabled chat users.
"""Get a list of disabled chat users.
:return: User list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/users/disabled"))
async def set_user_moderator(self, user_id: str, is_mod: bool) -> str:
"""
Grant or revoke moderator status for a user.
"""Grant or revoke moderator status for a user.
:param user_id: The user ID to modify.
:param is_mod: True to grant moderator, False to revoke.
@@ -330,16 +317,14 @@ class OwncastAdminClient(OwncastClient):
)
async def get_moderators(self) -> list[dict[str, Any]]:
"""
Get a list of moderator users.
"""Get a list of moderator users.
:return: User list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/users/moderators"))
async def ban_ip_address(self, ip: str) -> str:
"""
Ban an IP address from chat.
"""Ban an IP address from chat.
:param ip: The IP address to ban.
:return: Success message from the server.
@@ -348,8 +333,7 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/chat/users/ipbans/create", {"value": ip})
async def unban_ip_address(self, ip: str) -> str:
"""
Remove an IP address ban.
"""Remove an IP address ban.
:param ip: The IP address to unban.
:return: Success message from the server.
@@ -358,16 +342,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/chat/users/ipbans/remove", {"value": ip})
async def get_ip_address_bans(self) -> list[dict[str, Any]]:
"""
Get a list of banned IP addresses.
"""Get a list of banned IP addresses.
:return: IP ban list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/users/ipbans"))
async def set_stream_title(self, title: str) -> str:
"""
Set the stream title.
"""Set the stream title.
:param title: The new stream title.
:return: Success message from the server.
@@ -377,8 +359,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_server_name(self, name: str) -> str:
"""
Set the server name.
"""Set the server name.
:param name: The new server name.
:return: Success message from the server.
@@ -388,8 +369,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_server_summary(self, summary: str) -> str:
"""
Set the server summary.
"""Set the server summary.
:param summary: The new server summary.
:return: Success message from the server.
@@ -399,8 +379,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_welcome_message(self, message: str) -> str:
"""
Set the welcome message shown to new viewers.
"""Set the welcome message shown to new viewers.
:param message: The new welcome message.
:return: Success message from the server.
@@ -410,8 +389,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_offline_message(self, message: str) -> str:
"""
Set the message shown when the stream is offline.
"""Set the message shown when the stream is offline.
:param message: The new offline message.
:return: Success message from the server.
@@ -421,8 +399,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_page_content(self, content: str) -> str:
"""
Set the custom page content (HTML/markdown below the player).
"""Set the custom page content (HTML/markdown below the player).
:param content: The page content.
:return: Success message from the server.
@@ -432,8 +409,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_admin_password(self, password: str) -> str:
"""
Change the admin password.
"""Change the admin password.
:param password: The new admin password.
:return: Success message from the server.
@@ -443,8 +419,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_server_url(self, url: str) -> str:
"""
Set the public server URL.
"""Set the public server URL.
:param url: The new server URL.
:return: Success message from the server.
@@ -454,8 +429,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_custom_styles(self, css: str) -> str:
"""
Set custom CSS styles for the web interface.
"""Set custom CSS styles for the web interface.
:param css: The CSS string.
:return: Success message from the server.
@@ -465,8 +439,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_custom_javascript(self, js: str) -> str:
"""
Set custom JavaScript for the web interface.
"""Set custom JavaScript for the web interface.
:param js: The JavaScript string.
:return: Success message from the server.
@@ -476,8 +449,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_socket_host_override(self, host: str) -> str:
"""
Set the WebSocket host override.
"""Set the WebSocket host override.
:param host: The WebSocket host override value.
:return: Success message from the server.
@@ -487,8 +459,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_ffmpeg_path(self, path: str) -> str:
"""
Set the path to the ffmpeg binary.
"""Set the path to the ffmpeg binary.
:param path: The ffmpeg binary path.
:return: Success message from the server.
@@ -498,8 +469,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_video_serving_endpoint(self, endpoint: str) -> str:
"""
Set the video serving endpoint (e.g., for CDN).
"""Set the video serving endpoint (e.g., for CDN).
:param endpoint: The video serving endpoint URL.
:return: Success message from the server.
@@ -509,8 +479,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_video_codec(self, codec: str) -> str:
"""
Set the video codec.
"""Set the video codec.
:param codec: The video codec name.
:return: Success message from the server.
@@ -520,8 +489,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_chat_disabled(self, disabled: bool) -> str:
"""
Enable or disable the chat.
"""Enable or disable the chat.
:param disabled: True to disable chat, False to enable it.
:return: Success message from the server.
@@ -531,8 +499,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_chat_join_messages_enabled(self, enabled: bool) -> str:
"""
Enable or disable chat join messages.
"""Enable or disable chat join messages.
:param enabled: True to show join messages, False to hide them.
:return: Success message from the server.
@@ -542,8 +509,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_chat_established_mode(self, enabled: bool) -> str:
"""
Enable or disable established user mode for chat.
"""Enable or disable established user mode for chat.
:param enabled: True to enable established mode.
:return: Success message from the server.
@@ -555,8 +521,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_chat_spam_protection(self, enabled: bool) -> str:
"""
Enable or disable chat spam protection.
"""Enable or disable chat spam protection.
:param enabled: True to enable spam protection.
:return: Success message from the server.
@@ -568,8 +533,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_chat_slur_filter(self, enabled: bool) -> str:
"""
Enable or disable the chat slur filter.
"""Enable or disable the chat slur filter.
:param enabled: True to enable the slur filter.
:return: Success message from the server.
@@ -579,8 +543,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_nsfw(self, nsfw: bool) -> str:
"""
Set the NSFW flag for the server.
"""Set the NSFW flag for the server.
:param nsfw: True to mark the server as NSFW.
:return: Success message from the server.
@@ -588,8 +551,7 @@ class OwncastAdminClient(OwncastClient):
return await self._set_config_value("/api/admin/config/nsfw", nsfw, "NSFW flag")
async def set_directory_enabled(self, enabled: bool) -> str:
"""
Enable or disable listing in the Owncast directory.
"""Enable or disable listing in the Owncast directory.
:param enabled: True to enable directory listing.
:return: Success message from the server.
@@ -599,8 +561,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_hide_viewer_count(self, hide: bool) -> str:
"""
Show or hide the viewer count.
"""Show or hide the viewer count.
:param hide: True to hide the viewer count.
:return: Success message from the server.
@@ -610,8 +571,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_disable_search_indexing(self, disabled: bool) -> str:
"""
Enable or disable search engine indexing.
"""Enable or disable search engine indexing.
:param disabled: True to disable search indexing.
:return: Success message from the server.
@@ -623,8 +583,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_forbidden_usernames(self, names: list[str]) -> str:
"""
Set the list of forbidden usernames.
"""Set the list of forbidden usernames.
:param names: List of forbidden username strings.
:return: Success message from the server.
@@ -634,8 +593,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_suggested_usernames(self, names: list[str]) -> str:
"""
Set the list of suggested usernames for new viewers.
"""Set the list of suggested usernames for new viewers.
:param names: List of suggested username strings.
:return: Success message from the server.
@@ -645,8 +603,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_tags(self, tags: list[str]) -> str:
"""
Set the server tags.
"""Set the server tags.
:param tags: List of tag strings.
:return: Success message from the server.
@@ -654,8 +611,7 @@ class OwncastAdminClient(OwncastClient):
return await self._set_config_value("/api/admin/config/tags", tags, "tags")
async def set_federation_blocked_domains(self, domains: list[str]) -> str:
"""
Set the list of blocked federation domains.
"""Set the list of blocked federation domains.
:param domains: List of domain strings to block.
:return: Success message from the server.
@@ -667,8 +623,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_stream_keys(self, keys: list[StreamKey]) -> str:
"""
Set the stream keys.
"""Set the stream keys.
:param keys: List of :class:`StreamKey` objects.
:return: Success message from the server.
@@ -680,8 +635,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_video_variants(self, variants: list[VideoVariant]) -> str:
"""
Set the video output variants (quality levels).
"""Set the video output variants (quality levels).
:param variants: List of :class:`VideoVariant` objects.
:return: Success message from the server.
@@ -693,8 +647,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_social_handles(self, handles: list[SocialHandle]) -> str:
"""
Set the social media handles displayed on the page.
"""Set the social media handles displayed on the page.
:param handles: List of :class:`SocialHandle` objects.
:return: Success message from the server.
@@ -706,8 +659,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_external_actions(self, actions: list[ExternalAction]) -> str:
"""
Set the external actions (buttons/links in the player).
"""Set the external actions (buttons/links in the player).
:param actions: List of :class:`ExternalAction` objects.
:return: Success message from the server.
@@ -727,8 +679,7 @@ class OwncastAdminClient(OwncastClient):
bucket: str,
region: str,
) -> str:
"""
Set the S3 storage configuration.
"""Set the S3 storage configuration.
:param enabled: Whether S3 storage is enabled.
:param endpoint: The S3 endpoint URL.
@@ -757,8 +708,7 @@ class OwncastAdminClient(OwncastClient):
webhook: str,
go_live_message: str,
) -> str:
"""
Set the Discord notification configuration.
"""Set the Discord notification configuration.
:param enabled: Whether Discord notifications are enabled.
:param webhook: The Discord webhook URL.
@@ -780,8 +730,7 @@ class OwncastAdminClient(OwncastClient):
enabled: bool,
go_live_message: str,
) -> str:
"""
Set the browser notification configuration.
"""Set the browser notification configuration.
:param enabled: Whether browser notifications are enabled.
:param go_live_message: The message shown when going live.
@@ -797,8 +746,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_color_variables(self, variables: dict[str, Any]) -> str:
"""
Set the custom color variables for the web interface.
"""Set the custom color variables for the web interface.
:param variables: Color variables dict.
:return: Success message from the server.
@@ -808,8 +756,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_stream_latency(self, level: int) -> str:
"""
Set the stream latency level.
"""Set the stream latency level.
:param level: Latency level value.
:return: Success message from the server.
@@ -819,8 +766,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_rtmp_port(self, port: int) -> str:
"""
Set the RTMP server port.
"""Set the RTMP server port.
:param port: The RTMP port number.
:return: Success message from the server.
@@ -830,8 +776,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_web_server_port(self, port: int) -> str:
"""
Set the web server port.
"""Set the web server port.
:param port: The web server port number.
:return: Success message from the server.
@@ -841,8 +786,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_web_server_ip(self, ip: str) -> str:
"""
Set the web server bind IP address.
"""Set the web server bind IP address.
:param ip: The IP address to bind to.
:return: Success message from the server.
@@ -852,8 +796,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_federation_enabled(self, enabled: bool) -> str:
"""
Enable or disable federation (ActivityPub).
"""Enable or disable federation (ActivityPub).
:param enabled: True to enable federation.
:return: Success message from the server.
@@ -863,8 +806,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_federation_username(self, name: str) -> str:
"""
Set the federation (ActivityPub) username.
"""Set the federation (ActivityPub) username.
:param name: The federation username.
:return: Success message from the server.
@@ -874,8 +816,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_federation_go_live_message(self, message: str) -> str:
"""
Set the message sent to followers when going live.
"""Set the message sent to followers when going live.
:param message: The go-live notification message.
:return: Success message from the server.
@@ -887,8 +828,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_logo(self, base64_data_url: str) -> str:
"""
Set the server logo from a base64 data URL.
"""Set the server logo from a base64 data URL.
:param base64_data_url: Data URL string
(e.g., ``data:image/png;base64,iVBOR...``).
@@ -899,8 +839,7 @@ class OwncastAdminClient(OwncastClient):
)
async def upload_emoji(self, name: str, data_base64: str) -> str:
"""
Upload a custom emoji.
"""Upload a custom emoji.
:param name: The emoji name.
:param data_base64: Base64-encoded image data for the emoji.
@@ -912,8 +851,7 @@ class OwncastAdminClient(OwncastClient):
)
async def delete_emoji(self, name: str) -> str:
"""
Delete a custom emoji.
"""Delete a custom emoji.
:param name: The emoji name to delete.
:return: Success message from the server.
@@ -922,16 +860,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/emoji/delete", {"name": name})
async def get_webhooks(self) -> list[dict[str, Any]]:
"""
Get all registered webhooks.
"""Get all registered webhooks.
:return: Webhook list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/webhooks"))
async def create_webhook(self, url: str, events: list[str]) -> str:
"""
Create a new webhook.
"""Create a new webhook.
:param url: The URL to send webhook events to.
:param events: List of event type strings to subscribe to.
@@ -943,8 +879,7 @@ class OwncastAdminClient(OwncastClient):
)
async def delete_webhook(self, webhook_id: int) -> str:
"""
Delete a webhook.
"""Delete a webhook.
:param webhook_id: The ID of the webhook to delete.
:return: Success message from the server.
@@ -953,16 +888,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/webhooks/delete", {"id": webhook_id})
async def get_access_tokens(self) -> list[dict[str, Any]]:
"""
Get all access tokens.
"""Get all access tokens.
:return: Token list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/accesstokens"))
async def create_access_token(self, name: str, scopes: list[str]) -> str:
"""
Create a new access token.
"""Create a new access token.
:param name: Display name for the token.
:param scopes: List of permission scope strings.
@@ -974,8 +907,7 @@ class OwncastAdminClient(OwncastClient):
)
async def delete_access_token(self, token: str) -> str:
"""
Delete an access token.
"""Delete an access token.
:param token: The token string to delete.
:return: Success message from the server.
@@ -985,8 +917,7 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/accesstokens/delete", {"token": token})
async def get_followers(self, offset: int = 0, limit: int = 25) -> dict[str, Any]:
"""
Get a paginated list of followers.
"""Get a paginated list of followers.
:param offset: Number of followers to skip.
:param limit: Maximum number of followers to return.
@@ -999,24 +930,21 @@ class OwncastAdminClient(OwncastClient):
)
async def get_pending_follow_requests(self) -> list[dict[str, Any]]:
"""
Get pending follow requests.
"""Get pending follow requests.
:return: Follower request list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/followers/pending"))
async def get_blocked_followers(self) -> list[dict[str, Any]]:
"""
Get blocked and rejected followers.
"""Get blocked and rejected followers.
:return: Follower list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/followers/blocked"))
async def approve_follower(self, actor_iri: str, approved: bool) -> str:
"""
Approve or reject a follow request.
"""Approve or reject a follow request.
:param actor_iri: The ActivityPub actor IRI of the follower.
:param approved: True to approve, False to reject.
@@ -1030,8 +958,7 @@ class OwncastAdminClient(OwncastClient):
)
async def send_federated_message(self, message: str) -> str:
"""
Send a message to all followers via federation.
"""Send a message to all followers via federation.
:param message: The message text to send.
:return: Success message from the server.
@@ -1040,24 +967,21 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/federation/send", {"value": message})
async def get_logs(self) -> list[dict[str, Any]]:
"""
Get server logs.
"""Get server logs.
:return: Log entry list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/logs"))
async def get_warnings(self) -> list[dict[str, Any]]:
"""
Get server warning and error logs.
"""Get server warning and error logs.
:return: Log entry list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/logs/warnings"))
async def get_playback_metrics(self) -> dict[str, Any]:
"""
Get playback quality metrics.
"""Get playback quality metrics.
:return: Playback metrics as returned by the Owncast API.
"""
@@ -1066,8 +990,7 @@ class OwncastAdminClient(OwncastClient):
async def _set_config_value(
self, endpoint: str, value: Any, description: str
) -> str:
"""
Set a configuration value via POST with a ``{"value": ...}`` body.
"""Set a configuration value via POST with a ``{"value": ...}`` body.
:param endpoint: The config API endpoint path.
:param value: The value to set.
+13 -26
View File
@@ -52,8 +52,7 @@ class OwncastError(Exception):
"""Raised when an Owncast API request fails."""
def __init__(self, status: int, message: str):
"""
Initialize the error.
"""Initialize the error.
:param status: HTTP status code from the failed request, or 0 if the
request failed due to a connection error before receiving a response.
@@ -72,8 +71,7 @@ class OwncastClient:
"""
def __init__(self, base_url: str, access_token: str, http_client: HttpClient):
"""
Initialize the Owncast client.
"""Initialize the Owncast client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
:param access_token: API access token from Owncast admin settings.
@@ -94,8 +92,7 @@ class OwncastClient:
return self._base_url
async def get_status(self) -> dict[str, Any]:
"""
Get the public server status.
"""Get the public server status.
This is a public endpoint that does not require authentication.
Returns server info including version, online status, and viewer count.
@@ -106,8 +103,7 @@ class OwncastClient:
return dict(await self._get("/api/status"))
async def send_message(self, body: str) -> str:
"""
Send a chat message visible to all viewers.
"""Send a chat message visible to all viewers.
:param body: The message text (supports markdown).
:return: Success message from the server.
@@ -116,8 +112,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/send", {"body": body})
async def send_system_message(self, body: str) -> str:
"""
Send a system message visible to all viewers.
"""Send a system message visible to all viewers.
System messages are styled differently from regular chat (typically
italicized or dimmed) and are used for announcements or notifications.
@@ -129,8 +124,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/system", {"body": body})
async def send_action(self, body: str) -> str:
"""
Send an action message (like IRC /me).
"""Send an action message (like IRC /me).
Action messages display as "*BotName does something*" and are used
for describing actions rather than speech.
@@ -142,8 +136,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/action", {"body": body})
async def send_system_message_to_client(self, client_id: int, body: str) -> str:
"""
Send a private system message to a specific viewer.
"""Send a private system message to a specific viewer.
The message is only visible to the targeted client, useful for
welcome messages or private notifications.
@@ -160,8 +153,7 @@ class OwncastClient:
async def set_message_visibility(
self, message_ids: list[str], visible: bool
) -> str:
"""
Hide or show chat messages (moderation).
"""Hide or show chat messages (moderation).
Hidden messages are removed from the chat display for all viewers.
This is typically used for moderation purposes.
@@ -179,8 +171,7 @@ class OwncastClient:
)
async def get_chat_history(self) -> list[dict[str, Any]]:
"""
Fetch recent chat messages.
"""Fetch recent chat messages.
:return: List of recent chat message objects with user info and content.
"""
@@ -188,8 +179,7 @@ class OwncastClient:
return list(await self._get("/api/integrations/chat"))
async def get_connected_clients(self) -> list[dict[str, Any]]:
"""
Get list of currently connected viewers.
"""Get list of currently connected viewers.
:return: List of connected client objects with user info and connection details.
"""
@@ -197,8 +187,7 @@ class OwncastClient:
return list(await self._get("/api/integrations/clients"))
async def set_stream_title(self, title: str) -> str:
"""
Update the stream title.
"""Update the stream title.
:param title: The new stream title.
:return: Success message from the server.
@@ -207,8 +196,7 @@ class OwncastClient:
return await self._post("/api/integrations/streamtitle", {"value": title})
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
"""
Send a POST request to the Owncast API.
"""Send a POST request to the Owncast API.
Owncast POST endpoints return ``{"success": true, "message": "..."}``.
This method validates the response and returns just the message string.
@@ -270,8 +258,7 @@ class OwncastClient:
raise OwncastError(0, str(e)) from e
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
"""
Send a GET request to the Owncast API.
"""Send a GET request to the Owncast API.
:param endpoint: The API endpoint path.
:param params: Optional query parameters.
+1 -2
View File
@@ -62,8 +62,7 @@ def on_route(
*,
methods: list[str] | None = None,
) -> Callable[[RouteHandler], RouteHandler]:
"""
Decorator to register an HTTP route handler.
"""Register an HTTP route handler.
Routes are namespaced under /owlbot/<module_name>/<path>.
+13 -24
View File
@@ -34,8 +34,7 @@ class StorageError(Exception):
class ModuleStorage:
"""
Module-scoped async SQLite storage backed by a lazy connection pool.
"""Module-scoped async SQLite storage backed by a lazy connection pool.
Each module gets its own isolated database file. Connections are created
lazily and pooled up to ``pool_size``. WAL mode is enabled so that
@@ -54,8 +53,7 @@ class ModuleStorage:
"""
def __init__(self, storage_dir: Path, module_name: str, pool_size: int = 4):
"""
Initialize the storage API.
"""Initialize the storage API.
:param storage_dir: Directory where module databases are stored.
:param module_name: Name of the module this storage belongs to.
@@ -92,8 +90,7 @@ class ModuleStorage:
@asynccontextmanager
async def transaction(self) -> AsyncIterator[ModuleStorage]:
"""
Context manager for explicit transaction control within a handler.
"""Context manager for explicit transaction control within a handler.
Use this when you need multiple operations to succeed or fail together
within a single handler. Commits on success, rolls back on exception.
@@ -105,6 +102,7 @@ class ModuleStorage:
# Both committed together, or both rolled back on error
:return: This ModuleStorage instance.
"""
self._logger.debug("Explicit transaction started.")
try:
@@ -119,8 +117,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Cursor:
"""
Execute a SQL statement.
"""Execute a SQL statement.
:param sql: SQL statement (use ? or :name for parameters).
:param parameters: Query parameters (tuple for ?, dict for :name).
@@ -140,8 +137,7 @@ class ModuleStorage:
sql: str,
parameters: list[tuple[Any, ...]] | list[dict[str, Any]],
) -> aiosqlite.Cursor:
"""
Execute a SQL statement with multiple parameter sets.
"""Execute a SQL statement with multiple parameter sets.
Useful for batch inserts/updates.
@@ -166,8 +162,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Row | None:
"""
Execute a query and fetch one row.
"""Execute a query and fetch one row.
:param sql: SELECT statement.
:param parameters: Query parameters.
@@ -189,8 +184,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> list[aiosqlite.Row]:
"""
Execute a query and fetch all rows.
"""Execute a query and fetch all rows.
:param sql: SELECT statement.
:param parameters: Query parameters.
@@ -212,8 +206,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> Any | None:
"""
Execute a query and fetch a single value.
"""Execute a query and fetch a single value.
:param sql: SELECT statement returning one column.
:param parameters: Query parameters.
@@ -260,8 +253,7 @@ class ModuleStorage:
@asynccontextmanager
async def _connection(self) -> AsyncIterator[aiosqlite.Connection]:
"""
Async context manager that provides a connection.
"""Async context manager that provides a connection.
If already inside a ``_checkout``, yields the checked-out connection
without releasing it. Otherwise acquires a standalone connection from
@@ -285,8 +277,7 @@ class ModuleStorage:
@asynccontextmanager
async def _checkout(self) -> AsyncIterator[None]:
"""
Check out a connection from the pool for the duration of a handler.
"""Check out a connection from the pool for the duration of a handler.
Sets a ContextVar so that all storage operations within the handler
reuse the same connection.
@@ -300,8 +291,7 @@ class ModuleStorage:
self._release(conn)
async def _commit(self) -> None:
"""
Commit the current transaction (internal use by bot).
"""Commit the current transaction (internal use by bot).
Called automatically after each handler completes successfully.
"""
@@ -311,8 +301,7 @@ class ModuleStorage:
self._logger.debug("Transaction committed.")
async def _rollback(self) -> None:
"""
Rollback the current transaction (internal use by bot).
"""Rollback the current transaction (internal use by bot).
Called automatically if a handler throws an exception.
"""