2023-02-26 01:27:00 +01:00
|
|
|
import logging
|
2022-08-09 21:14:19 +02:00
|
|
|
from dataclasses import dataclass
|
|
|
|
from datetime import datetime
|
|
|
|
from threading import Thread
|
|
|
|
from time import sleep
|
2022-08-10 17:35:06 +02:00
|
|
|
from typing import Any, Dict, List, Optional, Union
|
2022-08-09 21:14:19 +02:00
|
|
|
|
2023-03-01 17:13:02 +01:00
|
|
|
import requests
|
2022-08-09 21:14:19 +02:00
|
|
|
from libretime_api_client.v1 import ApiClient as LegacyClient
|
2022-08-10 17:35:06 +02:00
|
|
|
from libretime_shared.config import IcecastOutput, ShoutcastOutput
|
2022-08-09 21:14:19 +02:00
|
|
|
from lxml import etree
|
|
|
|
from requests import Session
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
from ..config import Config
|
2022-08-09 21:14:19 +02:00
|
|
|
|
2023-02-26 01:27:00 +01:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
AnyOutput = Union[IcecastOutput, ShoutcastOutput]
|
2022-08-09 21:14:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class Stats:
|
|
|
|
listeners: int
|
|
|
|
|
|
|
|
|
|
|
|
# pylint: disable=too-few-public-methods
|
|
|
|
class StatsCollector:
|
|
|
|
"""
|
|
|
|
Collect stats from Icecast and Shoutcast.
|
|
|
|
"""
|
|
|
|
|
|
|
|
_session: Session
|
|
|
|
|
|
|
|
def __init__(self, legacy_client: LegacyClient):
|
|
|
|
self._session = Session()
|
|
|
|
self._timeout = 30
|
|
|
|
self._legacy_client = legacy_client
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
def get_output_url(self, output: AnyOutput) -> str:
|
|
|
|
if output.kind == "icecast":
|
|
|
|
return f"http://{output.host}:{output.port}/admin/stats.xml"
|
|
|
|
return f"http://{output.host}:{output.port}/admin.cgi?sid=1&mode=viewxml"
|
2022-08-09 21:14:19 +02:00
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
def collect_output_stats(
|
|
|
|
self,
|
|
|
|
output: AnyOutput,
|
|
|
|
) -> Dict[str, Stats]:
|
|
|
|
response = self._session.get(
|
|
|
|
url=self.get_output_url(output),
|
2023-03-01 20:27:27 +01:00
|
|
|
auth=(output.admin_user, output.admin_password or ""),
|
2022-08-10 17:35:06 +02:00
|
|
|
timeout=self._timeout,
|
2022-08-09 21:14:19 +02:00
|
|
|
)
|
2022-08-10 17:35:06 +02:00
|
|
|
response.raise_for_status()
|
2022-08-09 21:14:19 +02:00
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
root = etree.fromstring( # nosec
|
|
|
|
response.content,
|
|
|
|
parser=etree.XMLParser(resolve_entities=False),
|
|
|
|
)
|
2022-08-09 21:14:19 +02:00
|
|
|
|
|
|
|
stats = {}
|
|
|
|
|
|
|
|
# Shoutcast specific parsing
|
2022-08-10 17:35:06 +02:00
|
|
|
if output.kind == "shoutcast":
|
2022-08-09 21:14:19 +02:00
|
|
|
listeners_el = root.find("CURRENTLISTENERS")
|
|
|
|
listeners = 0 if listeners_el is None else int(listeners_el.text)
|
|
|
|
|
|
|
|
stats["shoutcast"] = Stats(
|
|
|
|
listeners=listeners,
|
|
|
|
)
|
|
|
|
return stats
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
# Icecast specific parsing
|
2022-08-09 21:14:19 +02:00
|
|
|
for source in root.iterchildren("source"):
|
|
|
|
mount = source.attrib.get("mount")
|
|
|
|
if mount is None:
|
|
|
|
continue
|
|
|
|
|
|
|
|
listeners_el = source.find("listeners")
|
|
|
|
listeners = 0 if listeners_el is None else int(listeners_el.text)
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
mount = mount.lstrip("/")
|
2022-08-09 21:14:19 +02:00
|
|
|
stats[mount] = Stats(
|
|
|
|
listeners=listeners,
|
|
|
|
)
|
|
|
|
|
|
|
|
return stats
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
def collect(
|
|
|
|
self,
|
|
|
|
outputs: List[AnyOutput],
|
|
|
|
*,
|
|
|
|
_timestamp: Optional[datetime] = None,
|
2023-03-01 20:27:27 +01:00
|
|
|
) -> None:
|
2022-08-09 21:14:19 +02:00
|
|
|
if _timestamp is None:
|
|
|
|
_timestamp = datetime.utcnow()
|
|
|
|
|
|
|
|
stats: List[Dict[str, Any]] = []
|
|
|
|
stats_timestamp = _timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
2022-08-10 17:35:06 +02:00
|
|
|
cache: Dict[str, Dict[str, Stats]] = {}
|
|
|
|
|
|
|
|
for output_id, output in enumerate(outputs, start=1):
|
|
|
|
if (
|
|
|
|
output.kind not in ("icecast", "shoutcast")
|
|
|
|
or not output.enabled
|
|
|
|
or output.admin_password is None
|
|
|
|
):
|
2022-08-09 21:14:19 +02:00
|
|
|
continue
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
output_url = self.get_output_url(output)
|
|
|
|
if output_url not in cache:
|
|
|
|
try:
|
|
|
|
cache[output_url] = self.collect_output_stats(output)
|
|
|
|
except (
|
|
|
|
etree.XMLSyntaxError,
|
2023-03-01 17:13:02 +01:00
|
|
|
requests.exceptions.ConnectionError,
|
|
|
|
requests.exceptions.HTTPError,
|
|
|
|
requests.exceptions.Timeout,
|
2022-08-10 17:35:06 +02:00
|
|
|
) as exception:
|
|
|
|
logger.exception(exception)
|
|
|
|
self._legacy_client.update_stream_setting_table(
|
|
|
|
{output_id: str(exception)}
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
|
|
|
|
output_stats = cache[output_url]
|
|
|
|
|
|
|
|
mount = "shoutcast" if output.kind == "shoutcast" else output.mount
|
|
|
|
|
|
|
|
if mount in output_stats:
|
|
|
|
stats.append(
|
|
|
|
{
|
|
|
|
"timestamp": stats_timestamp,
|
|
|
|
"num_listeners": output_stats[mount].listeners,
|
|
|
|
"mount_name": mount,
|
|
|
|
}
|
|
|
|
)
|
2022-08-09 21:14:19 +02:00
|
|
|
|
|
|
|
if stats:
|
|
|
|
self._legacy_client.push_stream_stats(stats)
|
|
|
|
|
|
|
|
|
|
|
|
class StatsCollectorThread(Thread):
|
|
|
|
name = "stats collector"
|
|
|
|
daemon = True
|
|
|
|
|
2022-08-10 17:35:06 +02:00
|
|
|
def __init__(self, config: Config, legacy_client: LegacyClient) -> None:
|
2022-08-09 21:14:19 +02:00
|
|
|
super().__init__()
|
2022-08-10 17:35:06 +02:00
|
|
|
self._config = config
|
2022-08-09 21:14:19 +02:00
|
|
|
self._collector = StatsCollector(legacy_client)
|
|
|
|
|
2023-03-01 20:27:27 +01:00
|
|
|
def run(self) -> None:
|
2023-02-26 12:01:59 +01:00
|
|
|
logger.info("starting %s", self.name)
|
2022-08-09 21:14:19 +02:00
|
|
|
while True:
|
|
|
|
try:
|
2022-08-10 17:35:06 +02:00
|
|
|
self._collector.collect(self._config.stream.outputs.merged)
|
2023-03-01 18:17:34 +01:00
|
|
|
except Exception as exception: # pylint: disable=broad-exception-caught
|
2022-08-09 21:14:19 +02:00
|
|
|
logger.exception(exception)
|
|
|
|
sleep(120)
|