PlanOpticon

planopticon / video_processor / sources / zoom_source.py
Source Blame History 399 lines
0981a08… noreply 1 """Zoom cloud recordings source integration with OAuth support."""
0981a08… noreply 2
0981a08… noreply 3 import base64
0981a08… noreply 4 import hashlib
0981a08… noreply 5 import json
0981a08… noreply 6 import logging
0981a08… noreply 7 import os
0981a08… noreply 8 import secrets
0981a08… noreply 9 import time
0981a08… noreply 10 import webbrowser
0981a08… noreply 11 from pathlib import Path
0981a08… noreply 12 from typing import Dict, List, Optional
0981a08… noreply 13
0981a08… noreply 14 import requests
0981a08… noreply 15
0981a08… noreply 16 from video_processor.sources.base import BaseSource, SourceFile
0981a08… noreply 17
0981a08… noreply 18 logger = logging.getLogger(__name__)
0981a08… noreply 19
0981a08… noreply 20 _TOKEN_PATH = Path.home() / ".planopticon" / "zoom_token.json"
0981a08… noreply 21 _BASE_URL = "https://api.zoom.us/v2"
0981a08… noreply 22 _OAUTH_BASE = "https://zoom.us/oauth"
0981a08… noreply 23
0981a08… noreply 24 # Map Zoom file_type values to MIME types
0981a08… noreply 25 _MIME_TYPES = {
0981a08… noreply 26 "MP4": "video/mp4",
0981a08… noreply 27 "M4A": "audio/mp4",
0981a08… noreply 28 "CHAT": "text/plain",
0981a08… noreply 29 "TRANSCRIPT": "text/vtt",
0981a08… noreply 30 "CSV": "text/csv",
0981a08… noreply 31 "TIMELINE": "application/json",
0981a08… noreply 32 }
0981a08… noreply 33
0981a08… noreply 34
0981a08… noreply 35 class ZoomSource(BaseSource):
0981a08… noreply 36 """
0981a08… noreply 37 Zoom cloud recordings source with OAuth2 support.
0981a08… noreply 38
0981a08… noreply 39 Auth methods (tried in order):
0981a08… noreply 40 1. Saved token: Load from token_path, refresh if expired
0981a08… noreply 41 2. Server-to-Server OAuth: Uses account_id with client credentials
0981a08… noreply 42 3. OAuth2 Authorization Code with PKCE: Interactive browser flow
0981a08… noreply 43 """
0981a08… noreply 44
0981a08… noreply 45 def __init__(
0981a08… noreply 46 self,
0981a08… noreply 47 client_id: Optional[str] = None,
0981a08… noreply 48 client_secret: Optional[str] = None,
0981a08… noreply 49 account_id: Optional[str] = None,
0981a08… noreply 50 token_path: Optional[Path] = None,
0981a08… noreply 51 ):
0981a08… noreply 52 """
0981a08… noreply 53 Initialize Zoom source.
0981a08… noreply 54
0981a08… noreply 55 Parameters
0981a08… noreply 56 ----------
0981a08… noreply 57 client_id : str, optional
0981a08… noreply 58 Zoom OAuth app client ID. Falls back to ZOOM_CLIENT_ID env var.
0981a08… noreply 59 client_secret : str, optional
0981a08… noreply 60 Zoom OAuth app client secret. Falls back to ZOOM_CLIENT_SECRET env var.
0981a08… noreply 61 account_id : str, optional
0981a08… noreply 62 Zoom account ID for Server-to-Server OAuth. Falls back to ZOOM_ACCOUNT_ID env var.
0981a08… noreply 63 token_path : Path, optional
0981a08… noreply 64 Where to store/load OAuth tokens.
0981a08… noreply 65 """
0981a08… noreply 66 self.client_id = client_id or os.environ.get("ZOOM_CLIENT_ID")
0981a08… noreply 67 self.client_secret = client_secret or os.environ.get("ZOOM_CLIENT_SECRET")
0981a08… noreply 68 self.account_id = account_id or os.environ.get("ZOOM_ACCOUNT_ID")
0981a08… noreply 69 self.token_path = token_path or _TOKEN_PATH
0981a08… noreply 70 self._access_token: Optional[str] = None
0981a08… noreply 71 self._token_data: Optional[Dict] = None
0981a08… noreply 72
0981a08… noreply 73 def authenticate(self) -> bool:
0981a08… noreply 74 """Authenticate with Zoom API."""
0981a08… noreply 75 # Try 1: Load saved token
0981a08… noreply 76 if self.token_path.exists():
0981a08… noreply 77 if self._auth_saved_token():
0981a08… noreply 78 return True
0981a08… noreply 79
0981a08… noreply 80 # Try 2: Server-to-Server OAuth (if account_id is set)
0981a08… noreply 81 if self.account_id:
0981a08… noreply 82 return self._auth_server_to_server()
0981a08… noreply 83
0981a08… noreply 84 # Try 3: OAuth2 Authorization Code flow with PKCE
0981a08… noreply 85 return self._auth_oauth_pkce()
0981a08… noreply 86
0981a08… noreply 87 def _auth_saved_token(self) -> bool:
0981a08… noreply 88 """Authenticate using a saved OAuth token, refreshing if expired."""
0981a08… noreply 89 try:
0981a08… noreply 90 data = json.loads(self.token_path.read_text())
0981a08… noreply 91 expires_at = data.get("expires_at", 0)
0981a08… noreply 92
0981a08… noreply 93 if time.time() < expires_at:
0981a08… noreply 94 # Token still valid
0981a08… noreply 95 self._access_token = data["access_token"]
0981a08… noreply 96 self._token_data = data
0981a08… noreply 97 logger.info("Authenticated with Zoom via saved token")
0981a08… noreply 98 return True
0981a08… noreply 99
0981a08… noreply 100 # Token expired, try to refresh
0981a08… noreply 101 if data.get("refresh_token"):
0981a08… noreply 102 return self._refresh_token()
0981a08… noreply 103
0981a08… noreply 104 # Server-to-Server tokens don't have refresh tokens;
0981a08… noreply 105 # fall through to re-authenticate
0981a08… noreply 106 return False
0981a08… noreply 107 except Exception:
0981a08… noreply 108 return False
0981a08… noreply 109
0981a08… noreply 110 def _auth_server_to_server(self) -> bool:
0981a08… noreply 111 """Authenticate using Server-to-Server OAuth (account credentials)."""
0981a08… noreply 112 if not self.client_id or not self.client_secret:
0981a08… noreply 113 logger.error(
0981a08… noreply 114 "Zoom client_id and client_secret required for Server-to-Server OAuth. "
0981a08… noreply 115 "Set ZOOM_CLIENT_ID and ZOOM_CLIENT_SECRET env vars."
0981a08… noreply 116 )
0981a08… noreply 117 return False
0981a08… noreply 118
0981a08… noreply 119 try:
0981a08… noreply 120 resp = requests.post(
0981a08… noreply 121 f"{_OAUTH_BASE}/token",
0981a08… noreply 122 params={
0981a08… noreply 123 "grant_type": "account_credentials",
0981a08… noreply 124 "account_id": self.account_id,
0981a08… noreply 125 },
0981a08… noreply 126 auth=(self.client_id, self.client_secret),
0981a08… noreply 127 timeout=30,
0981a08… noreply 128 )
0981a08… noreply 129 resp.raise_for_status()
0981a08… noreply 130 token_data = resp.json()
0981a08… noreply 131
0981a08… noreply 132 self._access_token = token_data["access_token"]
0981a08… noreply 133 self._token_data = {
0981a08… noreply 134 "access_token": token_data["access_token"],
0981a08… noreply 135 "expires_at": time.time() + token_data.get("expires_in", 3600) - 60,
0981a08… noreply 136 "token_type": token_data.get("token_type", "bearer"),
0981a08… noreply 137 }
0981a08… noreply 138
0981a08… noreply 139 self._save_token(self._token_data)
0981a08… noreply 140 logger.info("Authenticated with Zoom via Server-to-Server OAuth")
0981a08… noreply 141 return True
0981a08… noreply 142 except Exception as e:
0981a08… noreply 143 logger.error(f"Zoom Server-to-Server OAuth failed: {e}")
0981a08… noreply 144 return False
0981a08… noreply 145
0981a08… noreply 146 def _auth_oauth_pkce(self) -> bool:
0981a08… noreply 147 """Run OAuth2 Authorization Code flow with PKCE."""
0981a08… noreply 148 if not self.client_id:
0981a08… noreply 149 logger.error("Zoom client_id required for OAuth. Set ZOOM_CLIENT_ID env var.")
0981a08… noreply 150 return False
0981a08… noreply 151
0981a08… noreply 152 try:
0981a08… noreply 153 # Generate PKCE code verifier and challenge
0981a08… noreply 154 code_verifier = secrets.token_urlsafe(64)
0981a08… noreply 155 code_challenge = (
0981a08… noreply 156 base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
0981a08… noreply 157 .rstrip(b"=")
0981a08… noreply 158 .decode("ascii")
0981a08… noreply 159 )
0981a08… noreply 160
0981a08… noreply 161 authorize_url = (
0981a08… noreply 162 f"{_OAUTH_BASE}/authorize"
0981a08… noreply 163 f"?response_type=code"
0981a08… noreply 164 f"&client_id={self.client_id}"
0981a08… noreply 165 f"&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
0981a08… noreply 166 f"&code_challenge={code_challenge}"
0981a08… noreply 167 f"&code_challenge_method=S256"
0981a08… noreply 168 )
0981a08… noreply 169
0981a08… noreply 170 print(f"\nOpen this URL to authorize PlanOpticon:\n{authorize_url}\n")
0981a08… noreply 171
0981a08… noreply 172 try:
0981a08… noreply 173 webbrowser.open(authorize_url)
0981a08… noreply 174 except Exception:
0981a08… noreply 175 pass
0981a08… noreply 176
0981a08… noreply 177 auth_code = input("Enter the authorization code: ").strip()
0981a08… noreply 178
0981a08… noreply 179 # Exchange authorization code for tokens
0981a08… noreply 180 payload = {
0981a08… noreply 181 "grant_type": "authorization_code",
0981a08… noreply 182 "code": auth_code,
0981a08… noreply 183 "redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
0981a08… noreply 184 "code_verifier": code_verifier,
0981a08… noreply 185 }
0981a08… noreply 186
0981a08… noreply 187 resp = requests.post(
0981a08… noreply 188 f"{_OAUTH_BASE}/token",
0981a08… noreply 189 data=payload,
0981a08… noreply 190 auth=(self.client_id, self.client_secret or ""),
0981a08… noreply 191 timeout=30,
0981a08… noreply 192 )
0981a08… noreply 193 resp.raise_for_status()
0981a08… noreply 194 token_data = resp.json()
0981a08… noreply 195
0981a08… noreply 196 self._access_token = token_data["access_token"]
0981a08… noreply 197 self._token_data = {
0981a08… noreply 198 "access_token": token_data["access_token"],
0981a08… noreply 199 "refresh_token": token_data.get("refresh_token"),
0981a08… noreply 200 "expires_at": time.time() + token_data.get("expires_in", 3600) - 60,
0981a08… noreply 201 "token_type": token_data.get("token_type", "bearer"),
0981a08… noreply 202 "client_id": self.client_id,
0981a08… noreply 203 "client_secret": self.client_secret or "",
0981a08… noreply 204 }
0981a08… noreply 205
0981a08… noreply 206 self._save_token(self._token_data)
0981a08… noreply 207 logger.info("Authenticated with Zoom via OAuth PKCE")
0981a08… noreply 208 return True
0981a08… noreply 209 except Exception as e:
0981a08… noreply 210 logger.error(f"Zoom OAuth PKCE failed: {e}")
0981a08… noreply 211 return False
0981a08… noreply 212
0981a08… noreply 213 def _refresh_token(self) -> bool:
0981a08… noreply 214 """Refresh an expired OAuth token."""
0981a08… noreply 215 try:
0981a08… noreply 216 data = json.loads(self.token_path.read_text())
0981a08… noreply 217 refresh_token = data.get("refresh_token")
0981a08… noreply 218 client_id = data.get("client_id") or self.client_id
0981a08… noreply 219 client_secret = data.get("client_secret") or self.client_secret
0981a08… noreply 220
0981a08… noreply 221 if not refresh_token or not client_id:
0981a08… noreply 222 return False
0981a08… noreply 223
0981a08… noreply 224 resp = requests.post(
0981a08… noreply 225 f"{_OAUTH_BASE}/token",
0981a08… noreply 226 data={
0981a08… noreply 227 "grant_type": "refresh_token",
0981a08… noreply 228 "refresh_token": refresh_token,
0981a08… noreply 229 },
0981a08… noreply 230 auth=(client_id, client_secret or ""),
0981a08… noreply 231 timeout=30,
0981a08… noreply 232 )
0981a08… noreply 233 resp.raise_for_status()
0981a08… noreply 234 token_data = resp.json()
0981a08… noreply 235
0981a08… noreply 236 self._access_token = token_data["access_token"]
0981a08… noreply 237 self._token_data = {
0981a08… noreply 238 "access_token": token_data["access_token"],
0981a08… noreply 239 "refresh_token": token_data.get("refresh_token", refresh_token),
0981a08… noreply 240 "expires_at": time.time() + token_data.get("expires_in", 3600) - 60,
0981a08… noreply 241 "token_type": token_data.get("token_type", "bearer"),
0981a08… noreply 242 "client_id": client_id,
0981a08… noreply 243 "client_secret": client_secret or "",
0981a08… noreply 244 }
0981a08… noreply 245
0981a08… noreply 246 self._save_token(self._token_data)
0981a08… noreply 247 logger.info("Refreshed Zoom OAuth token")
0981a08… noreply 248 return True
0981a08… noreply 249 except Exception as e:
0981a08… noreply 250 logger.error(f"Zoom token refresh failed: {e}")
0981a08… noreply 251 return False
0981a08… noreply 252
0981a08… noreply 253 def _save_token(self, data: Dict) -> None:
0981a08… noreply 254 """Save token data to disk."""
0981a08… noreply 255 self.token_path.parent.mkdir(parents=True, exist_ok=True)
0981a08… noreply 256 self.token_path.write_text(json.dumps(data))
0981a08… noreply 257 logger.info(f"OAuth token saved to {self.token_path}")
0981a08… noreply 258
0981a08… noreply 259 def _api_get(self, endpoint: str, params: Optional[Dict] = None) -> requests.Response:
0981a08… noreply 260 """Make an authenticated GET request to the Zoom API."""
0981a08… noreply 261 if not self._access_token:
0981a08… noreply 262 raise RuntimeError("Not authenticated. Call authenticate() first.")
0981a08… noreply 263
0981a08… noreply 264 url = f"{_BASE_URL}/{endpoint.lstrip('/')}"
0981a08… noreply 265 resp = requests.get(
0981a08… noreply 266 url,
0981a08… noreply 267 headers={"Authorization": f"Bearer {self._access_token}"},
0981a08… noreply 268 params=params,
0981a08… noreply 269 timeout=30,
0981a08… noreply 270 )
0981a08… noreply 271 resp.raise_for_status()
0981a08… noreply 272 return resp
0981a08… noreply 273
0981a08… noreply 274 def list_videos(
0981a08… noreply 275 self,
0981a08… noreply 276 folder_id: Optional[str] = None,
0981a08… noreply 277 folder_path: Optional[str] = None,
0981a08… noreply 278 patterns: Optional[List[str]] = None,
0981a08… noreply 279 ) -> List[SourceFile]:
0981a08… noreply 280 """List video files from Zoom cloud recordings."""
0981a08… noreply 281 if not self._access_token:
0981a08… noreply 282 raise RuntimeError("Not authenticated. Call authenticate() first.")
0981a08… noreply 283
0981a08… noreply 284 files: List[SourceFile] = []
0981a08… noreply 285 next_page_token = ""
0981a08… noreply 286
0981a08… noreply 287 while True:
0981a08… noreply 288 params: Dict = {}
0981a08… noreply 289 if next_page_token:
0981a08… noreply 290 params["next_page_token"] = next_page_token
0981a08… noreply 291
0981a08… noreply 292 resp = self._api_get("users/me/recordings", params=params)
0981a08… noreply 293 data = resp.json()
0981a08… noreply 294
0981a08… noreply 295 for meeting in data.get("meetings", []):
0981a08… noreply 296 meeting_id = str(meeting.get("id", ""))
0981a08… noreply 297 topic = meeting.get("topic", "Untitled Meeting")
0981a08… noreply 298 start_time = meeting.get("start_time")
0981a08… noreply 299
0981a08… noreply 300 for rec_file in meeting.get("recording_files", []):
0981a08… noreply 301 file_type = rec_file.get("file_type", "")
0981a08… noreply 302 mime_type = _MIME_TYPES.get(file_type)
0981a08… noreply 303
0981a08… noreply 304 # Build a descriptive name
0981a08… noreply 305 file_ext = rec_file.get("file_extension", file_type).lower()
0981a08… noreply 306 file_name = f"{topic}.{file_ext}"
0981a08… noreply 307
0981a08… noreply 308 if patterns:
0981a08… noreply 309 if not any(file_name.endswith(p.replace("*", "")) for p in patterns):
0981a08… noreply 310 continue
0981a08… noreply 311
0981a08… noreply 312 files.append(
0981a08… noreply 313 SourceFile(
0981a08… noreply 314 name=file_name,
0981a08… noreply 315 id=meeting_id,
0981a08… noreply 316 size_bytes=rec_file.get("file_size"),
0981a08… noreply 317 mime_type=mime_type,
0981a08… noreply 318 modified_at=start_time,
0981a08… noreply 319 path=rec_file.get("download_url"),
0981a08… noreply 320 )
0981a08… noreply 321 )
0981a08… noreply 322
0981a08… noreply 323 next_page_token = data.get("next_page_token", "")
0981a08… noreply 324 if not next_page_token:
0981a08… noreply 325 break
0981a08… noreply 326
0981a08… noreply 327 logger.info(f"Found {len(files)} recordings in Zoom")
0981a08… noreply 328 return files
0981a08… noreply 329
0981a08… noreply 330 def download(self, file: SourceFile, destination: Path) -> Path:
0981a08… noreply 331 """Download a recording file from Zoom."""
0981a08… noreply 332 if not self._access_token:
0981a08… noreply 333 raise RuntimeError("Not authenticated. Call authenticate() first.")
0981a08… noreply 334
0981a08… noreply 335 destination = Path(destination)
0981a08… noreply 336 destination.parent.mkdir(parents=True, exist_ok=True)
0981a08… noreply 337
0981a08… noreply 338 download_url = file.path
0981a08… noreply 339 if not download_url:
0981a08… noreply 340 raise ValueError(f"No download URL for file: {file.name}")
0981a08… noreply 341
0981a08… noreply 342 resp = requests.get(
0981a08… noreply 343 download_url,
0981a08… noreply 344 headers={"Authorization": f"Bearer {self._access_token}"},
0981a08… noreply 345 stream=True,
0981a08… noreply 346 timeout=60,
0981a08… noreply 347 )
0981a08… noreply 348 resp.raise_for_status()
0981a08… noreply 349
0981a08… noreply 350 with open(destination, "wb") as f:
0981a08… noreply 351 for chunk in resp.iter_content(chunk_size=8192):
0981a08… noreply 352 f.write(chunk)
0981a08… noreply 353
0981a08… noreply 354 logger.info(f"Downloaded {file.name} to {destination}")
0981a08… noreply 355 return destination
0981a08… noreply 356
0981a08… noreply 357 def fetch_transcript(self, meeting_id: str) -> Optional[str]:
0981a08… noreply 358 """
0981a08… noreply 359 Fetch the transcript (VTT) for a Zoom meeting recording.
0981a08… noreply 360
0981a08… noreply 361 Looks for transcript files in the recording's file list and downloads
0981a08… noreply 362 the content as text.
0981a08… noreply 363
0981a08… noreply 364 Parameters
0981a08… noreply 365 ----------
0981a08… noreply 366 meeting_id : str
0981a08… noreply 367 The Zoom meeting ID.
0981a08… noreply 368
0981a08… noreply 369 Returns
0981a08… noreply 370 -------
0981a08… noreply 371 str or None
0981a08… noreply 372 Transcript text if available, None otherwise.
0981a08… noreply 373 """
0981a08… noreply 374 if not self._access_token:
0981a08… noreply 375 raise RuntimeError("Not authenticated. Call authenticate() first.")
0981a08… noreply 376
0981a08… noreply 377 try:
0981a08… noreply 378 resp = self._api_get(f"meetings/{meeting_id}/recordings")
0981a08… noreply 379 data = resp.json()
0981a08… noreply 380
0981a08… noreply 381 for rec_file in data.get("recording_files", []):
0981a08… noreply 382 file_type = rec_file.get("file_type", "")
0981a08… noreply 383 if file_type == "TRANSCRIPT":
0981a08… noreply 384 download_url = rec_file.get("download_url")
0981a08… noreply 385 if download_url:
0981a08… noreply 386 dl_resp = requests.get(
0981a08… noreply 387 download_url,
0981a08… noreply 388 headers={"Authorization": f"Bearer {self._access_token}"},
0981a08… noreply 389 timeout=30,
0981a08… noreply 390 )
0981a08… noreply 391 dl_resp.raise_for_status()
0981a08… noreply 392 logger.info(f"Fetched transcript for meeting {meeting_id}")
0981a08… noreply 393 return dl_resp.text
0981a08… noreply 394
0981a08… noreply 395 logger.info(f"No transcript found for meeting {meeting_id}")
0981a08… noreply 396 return None
0981a08… noreply 397 except Exception as e:
0981a08… noreply 398 logger.error(f"Failed to fetch transcript for meeting {meeting_id}: {e}")
0981a08… noreply 399 return None

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button