openmd-cli 0.1.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 (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1017 @@
1
+ """Xiaoyuzhou runtime backend providers.
2
+
3
+ This module is loaded by ``openmd.backends.registry`` through the platform
4
+ extension naming convention. Providers here stay behind ``--enable-browser-
5
+ backends`` because both OpenCLI and Podwise rely on local browser/session or
6
+ authenticated CLI state. Importing the module does not read credentials, run
7
+ OpenCLI, run Podwise, download media, or submit paid processing jobs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.parse import urlparse
23
+
24
+ from openmd.core.opencli_output import load_opencli_json
25
+ from openmd.backends.base import (
26
+ BackendCapability,
27
+ BackendDescriptor,
28
+ BackendExtractRequest,
29
+ BackendHealth,
30
+ DiscoveryRequest,
31
+ DiscoveryResult,
32
+ )
33
+ from openmd.backends.executor import BackendProviderRegistry, DiscoveryProviderRegistry
34
+ from openmd.backends.registry import BackendRuntimeOptions
35
+ from openmd.core.models import ContentItem, TranscriptResult
36
+ from openmd.core.secrets import sanitize_error_summary
37
+ from openmd.runtime.tempdirs import owned_temporary_directory
38
+ from openmd.runtime.interpreters import run_interruptibly
39
+
40
+
41
+ OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND = "opencli.xiaoyuzhou.transcript"
42
+ OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND = "opencli.xiaoyuzhou.podcast-episodes"
43
+ PODWISE_TRANSCRIPT_BACKEND = "podwise.transcript"
44
+ OPENCLI_WEB_READ_BACKEND = "opencli.web.read"
45
+
46
+ XIAOYUZHOU_CREDENTIALS_PATH = Path.home() / ".opencli" / "xiaoyuzhou.json"
47
+ XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS = frozenset(
48
+ {"uuid", "android_id", "oaid", "vaid", "aaid"}
49
+ )
50
+ XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS = frozenset({"idfa", "idfv"})
51
+ XIAOYUZHOU_IOS_REQUEST_METADATA_GROUPS = (
52
+ ("user-agent",),
53
+ ("os",),
54
+ ("os-version",),
55
+ ("manufacturer",),
56
+ ("model",),
57
+ ("market",),
58
+ ("app-version",),
59
+ ("app-buildno",),
60
+ ("bundleid", "applicationid"),
61
+ )
62
+
63
+ PODWISE_EPISODE_RE = re.compile(r"https://podwise\.ai/dashboard/episodes/\d+")
64
+ DEFAULT_OPENCLI_TIMEOUT_SECONDS = 180
65
+ DEFAULT_PODWISE_SEARCH_TIMEOUT_SECONDS = 120
66
+ DEFAULT_PODWISE_GET_TIMEOUT_SECONDS = 180
67
+ DEFAULT_MIN_TRANSCRIPT_CHARS = 1
68
+
69
+ CommandRunner = Callable[..., tuple[int, str, str]]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class CredentialShapeCheck:
74
+ ok: bool
75
+ code: str
76
+ summary: str
77
+
78
+
79
+ def register_backend_providers(
80
+ registry: BackendProviderRegistry,
81
+ options: BackendRuntimeOptions,
82
+ ) -> None:
83
+ """Register Xiaoyuzhou extraction providers behind explicit runtime gates."""
84
+ requested = options.backend_override
85
+ if not options.enable_browser_backends:
86
+ return
87
+
88
+ if requested in {None, OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND}:
89
+ registry.register(
90
+ OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND,
91
+ OpenCliXiaoyuzhouTranscriptProvider(),
92
+ )
93
+ if requested in {None, PODWISE_TRANSCRIPT_BACKEND}:
94
+ registry.register(PODWISE_TRANSCRIPT_BACKEND, PodwiseTranscriptProvider())
95
+
96
+
97
+ def register_discovery_providers(
98
+ registry: DiscoveryProviderRegistry,
99
+ options: BackendRuntimeOptions,
100
+ ) -> None:
101
+ """Register podcast episode discovery behind explicit browser/backend gates."""
102
+ requested = options.requested_discovery_provider
103
+ if not options.enable_browser_backends:
104
+ return
105
+ if requested in {None, OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND}:
106
+ registry.register(
107
+ OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND,
108
+ OpenCliXiaoyuzhouPodcastEpisodesProvider(),
109
+ platforms=("xiaoyuzhou",),
110
+ default_for_platforms=requested is None,
111
+ )
112
+
113
+
114
+ def build_opencli_xiaoyuzhou_transcript_args(episode_id: str, *, output_dir: str) -> list[str]:
115
+ return [
116
+ "opencli",
117
+ "xiaoyuzhou",
118
+ "transcript",
119
+ episode_id,
120
+ "--output",
121
+ output_dir,
122
+ "--json",
123
+ "true",
124
+ "--text",
125
+ "true",
126
+ "-f",
127
+ "json",
128
+ ]
129
+
130
+
131
+ def build_opencli_xiaoyuzhou_podcast_episodes_args(
132
+ podcast_id: str,
133
+ *,
134
+ limit: int | None,
135
+ ) -> list[str]:
136
+ command = [
137
+ "opencli",
138
+ "xiaoyuzhou",
139
+ "podcast-episodes",
140
+ podcast_id,
141
+ "-f",
142
+ "json",
143
+ ]
144
+ if limit is not None:
145
+ command.extend(["--limit", str(max(0, limit))])
146
+ return command
147
+
148
+
149
+ def build_opencli_web_read_args(url: str) -> list[str]:
150
+ return ["opencli", "web", "read", "--url", url, "--stdout"]
151
+
152
+
153
+ def build_podwise_search_args(title: str) -> list[str]:
154
+ return ["podwise", "search", "episode", title, "--limit", "5"]
155
+
156
+
157
+ def build_podwise_get_transcript_args(podwise_episode_url: str) -> list[str]:
158
+ return ["podwise", "get", "transcript", podwise_episode_url]
159
+
160
+
161
+ class OpenCliXiaoyuzhouTranscriptProvider:
162
+ """Fetch existing Xiaoyuzhou transcripts through OpenCLI credentials."""
163
+
164
+ name = OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND
165
+
166
+ def __init__(
167
+ self,
168
+ *,
169
+ runner: CommandRunner | None = None,
170
+ credential_path: Path | None = None,
171
+ timeout: int = DEFAULT_OPENCLI_TIMEOUT_SECONDS,
172
+ min_transcript_chars: int = DEFAULT_MIN_TRANSCRIPT_CHARS,
173
+ ) -> None:
174
+ self._runner = runner or _run_command
175
+ self.credential_path = credential_path or XIAOYUZHOU_CREDENTIALS_PATH
176
+ self.timeout = timeout
177
+ self.min_transcript_chars = max(0, min_transcript_chars)
178
+
179
+ def descriptor(self) -> BackendDescriptor:
180
+ capability = BackendCapability(
181
+ platforms=("xiaoyuzhou",),
182
+ source_types=("audio",),
183
+ input_types=("single", "batch", "collection"),
184
+ source_levels=("native_caption", "third_party_extractor", "partial_extraction"),
185
+ requires_network=True,
186
+ requires_credentials=True,
187
+ optional_config=("opencli", "~/.opencli/xiaoyuzhou.json"),
188
+ )
189
+ return BackendDescriptor(
190
+ name=self.name,
191
+ layer="L3 browser/session fallback",
192
+ capabilities=(capability,),
193
+ description="Fetch Xiaoyuzhou transcript text through OpenCLI using local credentials.",
194
+ provider="opencli.xiaoyuzhou",
195
+ )
196
+
197
+ def health(self) -> BackendHealth:
198
+ if self._runner is not _run_command:
199
+ return BackendHealth(ok=True, dependencies={"opencli": "injected"})
200
+ if not _executable_available("opencli"):
201
+ return BackendHealth(
202
+ ok=False,
203
+ dependencies={"opencli": "missing"},
204
+ error_code="backend_dependency_missing",
205
+ error_summary="opencli executable is not available on PATH.",
206
+ )
207
+ return BackendHealth(ok=True, dependencies={"opencli": "available"})
208
+
209
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
210
+ item = request.item
211
+ if item.platform != "xiaoyuzhou":
212
+ return _failed(
213
+ self.name,
214
+ "unsupported_platform",
215
+ f"Xiaoyuzhou provider only supports xiaoyuzhou, got {item.platform}.",
216
+ )
217
+
218
+ credential_check = check_xiaoyuzhou_credentials(self.credential_path)
219
+ if not credential_check.ok:
220
+ return _failed(
221
+ self.name,
222
+ credential_check.code,
223
+ credential_check.summary,
224
+ notes=[
225
+ "Skipped OpenCLI xiaoyuzhou transcript before making an API request.",
226
+ "No credential values or raw command streams were persisted by Openmd.",
227
+ ],
228
+ )
229
+
230
+ episode_id = extract_xiaoyuzhou_episode_id(item)
231
+ if not episode_id:
232
+ return _failed(self.name, "invalid_input", "Could not extract a Xiaoyuzhou episode id.")
233
+
234
+ with owned_temporary_directory(prefix="openmd-xiaoyuzhou-") as tmp_dir:
235
+ command = build_opencli_xiaoyuzhou_transcript_args(episode_id, output_dir=tmp_dir)
236
+ exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
237
+ if exit_code != 0:
238
+ return _failed(
239
+ self.name,
240
+ classify_opencli_xiaoyuzhou_failure(exit_code, stderr or stdout),
241
+ stderr or stdout or f"OpenCLI exited with code {exit_code}.",
242
+ metadata={"exit_code": exit_code, "episode_id": episode_id},
243
+ )
244
+
245
+ text = _read_opencli_transcript_text(Path(tmp_dir), stdout).strip()
246
+ if len("".join(text.split())) < self.min_transcript_chars:
247
+ return _failed(
248
+ self.name,
249
+ "empty_result",
250
+ "OpenCLI xiaoyuzhou transcript produced no usable text.",
251
+ metadata={"episode_id": episode_id},
252
+ )
253
+
254
+ author = item.author or _reported_podcast(stdout)
255
+ if not author:
256
+ # Transcript output omits podcast metadata in current OpenCLI.
257
+ # Public episode metadata uses no browser and never starts media/ASR.
258
+ try:
259
+ code, episode_stdout, _ = self._runner(
260
+ ["opencli", "xiaoyuzhou", "episode", episode_id, "-f", "json"],
261
+ timeout=min(self.timeout, 30),
262
+ )
263
+ if code == 0:
264
+ author = _reported_podcast(episode_stdout)
265
+ except (OSError, ValueError, subprocess.SubprocessError):
266
+ pass # Metadata unavailability must not discard a usable transcript.
267
+
268
+ return TranscriptResult(
269
+ text=text.rstrip() + "\n",
270
+ status="verified",
271
+ source_level="native_caption",
272
+ backend=self.name,
273
+ title=_reported_title(stdout) or item.title,
274
+ author=author,
275
+ published_at=item.published_at,
276
+ source_notes=[
277
+ "OpenCLI xiaoyuzhou transcript returned existing transcript text.",
278
+ "Credential shape was checked locally before the OpenCLI request.",
279
+ "Generated text was read from a temporary directory; raw command streams were not persisted.",
280
+ "No media download, ASR, or Podwise paid processing action was attempted.",
281
+ ],
282
+ metadata={"episode_id": episode_id, "strategy": "opencli_transcript"},
283
+ )
284
+
285
+
286
+ class PodwiseTranscriptProvider:
287
+ """Fetch already-processed Podwise transcripts for Xiaoyuzhou episodes."""
288
+
289
+ name = PODWISE_TRANSCRIPT_BACKEND
290
+
291
+ def __init__(
292
+ self,
293
+ *,
294
+ podwise_runner: CommandRunner | None = None,
295
+ opencli_runner: CommandRunner | None = None,
296
+ search_timeout: int = DEFAULT_PODWISE_SEARCH_TIMEOUT_SECONDS,
297
+ get_timeout: int = DEFAULT_PODWISE_GET_TIMEOUT_SECONDS,
298
+ web_timeout: int = DEFAULT_OPENCLI_TIMEOUT_SECONDS,
299
+ min_transcript_chars: int = DEFAULT_MIN_TRANSCRIPT_CHARS,
300
+ ) -> None:
301
+ self._podwise_runner = podwise_runner or _run_command
302
+ self._opencli_runner = opencli_runner or _run_command
303
+ self.search_timeout = search_timeout
304
+ self.get_timeout = get_timeout
305
+ self.web_timeout = web_timeout
306
+ self.min_transcript_chars = max(0, min_transcript_chars)
307
+
308
+ def descriptor(self) -> BackendDescriptor:
309
+ capability = BackendCapability(
310
+ platforms=("xiaoyuzhou",),
311
+ source_types=("audio",),
312
+ input_types=("single", "batch", "collection"),
313
+ source_levels=("third_party_extractor", "partial_extraction"),
314
+ requires_network=True,
315
+ requires_credentials=True,
316
+ optional_config=("podwise", "opencli web read for title lookup"),
317
+ )
318
+ return BackendDescriptor(
319
+ name=self.name,
320
+ layer="L3 third-party transcript fallback",
321
+ capabilities=(capability,),
322
+ description="Fetch already-processed Podwise transcripts; never submits Podwise processing jobs.",
323
+ provider="podwise",
324
+ is_fallback=True,
325
+ )
326
+
327
+ def health(self) -> BackendHealth:
328
+ if self._podwise_runner is not _run_command or self._opencli_runner is not _run_command:
329
+ return BackendHealth(ok=True, dependencies={"podwise": "injected", "opencli": "injected"})
330
+ dependencies = {
331
+ "podwise": "available" if _executable_available("podwise") else "missing",
332
+ "opencli": "available" if _executable_available("opencli") else "missing",
333
+ }
334
+ ok = dependencies["podwise"] == "available"
335
+ return BackendHealth(
336
+ ok=ok,
337
+ dependencies=dependencies,
338
+ error_code=None if ok else "backend_dependency_missing",
339
+ error_summary=None if ok else "podwise executable is not available on PATH.",
340
+ )
341
+
342
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
343
+ item = request.item
344
+ if item.platform != "xiaoyuzhou":
345
+ return _failed(
346
+ self.name,
347
+ "unsupported_platform",
348
+ f"Podwise Xiaoyuzhou fallback only supports xiaoyuzhou, got {item.platform}.",
349
+ )
350
+
351
+ podwise_episode_url = extract_podwise_episode_url(item.url)
352
+ notes: list[str] = []
353
+ if podwise_episode_url is None:
354
+ lookup_result = self._find_podwise_episode_url(item.url)
355
+ if isinstance(lookup_result, TranscriptResult):
356
+ return lookup_result
357
+ podwise_episode_url = lookup_result
358
+ notes.append("Podwise episode URL was resolved through OpenCLI web read plus Podwise search.")
359
+ else:
360
+ notes.append("Input already contained a Podwise episode URL.")
361
+
362
+ command = build_podwise_get_transcript_args(podwise_episode_url)
363
+ exit_code, stdout, stderr = self._podwise_runner(command, timeout=self.get_timeout)
364
+ if exit_code != 0:
365
+ if _is_podwise_not_processed(stderr or stdout):
366
+ return _failed(
367
+ self.name,
368
+ "podwise_episode_not_processed",
369
+ (
370
+ "Podwise has not processed this episode yet. Openmd does not run "
371
+ "`podwise process` automatically because it can consume account credits."
372
+ ),
373
+ notes=["Only Podwise search/get are allowed in this fallback path."],
374
+ metadata={"podwise_episode_url": podwise_episode_url},
375
+ )
376
+ return _failed(
377
+ self.name,
378
+ f"backend_exit_{exit_code}",
379
+ stderr or stdout or f"Podwise exited with code {exit_code}.",
380
+ metadata={"exit_code": exit_code, "podwise_episode_url": podwise_episode_url},
381
+ )
382
+
383
+ text = stdout.strip()
384
+ if len("".join(text.split())) < self.min_transcript_chars:
385
+ return _failed(
386
+ self.name,
387
+ "empty_result",
388
+ "Podwise transcript command returned no usable text.",
389
+ metadata={"podwise_episode_url": podwise_episode_url},
390
+ )
391
+
392
+ return TranscriptResult(
393
+ text=text.rstrip() + "\n",
394
+ status="verified",
395
+ source_level="third_party_extractor",
396
+ backend=self.name,
397
+ title=item.title,
398
+ author=item.author,
399
+ published_at=item.published_at,
400
+ source_notes=[
401
+ *notes,
402
+ "Podwise transcript fallback was used for Xiaoyuzhou.",
403
+ "Only Podwise search/get were used; Podwise paid processing was not run.",
404
+ "Raw command streams were not persisted by Openmd.",
405
+ ],
406
+ metadata={
407
+ "podwise_episode_url": podwise_episode_url,
408
+ "strategy": "podwise_already_processed_transcript",
409
+ },
410
+ )
411
+
412
+ def _find_podwise_episode_url(self, xiaoyuzhou_url: str) -> str | TranscriptResult:
413
+ with owned_temporary_directory(prefix="openmd-xiaoyuzhou-web-") as tmp_dir:
414
+ exit_code, stdout, stderr = self._opencli_runner(
415
+ build_opencli_web_read_args(xiaoyuzhou_url),
416
+ timeout=self.web_timeout,
417
+ cwd=tmp_dir,
418
+ )
419
+ if exit_code != 0:
420
+ return _failed(
421
+ self.name,
422
+ "podwise_lookup_web_read_failed",
423
+ stderr or stdout or f"OpenCLI web read exited with code {exit_code}.",
424
+ metadata={"exit_code": exit_code},
425
+ )
426
+ title = (
427
+ extract_markdown_title(stdout)
428
+ or extract_table_title(stdout)
429
+ or extract_first_markdown_title(Path(tmp_dir))
430
+ )
431
+
432
+ if not title:
433
+ return _failed(
434
+ self.name,
435
+ "podwise_lookup_failed",
436
+ "Could not derive a Podwise search title from the Xiaoyuzhou page.",
437
+ )
438
+
439
+ exit_code, stdout, stderr = self._podwise_runner(
440
+ build_podwise_search_args(title),
441
+ timeout=self.search_timeout,
442
+ )
443
+ if exit_code != 0:
444
+ return _failed(
445
+ self.name,
446
+ f"podwise_search_exit_{exit_code}",
447
+ stderr or stdout or f"Podwise search exited with code {exit_code}.",
448
+ metadata={"exit_code": exit_code},
449
+ )
450
+ podwise_episode_url = extract_podwise_episode_url(stdout)
451
+ if not podwise_episode_url:
452
+ return _failed(
453
+ self.name,
454
+ "podwise_episode_not_found",
455
+ f"Podwise search did not return an episode URL for the derived title: {title}",
456
+ )
457
+ return podwise_episode_url
458
+
459
+
460
+ class OpenCliXiaoyuzhouPodcastEpisodesProvider:
461
+ """Discover Xiaoyuzhou podcast episodes through OpenCLI."""
462
+
463
+ name = OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND
464
+
465
+ def __init__(
466
+ self,
467
+ *,
468
+ runner: CommandRunner | None = None,
469
+ timeout: int = DEFAULT_OPENCLI_TIMEOUT_SECONDS,
470
+ ) -> None:
471
+ self._runner = runner or _run_command
472
+ self.timeout = timeout
473
+
474
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
475
+ if request.platform != "xiaoyuzhou":
476
+ return DiscoveryResult.failure(
477
+ provider=self.name,
478
+ error_code="unsupported_platform",
479
+ error_summary=f"Xiaoyuzhou discovery only supports xiaoyuzhou, got {request.platform}.",
480
+ )
481
+
482
+ podcast_id = extract_xiaoyuzhou_podcast_id(request.input_url)
483
+ if not podcast_id:
484
+ return DiscoveryResult.failure(
485
+ provider=self.name,
486
+ error_code="invalid_input",
487
+ error_summary="Could not extract a Xiaoyuzhou podcast id.",
488
+ metadata={"platform": request.platform},
489
+ )
490
+
491
+ command = build_opencli_xiaoyuzhou_podcast_episodes_args(
492
+ podcast_id,
493
+ limit=request.limit,
494
+ )
495
+ exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
496
+ if exit_code != 0:
497
+ return DiscoveryResult.failure(
498
+ provider=self.name,
499
+ error_code=f"backend_exit_{exit_code}",
500
+ error_summary=_sanitize_error(stderr or stdout or f"OpenCLI exited with code {exit_code}.")
501
+ or "OpenCLI xiaoyuzhou podcast-episodes failed.",
502
+ source_notes=[
503
+ "OpenCLI xiaoyuzhou podcast-episodes failed before item extraction.",
504
+ "Raw command streams were not persisted by Openmd.",
505
+ ],
506
+ metadata={"exit_code": exit_code, "podcast_id": podcast_id},
507
+ )
508
+
509
+ items = parse_xiaoyuzhou_episode_items(stdout, podcast_id=podcast_id)
510
+ if request.limit is not None and not request.allow_all:
511
+ items = items[: max(0, request.limit)]
512
+ if not items:
513
+ return DiscoveryResult.failure(
514
+ provider=self.name,
515
+ error_code="empty_discovery_result",
516
+ error_summary="OpenCLI xiaoyuzhou podcast-episodes returned no episodes.",
517
+ metadata={"podcast_id": podcast_id},
518
+ )
519
+
520
+ return DiscoveryResult.success(
521
+ provider=self.name,
522
+ items=items,
523
+ source_notes=[
524
+ "OpenCLI xiaoyuzhou podcast-episodes returned episode manifest items.",
525
+ "Manifest discovery does not download media, run ASR, or fetch transcripts.",
526
+ ],
527
+ metadata={
528
+ "podcast_id": podcast_id,
529
+ "normalized_url": f"https://www.xiaoyuzhoufm.com/podcast/{podcast_id}",
530
+ },
531
+ )
532
+
533
+
534
+ def check_xiaoyuzhou_credentials(path: Path = XIAOYUZHOU_CREDENTIALS_PATH) -> CredentialShapeCheck:
535
+ """Validate a local Xiaoyuzhou credential profile without exposing values."""
536
+ if not path.exists():
537
+ return CredentialShapeCheck(
538
+ False,
539
+ "xiaoyuzhou_credentials_missing",
540
+ "Missing Xiaoyuzhou credential file at ~/.opencli/xiaoyuzhou.json.",
541
+ )
542
+
543
+ try:
544
+ credentials = json.loads(path.read_text(encoding="utf-8"))
545
+ except (OSError, json.JSONDecodeError):
546
+ return CredentialShapeCheck(
547
+ False,
548
+ "xiaoyuzhou_credentials_invalid_json",
549
+ "Xiaoyuzhou credential file is not valid JSON.",
550
+ )
551
+
552
+ for field in ("access_token", "refresh_token", "device_id"):
553
+ value = credentials.get(field)
554
+ if not isinstance(value, str) or not value.strip():
555
+ return CredentialShapeCheck(
556
+ False,
557
+ "xiaoyuzhou_credentials_incomplete",
558
+ "Xiaoyuzhou credential profile is missing required authentication/device fields.",
559
+ )
560
+
561
+ device_id = credentials["device_id"].strip()
562
+ device_properties = credentials.get("device_properties")
563
+ if not isinstance(device_properties, str) or not device_properties.strip():
564
+ return CredentialShapeCheck(
565
+ False,
566
+ "xiaoyuzhou_device_properties_missing",
567
+ (
568
+ "Xiaoyuzhou device_properties is required; capture the real "
569
+ "device properties value and ensure its uuid matches device_id."
570
+ ),
571
+ )
572
+
573
+ try:
574
+ parsed_properties = json.loads(device_properties)
575
+ except json.JSONDecodeError:
576
+ return CredentialShapeCheck(
577
+ False,
578
+ "xiaoyuzhou_device_properties_invalid_json",
579
+ "Xiaoyuzhou device_properties must be a JSON string.",
580
+ )
581
+
582
+ if not isinstance(parsed_properties, dict):
583
+ return CredentialShapeCheck(
584
+ False,
585
+ "xiaoyuzhou_device_properties_invalid_json",
586
+ "Xiaoyuzhou device_properties must decode to a JSON object.",
587
+ )
588
+
589
+ property_keys = set(parsed_properties.keys())
590
+ if XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS <= property_keys:
591
+ if parsed_properties.get("uuid") != device_id:
592
+ return CredentialShapeCheck(
593
+ False,
594
+ "xiaoyuzhou_device_mismatch",
595
+ "Xiaoyuzhou Android device_properties.uuid must match device_id.",
596
+ )
597
+ return CredentialShapeCheck(
598
+ True,
599
+ "ok",
600
+ "Xiaoyuzhou credential profile passed local shape checks.",
601
+ )
602
+
603
+ if XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS <= property_keys:
604
+ request_metadata = credentials.get("request_headers") or credentials.get("headers")
605
+ if not isinstance(request_metadata, dict) or not request_metadata:
606
+ return CredentialShapeCheck(
607
+ True,
608
+ "ok",
609
+ (
610
+ "Xiaoyuzhou credential profile passed local shape checks "
611
+ "for an OpenCLI-compatible iOS device profile."
612
+ ),
613
+ )
614
+
615
+ missing_groups = [
616
+ group
617
+ for group in XIAOYUZHOU_IOS_REQUEST_METADATA_GROUPS
618
+ if _metadata_lookup(request_metadata, group) is None
619
+ ]
620
+ if missing_groups:
621
+ return CredentialShapeCheck(
622
+ False,
623
+ "xiaoyuzhou_request_metadata_incomplete",
624
+ "Xiaoyuzhou iOS profile is missing required same-device request metadata.",
625
+ )
626
+
627
+ metadata_device_id = _metadata_lookup(request_metadata, ("x-jike-device-id",))
628
+ if metadata_device_id is not None and metadata_device_id != device_id:
629
+ return CredentialShapeCheck(
630
+ False,
631
+ "xiaoyuzhou_device_mismatch",
632
+ "Xiaoyuzhou same-device request metadata must match device_id.",
633
+ )
634
+
635
+ return CredentialShapeCheck(
636
+ True,
637
+ "ok",
638
+ "Xiaoyuzhou credential profile passed local shape checks.",
639
+ )
640
+
641
+ android_missing = sorted(XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS - property_keys)
642
+ ios_missing = sorted(XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS - property_keys)
643
+ if android_missing and ios_missing:
644
+ return CredentialShapeCheck(
645
+ False,
646
+ "xiaoyuzhou_device_properties_incomplete",
647
+ (
648
+ "Xiaoyuzhou device_properties must look like either Android "
649
+ f"({', '.join(sorted(XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS))}) "
650
+ f"or iOS ({', '.join(sorted(XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS))})."
651
+ ),
652
+ )
653
+
654
+ return CredentialShapeCheck(
655
+ True,
656
+ "ok",
657
+ "Xiaoyuzhou credential profile passed local shape checks.",
658
+ )
659
+
660
+
661
+ def extract_xiaoyuzhou_episode_id(item: ContentItem) -> str | None:
662
+ if item.id:
663
+ return item.id.strip()
664
+ parsed = urlparse(item.url)
665
+ parts = [part for part in parsed.path.split("/") if part]
666
+ if len(parts) >= 2 and parts[0] == "episode":
667
+ return parts[1]
668
+ if parts:
669
+ return parts[-1]
670
+ return None
671
+
672
+
673
+ def extract_xiaoyuzhou_podcast_id(input_value: str) -> str | None:
674
+ value = input_value.strip()
675
+ if not value:
676
+ return None
677
+ parsed = urlparse(value if "://" in value else f"https://www.xiaoyuzhoufm.com/podcast/{value}")
678
+ parts = [part for part in parsed.path.split("/") if part]
679
+ if len(parts) >= 2 and parts[0] == "podcast":
680
+ return parts[1]
681
+ if parts and parsed.netloc.endswith("xiaoyuzhoufm.com"):
682
+ return parts[-1]
683
+ if "://" not in value and "/" not in value:
684
+ return value
685
+ return None
686
+
687
+
688
+ def parse_xiaoyuzhou_episode_items(stdout: str, *, podcast_id: str) -> list[ContentItem]:
689
+ try:
690
+ payload = load_opencli_json(stdout.strip())
691
+ except json.JSONDecodeError:
692
+ return []
693
+ records = _episode_records_from_payload(payload)
694
+ items: list[ContentItem] = []
695
+ for record in records:
696
+ item = _episode_item_from_record(record, podcast_id=podcast_id)
697
+ if item is not None:
698
+ items.append(item)
699
+ return items
700
+
701
+
702
+ def _episode_records_from_payload(payload: Any) -> list[dict[str, Any]]:
703
+ if isinstance(payload, list):
704
+ return [item for item in payload if isinstance(item, dict)]
705
+ if not isinstance(payload, dict):
706
+ return []
707
+ for key in ("episodes", "items", "data", "list", "rows"):
708
+ value = payload.get(key)
709
+ if isinstance(value, list):
710
+ return [item for item in value if isinstance(item, dict)]
711
+ if isinstance(value, dict):
712
+ nested = _episode_records_from_payload(value)
713
+ if nested:
714
+ return nested
715
+ return []
716
+
717
+
718
+ def _episode_item_from_record(record: dict[str, Any], *, podcast_id: str) -> ContentItem | None:
719
+ episode_id = _first_text(record, "eid", "episode_id", "id")
720
+ url = _first_text(record, "url", "episode_url", "link")
721
+ if not episode_id and url:
722
+ episode_id = extract_xiaoyuzhou_episode_id(
723
+ ContentItem(platform="xiaoyuzhou", input_type="single", url=url)
724
+ )
725
+ if not episode_id:
726
+ return None
727
+ episode_url = url or f"https://www.xiaoyuzhoufm.com/episode/{episode_id}"
728
+ return ContentItem(
729
+ platform="xiaoyuzhou",
730
+ input_type="collection",
731
+ url=episode_url,
732
+ id=episode_id,
733
+ title=_first_text(record, "title", "name"),
734
+ author=_first_text(record, "podcast_title", "author"),
735
+ published_at=_first_text(record, "published_at", "date", "pub_date"),
736
+ source_type="audio",
737
+ raw_meta={
738
+ "profile": "media",
739
+ "podcast_id": podcast_id,
740
+ "duration": _first_text(record, "duration"),
741
+ "plays": _first_text(record, "plays", "play_count"),
742
+ "caption_status": "unknown",
743
+ "discovery_provider": OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND,
744
+ },
745
+ )
746
+
747
+
748
+ def _first_text(record: dict[str, Any], *keys: str) -> str | None:
749
+ for key in keys:
750
+ value = record.get(key)
751
+ if value is None:
752
+ continue
753
+ text = str(value).strip()
754
+ if text:
755
+ return text
756
+ return None
757
+
758
+
759
+ def extract_podwise_episode_url(text: str) -> str | None:
760
+ match = PODWISE_EPISODE_RE.search(text)
761
+ return match.group(0) if match else None
762
+
763
+
764
+ def extract_markdown_title(markdown: str) -> str | None:
765
+ for line in markdown.splitlines():
766
+ text = line.strip()
767
+ if text.startswith("# "):
768
+ return text.removeprefix("# ").strip() or None
769
+ return None
770
+
771
+
772
+ def extract_table_title(stdout: str) -> str | None:
773
+ for line in stdout.splitlines():
774
+ if not line.startswith("|"):
775
+ continue
776
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
777
+ if not cells:
778
+ continue
779
+ title = cells[0]
780
+ if title and title.lower() != "title" and set(title) != {"-"}:
781
+ return title
782
+ return None
783
+
784
+
785
+ def extract_first_markdown_title(directory: Path) -> str | None:
786
+ for markdown_file in sorted(directory.rglob("*.md")):
787
+ title = extract_markdown_title(
788
+ markdown_file.read_text(encoding="utf-8", errors="replace")
789
+ )
790
+ if title:
791
+ return title
792
+ return None
793
+
794
+
795
+ def classify_opencli_xiaoyuzhou_failure(exit_code: int, message: object) -> str:
796
+ text = str(message or "").lower()
797
+ if exit_code == 78 and "xiaoyuzhou" in text and "credential" in text:
798
+ return "xiaoyuzhou_credentials_unavailable"
799
+ if "timed out" in text or "timeout" in text:
800
+ return "opencli_xiaoyuzhou_transcript_timeout"
801
+ if "credential" in text or "login required" in text or "unauthorized" in text:
802
+ return "xiaoyuzhou_credentials_unavailable"
803
+ return f"backend_exit_{exit_code}"
804
+
805
+
806
+ def _run_command(
807
+ command: list[str],
808
+ *,
809
+ timeout: int = 120,
810
+ cwd: str | Path | None = None,
811
+ ) -> tuple[int, str, str]:
812
+ try:
813
+ completed = run_interruptibly(
814
+ [_resolve_executable(command[0]), *command[1:]],
815
+ cwd=cwd,
816
+ stdin=subprocess.DEVNULL,
817
+ capture_output=True,
818
+ text=True,
819
+ timeout=timeout,
820
+ check=False,
821
+ encoding="utf-8",
822
+ errors="replace",
823
+ )
824
+ except subprocess.TimeoutExpired as exc:
825
+ return 124, _to_text(exc.stdout), f"command timed out after {timeout} seconds."
826
+ except OSError as exc:
827
+ return 127, "", _sanitize_error(exc) or ""
828
+ return completed.returncode, completed.stdout or "", completed.stderr or ""
829
+
830
+
831
+ def _resolve_executable(executable: str) -> str:
832
+ if os.name == "nt" and not Path(executable).suffix:
833
+ for extension in (".cmd", ".bat", ".exe"):
834
+ resolved = shutil.which(f"{executable}{extension}")
835
+ if resolved:
836
+ return resolved
837
+ return shutil.which(executable) or executable
838
+
839
+
840
+ def _executable_available(executable: str) -> bool:
841
+ if shutil.which(executable):
842
+ return True
843
+ if os.name != "nt":
844
+ return False
845
+ return any(shutil.which(f"{executable}{extension}") for extension in (".cmd", ".bat", ".exe"))
846
+
847
+
848
+ def _reported_title(stdout: str) -> str | None:
849
+ try:
850
+ payload = load_opencli_json(stdout)
851
+ except json.JSONDecodeError:
852
+ return None
853
+ row = payload[0] if isinstance(payload, list) and payload else payload
854
+ title = row.get("title") if isinstance(row, dict) else None
855
+ return title.strip() or None if isinstance(title, str) else None
856
+
857
+
858
+ def _reported_podcast(stdout: str) -> str | None:
859
+ try:
860
+ payload = load_opencli_json(stdout)
861
+ except json.JSONDecodeError:
862
+ return None
863
+ row = payload[0] if isinstance(payload, list) and payload else payload
864
+ if not isinstance(row, dict):
865
+ return None
866
+ for key in ("podcast", "podcast_title", "podcastTitle", "author"):
867
+ value = row.get(key)
868
+ if isinstance(value, dict):
869
+ value = value.get("title")
870
+ if isinstance(value, str) and value.strip():
871
+ return value.strip()
872
+ return None
873
+
874
+
875
+ def _read_timed_transcript(directory: Path) -> str:
876
+ """Render OpenCLI's segment JSON with timestamps; empty when segments are unusable."""
877
+ for path in sorted(directory.rglob("transcript.json")):
878
+ try:
879
+ segments = json.loads(path.read_text(encoding="utf-8", errors="replace"))
880
+ except json.JSONDecodeError:
881
+ return ""
882
+ if not isinstance(segments, list):
883
+ return ""
884
+ lines = []
885
+ for segment in segments:
886
+ if not isinstance(segment, dict):
887
+ return ""
888
+ start, text = segment.get("startMs"), segment.get("text")
889
+ if not isinstance(start, (int, float)) or not isinstance(text, str):
890
+ return ""
891
+ seconds = int(start // 1000)
892
+ lines.append(f"[{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}] {text.strip()}")
893
+ return "\n".join(lines)
894
+ return ""
895
+
896
+
897
+ def _read_opencli_transcript_text(directory: Path, stdout: str) -> str:
898
+ timed = _read_timed_transcript(directory)
899
+ if timed:
900
+ return timed
901
+ text_files = sorted(directory.rglob("transcript.txt"))
902
+ if text_files:
903
+ return text_files[0].read_text(encoding="utf-8", errors="replace")
904
+ return _extract_text_from_stdout(stdout)
905
+
906
+
907
+ def _extract_text_from_stdout(stdout: str) -> str:
908
+ stripped = stdout.strip()
909
+ if not stripped:
910
+ return ""
911
+ try:
912
+ payload = load_opencli_json(stripped)
913
+ except json.JSONDecodeError:
914
+ return stripped
915
+ return _text_from_json_payload(payload).strip()
916
+
917
+
918
+ def _text_from_json_payload(payload: Any) -> str:
919
+ if isinstance(payload, str):
920
+ return payload
921
+ if isinstance(payload, list):
922
+ lines = [_text_from_json_payload(item).strip() for item in payload]
923
+ return "\n".join(line for line in lines if line)
924
+ if not isinstance(payload, dict):
925
+ return ""
926
+
927
+ for key in ("text", "transcript", "markdown", "content"):
928
+ value = payload.get(key)
929
+ if isinstance(value, str) and value.strip():
930
+ return value
931
+
932
+ for key in ("segments", "snippets", "items", "lines"):
933
+ value = payload.get(key)
934
+ if isinstance(value, list):
935
+ lines = [_text_from_json_payload(item).strip() for item in value]
936
+ return "\n".join(line for line in lines if line)
937
+ return ""
938
+
939
+
940
+ def _metadata_lookup(metadata: object, names: tuple[str, ...]) -> str | None:
941
+ if not isinstance(metadata, dict):
942
+ return None
943
+ normalized = {str(key).lower(): value for key, value in metadata.items()}
944
+ for name in names:
945
+ value = normalized.get(name.lower())
946
+ if isinstance(value, str) and value.strip():
947
+ return value.strip()
948
+ return None
949
+
950
+
951
+ def _is_podwise_not_processed(message: object) -> bool:
952
+ text = str(message or "").lower()
953
+ return (
954
+ "not been processed yet" in text
955
+ or "not processed" in text
956
+ or "process this episode" in text
957
+ )
958
+
959
+
960
+ def _failed(
961
+ backend: str,
962
+ code: str,
963
+ error: object,
964
+ *,
965
+ notes: list[str] | None = None,
966
+ metadata: dict[str, Any] | None = None,
967
+ ) -> TranscriptResult:
968
+ return TranscriptResult(
969
+ text="",
970
+ status="failed",
971
+ source_level="partial_extraction",
972
+ backend=backend,
973
+ source_notes=["backend returned structured failure", *(notes or [])],
974
+ error_code=code,
975
+ error_summary=_sanitize_error(error),
976
+ metadata=metadata or {},
977
+ )
978
+
979
+
980
+ def _sanitize_error(value: object, *, max_length: int = 240) -> str | None:
981
+ if value is None:
982
+ return None
983
+ text = str(value).replace("\r", " ").replace("\n", " ")
984
+ text = re.sub(r"(?i)\bauthorization\b\s+(?:bearer\s+)?[^,;\s]+", "[redacted]", text)
985
+ text = re.sub(r"(?i)\bbearer\s+[^,;\s]+", "[redacted]", text)
986
+ text = re.sub(
987
+ r"(?i)\b[A-Za-z0-9_-]*(?:token|authorization|cookie|headers?)[A-Za-z0-9_-]*\b"
988
+ r"\s*[:=]\s*[^,;\s]+",
989
+ "[redacted]",
990
+ text,
991
+ )
992
+ text = re.sub(
993
+ r"(?i)\b(?:raw|full)[-_ ]?(?:transcript|subtitle|html|response|api_response|media)\b"
994
+ r"\s*[:=]?\s*[^,;\s]*",
995
+ "[redacted]",
996
+ text,
997
+ )
998
+ sanitized = sanitize_error_summary(text, max_length=max_length) or ""
999
+ sanitized = re.sub(
1000
+ r"(?i)\b[A-Za-z0-9_-]*(?:token|authorization|cookie|headers?)[A-Za-z0-9_-]*\b",
1001
+ "[redacted]",
1002
+ sanitized,
1003
+ )
1004
+ sanitized = re.sub(
1005
+ r"(?i)\b(?:raw|full)[-_ ]?(?:transcript|subtitle|html|response|api_response|media)\b",
1006
+ "[redacted]",
1007
+ sanitized,
1008
+ )
1009
+ return sanitized
1010
+
1011
+
1012
+ def _to_text(value: object) -> str:
1013
+ if value is None:
1014
+ return ""
1015
+ if isinstance(value, bytes):
1016
+ return value.decode("utf-8", errors="replace")
1017
+ return str(value)