Modernized codebase with tooling configuration and CI/CD workflows.
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s

- 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:
2026-03-11 14:37:56 -04:00
parent 314e1bf399
commit d05d73eddc
18 changed files with 2472 additions and 598 deletions
+63 -32
View File
@@ -1,47 +1,68 @@
# 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 json
from typing import Optional
if TYPE_CHECKING:
import logging
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:
"""HTTP client for communicating with Owncast instances."""
def __init__(self, logger):
"""
Initialize the Owncast client with an HTTP session.
def __init__(self, logger: logging.Logger, version: str) -> None:
"""Initialize the Owncast client with an HTTP session.
:param logger: Logger instance for debugging
:param version: Plugin version string for the User-Agent header
"""
self.log = logger
# Set up HTTP session configuration
headers = {"User-Agent": USER_AGENT}
headers = {"User-Agent": user_agent(version)}
cookie_jar = aiohttp.DummyCookieJar()
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)
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]:
"""
Get the current stream state for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports are supported.
async def get_stream_state(self, domain: str) -> StreamState | None:
"""Get the current stream state for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports
are supported.
:param domain: The domain (not URL) where the stream is hosted.
:return: A 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...")
status_url = "https://" + domain + OWNCAST_STATUS_PATH
@@ -60,20 +81,24 @@ class OwncastClient:
# Check the response code is success
if response.status != 200:
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
# Try and interpret the response as JSON
# Try to interpret the response as JSON
try:
new_state = json.loads(await response.read())
except Exception as e:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as could not be interpreted as JSON: {e}"
f"[{domain}] Rejecting response to request on "
f"{OWNCAST_STATUS_PATH} as could not be "
f"interpreted as JSON: {e}"
)
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 = [
"lastConnectTime",
"lastDisconnectTime",
@@ -83,19 +108,22 @@ class OwncastClient:
for field in required_fields:
if field not in new_state:
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 StreamState.from_api_response(new_state, domain)
async def get_stream_config(self, domain: str) -> Optional[StreamConfig]:
"""
Get the current stream config for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports are supported.
async def get_stream_config(self, domain: str) -> StreamConfig | None:
"""Get the current stream config for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports
are supported.
:param domain: The domain (not URL) where the stream is hosted.
:return: A 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...")
config_url = "https://" + domain + OWNCAST_CONFIG_PATH
@@ -114,25 +142,28 @@ class OwncastClient:
# Check the response code is success
if response.status != 200:
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
# Try and interpret the response as JSON
# Try to interpret the response as JSON
try:
config = json.loads(await response.read())
except Exception as e:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_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
# Create StreamConfig with validated fields
# Create StreamConfig from response (fields are truncated to max lengths)
return StreamConfig.from_api_response(config)
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
:return: True if valid Owncast instance, False otherwise