teams-transcripts 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. legacy/__init__.py +0 -0
  2. legacy/transcript_download.py +3969 -0
  3. teams_transcripts/__init__.py +1 -0
  4. teams_transcripts/__main__.py +288 -0
  5. teams_transcripts/adapters/__init__.py +1 -0
  6. teams_transcripts/adapters/cdp/__init__.py +1 -0
  7. teams_transcripts/adapters/cdp/browser.py +196 -0
  8. teams_transcripts/adapters/cdp/connection.py +168 -0
  9. teams_transcripts/adapters/cdp/helpers.py +449 -0
  10. teams_transcripts/adapters/cdp/teams.py +463 -0
  11. teams_transcripts/adapters/vertex.py +274 -0
  12. teams_transcripts/domain/__init__.py +1 -0
  13. teams_transcripts/domain/chat.py +115 -0
  14. teams_transcripts/domain/html.py +126 -0
  15. teams_transcripts/domain/mcps.py +343 -0
  16. teams_transcripts/domain/models.py +531 -0
  17. teams_transcripts/domain/participants.py +292 -0
  18. teams_transcripts/domain/scoring.py +106 -0
  19. teams_transcripts/domain/speakers.py +130 -0
  20. teams_transcripts/domain/summary.py +266 -0
  21. teams_transcripts/domain/timestamps.py +350 -0
  22. teams_transcripts/domain/vtt.py +444 -0
  23. teams_transcripts/infrastructure/__init__.py +1 -0
  24. teams_transcripts/infrastructure/config.py +987 -0
  25. teams_transcripts/infrastructure/errors.py +120 -0
  26. teams_transcripts/infrastructure/logging.py +69 -0
  27. teams_transcripts/infrastructure/signals.py +143 -0
  28. teams_transcripts/mcp_server.py +818 -0
  29. teams_transcripts/ports/__init__.py +1 -0
  30. teams_transcripts/ports/agent.py +58 -0
  31. teams_transcripts/ports/browser.py +126 -0
  32. teams_transcripts/services/__init__.py +1 -0
  33. teams_transcripts/services/downloader.py +1283 -0
  34. teams_transcripts/services/extraction.py +1603 -0
  35. teams_transcripts/services/listing.py +826 -0
  36. teams_transcripts/services/navigation.py +1721 -0
  37. teams_transcripts/services/output.py +84 -0
  38. teams_transcripts/services/tenant.py +684 -0
  39. teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
  40. teams_transcripts-0.5.0.dist-info/RECORD +42 -0
  41. teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
  42. teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,1283 @@
