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,266 @@
1
+ """Pure result summary and VTT verification functions.
2
+
3
+ All functions in this module are pure: no I/O, no network, no logging,
4
+ no global state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import typing
11
+ from collections import Counter
12
+
13
+ from teams_transcripts.domain.timestamps import parse_meeting_times
14
+
15
+ if typing.TYPE_CHECKING:
16
+ from teams_transcripts.domain.models import ChatMessage, MeetingParticipants, TranscriptCue
17
+
18
+
19
+ def build_result_summary(
20
+ config: dict,
21
+ *,
22
+ num_parts: int = 1,
23
+ verification: dict | None = None,
24
+ chat_count: int = 0,
25
+ participants: MeetingParticipants | None = None,
26
+ chat_messages: list[ChatMessage] | None = None,
27
+ transcript_cues: list[TranscriptCue] | None = None,
28
+ include_transcript: bool = True,
29
+ include_chat: bool = True,
30
+ include_metadata: bool = True,
31
+ ) -> dict:
32
+ """Build a structured result summary from config and verification data.
33
+
34
+ Used for both ``--format json`` output and ``--dry-run`` summaries.
35
+
36
+ The output is composable: each component flag controls which fields
37
+ are included. When all three are True (default), backward-compatible
38
+ schema is produced.
39
+
40
+ When ``participants`` is provided and ``include_metadata`` is True,
41
+ the summary uses categorised fields (``organiser``, ``invited``,
42
+ ``speakers_list``, ``attendees``).
43
+
44
+ When ``chat_messages`` is provided and ``include_chat`` is True,
45
+ the full ``chat`` array is included. When all components are
46
+ included, ``chat_messages`` count is also kept for backward compat.
47
+
48
+ Args:
49
+ config: Meeting configuration dict.
50
+ num_parts: Number of transcript parts.
51
+ verification: Verification result dict (from :func:`verify_vtt_content`),
52
+ or ``None`` for dry-run.
53
+ chat_count: Number of extracted chat messages (backward compat).
54
+ participants: Optional :class:`MeetingParticipants` with
55
+ categorised participant lists.
56
+ chat_messages: Full list of :class:`ChatMessage` instances for the
57
+ ``"chat"`` array. ``None`` when chat was not extracted.
58
+ include_transcript: Whether transcript component is included.
59
+ include_chat: Whether chat component is included.
60
+ include_metadata: Whether metadata component is included.
61
+
62
+ Returns:
63
+ Summary dict matching the JSON output schema.
64
+ """
65
+ summary: dict[str, object] = {"status": "ok"}
66
+ all_components = include_transcript and include_chat and include_metadata
67
+
68
+ # Meeting identity (always present)
69
+ summary["meeting"] = config["meeting"]
70
+
71
+ # Tenant (included only when --tenant was specified and verified)
72
+ if config.get("tenant"):
73
+ summary["tenant"] = config["tenant"]
74
+
75
+ time_text = config.get("meeting_time_text", "")
76
+ if time_text:
77
+ start_dt, end_dt = parse_meeting_times(time_text, config["date"])
78
+ summary["start"] = start_dt
79
+ if end_dt:
80
+ summary["end"] = end_dt
81
+ else:
82
+ summary["start"] = config["date"]
83
+ if config.get("time"):
84
+ summary["start"] = f"{config['date']}T{config['time']}"
85
+
86
+ # Participants (when metadata is included)
87
+ if include_metadata:
88
+ if participants is not None:
89
+ _add_participant_fields(summary, participants)
90
+ else:
91
+ _add_legacy_member_fields(summary, config)
92
+
93
+ # Transcript fields (when transcript is included)
94
+ if include_transcript:
95
+ summary["parts"] = num_parts
96
+
97
+ # Verification data (populated after download, absent for dry-run)
98
+ if verification and verification.get("valid"):
99
+ summary["cues"] = verification["cue_count"]
100
+ summary["speakers"] = verification["speakers"]
101
+ summary["file_size"] = verification["file_size"]
102
+ else:
103
+ # Dry-run: include speaker names with zero counts
104
+ speaker_map = config.get("speaker_map")
105
+ if speaker_map:
106
+ summary["speakers"] = dict.fromkeys(sorted(speaker_map.values()), 0)
107
+
108
+ # Structured transcript cues (when available)
109
+ if transcript_cues is not None:
110
+ summary["transcript"] = [_cue_to_dict(c) for c in transcript_cues]
111
+
112
+ # Chat (when chat is included)
113
+ if include_chat and chat_messages is not None:
114
+ summary["chat"] = [_chat_message_to_dict(m, config) for m in chat_messages]
115
+
116
+ # Backward compat: include chat_messages count when all components are on
117
+ if all_components and chat_count:
118
+ summary["chat_messages"] = chat_count
119
+ elif include_chat and not all_components and chat_count:
120
+ # Non-all-components mode: still include count for convenience
121
+ summary["chat_messages"] = chat_count
122
+
123
+ return summary
124
+
125
+
126
+ def _chat_message_to_dict(message: ChatMessage, config: dict) -> dict:
127
+ """Convert a ChatMessage to a plain dict for JSON output.
128
+
129
+ Computes the offset from meeting start and includes reactions.
130
+ """
131
+ d: dict[str, object] = {
132
+ "author": message.author,
133
+ "text": message.text,
134
+ }
135
+
136
+ # Compute offset from meeting start
137
+ time_text = config.get("meeting_time_text", "")
138
+ offset = _compute_chat_offset(message.timestamp, time_text, config.get("date", ""))
139
+ d["offset"] = offset or None
140
+
141
+ # Reactions
142
+ if message.reactions:
143
+ d["reactions"] = [
144
+ {"type": r.type, "count": len(r.users), "users": r.users} for r in message.reactions
145
+ ]
146
+ else:
147
+ d["reactions"] = None
148
+
149
+ return d
150
+
151
+
152
+ def _cue_to_dict(cue: TranscriptCue) -> dict:
153
+ """Convert a TranscriptCue to a plain dict for JSON output.
154
+
155
+ Returns:
156
+ Dict with keys: id, start, end, start_sec, end_sec, speaker, text.
157
+ """
158
+ return {
159
+ "id": cue.id,
160
+ "start": cue.start,
161
+ "end": cue.end,
162
+ "start_sec": cue.start_sec,
163
+ "end_sec": cue.end_sec,
164
+ "speaker": cue.speaker,
165
+ "text": cue.text,
166
+ }
167
+
168
+
169
+ def _compute_chat_offset(timestamp: str, meeting_time_text: str, date: str) -> str:
170
+ """Compute HH:MM:SS.mmm offset from meeting start for a chat message.
171
+
172
+ Returns empty string if offset cannot be computed.
173
+ """
174
+ from datetime import datetime as dt
175
+
176
+ from teams_transcripts.domain.timestamps import format_vtt_timestamp
177
+
178
+ if not meeting_time_text:
179
+ return ""
180
+
181
+ start_dt_str, _ = parse_meeting_times(meeting_time_text, date)
182
+ if not start_dt_str:
183
+ return ""
184
+
185
+ try:
186
+ # Parse message timestamp and meeting start
187
+ msg_time = dt.fromisoformat(timestamp)
188
+ start_time = dt.fromisoformat(start_dt_str)
189
+ delta = msg_time - start_time
190
+ seconds = max(0.0, delta.total_seconds())
191
+ return format_vtt_timestamp(seconds)
192
+ except (ValueError, TypeError):
193
+ return ""
194
+
195
+
196
+ def _member_to_dict(member: object) -> dict[str, str]:
197
+ """Convert a RosterMember to a plain dict with name, email, and optional rsvp_status."""
198
+ d: dict[str, str] = {"name": member.name, "email": member.email} # type: ignore[union-attr]
199
+ rsvp = getattr(member, "rsvp_status", "")
200
+ if rsvp:
201
+ d["rsvp_status"] = rsvp
202
+ return d
203
+
204
+
205
+ def _add_participant_fields(summary: dict, participants: MeetingParticipants) -> None:
206
+ """Add categorised participant fields to the summary dict."""
207
+ if participants.organiser:
208
+ org = participants.organiser[0]
209
+ summary["organiser"] = _member_to_dict(org)
210
+
211
+ if participants.invited:
212
+ summary["invited"] = [_member_to_dict(m) for m in participants.invited]
213
+
214
+ if participants.speakers:
215
+ summary["speakers_list"] = [_member_to_dict(m) for m in participants.speakers]
216
+
217
+ if participants.attendees:
218
+ summary["attendees"] = [_member_to_dict(m) for m in participants.attendees]
219
+
220
+
221
+ def _add_legacy_member_fields(summary: dict, config: dict) -> None:
222
+ """Add legacy members/organiser fields from roster or speaker_map."""
223
+ roster = config.get("roster_members", [])
224
+ if roster:
225
+ summary["members"] = sorted(m["name"] for m in roster)
226
+ organisers = sorted(m["name"] for m in roster if m["role"] == "organiser")
227
+ if organisers:
228
+ summary["organiser"] = organisers[0] if len(organisers) == 1 else organisers
229
+ else:
230
+ speaker_map = config.get("speaker_map")
231
+ if speaker_map:
232
+ summary["members"] = sorted(speaker_map.values())
233
+
234
+
235
+ def verify_vtt_content(content: str, *, require_cues: bool = False) -> dict:
236
+ """Verify VTT content and return a summary dict.
237
+
238
+ This is the pure version of ``verify_output()`` -- it operates on
239
+ content strings rather than file paths.
240
+
241
+ Args:
242
+ content: VTT file content string.
243
+
244
+ Returns:
245
+ Dict with ``"valid"`` bool and either error info or
246
+ ``"cue_count"``, ``"speakers"`` keys.
247
+ """
248
+ if not content.startswith("WEBVTT"):
249
+ return {"valid": False, "error": "missing WEBVTT header"}
250
+
251
+ # Count cues
252
+ cue_count = len(re.findall(r"\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}", content))
253
+ if require_cues and cue_count == 0:
254
+ return {"valid": False, "error": "no transcript cues found"}
255
+
256
+ # Extract speakers
257
+ speaker_pattern = re.compile(r"^<v ([^>]+)>", re.MULTILINE)
258
+ speakers = speaker_pattern.findall(content)
259
+ speaker_counts = Counter(speakers)
260
+
261
+ return {
262
+ "valid": True,
263
+ "cue_count": cue_count,
264
+ "speakers": dict(speaker_counts),
265
+ "file_size": len(content.encode("utf-8")),
266
+ }
@@ -0,0 +1,350 @@
1
+ """Pure timestamp parsing, formatting, and manipulation functions.
2
+
3
+ All functions in this module are pure: no I/O, no network, no logging,
4
+ no global state. They form the foundation for VTT generation and
5
+ meeting time resolution.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # VTT timestamp parsing and formatting
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ def parse_vtt_timestamp(ts: str) -> float:
19
+ """Parse a VTT timestamp (``HH:MM:SS.mmm``) to seconds.
20
+
21
+ Args:
22
+ ts: Timestamp string in ``HH:MM:SS.mmm`` format.
23
+
24
+ Returns:
25
+ Time in seconds as a float.
26
+ """
27
+ parts = ts.split(":")
28
+ h = int(parts[0])
29
+ m = int(parts[1])
30
+ s_ms = parts[2].split(".")
31
+ s = int(s_ms[0])
32
+ ms = int(s_ms[1]) if len(s_ms) > 1 else 0
33
+ return h * 3600 + m * 60 + s + ms / 1000
34
+
35
+
36
+ def format_vtt_timestamp(seconds: float) -> str:
37
+ """Format seconds as a VTT timestamp (``HH:MM:SS.mmm``).
38
+
39
+ Negative values are clamped to zero. Sub-millisecond fractions are
40
+ truncated (not rounded) to match the original implementation.
41
+
42
+ Args:
43
+ seconds: Time in seconds.
44
+
45
+ Returns:
46
+ Formatted timestamp string.
47
+ """
48
+ seconds = max(0.0, seconds)
49
+ h = int(seconds) // 3600
50
+ m = (int(seconds) % 3600) // 60
51
+ s = int(seconds) % 60
52
+ ms = int((seconds % 1) * 1000)
53
+ return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Approach-specific timestamp conversions
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def sec_to_vtt_float(s: float) -> str:
62
+ """Convert float seconds to VTT timestamp (Approach A).
63
+
64
+ Identical to :func:`format_vtt_timestamp` -- this alias exists to
65
+ make the Approach A origin explicit. Used when transcript data has
66
+ sub-second precision (React props extraction).
67
+
68
+ Args:
69
+ s: Time in seconds (float, may have fractional milliseconds).
70
+
71
+ Returns:
72
+ Formatted VTT timestamp.
73
+ """
74
+ s = max(0.0, s)
75
+ h = int(s) // 3600
76
+ m = (int(s) % 3600) // 60
77
+ sec = int(s) % 60
78
+ ms = int((s % 1) * 1000)
79
+ return f"{h:02d}:{m:02d}:{sec:02d}.{ms:03d}"
80
+
81
+
82
+ def sec_to_vtt_int(s: int) -> str:
83
+ """Convert integer seconds to VTT timestamp (Approach B).
84
+
85
+ Always produces ``.000`` milliseconds since DOM-extracted timestamps
86
+ have only second-level granularity.
87
+
88
+ Args:
89
+ s: Time in integer seconds.
90
+
91
+ Returns:
92
+ Formatted VTT timestamp with ``.000`` milliseconds.
93
+ """
94
+ return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}.000"
95
+
96
+
97
+ def ts_to_sec(ts: str | None) -> int | None:
98
+ """Convert a Teams DOM timestamp (``MM:SS`` or ``HH:MM:SS``) to seconds.
99
+
100
+ Used for Approach B (DOM extraction) where timestamps come from the
101
+ virtualised transcript list.
102
+
103
+ Args:
104
+ ts: Timestamp string, or ``None``/empty string.
105
+
106
+ Returns:
107
+ Integer seconds, or ``None`` when input is falsy. Returns 0
108
+ for unrecognised formats.
109
+ """
110
+ if not ts:
111
+ return None
112
+ parts = ts.split(":")
113
+ if len(parts) == 2:
114
+ return int(parts[0]) * 60 + int(parts[1])
115
+ if len(parts) == 3:
116
+ return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
117
+ return 0
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Occurrence duration parsing
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def parse_occurrence_duration(text: str) -> int | None:
126
+ """Parse meeting duration in seconds from occurrence dropdown text.
127
+
128
+ Expected format: ``'17 April 2026 19:30 - 20:00'`` or similar.
129
+
130
+ Handles midnight crossing (e.g. ``23:30 - 00:30`` = 60 minutes).
131
+
132
+ Args:
133
+ text: Raw occurrence text from the dropdown.
134
+
135
+ Returns:
136
+ Duration in seconds, or ``None`` if the text cannot be parsed.
137
+ """
138
+ m = re.search(r"(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})", text)
139
+ if not m:
140
+ return None
141
+ start_min = int(m.group(1)) * 60 + int(m.group(2))
142
+ end_min = int(m.group(3)) * 60 + int(m.group(4))
143
+ if end_min <= start_min:
144
+ end_min += 24 * 60 # crosses midnight
145
+ return (end_min - start_min) * 60
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Meeting time parsing
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ # Month name lookup for Recap header text.
154
+ _MONTH_NAMES: dict[str, str] = {
155
+ "january": "01",
156
+ "february": "02",
157
+ "march": "03",
158
+ "april": "04",
159
+ "may": "05",
160
+ "june": "06",
161
+ "july": "07",
162
+ "august": "08",
163
+ "september": "09",
164
+ "october": "10",
165
+ "november": "11",
166
+ "december": "12",
167
+ }
168
+
169
+
170
+ def parse_meeting_times(text: str, fallback_date: str) -> tuple[str, str]:
171
+ """Parse occurrence text into ISO 8601 start and end datetime strings.
172
+
173
+ *text* has the form ``22 April 2026 11:15 - 12:00``.
174
+ *fallback_date* is ``YYYY-MM-DD`` used when the text cannot be parsed.
175
+
176
+ The Recap header displays times in the user's local timezone. The
177
+ local UTC offset for the parsed date is appended so the output is a
178
+ fully-qualified ISO 8601 datetime (e.g. ``2026-04-22T11:15+01:00``).
179
+
180
+ Args:
181
+ text: Raw Recap header time text.
182
+ fallback_date: Date string to return when parsing fails.
183
+
184
+ Returns:
185
+ ``(start, end)`` where each is ``YYYY-MM-DDTHH:MM+HH:MM`` or
186
+ just ``YYYY-MM-DD`` if the time component is missing.
187
+ """
188
+ m = re.match(
189
+ r"(\d{1,2})\s+(\w+)\s+(\d{4})\s+(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})",
190
+ text.strip(),
191
+ )
192
+ if not m:
193
+ return fallback_date, fallback_date
194
+
195
+ day = int(m.group(1))
196
+ month = _MONTH_NAMES.get(m.group(2).lower(), "01")
197
+ year = m.group(3)
198
+ start_time = m.group(4).zfill(5) # ensure HH:MM
199
+ end_time = m.group(5).zfill(5)
200
+
201
+ date_str = f"{year}-{month}-{day:02d}"
202
+
203
+ # Determine the local UTC offset for this specific date. The offset
204
+ # may differ from today's offset due to DST transitions.
205
+ sh, sm = (int(x) for x in start_time.split(":"))
206
+ try:
207
+ naive = datetime(int(year), int(month), day, sh, sm, tzinfo=timezone.utc)
208
+ local_dt = naive.astimezone(tz=None)
209
+ utc_offset = local_dt.utcoffset() or timedelta(0)
210
+ except (ValueError, OSError):
211
+ utc_offset = datetime.now(timezone.utc).astimezone().utcoffset() or timedelta(0)
212
+
213
+ offset_str = _format_utc_offset(utc_offset)
214
+
215
+ return f"{date_str}T{start_time}{offset_str}", f"{date_str}T{end_time}{offset_str}"
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # UTC to local time conversion
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def utc_to_local_iso(utc_timestamp: str) -> str:
224
+ """Convert a UTC ISO 8601 timestamp to local time with UTC offset.
225
+
226
+ Accepts strings like ``2026-04-22T10:15:48.462Z`` and returns
227
+ ``2026-04-22T11:15:48.462+01:00`` (for BST). If parsing fails
228
+ the original string is returned unchanged.
229
+
230
+ Args:
231
+ utc_timestamp: UTC timestamp string (``Z`` or ``+00:00`` suffix).
232
+
233
+ Returns:
234
+ Local ISO 8601 string with UTC offset, or the original string
235
+ on parse failure.
236
+ """
237
+ try:
238
+ dt = datetime.fromisoformat(utc_timestamp.replace("Z", "+00:00"))
239
+ local_dt = dt.astimezone(tz=None)
240
+ offset = local_dt.utcoffset() or timedelta(0)
241
+ offset_str = _format_utc_offset(offset)
242
+ # Format with milliseconds if present in original
243
+ if "." in utc_timestamp:
244
+ ms = f"{local_dt.microsecond // 1000:03d}"
245
+ return f"{local_dt.strftime('%Y-%m-%dT%H:%M:%S')}.{ms}{offset_str}"
246
+ return local_dt.strftime(f"%Y-%m-%dT%H:%M:%S{offset_str}")
247
+ except (ValueError, OSError):
248
+ return utc_timestamp
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Timestamp fill algorithms (Approach B)
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def forward_fill(segments: list[dict]) -> None:
257
+ """Forward-fill null timestamps: set each to ``last_good + 10``.
258
+
259
+ Modifies *segments* in place. Leading ``None`` values are left
260
+ unchanged (handled by :func:`backfill`).
261
+
262
+ Args:
263
+ segments: List of segment dicts with a ``"ts_sec"`` key.
264
+ """
265
+ last_good = None
266
+ for seg in segments:
267
+ if seg["ts_sec"] is not None:
268
+ last_good = seg["ts_sec"]
269
+ elif last_good is not None:
270
+ seg["ts_sec"] = last_good + 10
271
+ last_good = seg["ts_sec"]
272
+
273
+
274
+ def backfill(segments: list[dict]) -> None:
275
+ """Backfill remaining null timestamps: set each to ``next_good - 10``.
276
+
277
+ Values are clamped to 0. Trailing ``None`` values are left unchanged
278
+ (handled by :func:`final_fallback`). Modifies *segments* in place.
279
+
280
+ Args:
281
+ segments: List of segment dicts with a ``"ts_sec"`` key.
282
+ """
283
+ next_good = None
284
+ for seg in reversed(segments):
285
+ if seg["ts_sec"] is not None:
286
+ next_good = seg["ts_sec"]
287
+ elif next_good is not None:
288
+ seg["ts_sec"] = max(0, next_good - 10)
289
+ next_good = seg["ts_sec"]
290
+
291
+
292
+ def final_fallback(segments: list[dict]) -> None:
293
+ """Set any remaining null timestamps to 0.
294
+
295
+ Called after :func:`forward_fill` and :func:`backfill` to handle the
296
+ edge case where all timestamps were ``None``. Modifies *segments*
297
+ in place.
298
+
299
+ Args:
300
+ segments: List of segment dicts with a ``"ts_sec"`` key.
301
+ """
302
+ for seg in segments:
303
+ if seg["ts_sec"] is None:
304
+ seg["ts_sec"] = 0
305
+
306
+
307
+ def detect_discontinuity(segments: list[dict]) -> list[dict]:
308
+ """Truncate segments at a timestamp discontinuity.
309
+
310
+ A discontinuity is defined as a gap > 600 seconds AND a timestamp
311
+ > 2x the running maximum. These typically arise from virtualised
312
+ scroll artefacts in the Teams DOM.
313
+
314
+ With 2 or fewer segments, no truncation is applied.
315
+
316
+ Args:
317
+ segments: List of segment dicts with a ``"ts_sec"`` key.
318
+
319
+ Returns:
320
+ The (possibly truncated) segment list.
321
+ """
322
+ if len(segments) <= 2:
323
+ return segments
324
+ running_max = 0
325
+ cut_at = None
326
+ for i, seg in enumerate(segments):
327
+ ts = seg["ts_sec"]
328
+ if i > 0 and ts is not None and running_max > 0:
329
+ gap = ts - running_max
330
+ if gap > 600 and ts > running_max * 2:
331
+ cut_at = i
332
+ break
333
+ if ts is not None and ts > running_max:
334
+ running_max = ts
335
+ if cut_at is not None:
336
+ return segments[:cut_at]
337
+ return segments
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Internal helpers
342
+ # ---------------------------------------------------------------------------
343
+
344
+
345
+ def _format_utc_offset(utc_offset: timedelta) -> str:
346
+ """Format a UTC offset timedelta as ``+HH:MM`` or ``-HH:MM``."""
347
+ total_seconds = int(utc_offset.total_seconds())
348
+ sign = "+" if total_seconds >= 0 else "-"
349
+ abs_seconds = abs(total_seconds)
350
+ return f"{sign}{abs_seconds // 3600:02d}:{(abs_seconds % 3600) // 60:02d}"