media.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. from __future__ import annotations
  2. import time
  3. from pathlib import Path
  4. from typing import Any
  5. import httpx
  6. from .config import Settings
  7. class ThreeCXMediaClient:
  8. """
  9. Separater 3CX-Client für Media/RoutePoint.
  10. Token wird gecacht, bei HTTP 401 aber sofort verworfen
  11. und einmalig neu bezogen.
  12. """
  13. def __init__(self, settings: Settings):
  14. self.s = settings
  15. self._token: str | None = None
  16. self._expires_at = 0.0
  17. async def token(self, force: bool = False) -> str:
  18. now = time.time()
  19. if (
  20. not force
  21. and self._token
  22. and now < self._expires_at - 60
  23. ):
  24. return self._token
  25. key = Path(
  26. self.s.threecx_media_api_key
  27. ).read_text().strip()
  28. async with httpx.AsyncClient(
  29. verify=self.s.threecx_verify_tls,
  30. timeout=15,
  31. ) as client:
  32. response = await client.post(
  33. f"{self.s.threecx_base_url}/connect/token",
  34. data={
  35. "client_id": self.s.threecx_media_client_id,
  36. "client_secret": key,
  37. "grant_type": "client_credentials",
  38. },
  39. )
  40. response.raise_for_status()
  41. data = response.json()
  42. self._token = data["access_token"]
  43. # Nicht blind auf 3600 Sekunden verlassen.
  44. expires_in = int(data.get("expires_in", 300))
  45. self._expires_at = (
  46. time.time() + max(30, expires_in)
  47. )
  48. return self._token
  49. def invalidate_token(self) -> None:
  50. self._token = None
  51. self._expires_at = 0.0
  52. async def participants(
  53. self,
  54. dn: str | None = None,
  55. ) -> list[dict[str, Any]]:
  56. dn = (
  57. dn
  58. or self.s.threecx_media_routepoint_dn
  59. )
  60. for attempt in range(2):
  61. token = await self.token(
  62. force=(attempt == 1)
  63. )
  64. async with httpx.AsyncClient(
  65. verify=self.s.threecx_verify_tls,
  66. timeout=15,
  67. ) as client:
  68. response = await client.get(
  69. f"{self.s.threecx_base_url}"
  70. f"/callcontrol/{dn}/participants",
  71. headers={
  72. "Authorization": f"Bearer {token}",
  73. "Accept": "application/json",
  74. },
  75. )
  76. if response.status_code == 401:
  77. self.invalidate_token()
  78. continue
  79. response.raise_for_status()
  80. data = response.json()
  81. if isinstance(data, list):
  82. return data
  83. return data.get(
  84. "participants",
  85. [],
  86. )
  87. raise RuntimeError(
  88. "3CX participants: "
  89. "Authentication nach Token-Refresh "
  90. "weiterhin abgelehnt"
  91. )
  92. async def stream(
  93. self,
  94. participant_id: int | str,
  95. output: Path,
  96. dn: str | None = None,
  97. max_bytes: int | None = None,
  98. ) -> int:
  99. dn = (
  100. dn
  101. or self.s.threecx_media_routepoint_dn
  102. )
  103. output.parent.mkdir(
  104. parents=True,
  105. exist_ok=True,
  106. )
  107. for attempt in range(2):
  108. token = await self.token(
  109. force=(attempt == 1)
  110. )
  111. received = 0
  112. try:
  113. async with httpx.AsyncClient(
  114. verify=self.s.threecx_verify_tls,
  115. timeout=None,
  116. ) as client:
  117. async with client.stream(
  118. "GET",
  119. (
  120. f"{self.s.threecx_base_url}"
  121. f"/callcontrol/{dn}"
  122. f"/participants/{participant_id}"
  123. f"/stream"
  124. ),
  125. headers={
  126. "Authorization":
  127. f"Bearer {token}",
  128. "Accept":
  129. "application/octet-stream",
  130. },
  131. ) as response:
  132. if response.status_code == 401:
  133. self.invalidate_token()
  134. # Stream wurde noch nicht
  135. # verarbeitet → einmal neu versuchen.
  136. if attempt == 0:
  137. continue
  138. response.raise_for_status()
  139. with output.open("wb") as file:
  140. async for chunk in response.aiter_bytes(
  141. 8192
  142. ):
  143. file.write(chunk)
  144. received += len(chunk)
  145. if (
  146. max_bytes is not None
  147. and received >= max_bytes
  148. ):
  149. return received
  150. return received
  151. except httpx.HTTPStatusError as exc:
  152. if (
  153. exc.response.status_code == 401
  154. and attempt == 0
  155. ):
  156. self.invalidate_token()
  157. continue
  158. raise
  159. raise RuntimeError(
  160. "3CX Media Stream: "
  161. "Authentication nach Token-Refresh "
  162. "weiterhin abgelehnt"
  163. )