1
+ """Main transcript download orchestration.
2
+
3
+ Coordinates the full pipeline: CDP target discovery, meeting navigation,
4
+ transcript extraction, VTT post-processing, and output emission.
5
+
6
+ This module is the composition root -- it depends on adapters (CDP helpers,
7
+ Vertex agent), service functions (navigation, extraction), and domain
8
+ functions (VTT processing, summary building). It also performs all file
9
+ I/O.
10
+
11
+ Extracted from ``transcript_download.py`` ``main()`` (lines 3635-3967).
12
+
13
+ Schema version: 2.0.0
14
+ Created: 2026-04-25
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime as dt_module
20
+ import json
21
+ import sys
22
+ import typing
23
+ from dataclasses import dataclass, field
24
+
25
+ import websockets.exceptions
26
+
27
+ from teams_transcripts.adapters.cdp.browser import CdpBrowserAdapter
28
+ from teams_transcripts.adapters.cdp.helpers import (
29
+ find_iframe_target,
30
+ find_search_target,
31
+ find_teams_targets,
32
+ find_xplatplugins_targets_io,
33
+ verify_cdp,
34
+ )
35
+ from teams_transcripts.adapters.cdp.teams import ensure_teams_running
36
+ from teams_transcripts.adapters.vertex import VertexAgentAdapter, create_agent_client
37
+ from teams_transcripts.domain.chat import build_chat_note_block, insert_chat_block
38
+ from teams_transcripts.domain.models import ChatMessage, MeetingParticipants, RosterMember
39
+ from teams_transcripts.domain.participants import (
40
+ build_meeting_participants,
41
+ parse_partlist_xml,
42
+ parse_tracking_entries,
43
+ )
44
+ from teams_transcripts.domain.speakers import (
45
+ apply_speaker_mapping,
46
+ build_speaker_map_from_roster,
47
+ )
48
+ from teams_transcripts.domain.summary import build_result_summary, verify_vtt_content
49
+ from teams_transcripts.domain.timestamps import parse_meeting_times
50
+ from teams_transcripts.domain.vtt import (
51
+ ensure_trailing_newline,
52
+ merge_parts,
53
+ prepend_metadata,
54
+ )
55
+ from teams_transcripts.infrastructure.errors import (
56
+ EXIT_CDP_UNAVAILABLE,
57
+ EXIT_DOWNLOAD_FAILED,
58
+ EXIT_MEETING_NOT_FOUND,
59
+ EXIT_OK,
60
+ EXIT_VERIFICATION_FAILED,
61
+ exit_error,
62
+ )
63
+ from teams_transcripts.services.extraction import (
64
+ check_permission,
65
+ click_part_and_transcript,
66
+ detect_part_tabs,
67
+ download_native_vtt,
68
+ extract_chat_messages,
69
+ extract_dom_vtt,
70
+ extract_roster_members,
71
+ extract_tracking_data,
72
+ extract_via_react_props,
73
+ retry_click_transcript,
74
+ )
75
+ from teams_transcripts.services.navigation import (
76
+ navigate_to_meeting,
77
+ navigate_to_thread_direct,
78
+ navigate_to_transcript,
79
+ verify_meeting_thread_id,
80
+ )
81
+ from teams_transcripts.services.output import (
82
+ _build_config_dict,
83
+ _emit_output,
84
+ )
85
+
86
+ if typing.TYPE_CHECKING:
87
+ from teams_transcripts.domain.models import MeetingConfig
88
+ from teams_transcripts.infrastructure.logging import Logger
89
+ from teams_transcripts.infrastructure.signals import ShutdownCoordinator
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Constants
93
+ # ---------------------------------------------------------------------------
94
+
95
+ MAX_SEARCH_RETRIES: int = 4
96
+ """Maximum number of search result indices to try before giving up."""
97
+
98
+ _MONTH_NAMES: tuple[str, ...] = (
99
+ "January",
100
+ "February",
101
+ "March",
102
+ "April",
103
+ "May",
104
+ "June",
105
+ "July",
106
+ "August",
107
+ "September",
108
+ "October",
109
+ "November",
110
+ "December",
111
+ )
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Internal helpers
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ def _parse_date_from_meeting_time(meeting_time_text: str) -> str:
120
+ """Extract ISO date from meeting time text.
121
+
122
+ Teams Recap headers use the format ``"5 January 2026 09:00 - 09:30"``.
123
+ Parses the day/month/year and returns an ISO ``YYYY-MM-DD`` string.
124
+
125
+ Returns an empty string if parsing fails.
126
+ """
127
+ import re
128
+
129
+ match = re.match(r"(\d{1,2})\s+(\w+)\s+(\d{4})", meeting_time_text.strip())
130
+ if not match:
131
+ return ""
132
+ day, month_name, year = match.groups()
133
+ try:
134
+ parsed = dt_module.datetime.strptime(f"{day} {month_name} {year}", "%d %B %Y").replace(
135
+ tzinfo=dt_module.timezone.utc,
136
+ )
137
+ return parsed.date().isoformat()
138
+ except ValueError:
139
+ return ""
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Internal data: navigation result threading
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ @dataclass
148
+ class _NavMetadata:
149
+ """Metadata returned from the Step 4+5 retry loop.
150
+
151
+ Avoids stashing temporary data on the browser adapter.
152
+ """
153
+
154
+ search_target_id: str = ""
155
+ speaker_map: dict[str, str] = field(default_factory=dict)
156
+ meeting_time_text: str = ""
157
+ pre_nav_iframe_ids: set[str] = field(default_factory=set)
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Main orchestration
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ async def run_download(
166
+ config: MeetingConfig,
167
+ logger: Logger,
168
+ shutdown: ShutdownCoordinator,
169
+ ) -> None:
170
+ """Execute the full transcript download pipeline.
171
+
172
+ Steps:
173
+ 1. Ensure Teams is running with CDP enabled.
174
+ 2. Verify CDP is responding.
175
+ 3. Discover Teams page targets.
176
+ 4-5. Navigate to meeting and transcript (with retry loop).
177
+ 6. Find the transcript iframe target.
178
+ 7. Download VTT (native blob or DOM extraction).
179
+ 8. Verify the output.
180
+
181
+ Output is emitted according to the ``--output`` / ``--format``
182
+ combination on the config.
183
+
184
+ Args:
185
+ config: Resolved meeting configuration.
186
+ logger: Logger instance.
187
+ shutdown: Shutdown coordinator.
188
+ """
189
+ # Log configuration
190
+ datetime_label = config.date
191
+ if config.time:
192
+ datetime_label += f"T{config.time}"
193
+ if config.meeting_id:
194
+ logger.log("main", f"Meeting ID: {config.meeting_id}")
195
+ else:
196
+ logger.log("main", f"Meeting: {config.meeting}")
197
+ logger.log("main", f"Date: {datetime_label}")
198
+ logger.log("main", f"Output: {config.output}")
199
+ logger.log("main", f"CDP port: {config.port}")
200
+ logger.log("main", f"Model: {config.model}")
201
+ logger.log("main", f"GCP project: {config.gcp_project}")
202
+ logger.log("main", f"GCP region: {config.gcp_region}")
203
+
204
+ # Step 1: Ensure Teams is running
205
+ ensure_teams_running(
206
+ config.port,
207
+ logger=logger,
208
+ shutdown=shutdown,
209
+ format_json=config.format_json,
210
+ force=config.force_restart,
211
+ )
212
+
213
+ # Step 2: Verify CDP
214
+ verify_cdp(
215
+ config.port,
216
+ logger=logger,
217
+ shutdown=shutdown,
218
+ format_json=config.format_json,
219
+ )
220
+
221
+ # Step 3: Find main Teams targets
222
+ targets = find_teams_targets(
223
+ config.port,
224
+ logger=logger,
225
+ format_json=config.format_json,
226
+ )
227
+ if not targets:
228
+ exit_error(
229
+ EXIT_CDP_UNAVAILABLE,
230
+ "NO_TARGETS",
231
+ "No Teams page targets found via CDP.",
232
+ step="step3",
233
+ format_json=config.format_json,
234
+ )
235
+ logger.log("step3", f"Found {len(targets)} Teams page target(s)")
236
+ for t in targets:
237
+ logger.log("step3", f" {t['id'][:20]}... {t.get('title', '')[:60]}")
238
+
239
+ # Create agent client and adapter
240
+ agent_client = create_agent_client(config)
241
+ agent = VertexAgentAdapter(client=agent_client, model=config.model, logger=logger)
242
+ browser = CdpBrowserAdapter(port=config.port)
243
+
244
+ # Step 3b: Verify tenant (when --tenant is specified)
245
+ tenant_domain: str | None = None
246
+ tenant_id: str | None = None
247
+ if config.tenant:
248
+ from teams_transcripts.adapters.cdp.teams import relaunch_teams_on_tenant
249
+ from teams_transcripts.services.tenant import (
250
+ TenantSwitchNeededError,
251
+ TenantVerification,
252
+ ensure_tenant,
253
+ pick_detection_target,
254
+ )
255
+
256
+ # Pick the best target for tenant detection. Prefer a target with
257
+ # a descriptive title (e.g. "Chat | ... | TenantName | ...") over
258
+ # a generic "Microsoft Teams" shell page.
259
+ detection_target_id = pick_detection_target(targets)
260
+
261
+ await browser.connect(detection_target_id)
262
+ try:
263
+ verified_tenant = await ensure_tenant(browser, config, logger)
264
+ except TenantSwitchNeededError as switch:
265
+ await browser.disconnect()
266
+ # Kill Teams and relaunch on the correct tenant
267
+ relaunch_teams_on_tenant(
268
+ config.port,
269
+ switch.tenant_id,
270
+ logger=logger,
271
+ shutdown=shutdown,
272
+ format_json=config.format_json,
273
+ force=config.force_restart,
274
+ )
275
+ # Re-verify CDP after relaunch
276
+ verify_cdp(
277
+ config.port,
278
+ logger=logger,
279
+ shutdown=shutdown,
280
+ format_json=config.format_json,
281
+ )
282
+ # Re-discover targets on the new tenant
283
+ targets = find_teams_targets(
284
+ config.port,
285
+ logger=logger,
286
+ format_json=config.format_json,
287
+ )
288
+ if not targets:
289
+ exit_error(
290
+ EXIT_CDP_UNAVAILABLE,
291
+ "NO_TARGETS",
292
+ "No Teams page targets found after tenant switch.",
293
+ step="step3b",
294
+ format_json=config.format_json,
295
+ )
296
+ logger.log("step3b", f"Found {len(targets)} target(s) after switch")
297
+ for t in targets:
298
+ logger.log("step3b", f" {t['id'][:20]}... {t.get('title', '')[:60]}")
299
+
300
+ # Re-create browser adapter (old websocket is dead)
301
+ browser = CdpBrowserAdapter(port=config.port)
302
+
303
+ # Re-verify tenant on the new targets
304
+ detection_target_id = pick_detection_target(targets)
305
+ await browser.connect(detection_target_id)
306
+ try:
307
+ verified_tenant = await ensure_tenant(browser, config, logger)
308
+ except TenantSwitchNeededError as retry_switch:
309
+ # Switch failed -- the relaunch didn't land on the right tenant.
310
+ exit_error(
311
+ EXIT_CDP_UNAVAILABLE,
312
+ "TENANT_SWITCH_FAILED",
313
+ f"Tenant switch failed: Teams opened on the wrong tenant "
314
+ f"after relaunch. Expected '{config.tenant}', "
315
+ f"got a different tenant. Target was "
316
+ f"'{retry_switch.domain}' ({retry_switch.tenant_id[:8]}...).",
317
+ step="step3b",
318
+ format_json=config.format_json,
319
+ )
320
+ finally:
321
+ await browser.disconnect()
322
+ else:
323
+ await browser.disconnect()
324
+ if isinstance(verified_tenant, TenantVerification):
325
+ tenant_domain = verified_tenant.domain
326
+ tenant_id = verified_tenant.tenant_id
327
+
328
+ # Steps 4+5: Navigate to meeting and Recap > Transcript (with retries)
329
+ # When --call-id is used, always resolve via MCPS (string match required).
330
+ # When --meeting-id is used, try direct navigation first (skips the MCPS
331
+ # scan entirely for non-recurring meetings). Only fall back to MCPS if
332
+ # direct navigation fails or the meeting is recurring.
333
+ resolved_thread_id: str | None = None
334
+ nav_meta: _NavMetadata | None = None
335
+ if config.call_id and not config.meeting:
336
+ from teams_transcripts.services.listing import resolve_call_id
337
+
338
+ resolved_subject, resolved_date, resolved_thread_id = await resolve_call_id(
339
+ config.port,
340
+ config.call_id,
341
+ logger=logger,
342
+ shutdown=shutdown,
343
+ format_json=config.format_json,
344
+ tenant_id=tenant_id,
345
+ force_restart=config.force_restart,
346
+ )
347
+ config = config.model_copy(
348
+ update={"meeting": resolved_subject, "date": resolved_date},
349
+ )
350
+ logger.log("main", f"Resolved call ID to: {config.meeting}")
351
+ logger.log("main", f"Meeting date: {config.date}")
352
+
353
+ elif config.meeting_id and not config.meeting:
354
+ # OPTIMISATION: Attempt direct navigation without MCPS scan.
355
+ # For non-recurring meetings (the common case), this saves 10-20s
356
+ # by building thread_id from the UUID and navigating directly.
357
+ from teams_transcripts.domain.mcps import build_thread_id
358
+
359
+ speculative_thread_id = build_thread_id(config.meeting_id)
360
+ logger.log(
361
+ "main",
362
+ f"Attempting direct navigation for meeting ID {config.meeting_id[:12]}...",
363
+ )
364
+
365
+ # Discover search target for navigation
366
+ search_target_id_spec = await find_search_target(
367
+ config.port,
368
+ targets,
369
+ logger=logger,
370
+ )
371
+ speculative_nav_title: str | None = None
372
+ if search_target_id_spec:
373
+ speculative_nav_title = await navigate_to_thread_direct(
374
+ browser,
375
+ search_target_id_spec,
376
+ speculative_thread_id,
377
+ logger,
378
+ shutdown,
379
+ )
380
+
381
+ if speculative_nav_title and search_target_id_spec:
382
+ # Sidebar found the chat. Now try Recap/Transcript.
383
+ # config.date is "" here — _select_correct_occurrence handles
384
+ # empty date by returning False for recurring meetings (dropdown
385
+ # present). Non-recurring meetings have no dropdown → success.
386
+ logger.log("main", "Direct thread navigation succeeded (speculative).")
387
+ pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(config.port)}
388
+
389
+ nav_result = await navigate_to_transcript(
390
+ browser,
391
+ agent,
392
+ search_target_id_spec,
393
+ config.date, # "" for speculative path
394
+ config.time,
395
+ config.model,
396
+ logger,
397
+ shutdown,
398
+ format_json=config.format_json,
399
+ require_recap=config.include_transcript or config.include_metadata,
400
+ require_transcript=config.include_transcript,
401
+ )
402
+ if nav_result.success:
403
+ # Non-recurring meeting — speculative path succeeded!
404
+ # Derive subject from sidebar title, date from meeting time.
405
+ resolved_thread_id = speculative_thread_id
406
+ derived_date = _parse_date_from_meeting_time(nav_result.meeting_time_text)
407
+ config = config.model_copy(
408
+ update={
409
+ "meeting": speculative_nav_title,
410
+ "date": derived_date or config.date,
411
+ },
412
+ )
413
+ logger.log(
414
+ "main",
415
+ f"Speculative navigation succeeded: '{config.meeting}' on {config.date}",
416
+ )
417
+ nav_meta = _NavMetadata(
418
+ search_target_id=search_target_id_spec,
419
+ speaker_map=nav_result.speaker_map,
420
+ meeting_time_text=nav_result.meeting_time_text,
421
+ pre_nav_iframe_ids=pre_nav_iframe_ids,
422
+ )
423
+ else:
424
+ # Recurring meeting — occurrence selection failed (no date).
425
+ # Fall back to MCPS to get the date, then retry navigation.
426
+ logger.log(
427
+ "main",
428
+ "Speculative navigation failed (recurring meeting likely). "
429
+ "Falling back to MCPS resolution.",
430
+ )
431
+ nav_meta = None
432
+ # Resolve via MCPS (the slow path)
433
+ from teams_transcripts.services.listing import resolve_meeting_id
434
+
435
+ resolved_subject, resolved_date, resolved_thread_id = await resolve_meeting_id(
436
+ config.port,
437
+ config.meeting_id,
438
+ logger=logger,
439
+ shutdown=shutdown,
440
+ format_json=config.format_json,
441
+ tenant_id=tenant_id,
442
+ force_restart=config.force_restart,
443
+ )
444
+ config = config.model_copy(
445
+ update={"meeting": resolved_subject, "date": resolved_date},
446
+ )
447
+ logger.log("main", f"Resolved meeting ID to: {config.meeting}")
448
+ logger.log("main", f"Meeting date: {config.date}")
449
+ else:
450
+ # Sidebar miss — fall back to MCPS for full resolution
451
+ logger.log(
452
+ "main",
453
+ "Speculative sidebar navigation failed. Falling back to MCPS resolution.",
454
+ )
455
+ from teams_transcripts.services.listing import resolve_meeting_id
456
+
457
+ resolved_subject, resolved_date, resolved_thread_id = await resolve_meeting_id(
458
+ config.port,
459
+ config.meeting_id,
460
+ logger=logger,
461
+ shutdown=shutdown,
462
+ format_json=config.format_json,
463
+ tenant_id=tenant_id,
464
+ force_restart=config.force_restart,
465
+ )
466
+ config = config.model_copy(
467
+ update={"meeting": resolved_subject, "date": resolved_date},
468
+ )
469
+ logger.log("main", f"Resolved meeting ID to: {config.meeting}")
470
+ logger.log("main", f"Meeting date: {config.date}")
471
+
472
+ # When we have a thread_id from resolution (MCPS or speculative) and
473
+ # nav_meta hasn't been set yet, attempt direct navigation via the
474
+ # sidebar fibre walk.
475
+ direct_nav_ok = False
476
+ search_target_id: str | None = None
477
+ if nav_meta is None and resolved_thread_id:
478
+ # We need a search target for Step 5 (navigate_to_transcript).
479
+ # Discover it first so we can use it regardless of nav path.
480
+ search_target_id = await find_search_target(
481
+ config.port,
482
+ targets,
483
+ logger=logger,
484
+ )
485
+ if search_target_id:
486
+ direct_nav_title = await navigate_to_thread_direct(
487
+ browser,
488
+ search_target_id,
489
+ resolved_thread_id,
490
+ logger,
491
+ shutdown,
492
+ )
493
+ direct_nav_ok = direct_nav_title is not None
494
+ if direct_nav_ok:
495
+ logger.log("main", "Direct thread navigation succeeded.")
496
+
497
+ if direct_nav_ok and search_target_id:
498
+ # Direct navigation landed on the correct chat.
499
+ # Record pre-navigation iframe IDs, then proceed to Step 5
500
+ # (Recap > Transcript).
501
+ pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(config.port)}
502
+
503
+ nav_result = await navigate_to_transcript(
504
+ browser,
505
+ agent,
506
+ search_target_id,
507
+ config.date,
508
+ config.time,
509
+ config.model,
510
+ logger,
511
+ shutdown,
512
+ format_json=config.format_json,
513
+ require_recap=config.include_transcript or config.include_metadata,
514
+ require_transcript=config.include_transcript,
515
+ )
516
+ if nav_result.success:
517
+ nav_message = (
518
+ "Correct occurrence found and transcript tab loaded."
519
+ if config.include_transcript
520
+ else "Correct occurrence found. Transcript tab was not required."
521
+ )
522
+ logger.log("main", nav_message)
523
+ nav_meta = _NavMetadata(
524
+ search_target_id=search_target_id,
525
+ speaker_map=nav_result.speaker_map,
526
+ meeting_time_text=nav_result.meeting_time_text,
527
+ pre_nav_iframe_ids=pre_nav_iframe_ids,
528
+ )
529
+ else:
530
+ # Occurrence not found via direct nav — fall back to full retry loop
531
+ logger.log(
532
+ "main",
533
+ "Direct navigation reached a chat but occurrence mismatch. "
534
+ "Falling back to search-based navigation.",
535
+ )
536
+ nav_meta = None
537
+
538
+ if nav_meta is None:
539
+ nav_meta = await _navigate_with_retries(
540
+ browser,
541
+ agent,
542
+ targets,
543
+ config,
544
+ logger,
545
+ shutdown,
546
+ )
547
+ search_target_id = nav_meta.search_target_id
548
+ speaker_map = nav_meta.speaker_map
549
+
550
+ # Detect multi-part meetings (quick DOM check, no navigation).
551
+ # Always runs (even for dry-run) so the summary can report the
552
+ # actual part count. Navigation to the Transcript tab has already
553
+ # completed by this point.
554
+ num_parts = await detect_part_tabs(browser, search_target_id)
555
+ logger.log("main", f"Meeting parts detected: {num_parts}")
556
+
557
+ # --- Dry-run exit ---
558
+ # Must happen before download (matching legacy behaviour). Dry-run
559
+ # should be non-destructive: no iframe discovery, no chat tab
560
+ # navigation, no Details tab navigation. Roster extraction is kept
561
+ # because it only opens the participant popover and is part of the
562
+ # documented dry-run metadata contract.
563
+ if config.dry_run:
564
+ roster_members_raw: list[dict] = []
565
+ organizer_id = ""
566
+ if config.include_metadata or config.include_chat:
567
+ (
568
+ roster_members_raw,
569
+ speaker_map,
570
+ _member_map,
571
+ organizer_id,
572
+ ) = await _extract_roster_and_maps(
573
+ browser, search_target_id, nav_meta.speaker_map, logger, shutdown
574
+ )
575
+
576
+ participants: MeetingParticipants | None = None
577
+ if config.include_metadata:
578
+ roster_as_members = [
579
+ RosterMember(
580
+ name=m["name"],
581
+ email=m.get("email", ""),
582
+ object_id=m.get("objectId", ""),
583
+ role=m.get("role", "member"),
584
+ )
585
+ for m in roster_members_raw
586
+ ]
587
+ participants = build_meeting_participants(
588
+ all_members=roster_as_members,
589
+ speaker_map=speaker_map,
590
+ attendance=[],
591
+ organizer_id=organizer_id,
592
+ )
593
+
594
+ config_dict = _build_config_dict(
595
+ config,
596
+ speaker_map,
597
+ roster_members_raw,
598
+ nav_meta.meeting_time_text,
599
+ tenant_domain=tenant_domain,
600
+ )
601
+ summary = build_result_summary(
602
+ config_dict,
603
+ num_parts=num_parts,
604
+ chat_count=0,
605
+ participants=participants,
606
+ chat_messages=None,
607
+ include_transcript=config.include_transcript,
608
+ include_chat=config.include_chat,
609
+ include_metadata=config.include_metadata,
610
+ )
611
+ summary["dry_run"] = True
612
+ if config.format_json:
613
+ print(
614
+ json.dumps(summary, ensure_ascii=False, indent=2),
615
+ flush=True,
616
+ )
617
+ else:
618
+ logger.log(
619
+ "main",
620
+ f"Dry-run summary: {json.dumps(summary, ensure_ascii=False)}",
621
+ )
622
+ sys.exit(EXIT_OK)
623
+
624
+ # --- Transcript download (time-critical: iframe is ephemeral) ---
625
+ # The xplatplugins iframe target only exists for ~8 seconds after
626
+ # the Recap/Transcript tab loads. Download MUST happen immediately
627
+ # before any roster/chat/metadata extraction that might navigate
628
+ # away or cause the SPA to unmount the Recap panel.
629
+ part_vtt_contents: list[str] = []
630
+ if config.include_transcript:
631
+ part_vtt_contents = await _download_parts(
632
+ browser,
633
+ agent,
634
+ search_target_id,
635
+ config,
636
+ logger,
637
+ shutdown,
638
+ num_parts=num_parts,
639
+ speaker_map=speaker_map,
640
+ pre_nav_iframe_ids=nav_meta.pre_nav_iframe_ids,
641
+ )
642
+
643
+ if not part_vtt_contents:
644
+ exit_error(
645
+ EXIT_DOWNLOAD_FAILED,
646
+ "DOWNLOAD_FAILED",
647
+ "No transcript parts were downloaded.",
648
+ step="step7",
649
+ format_json=config.format_json,
650
+ )
651
+
652
+ # --- Metadata extraction (safe to navigate away now) ---
653
+ # Roster extraction (after steps 4+5 so speaker_map is available)
654
+ # Run roster extraction when metadata or chat is needed (chat uses member_map
655
+ # for author name resolution).
656
+ roster_members_raw: list[dict] = []
657
+ member_map: dict[str, str] | None = None
658
+ organizer_id = ""
659
+ if config.include_metadata or config.include_chat:
660
+ roster_members_raw, speaker_map, member_map, organizer_id = await _extract_roster_and_maps(
661
+ browser,
662
+ search_target_id,
663
+ nav_meta.speaker_map,
664
+ logger,
665
+ shutdown,
666
+ )
667
+
668
+ # Build config dict for domain functions (they expect plain dicts)
669
+ config_dict = _build_config_dict(
670
+ config,
671
+ speaker_map,
672
+ roster_members_raw,
673
+ nav_meta.meeting_time_text,
674
+ tenant_domain=tenant_domain,
675
+ )
676
+
677
+ # --- Extract non-transcript components (chat + metadata) ---
678
+ # These are extracted regardless of --dry-run when requested.
679
+ chat_messages: list[ChatMessage] = []
680
+ event_call_contents: list[str] = []
681
+ participants: MeetingParticipants | None = None
682
+ chat_count = 0
683
+
684
+ # Chat extraction: needed when include_chat OR include_metadata (for attendance data)
685
+ if config.include_chat or config.include_metadata:
686
+ chat_messages, event_call_contents = await extract_chat_messages(
687
+ browser,
688
+ search_target_id,
689
+ config.date,
690
+ logger,
691
+ shutdown,
692
+ meeting_time_text=config_dict.get("meeting_time_text", ""),
693
+ member_map=member_map,
694
+ speaker_map=speaker_map,
695
+ )
696
+ if config.include_chat:
697
+ chat_count = len(chat_messages)
698
+
699
+ # Build categorised participant lists
700
+ if config.include_metadata:
701
+ roster_as_members = [
702
+ RosterMember(
703
+ name=m["name"],
704
+ email=m.get("email", ""),
705
+ object_id=m.get("objectId", ""),
706
+ role=m.get("role", "member"),
707
+ )
708
+ for m in roster_members_raw
709
+ ]
710
+
711
+ attendance: list[dict] = []
712
+ for ec_content in event_call_contents:
713
+ attendance.extend(parse_partlist_xml(ec_content))
714
+ if attendance:
715
+ logger.log("main", f"Parsed {len(attendance)} attendance record(s) from partlists")
716
+
717
+ # Extract tracking/RSVP data from the Details tab
718
+ shutdown.check_shutdown()
719
+ tracking_labels = await extract_tracking_data(
720
+ browser,
721
+ search_target_id,
722
+ logger,
723
+ shutdown,
724
+ )
725
+ tracking_entries = parse_tracking_entries(tracking_labels) if tracking_labels else []
726
+ if tracking_entries:
727
+ logger.log("main", f"Parsed {len(tracking_entries)} tracking/RSVP entries")
728
+
729
+ participants = build_meeting_participants(
730
+ all_members=roster_as_members,
731
+ speaker_map=speaker_map,
732
+ attendance=attendance,
733
+ organizer_id=organizer_id,
734
+ tracking=tracking_entries or None,
735
+ )
736
+ rsvp_count = sum(1 for m in participants.invited if m.rsvp_status)
737
+ logger.log(
738
+ "main",
739
+ f"Participants: organiser={len(participants.organiser)}, "
740
+ f"invited={len(participants.invited)}, "
741
+ f"speakers={len(participants.speakers)}, "
742
+ f"attendees={len(participants.attendees)}"
743
+ + (f", rsvp_enriched={rsvp_count}" if rsvp_count else ""),
744
+ )
745
+
746
+ # --- Transcript excluded: JSON-only output path ---
747
+ if not config.include_transcript:
748
+ summary = build_result_summary(
749
+ config_dict,
750
+ num_parts=num_parts,
751
+ chat_count=chat_count,
752
+ participants=participants,
753
+ chat_messages=chat_messages if config.include_chat else None,
754
+ include_transcript=False,
755
+ include_chat=config.include_chat,
756
+ include_metadata=config.include_metadata,
757
+ )
758
+ if config.output:
759
+ # Write JSON to file
760
+ with open(config.output, "w") as f:
761
+ json.dump(summary, f, ensure_ascii=False, indent=2)
762
+ f.write("\n")
763
+ logger.log("main", f"JSON output written to {config.output}")
764
+ else:
765
+ # JSON to stdout
766
+ print(
767
+ json.dumps(summary, ensure_ascii=False, indent=2),
768
+ flush=True,
769
+ )
770
+ sys.exit(EXIT_OK)
771
+
772
+ # --- Transcript included: full download path ---
773
+
774
+ # Assemble final VTT content
775
+ if num_parts > 1:
776
+ logger.log("main", f"Merging {len(part_vtt_contents)} parts...")
777
+ content = merge_parts(part_vtt_contents)
778
+ else:
779
+ content = part_vtt_contents[0]
780
+
781
+ shutdown.check_shutdown()
782
+
783
+ # If we haven't done chat extraction yet (e.g. include_chat=True but
784
+ # extraction was deferred), it's already done above. The extraction
785
+ # now happens BEFORE the transcript download for the component-flag flow.
786
+
787
+ # Insert metadata (when metadata is included)
788
+ if config.include_metadata and participants is not None:
789
+ content = prepend_metadata(content, config_dict, participants=participants)
790
+ elif config.include_metadata:
791
+ # Fallback: legacy metadata from roster (no MeetingParticipants)
792
+ content = prepend_metadata(content, config_dict)
793
+
794
+ # Append chat messages (when chat is included)
795
+ if config.include_chat and chat_messages:
796
+ time_text = config_dict.get("meeting_time_text", "")
797
+ if time_text:
798
+ meeting_start, _ = parse_meeting_times(time_text, config.date)
799
+ elif config.time:
800
+ meeting_start = f"{config.date}T{config.time}"
801
+ else:
802
+ meeting_start = config.date
803
+
804
+ chat_dicts = [
805
+ {
806
+ "timestamp": m.timestamp,
807
+ "author": m.author,
808
+ "text": m.text,
809
+ "reactions": (
810
+ [{"type": r.type, "users": r.users} for r in m.reactions]
811
+ if m.reactions
812
+ else None
813
+ ),
814
+ }
815
+ for m in chat_messages
816
+ ]
817
+ chat_block = build_chat_note_block(chat_dicts, meeting_start)
818
+ content = insert_chat_block(content, chat_block)
819
+ elif not config.include_chat:
820
+ logger.log("main", "Chat excluded by --chat no.")
821
+ else:
822
+ logger.log("main", "No chat messages found for this meeting occurrence.")
823
+
824
+ # Ensure trailing newline
825
+ content = ensure_trailing_newline(content)
826
+
827
+ # Write VTT to output file (only when format is VTT;
828
+ # JSON format writes structured JSON later in _emit_output)
829
+ if not config.format_json:
830
+ assert config.output is not None
831
+ with open(config.output, "w") as f:
832
+ f.write(content)
833
+
834
+ # Step 8: Verify
835
+ verification = verify_vtt_content(content, require_cues=config.include_transcript)
836
+ if verification["valid"]:
837
+ logger.log("step8", f"Cue count: {verification['cue_count']}")
838
+ for sp, cnt in sorted(verification["speakers"].items()):
839
+ logger.log("step8", f" {sp}: {cnt}")
840
+ logger.log("main", "Transcript download complete.")
841
+ else:
842
+ exit_error(
843
+ EXIT_VERIFICATION_FAILED,
844
+ "VERIFICATION_FAILED",
845
+ f"Output file verification failed: {verification.get('error')}",
846
+ step="step8",
847
+ format_json=config.format_json,
848
+ )
849
+
850
+ # Emit final output
851
+ _emit_output(
852
+ config,
853
+ config_dict,
854
+ content,
855
+ num_parts,
856
+ verification,
857
+ chat_count,
858
+ participants,
859
+ chat_messages=chat_messages if config.include_chat else None,
860
+ )
861
+
862
+
863
+ # ---------------------------------------------------------------------------
864
+ # Internal: navigation retry loop (Steps 4+5)
865
+ # ---------------------------------------------------------------------------
866
+
867
+
868
+ async def _navigate_with_retries(
869
+ browser: CdpBrowserAdapter,
870
+ agent: VertexAgentAdapter,
871
+ targets: list[dict],
872
+ config: MeetingConfig,
873
+ logger: Logger,
874
+ shutdown: ShutdownCoordinator,
875
+ ) -> _NavMetadata:
876
+ """Run the Step 4+5 retry loop, returning navigation metadata.
877
+
878
+ Exits the process with ``EXIT_MEETING_NOT_FOUND`` if all retries
879
+ are exhausted.
880
+ """
881
+ port = config.port
882
+ result_index_override: int | None = None
883
+ search_target_id: str | None = None
884
+
885
+ datetime_label = config.date
886
+ if config.time:
887
+ datetime_label += f"T{config.time}"
888
+
889
+ for attempt in range(MAX_SEARCH_RETRIES):
890
+ shutdown.check_shutdown()
891
+
892
+ # Record existing iframe targets before navigation
893
+ pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
894
+ if pre_nav_iframe_ids:
895
+ logger.log("main", f"Pre-existing iframe targets: {len(pre_nav_iframe_ids)}")
896
+
897
+ try:
898
+ # Step 4: Find search target and navigate to meeting.
899
+ # After a fresh relaunch, Teams may take time to load content
900
+ # targets -- the shell target appears first but the page
901
+ # targets with the search input load later. Retry with
902
+ # target re-discovery to handle this.
903
+ search_target_id = await find_search_target(
904
+ port,
905
+ targets,
906
+ logger=logger,
907
+ )
908
+ if not search_target_id:
909
+ for search_wait in range(3):
910
+ wait_secs = 5 + search_wait * 5
911
+ logger.log(
912
+ "step4",
913
+ f"Search input not found. Waiting {wait_secs}s "
914
+ f"for Teams to finish loading "
915
+ f"(attempt {search_wait + 1}/3)...",
916
+ )
917
+ await shutdown.interruptible_sleep(wait_secs)
918
+ # Re-discover targets -- new ones may have appeared
919
+ targets = find_teams_targets(
920
+ port,
921
+ logger=logger,
922
+ format_json=config.format_json,
923
+ )
924
+ if targets:
925
+ search_target_id = await find_search_target(
926
+ port,
927
+ targets,
928
+ logger=logger,
929
+ )
930
+ if search_target_id:
931
+ break
932
+
933
+ if not search_target_id:
934
+ exit_error(
935
+ EXIT_CDP_UNAVAILABLE,
936
+ "NO_TARGETS",
937
+ "Could not find Teams search input on any CDP target.",
938
+ step="step4",
939
+ format_json=config.format_json,
940
+ )
941
+
942
+ nav_ok = await navigate_to_meeting(
943
+ browser,
944
+ agent,
945
+ search_target_id,
946
+ config.meeting,
947
+ config.date,
948
+ logger,
949
+ shutdown,
950
+ format_json=config.format_json,
951
+ result_index_override=result_index_override,
952
+ )
953
+
954
+ if not nav_ok:
955
+ if result_index_override is None:
956
+ result_index_override = 1
957
+ else:
958
+ result_index_override += 1
959
+ logger.log(
960
+ "main",
961
+ f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed "
962
+ f"(navigation). Retrying with index {result_index_override}...",
963
+ )
964
+ continue
965
+
966
+ # Step 4b: Verify thread_id when --meeting-id was used
967
+ if config.meeting_id:
968
+ from teams_transcripts.domain.mcps import build_thread_id
969
+
970
+ expected_tid = build_thread_id(config.meeting_id)
971
+ tid_ok = await verify_meeting_thread_id(
972
+ browser,
973
+ search_target_id,
974
+ expected_tid,
975
+ logger,
976
+ )
977
+ if not tid_ok:
978
+ if result_index_override is None:
979
+ result_index_override = 1
980
+ else:
981
+ result_index_override += 1
982
+ logger.log(
983
+ "main",
984
+ f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed "
985
+ f"(thread_id mismatch). "
986
+ f"Retrying with index {result_index_override}...",
987
+ )
988
+ continue
989
+
990
+ # Step 5: Navigate to Recap > Transcript
991
+ nav_result = await navigate_to_transcript(
992
+ browser,
993
+ agent,
994
+ search_target_id,
995
+ config.date,
996
+ config.time,
997
+ config.model,
998
+ logger,
999
+ shutdown,
1000
+ format_json=config.format_json,
1001
+ require_recap=config.include_transcript or config.include_metadata,
1002
+ require_transcript=config.include_transcript,
1003
+ )
1004
+
1005
+ if nav_result.success:
1006
+ nav_message = (
1007
+ "Correct occurrence found and transcript tab loaded."
1008
+ if config.include_transcript
1009
+ else "Correct occurrence found. Transcript tab was not required."
1010
+ )
1011
+ logger.log("main", nav_message)
1012
+ return _NavMetadata(
1013
+ search_target_id=search_target_id,
1014
+ speaker_map=nav_result.speaker_map,
1015
+ meeting_time_text=nav_result.meeting_time_text,
1016
+ pre_nav_iframe_ids=pre_nav_iframe_ids,
1017
+ )
1018
+
1019
+ # Occurrence not found -- try the next search result
1020
+ if result_index_override is None:
1021
+ result_index_override = 1
1022
+ else:
1023
+ result_index_override += 1
1024
+ logger.log(
1025
+ "main",
1026
+ f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed. "
1027
+ f"Retrying with search result index {result_index_override}...",
1028
+ )
1029
+
1030
+ except websockets.exceptions.ConnectionClosed as exc:
1031
+ # The CDP WebSocket was dropped -- typically because Teams
1032
+ # navigated away or the target was destroyed. Treat as a
1033
+ # retriable failure: re-discover targets on the next attempt.
1034
+ logger.log(
1035
+ "main",
1036
+ f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} lost "
1037
+ f"WebSocket connection: {exc}. Re-discovering targets...",
1038
+ )
1039
+ targets = find_teams_targets(
1040
+ port,
1041
+ logger=logger,
1042
+ format_json=config.format_json,
1043
+ )
1044
+ search_target_id = None
1045
+ else:
1046
+ # Exhausted all retries
1047
+ exit_error(
1048
+ EXIT_MEETING_NOT_FOUND,
1049
+ "MEETING_NOT_FOUND",
1050
+ f"Could not find the target {datetime_label} in any "
1051
+ f"of the first {MAX_SEARCH_RETRIES} search results.",
1052
+ step="step5",
1053
+ format_json=config.format_json,
1054
+ )
1055
+
1056
+ # Unreachable: either the loop returned above or exit_error terminated.
1057
+ # Required by ruff RET503 since exit_error's NoReturn isn't visible
1058
+ # through the for/else structure.
1059
+ msg = "Unreachable: retry loop exhausted without exit"
1060
+ raise AssertionError(msg)
1061
+
1062
+
1063
+ # ---------------------------------------------------------------------------
1064
+ # Internal: roster and member map extraction
1065
+ # ---------------------------------------------------------------------------
1066
+
1067
+
1068
+ async def _extract_roster_and_maps(
1069
+ browser: CdpBrowserAdapter,
1070
+ search_target_id: str,
1071
+ speaker_map: dict[str, str],
1072
+ logger: Logger,
1073
+ shutdown: ShutdownCoordinator,
1074
+ ) -> tuple[list[dict], dict[str, str], dict[str, str], str]:
1075
+ """Extract roster members and build speaker/member maps.
1076
+
1077
+ Returns a tuple of ``(roster_members_raw, speaker_map, member_map, organizer_id)``.
1078
+ """
1079
+ # Extract roster
1080
+ roster_members, organizer_id = await extract_roster_members(
1081
+ browser,
1082
+ search_target_id,
1083
+ logger,
1084
+ shutdown,
1085
+ speaker_map=speaker_map or None,
1086
+ )
1087
+
1088
+ # Convert to plain dicts for domain functions
1089
+ roster_members_raw = [
1090
+ {
1091
+ "name": m.name,
1092
+ "email": m.email,
1093
+ "objectId": m.object_id,
1094
+ "role": m.role,
1095
+ }
1096
+ for m in roster_members
1097
+ ]
1098
+
1099
+ # Build combined member map
1100
+ member_map = build_speaker_map_from_roster(roster_members_raw, speaker_map or None)
1101
+
1102
+ return roster_members_raw, speaker_map, member_map, organizer_id
1103
+
1104
+
1105
+ # ---------------------------------------------------------------------------
1106
+ # Internal: per-part download loop
1107
+ # ---------------------------------------------------------------------------
1108
+
1109
+
1110
+ async def _download_parts(
1111
+ browser: CdpBrowserAdapter,
1112
+ agent: VertexAgentAdapter,
1113
+ search_target_id: str,
1114
+ config: MeetingConfig,
1115
+ logger: Logger,
1116
+ shutdown: ShutdownCoordinator,
1117
+ *,
1118
+ num_parts: int,
1119
+ speaker_map: dict[str, str],
1120
+ pre_nav_iframe_ids: set[str] | None = None,
1121
+ ) -> list[str]:
1122
+ """Download all transcript parts and return their VTT contents.
1123
+
1124
+ For multi-part meetings, clicks each Part tab and downloads
1125
+ separately.
1126
+
1127
+ Args:
1128
+ pre_nav_iframe_ids: Set of iframe target IDs that existed
1129
+ *before* the Step 4+5 navigation. For part 1, using this
1130
+ set lets us detect the xplatplugins target that appeared
1131
+ during Recap/Transcript tab load (which may be ephemeral).
1132
+ If not supplied, the current state is captured instead.
1133
+ """
1134
+ port = config.port
1135
+ part_vtt_contents: list[str] = []
1136
+ # For part 1: use the pre-navigation set captured before step 5,
1137
+ # so the xplatplugins target that appeared during Recap/Transcript
1138
+ # load is detected as "new". Falls back to current state if not
1139
+ # provided (backward compatibility).
1140
+ if pre_nav_iframe_ids is None:
1141
+ pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
1142
+
1143
+ for part_num in range(1, num_parts + 1):
1144
+ shutdown.check_shutdown()
1145
+ part_label = f"Part {part_num}/{num_parts}"
1146
+
1147
+ if part_num > 1:
1148
+ logger.log("main", f"Navigating to {part_label}...")
1149
+ pre_part_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
1150
+ ok = await click_part_and_transcript(
1151
+ browser,
1152
+ search_target_id,
1153
+ part_num,
1154
+ logger,
1155
+ shutdown,
1156
+ )
1157
+ if not ok:
1158
+ logger.log("main", f"WARNING: Could not navigate to {part_label}. Skipping.")
1159
+ continue
1160
+ pre_nav_iframe_ids = pre_part_iframe_ids
1161
+
1162
+ logger.log("main", f"Downloading transcript for {part_label}...")
1163
+
1164
+ # Step 6: Find iframe target. For parts > 1, use allow_missing
1165
+ # so that we can retry by re-clicking the Transcript tab rather
1166
+ # than hard-exiting.
1167
+ is_subsequent_part = part_num > 1
1168
+ iframe_target_id = find_iframe_target(
1169
+ port,
1170
+ config=config,
1171
+ agent_client=agent.client,
1172
+ logger=logger,
1173
+ shutdown=shutdown,
1174
+ pre_nav_iframe_ids=pre_nav_iframe_ids,
1175
+ allow_missing=is_subsequent_part,
1176
+ )
1177
+
1178
+ # Retry once for subsequent parts: re-click Transcript and poll
1179
+ # again. The initial click may have been a no-op if the tab was
1180
+ # already selected.
1181
+ if iframe_target_id is None and is_subsequent_part:
1182
+ logger.log("step6", "Retrying: re-clicking Transcript tab...")
1183
+ pre_retry_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
1184
+ ok = await retry_click_transcript(browser, search_target_id, logger, shutdown)
1185
+ if ok:
1186
+ iframe_target_id = find_iframe_target(
1187
+ port,
1188
+ config=config,
1189
+ agent_client=agent.client,
1190
+ logger=logger,
1191
+ shutdown=shutdown,
1192
+ pre_nav_iframe_ids=pre_retry_ids,
1193
+ allow_missing=True,
1194
+ )
1195
+
1196
+ if iframe_target_id is None:
1197
+ if num_parts == 1:
1198
+ exit_error(
1199
+ EXIT_DOWNLOAD_FAILED,
1200
+ "IFRAME_NOT_FOUND",
1201
+ "No xplatplugins iframe target found. "
1202
+ "Check that Step 5 completed and the Transcript tab loaded.",
1203
+ step="step6",
1204
+ format_json=config.format_json,
1205
+ )
1206
+ logger.log("main", f"WARNING: No iframe target for {part_label}. Skipping.")
1207
+ continue
1208
+
1209
+ # Step 7: Check permission.
1210
+ permission_state = await check_permission(
1211
+ browser,
1212
+ iframe_target_id,
1213
+ logger,
1214
+ shutdown,
1215
+ )
1216
+
1217
+ if permission_state == "no_button":
1218
+ if num_parts == 1:
1219
+ exit_error(
1220
+ EXIT_DOWNLOAD_FAILED,
1221
+ "DOWNLOAD_FAILED",
1222
+ "Download button not found. The transcript may not have loaded.",
1223
+ step="step7",
1224
+ format_json=config.format_json,
1225
+ )
1226
+ logger.log("main", f"WARNING: No download button for {part_label}. Skipping.")
1227
+ continue
1228
+
1229
+ # Step 7A or 7B based on permission
1230
+ vtt_content: str | None = None
1231
+ if permission_state == "has_permission":
1232
+ logger.log("main", f"{part_label}: Using Approach A (native blob interception)")
1233
+ vtt_content = await download_native_vtt(
1234
+ browser,
1235
+ iframe_target_id,
1236
+ logger,
1237
+ shutdown,
1238
+ )
1239
+ else:
1240
+ logger.log("main", f"{part_label}: Using Approach B (DOM extraction)")
1241
+ vtt_content = await extract_via_react_props(
1242
+ browser,
1243
+ iframe_target_id,
1244
+ logger,
1245
+ speaker_map=speaker_map or None,
1246
+ )
1247
+ if vtt_content is None:
1248
+ logger.log(
1249
+ "main",
1250
+ f"{part_label}: React props unavailable, falling back to scroll",
1251
+ )
1252
+ vtt_content = await extract_dom_vtt(
1253
+ browser,
1254
+ iframe_target_id,
1255
+ logger,
1256
+ )
1257
+
1258
+ if not vtt_content:
1259
+ if num_parts == 1:
1260
+ exit_error(
1261
+ EXIT_DOWNLOAD_FAILED,
1262
+ "DOWNLOAD_FAILED",
1263
+ "Transcript download failed.",
1264
+ step="step7",
1265
+ format_json=config.format_json,
1266
+ )
1267
+ logger.log("main", f"WARNING: Download failed for {part_label}. Skipping.")
1268
+ continue
1269
+
1270
+ # Normalise line endings: Teams blob downloads use CRLF (\r\n).
1271
+ # Domain functions and the VTT spec expect LF (\n). The old
1272
+ # monolith normalised this implicitly via file I/O round-trips
1273
+ # (Python's universal newlines mode).
1274
+ vtt_content = vtt_content.replace("\r\n", "\n")
1275
+
1276
+ # Apply speaker mapping
1277
+ if speaker_map:
1278
+ vtt_content = apply_speaker_mapping(vtt_content, speaker_map)
1279
+
1280
+ part_vtt_contents.append(vtt_content)
1281
+ logger.log("main", f"{part_label}: downloaded successfully")
1282
+
1283
+ return part_vtt_contents