PlanOpticon

planopticon / video_processor / sources / teams_recording_source.py
Blame History Raw 376 lines
1
"""Microsoft Teams meeting recording source using the m365 CLI.
2
3
Fetches Teams meeting recordings and transcripts via the Microsoft Graph API
4
through the `m365` CLI tool.
5
6
Requires: npm install -g @pnp/cli-microsoft365
7
Auth: m365 login (interactive)
8
Docs: https://pnp.github.io/cli-microsoft365/
9
"""
10
11
import logging
12
import re
13
import shutil
14
import subprocess
15
from pathlib import Path
16
from typing import List, Optional
17
18
from video_processor.sources.base import BaseSource, SourceFile
19
from video_processor.sources.m365_source import _run_m365
20
21
logger = logging.getLogger(__name__)
22
23
24
def _vtt_to_text(vtt: str) -> str:
25
"""Strip VTT timing metadata and return plain text.
26
27
Removes WEBVTT headers, timestamps (00:00:00.000 --> 00:00:05.000),
28
cue identifiers, and deduplicates consecutive identical lines.
29
"""
30
lines = vtt.splitlines()
31
text_lines: list[str] = []
32
prev_line = ""
33
34
for line in lines:
35
stripped = line.strip()
36
# Skip WEBVTT header and NOTE blocks
37
if stripped.startswith("WEBVTT") or stripped.startswith("NOTE"):
38
continue
39
# Skip timestamp lines (e.g. 00:00:01.000 --> 00:00:05.000)
40
if re.match(r"\d{2}:\d{2}[:\.][\d.]+ --> \d{2}:\d{2}[:\.][\d.]+", stripped):
41
continue
42
# Skip numeric cue identifiers
43
if re.match(r"^\d+$", stripped):
44
continue
45
# Skip blank lines
46
if not stripped:
47
continue
48
# Strip inline VTT tags like <v Speaker>
49
cleaned = re.sub(r"<[^>]+>", "", stripped).strip()
50
if cleaned and cleaned != prev_line:
51
text_lines.append(cleaned)
52
prev_line = cleaned
53
54
return "\n".join(text_lines)
55
56
57
class TeamsRecordingSource(BaseSource):
58
"""
59
Fetch Teams meeting recordings and transcripts via the m365 CLI / Graph API.
60
61
Usage:
62
source = TeamsRecordingSource(user_id="me")
63
source.authenticate()
64
recordings = source.list_videos()
65
source.download_all(recordings, Path("./recordings"))
66
67
# Fetch transcript for a specific meeting
68
transcript = source.fetch_transcript(meeting_id)
69
"""
70
71
def __init__(self, user_id: str = "me"):
72
self.user_id = user_id
73
74
def authenticate(self) -> bool:
75
"""Check if m365 CLI is installed and logged in."""
76
if not shutil.which("m365"):
77
logger.error("m365 CLI not found. Install with: npm install -g @pnp/cli-microsoft365")
78
return False
79
try:
80
result = _run_m365(["status"], timeout=10)
81
if isinstance(result, dict) and result.get("connectedAs"):
82
return True
83
if isinstance(result, str) and "Logged in" in result:
84
return True
85
logger.error("m365 not logged in. Run: m365 login")
86
return False
87
except (RuntimeError, subprocess.TimeoutExpired):
88
logger.error("m365 not logged in. Run: m365 login")
89
return False
90
91
def list_videos(
92
self,
93
folder_id: Optional[str] = None,
94
folder_path: Optional[str] = None,
95
patterns: Optional[List[str]] = None,
96
) -> List[SourceFile]:
97
"""List Teams meeting recordings available for the user.
98
99
Tries multiple approaches:
100
1. Graph API onlineMeetings endpoint
101
2. m365 teams meeting list command
102
3. Fallback: search chat messages for recording links
103
"""
104
files: List[SourceFile] = []
105
106
# Approach 1: Graph API — list online meetings
107
try:
108
result = _run_m365(
109
[
110
"request",
111
"--url",
112
f"https://graph.microsoft.com/v1.0/{self.user_id}/onlineMeetings",
113
"--method",
114
"get",
115
],
116
timeout=60,
117
)
118
meetings = self._extract_meetings_list(result)
119
for meeting in meetings:
120
recording_files = self._get_meeting_recordings(meeting)
121
files.extend(recording_files)
122
123
if files:
124
logger.info(f"Found {len(files)} recording(s) via Graph API onlineMeetings")
125
return files
126
except RuntimeError as e:
127
logger.debug(f"onlineMeetings endpoint failed: {e}")
128
129
# Approach 2: m365 teams meeting list
130
try:
131
result = _run_m365(["teams", "meeting", "list"], timeout=60)
132
meetings = result if isinstance(result, list) else []
133
for meeting in meetings:
134
recording_files = self._get_meeting_recordings(meeting)
135
files.extend(recording_files)
136
137
if files:
138
logger.info(f"Found {len(files)} recording(s) via m365 teams meeting list")
139
return files
140
except RuntimeError as e:
141
logger.debug(f"teams meeting list failed: {e}")
142
143
# Approach 3: Fallback — search chat messages for recording links
144
try:
145
result = _run_m365(
146
[
147
"request",
148
"--url",
149
(
150
f"https://graph.microsoft.com/v1.0/{self.user_id}/chats"
151
"?$expand=messages($top=50)"
152
"&$filter=chatType eq 'meeting'"
153
"&$top=25"
154
),
155
"--method",
156
"get",
157
],
158
timeout=60,
159
)
160
chats = self._extract_value_list(result)
161
for chat in chats:
162
messages = chat.get("messages", [])
163
for msg in messages:
164
body = msg.get("body", {}).get("content", "")
165
if "recording" in body.lower() or ".mp4" in body.lower():
166
topic = chat.get("topic", "Meeting Recording")
167
chat_id = chat.get("id", "")
168
msg_id = msg.get("id", "")
169
files.append(
170
SourceFile(
171
name=f"{topic}.mp4",
172
id=f"{chat_id}:{msg_id}",
173
mime_type="video/mp4",
174
modified_at=msg.get("createdDateTime"),
175
)
176
)
177
if files:
178
logger.info(f"Found {len(files)} recording link(s) in meeting chats")
179
except RuntimeError as e:
180
logger.debug(f"Chat message fallback failed: {e}")
181
182
if not files:
183
logger.warning("No Teams meeting recordings found")
184
185
return files
186
187
def download(self, file: SourceFile, destination: Path) -> Path:
188
"""Download a recording via its Graph API download URL."""
189
destination = Path(destination)
190
destination.parent.mkdir(parents=True, exist_ok=True)
191
192
download_url = file.path
193
if download_url:
194
# Use the direct download URL from contentUrl / @microsoft.graph.downloadUrl
195
_run_m365(
196
[
197
"request",
198
"--url",
199
download_url,
200
"--method",
201
"get",
202
"--filePath",
203
str(destination),
204
],
205
timeout=300,
206
)
207
else:
208
# Try to get download URL from the recording ID
209
meeting_id, _, recording_id = file.id.partition(":")
210
if recording_id:
211
url = (
212
f"https://graph.microsoft.com/v1.0/{self.user_id}"
213
f"/onlineMeetings/{meeting_id}"
214
f"/recordings/{recording_id}/content"
215
)
216
else:
217
url = (
218
f"https://graph.microsoft.com/v1.0/{self.user_id}"
219
f"/onlineMeetings/{meeting_id}/recordings"
220
)
221
# Fetch recording list to find the content URL
222
result = _run_m365(
223
["request", "--url", url, "--method", "get"],
224
timeout=60,
225
)
226
recordings = self._extract_value_list(result)
227
if recordings:
228
url = (
229
f"https://graph.microsoft.com/v1.0/{self.user_id}"
230
f"/onlineMeetings/{meeting_id}"
231
f"/recordings/{recordings[0].get('id', '')}/content"
232
)
233
234
_run_m365(
235
[
236
"request",
237
"--url",
238
url,
239
"--method",
240
"get",
241
"--filePath",
242
str(destination),
243
],
244
timeout=300,
245
)
246
247
logger.info(f"Downloaded {file.name} to {destination}")
248
return destination
249
250
def fetch_transcript(self, meeting_id: str) -> Optional[str]:
251
"""Fetch the transcript for a Teams meeting.
252
253
Queries the Graph API transcripts endpoint, downloads the transcript
254
content, and converts VTT format to plain text.
255
"""
256
try:
257
result = _run_m365(
258
[
259
"request",
260
"--url",
261
(
262
f"https://graph.microsoft.com/v1.0/{self.user_id}"
263
f"/onlineMeetings/{meeting_id}/transcripts"
264
),
265
"--method",
266
"get",
267
],
268
timeout=60,
269
)
270
except RuntimeError as e:
271
logger.warning(f"Failed to list transcripts for meeting {meeting_id}: {e}")
272
return None
273
274
transcripts = self._extract_value_list(result)
275
if not transcripts:
276
logger.info(f"No transcripts found for meeting {meeting_id}")
277
return None
278
279
# Download the first available transcript
280
transcript_id = transcripts[0].get("id", "")
281
try:
282
content_result = _run_m365(
283
[
284
"request",
285
"--url",
286
(
287
f"https://graph.microsoft.com/v1.0/{self.user_id}"
288
f"/onlineMeetings/{meeting_id}"
289
f"/transcripts/{transcript_id}/content"
290
),
291
"--method",
292
"get",
293
"--accept",
294
"text/vtt",
295
],
296
timeout=60,
297
)
298
except RuntimeError as e:
299
logger.warning(f"Failed to download transcript {transcript_id}: {e}")
300
return None
301
302
# content_result may be raw VTT text or a dict with raw key
303
if isinstance(content_result, dict):
304
raw = content_result.get("raw", "")
305
else:
306
raw = str(content_result)
307
308
if not raw:
309
logger.warning(f"Empty transcript content for meeting {meeting_id}")
310
return None
311
312
return _vtt_to_text(raw)
313
314
# ------------------------------------------------------------------
315
# Internal helpers
316
# ------------------------------------------------------------------
317
318
def _extract_meetings_list(self, result) -> list:
319
"""Extract meetings list from Graph API response."""
320
if isinstance(result, list):
321
return result
322
if isinstance(result, dict):
323
return result.get("value", [])
324
return []
325
326
def _extract_value_list(self, result) -> list:
327
"""Extract value list from a Graph API response."""
328
if isinstance(result, list):
329
return result
330
if isinstance(result, dict):
331
return result.get("value", [])
332
return []
333
334
def _get_meeting_recordings(self, meeting: dict) -> List[SourceFile]:
335
"""Fetch recordings for a single meeting and return SourceFile entries."""
336
meeting_id = meeting.get("id", "")
337
subject = meeting.get("subject", meeting.get("topic", "Teams Meeting"))
338
start_time = meeting.get("startDateTime", meeting.get("createdDateTime"))
339
340
if not meeting_id:
341
return []
342
343
try:
344
result = _run_m365(
345
[
346
"request",
347
"--url",
348
(
349
f"https://graph.microsoft.com/v1.0/{self.user_id}"
350
f"/onlineMeetings/{meeting_id}/recordings"
351
),
352
"--method",
353
"get",
354
],
355
timeout=60,
356
)
357
except RuntimeError:
358
return []
359
360
recordings = self._extract_value_list(result)
361
files: List[SourceFile] = []
362
for rec in recordings:
363
rec_id = rec.get("id", "")
364
download_url = rec.get("content.downloadUrl", rec.get("contentUrl"))
365
files.append(
366
SourceFile(
367
name=f"{subject}.mp4",
368
id=f"{meeting_id}:{rec_id}",
369
mime_type="video/mp4",
370
modified_at=start_time,
371
path=download_url,
372
)
373
)
374
375
return files
376

Keyboard Shortcuts

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