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,1245 @@
1
+ """YouTube collection discovery helpers and OpenCLI browser provider.
2
+
3
+ The helpers in this module are safe to import and validate without touching the
4
+ network. Data API providers and OpenCLI providers only run when explicitly
5
+ registered by the CLI, guarded by ``--enable-network-backends`` or
6
+ ``--enable-browser-backends`` respectively.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import time
18
+ from dataclasses import replace
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.error import HTTPError, URLError
23
+ from urllib.parse import parse_qs, urlencode, urlparse
24
+ from urllib.request import Request as UrlRequest, urlopen
25
+
26
+ from openmd.core.opencli_output import load_opencli_json
27
+ from openmd.core.detector import extract_first_url
28
+ from openmd.core.models import ContentItem
29
+ from openmd.core.secrets import sanitize_error_summary
30
+
31
+ from .base import DiscoveryRequest, DiscoveryResult
32
+
33
+ from openmd.runtime.interpreters import owned_popen, communicate_interruptibly, terminate_quietly as _terminate_process_tree
34
+
35
+
36
+ YOUTUBE_DISCOVERY_BACKEND = "opencli.youtube.discovery"
37
+ YOUTUBE_DATA_API_DISCOVERY_BACKEND = "youtube.data-api.discovery"
38
+ YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND = "youtube.data-api.playlist.discovery"
39
+ YOUTUBE_DATA_API_KEY_ENV = "OPENMD_YOUTUBE_API_KEY"
40
+ YOUTUBE_DATA_API_BASE_URL = "https://www.googleapis.com/youtube/v3"
41
+ YOUTUBE_COLLECTION_TABS: frozenset[str] = frozenset(
42
+ {"videos", "shorts", "streams", "playlists", "featured"}
43
+ )
44
+ YOUTUBE_HOSTS: tuple[str, ...] = (
45
+ "youtube.com",
46
+ "www.youtube.com",
47
+ "m.youtube.com",
48
+ "music.youtube.com",
49
+ "youtu.be",
50
+ )
51
+ DEFAULT_OPENCLI_CHANNEL_LIMIT = 20
52
+ DEFAULT_OPENCLI_PLAYLIST_LIMIT = 20
53
+ DEFAULT_YOUTUBE_DATA_API_LIMIT = 20
54
+ DEFAULT_YOUTUBE_DATA_API_TIMEOUT = 30
55
+ YOUTUBE_PLAYLIST_DISCOVERY_BACKEND = "opencli.youtube.playlist.discovery"
56
+ _URL_CANDIDATE_RE = re.compile(r"https?://[^\s|,)\]]+")
57
+ _PLAYLIST_ID_RE = re.compile(r"(?:PL|UU|LL|RD)[A-Za-z0-9_-]{3,}|OLAK5uy_[A-Za-z0-9_-]+")
58
+ OPENCLI_CHANNEL_METADATA_FIELDS: frozenset[str] = frozenset(
59
+ {
60
+ "channelid",
61
+ "description",
62
+ "handle",
63
+ "keywords",
64
+ "name",
65
+ "subscribers",
66
+ "url",
67
+ "---",
68
+ }
69
+ )
70
+ OpenCliRunner = Callable[..., tuple[int, str, str]]
71
+ YouTubeDataApiFetcher = Callable[..., tuple[int, str]]
72
+
73
+
74
+ def normalize_youtube_collection_url(input_value: str) -> str | None:
75
+ """Return a canonical YouTube collection URL when the input looks like one."""
76
+ value = extract_first_url(input_value).strip()
77
+ if not value:
78
+ return None
79
+
80
+ if _looks_like_playlist_id(value):
81
+ return f"https://www.youtube.com/playlist?list={value}"
82
+
83
+ if value.startswith("@"):
84
+ return f"https://www.youtube.com/{value.lstrip('/')}"
85
+
86
+ parsed = urlparse(value if "://" in value else f"https://{value}")
87
+ host = parsed.netloc.lower()
88
+ path_parts = [part for part in parsed.path.split("/") if part]
89
+ query = parse_qs(parsed.query)
90
+
91
+ if host not in YOUTUBE_HOSTS:
92
+ return None
93
+
94
+ if "list" in query and query["list"] and (
95
+ path_parts[:1] in (["playlist"], ["watch"]) or host == "youtu.be"
96
+ ):
97
+ return f"https://www.youtube.com/playlist?list={query['list'][0]}"
98
+
99
+ if not path_parts:
100
+ return None
101
+
102
+ first = path_parts[0]
103
+ if first.startswith("@"):
104
+ if len(path_parts) == 1:
105
+ return f"https://www.youtube.com/{first}"
106
+ if path_parts[1] in YOUTUBE_COLLECTION_TABS:
107
+ return f"https://www.youtube.com/{first}/{path_parts[1]}"
108
+ return None
109
+
110
+ if first in {"channel", "c", "user"} and len(path_parts) >= 2:
111
+ suffix = f"/{path_parts[2]}" if len(path_parts) >= 3 and path_parts[2] in YOUTUBE_COLLECTION_TABS else ""
112
+ return f"https://www.youtube.com/{first}/{path_parts[1]}{suffix}"
113
+
114
+ if first == "playlist" and "list" in query and query["list"]:
115
+ return f"https://www.youtube.com/playlist?list={query['list'][0]}"
116
+
117
+ return None
118
+
119
+
120
+ def is_youtube_collection_candidate(input_value: str) -> bool:
121
+ """Detect YouTube channel/profile/videos/playlist inputs without network access."""
122
+ return normalize_youtube_collection_url(input_value) is not None
123
+
124
+
125
+ def youtube_data_api_channel_lookup(input_value: str) -> tuple[str, str] | None:
126
+ """Return the YouTube Data API channel lookup parameter for channel inputs."""
127
+ normalized_url = normalize_youtube_collection_url(input_value)
128
+ if normalized_url is None:
129
+ return None
130
+
131
+ parsed = urlparse(normalized_url)
132
+ path_parts = [part for part in parsed.path.split("/") if part]
133
+ if not path_parts or path_parts[0] == "playlist":
134
+ return None
135
+
136
+ first = path_parts[0]
137
+ if first.startswith("@"):
138
+ return "forHandle", first.lstrip("@")
139
+ if first == "channel" and len(path_parts) >= 2:
140
+ return "id", path_parts[1]
141
+ if first == "user" and len(path_parts) >= 2:
142
+ return "forUsername", path_parts[1]
143
+ return None
144
+
145
+
146
+ def youtube_data_api_playlist_id(input_value: str) -> str | None:
147
+ """Return a safe YouTube playlist id for playlist URL/watch URL/raw id inputs."""
148
+ value = extract_first_url(input_value).strip()
149
+ if _looks_like_playlist_id(value):
150
+ return value
151
+
152
+ normalized_url = normalize_youtube_collection_url(value)
153
+ if normalized_url is None:
154
+ return None
155
+
156
+ parsed = urlparse(normalized_url)
157
+ path_parts = [part for part in parsed.path.split("/") if part]
158
+ if path_parts[:1] != ["playlist"]:
159
+ return None
160
+
161
+ playlist_ids = parse_qs(parsed.query).get("list")
162
+ playlist_id = playlist_ids[0] if playlist_ids and playlist_ids[0] else None
163
+ if playlist_id is None or not _looks_like_playlist_id(playlist_id):
164
+ return None
165
+ return playlist_id
166
+
167
+
168
+ def build_opencli_youtube_channel_args(
169
+ input_value: str,
170
+ *,
171
+ limit: int | None = None,
172
+ ) -> list[str] | None:
173
+ """Build the bounded OpenCLI channel command for supported channel inputs."""
174
+ target = opencli_youtube_channel_target(input_value)
175
+ if target is None:
176
+ return None
177
+ bounded_limit = DEFAULT_OPENCLI_CHANNEL_LIMIT if limit is None else max(0, limit)
178
+ return ["opencli", "youtube", "channel", target, "--limit", str(bounded_limit), "-f", "json"]
179
+
180
+
181
+ def opencli_youtube_channel_target(input_value: str) -> str | None:
182
+ """Return the OpenCLI target for channel-like inputs; playlists stay unsupported."""
183
+ normalized_url = normalize_youtube_collection_url(input_value)
184
+ if normalized_url is None:
185
+ return None
186
+
187
+ parsed = urlparse(normalized_url)
188
+ path_parts = [part for part in parsed.path.split("/") if part]
189
+ if not path_parts or path_parts[0] == "playlist":
190
+ return None
191
+
192
+ if path_parts[0].startswith("@"):
193
+ return path_parts[0]
194
+
195
+ return normalized_url
196
+
197
+
198
+ def parse_opencli_youtube_channel_json(
199
+ stdout: str,
200
+ *,
201
+ collection_url: str,
202
+ discovery_provider: str = YOUTUBE_DISCOVERY_BACKEND,
203
+ ) -> list[ContentItem]:
204
+ """Parse OpenCLI's ``youtube channel -f json`` output into ContentItems."""
205
+ payload = load_opencli_json(stdout)
206
+ if not isinstance(payload, list):
207
+ raise ValueError("OpenCLI YouTube channel output must be a JSON list.")
208
+
209
+ channel_meta: dict[str, str] = {}
210
+ items: list[ContentItem] = []
211
+ for row in payload:
212
+ if not isinstance(row, dict):
213
+ continue
214
+ field = str(row.get("field") or "").strip()
215
+ value = str(row.get("value") or "").strip()
216
+ field_key = field.lower()
217
+ if field_key in OPENCLI_CHANNEL_METADATA_FIELDS:
218
+ if field_key != "---" and value:
219
+ channel_meta[field_key] = value
220
+ continue
221
+
222
+ video_url = _extract_watch_url(value)
223
+ if video_url is None:
224
+ continue
225
+
226
+ duration, views, published_hint = _split_opencli_video_value(value, video_url)
227
+ raw_meta: dict[str, Any] = {
228
+ "collection_url": collection_url,
229
+ "discovery_provider": discovery_provider,
230
+ }
231
+ if channel_meta.get("channelid"):
232
+ raw_meta["channel_id"] = channel_meta["channelid"]
233
+ if channel_meta.get("name"):
234
+ raw_meta["channel_title"] = channel_meta["name"]
235
+ if duration:
236
+ raw_meta["duration"] = duration
237
+ if views:
238
+ raw_meta["views"] = views
239
+ if published_hint:
240
+ raw_meta["published_hint"] = published_hint
241
+
242
+ items.append(
243
+ ContentItem(
244
+ platform="youtube",
245
+ input_type="collection",
246
+ url=video_url,
247
+ id=_extract_video_id(video_url),
248
+ title=field or None,
249
+ author=channel_meta.get("name"),
250
+ source_type="video",
251
+ raw_meta=raw_meta,
252
+ )
253
+ )
254
+ return items
255
+
256
+
257
+ def opencli_youtube_playlist_target(input_value: str) -> str | None:
258
+ """Return a playlist id target for OpenCLI; channel inputs stay unsupported."""
259
+ value = extract_first_url(input_value).strip()
260
+ if _looks_like_playlist_id(value):
261
+ return value
262
+
263
+ normalized_url = normalize_youtube_collection_url(value)
264
+ if normalized_url is None:
265
+ return None
266
+
267
+ parsed = urlparse(normalized_url)
268
+ path_parts = [part for part in parsed.path.split("/") if part]
269
+ if path_parts[:1] != ["playlist"]:
270
+ return None
271
+
272
+ playlist_ids = parse_qs(parsed.query).get("list")
273
+ return playlist_ids[0] if playlist_ids and playlist_ids[0] else None
274
+
275
+
276
+ def build_opencli_youtube_playlist_args(
277
+ input_value: str,
278
+ *,
279
+ limit: int | None = None,
280
+ ) -> list[str] | None:
281
+ """Build the bounded OpenCLI playlist command for playlist inputs."""
282
+ target = opencli_youtube_playlist_target(input_value)
283
+ if target is None:
284
+ return None
285
+ bounded_limit = DEFAULT_OPENCLI_PLAYLIST_LIMIT if limit is None else max(0, limit)
286
+ return ["opencli", "youtube", "playlist", target, "--limit", str(bounded_limit), "-f", "json"]
287
+
288
+
289
+ def parse_opencli_youtube_playlist_output(
290
+ stdout: str,
291
+ *,
292
+ collection_url: str,
293
+ discovery_provider: str = YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
294
+ ) -> list[ContentItem]:
295
+ """Parse OpenCLI playlist JSON/table/CSV/Markdown output into ContentItems."""
296
+ if not stdout.strip():
297
+ raise ValueError("OpenCLI YouTube playlist output was empty.")
298
+
299
+ rows = _playlist_rows_from_json(stdout)
300
+ if rows is None:
301
+ rows = _playlist_rows_from_markdown_table(stdout)
302
+ if rows is None:
303
+ rows = _playlist_rows_from_csv(stdout)
304
+ if rows is None:
305
+ rows = _playlist_rows_from_text(stdout)
306
+
307
+ items: list[ContentItem] = []
308
+ seen_ids: set[str] = set()
309
+ for row in rows:
310
+ item = _playlist_row_to_item(
311
+ row,
312
+ collection_url=collection_url,
313
+ discovery_provider=discovery_provider,
314
+ )
315
+ if item is None or not item.id or item.id in seen_ids:
316
+ continue
317
+ seen_ids.add(item.id)
318
+ items.append(item)
319
+ return items
320
+
321
+
322
+ def _run_opencli(
323
+ args: list[str],
324
+ *,
325
+ timeout: int = 120,
326
+ stdin_text: str | None = None,
327
+ ) -> tuple[int, str, str]:
328
+ """Run OpenCLI without printing or persisting raw command output."""
329
+ process: subprocess.Popen[str] | None = None
330
+ try:
331
+ command = [_resolve_executable(args[0]), *args[1:]]
332
+ popen_args: list[str] | str = command
333
+ if _is_windows_batch_file(command[0]):
334
+ popen_args = _windows_batch_command_line(command)
335
+ process = owned_popen(
336
+ popen_args,
337
+ stdout=subprocess.PIPE,
338
+ stderr=subprocess.PIPE,
339
+ stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
340
+ text=True,
341
+ encoding="utf-8",
342
+ errors="replace",
343
+ creationflags=_subprocess_creation_flags(),
344
+ start_new_session=os.name != "nt",
345
+ )
346
+ stdout, stderr = communicate_interruptibly(process, input=stdin_text, timeout=timeout)
347
+ except subprocess.TimeoutExpired as exc:
348
+ if process is not None:
349
+ _terminate_process_tree(process)
350
+ try:
351
+ stdout, stderr = communicate_interruptibly(process, timeout=5)
352
+ except subprocess.TimeoutExpired:
353
+ stdout, stderr = exc.stdout, exc.stderr
354
+ else:
355
+ stdout, stderr = exc.stdout, exc.stderr
356
+ return 124, _to_text(stdout), f"OpenCLI command timed out after {timeout} seconds."
357
+ except OSError as exc:
358
+ return 127, "", sanitize_error_summary(exc)
359
+ return process.returncode or 0, stdout or "", stderr or ""
360
+
361
+
362
+ def _is_windows_batch_file(executable: str) -> bool:
363
+ return os.name == "nt" and Path(executable).suffix.lower() in {".cmd", ".bat"}
364
+
365
+
366
+ def _windows_batch_command_line(command: list[str]) -> str:
367
+ """Return a quoted command line for Windows batch shims.
368
+
369
+ ``subprocess.Popen([...])`` does not quote ``&``-bearing arguments for
370
+ ``.cmd``/``.bat`` shims on Windows, so signed URLs can be split by
371
+ ``cmd.exe`` before OpenCLI receives them.
372
+ """
373
+ return " ".join(_quote_windows_batch_arg(argument) for argument in command)
374
+
375
+
376
+ def _quote_windows_batch_arg(argument: str) -> str:
377
+ text = str(argument)
378
+ if not text:
379
+ return '""'
380
+ return '"' + text.replace('"', r"\"") + '"'
381
+
382
+
383
+ def _subprocess_creation_flags() -> int:
384
+ if os.name != "nt":
385
+ return 0
386
+ return getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
387
+
388
+
389
+ def _run_youtube_data_api(
390
+ endpoint: str,
391
+ params: dict[str, str],
392
+ *,
393
+ timeout: int = DEFAULT_YOUTUBE_DATA_API_TIMEOUT,
394
+ ) -> tuple[int, str]:
395
+ """Call YouTube Data API without printing request or response bodies."""
396
+ url = f"{YOUTUBE_DATA_API_BASE_URL}/{endpoint}?{urlencode(params)}"
397
+ try:
398
+ request = UrlRequest(url)
399
+ with urlopen(request, timeout=timeout) as response:
400
+ return response.status, _to_text(response.read())
401
+ except HTTPError as exc:
402
+ return exc.code, _to_text(exc.read())
403
+ except (OSError, URLError) as exc:
404
+ return 0, sanitize_error_summary(exc) or ""
405
+
406
+
407
+ def _resolve_executable(executable: str) -> str:
408
+ if os.name == "nt" and not Path(executable).suffix:
409
+ for extension in (".cmd", ".bat", ".exe"):
410
+ resolved = shutil.which(f"{executable}{extension}")
411
+ if resolved:
412
+ return resolved
413
+ return shutil.which(executable) or executable
414
+
415
+
416
+ class OpenCliYouTubeChannelProvider:
417
+ """Opt-in OpenCLI browser-context provider for YouTube channel discovery."""
418
+
419
+ name = YOUTUBE_DISCOVERY_BACKEND
420
+
421
+ def __init__(
422
+ self,
423
+ *,
424
+ runner: OpenCliRunner | None = None,
425
+ timeout: int = 120,
426
+ ) -> None:
427
+ self._runner = runner or _run_opencli
428
+ self.timeout = timeout
429
+
430
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
431
+ normalized_url = normalize_youtube_collection_url(request.input_url)
432
+ if request.platform != "youtube" or normalized_url is None:
433
+ return self._failure(
434
+ error_code="youtube_collection_input_not_supported",
435
+ error_summary="Input is not a supported YouTube collection/channel/playlist URL.",
436
+ source_notes=[
437
+ "OpenCLI YouTube browser-context discovery did not run because the input is unsupported",
438
+ ],
439
+ metadata={"platform": request.platform},
440
+ )
441
+
442
+ if request.allow_all:
443
+ return self._failure(
444
+ error_code="youtube_discovery_all_not_supported",
445
+ error_summary="OpenCLI YouTube channel discovery requires a bounded --limit; --all is not supported.",
446
+ source_notes=["OpenCLI YouTube browser-context discovery did not run unbounded collection discovery"],
447
+ metadata={"platform": request.platform, "normalized_url": normalized_url},
448
+ )
449
+
450
+ command = build_opencli_youtube_channel_args(normalized_url, limit=request.limit)
451
+ if command is None:
452
+ return self._failure(
453
+ error_code="youtube_collection_input_not_supported",
454
+ error_summary="OpenCLI YouTube channel discovery supports channel/profile URLs; playlist discovery is not implemented.",
455
+ source_notes=["OpenCLI YouTube browser-context discovery did not run for unsupported playlist input"],
456
+ metadata={"platform": request.platform, "normalized_url": normalized_url},
457
+ )
458
+
459
+ exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
460
+ metadata = {
461
+ "platform": request.platform,
462
+ "normalized_url": normalized_url,
463
+ "limit": DEFAULT_OPENCLI_CHANNEL_LIMIT if request.limit is None else max(0, request.limit),
464
+ "requires_browser_context": True,
465
+ }
466
+ if exit_code != 0:
467
+ return self._failure(
468
+ error_code="opencli_youtube_channel_failed",
469
+ error_summary=sanitize_error_summary(stderr or stdout or f"OpenCLI exited with code {exit_code}."),
470
+ source_notes=["OpenCLI YouTube browser-context discovery returned a command failure"],
471
+ metadata={**metadata, "exit_code": exit_code},
472
+ )
473
+
474
+ try:
475
+ items = parse_opencli_youtube_channel_json(
476
+ stdout,
477
+ collection_url=normalized_url,
478
+ discovery_provider=self.name,
479
+ )
480
+ except (json.JSONDecodeError, ValueError) as exc:
481
+ return self._failure(
482
+ error_code="opencli_youtube_channel_invalid_json",
483
+ error_summary=sanitize_error_summary(exc),
484
+ source_notes=["OpenCLI YouTube browser-context discovery stdout was not valid channel JSON"],
485
+ metadata=metadata,
486
+ )
487
+
488
+ if not items:
489
+ return self._failure(
490
+ error_code="opencli_youtube_channel_empty",
491
+ error_summary="OpenCLI YouTube channel discovery returned no video items.",
492
+ source_notes=["OpenCLI YouTube browser-context discovery returned no parseable watch URLs"],
493
+ metadata=metadata,
494
+ )
495
+
496
+ return DiscoveryResult.success(
497
+ provider=self.name,
498
+ items=items,
499
+ source_notes=[
500
+ "OpenCLI browser-context discovery resolved YouTube channel videos.",
501
+ "Browser cookies/session remain inside the local browser context; Openmd did not export cookies.",
502
+ ],
503
+ metadata={**metadata, "item_count": len(items)},
504
+ )
505
+
506
+ def _failure(
507
+ self,
508
+ *,
509
+ error_code: str,
510
+ error_summary: object,
511
+ source_notes: list[str] | None = None,
512
+ metadata: dict[str, Any] | None = None,
513
+ ) -> DiscoveryResult:
514
+ return DiscoveryResult.failure(
515
+ provider=self.name,
516
+ error_code=error_code,
517
+ error_summary=sanitize_error_summary(error_summary),
518
+ source_notes=source_notes or ["OpenCLI YouTube browser-context discovery returned structured failure"],
519
+ metadata=metadata or {},
520
+ )
521
+
522
+
523
+ class OpenCliYouTubePlaylistProvider:
524
+ """Opt-in OpenCLI browser-context provider for YouTube playlist discovery."""
525
+
526
+ name = YOUTUBE_PLAYLIST_DISCOVERY_BACKEND
527
+
528
+ def __init__(
529
+ self,
530
+ *,
531
+ runner: OpenCliRunner | None = None,
532
+ timeout: int = 120,
533
+ ) -> None:
534
+ self._runner = runner or _run_opencli
535
+ self.timeout = timeout
536
+
537
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
538
+ normalized_url = normalize_youtube_collection_url(request.input_url)
539
+ if request.platform != "youtube" or normalized_url is None:
540
+ return self._failure(
541
+ error_code="youtube_playlist_input_not_supported",
542
+ error_summary="Input is not a supported YouTube playlist URL.",
543
+ source_notes=["OpenCLI YouTube playlist discovery did not run because the input is unsupported"],
544
+ metadata={"platform": request.platform},
545
+ )
546
+
547
+ if request.allow_all:
548
+ return self._failure(
549
+ error_code="youtube_playlist_discovery_all_not_supported",
550
+ error_summary="OpenCLI YouTube playlist discovery requires a bounded --limit; --all is not supported.",
551
+ source_notes=["OpenCLI YouTube playlist discovery did not run unbounded collection discovery"],
552
+ metadata={"platform": request.platform, "normalized_url": normalized_url},
553
+ )
554
+
555
+ command = build_opencli_youtube_playlist_args(normalized_url, limit=request.limit)
556
+ if command is None:
557
+ return self._failure(
558
+ error_code="youtube_playlist_input_not_supported",
559
+ error_summary="OpenCLI YouTube playlist discovery supports playlist URLs only.",
560
+ source_notes=["OpenCLI YouTube playlist discovery did not run for unsupported channel input"],
561
+ metadata={"platform": request.platform, "normalized_url": normalized_url},
562
+ )
563
+
564
+ limit = DEFAULT_OPENCLI_PLAYLIST_LIMIT if request.limit is None else max(0, request.limit)
565
+ playlist_id = opencli_youtube_playlist_target(normalized_url)
566
+ metadata = {
567
+ "platform": request.platform,
568
+ "normalized_url": normalized_url,
569
+ "playlist_id": playlist_id,
570
+ "limit": limit,
571
+ "requires_browser_context": True,
572
+ }
573
+ exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
574
+ if exit_code != 0:
575
+ error = stderr or stdout or f"OpenCLI exited with code {exit_code}."
576
+ return self._failure(
577
+ error_code=classify_youtube_playlist_discovery_failure(error),
578
+ error_summary=sanitize_error_summary(error),
579
+ source_notes=["OpenCLI YouTube playlist discovery returned a command failure"],
580
+ metadata={**metadata, "exit_code": exit_code},
581
+ )
582
+
583
+ try:
584
+ items = parse_opencli_youtube_playlist_output(
585
+ stdout,
586
+ collection_url=normalized_url,
587
+ discovery_provider=self.name,
588
+ )[:limit]
589
+ except (json.JSONDecodeError, ValueError) as exc:
590
+ return self._failure(
591
+ error_code="opencli_youtube_playlist_invalid_output",
592
+ error_summary=sanitize_error_summary(exc),
593
+ source_notes=["OpenCLI YouTube playlist discovery stdout had an unexpected shape"],
594
+ metadata=metadata,
595
+ )
596
+
597
+ if not items:
598
+ return self._failure(
599
+ error_code="opencli_youtube_playlist_empty",
600
+ error_summary="OpenCLI YouTube playlist discovery returned no video items.",
601
+ source_notes=["OpenCLI YouTube playlist discovery returned no parseable watch URLs"],
602
+ metadata=metadata,
603
+ )
604
+
605
+ return DiscoveryResult.success(
606
+ provider=self.name,
607
+ items=items,
608
+ source_notes=[
609
+ "OpenCLI browser-context discovery resolved YouTube playlist videos.",
610
+ "Discovery stayed bounded by --limit and did not extract transcripts.",
611
+ "Browser cookies/session remain inside the local browser context; Openmd did not export cookies.",
612
+ ],
613
+ metadata={**metadata, "item_count": len(items)},
614
+ )
615
+
616
+ def _failure(
617
+ self,
618
+ *,
619
+ error_code: str,
620
+ error_summary: object,
621
+ source_notes: list[str] | None = None,
622
+ metadata: dict[str, Any] | None = None,
623
+ ) -> DiscoveryResult:
624
+ return DiscoveryResult.failure(
625
+ provider=self.name,
626
+ error_code=error_code,
627
+ error_summary=sanitize_error_summary(error_summary),
628
+ source_notes=source_notes or ["OpenCLI YouTube playlist discovery returned structured failure"],
629
+ metadata=metadata or {},
630
+ )
631
+
632
+
633
+ class YouTubeDataApiChannelProvider:
634
+ """Opt-in Data API discovery with explicit exhaustion and finite budgets."""
635
+
636
+ name = YOUTUBE_DATA_API_DISCOVERY_BACKEND
637
+ empty_error_code = "youtube_data_api_channel_empty"
638
+
639
+ def __init__(self, *, api_key: str | None = None,
640
+ fetcher: YouTubeDataApiFetcher | None = None,
641
+ timeout: int = DEFAULT_YOUTUBE_DATA_API_TIMEOUT,
642
+ max_pages: int = 100, max_requests: int = 205,
643
+ request_interval: float = 0.1,
644
+ sleeper: Callable[[float], None] = time.sleep) -> None:
645
+ if max_pages < 1 or max_requests < 1 or request_interval < 0:
646
+ raise ValueError("YouTube discovery budgets must be positive and interval nonnegative")
647
+ self.api_key = api_key if api_key is not None else os.environ.get(YOUTUBE_DATA_API_KEY_ENV)
648
+ self._fetcher = fetcher or _run_youtube_data_api
649
+ self.timeout = timeout
650
+ self.max_pages = max_pages
651
+ self.max_requests = max_requests
652
+ self.request_interval = request_interval
653
+ self._sleep = sleeper
654
+
655
+ def _metadata(self, request: DiscoveryRequest, normalized_url: str) -> dict[str, Any]:
656
+ return {"platform": request.platform, "normalized_url": normalized_url,
657
+ "limit": None if request.allow_all else DEFAULT_YOUTUBE_DATA_API_LIMIT if request.limit is None else max(0, request.limit),
658
+ "requires_network": True, "api_key_env": YOUTUBE_DATA_API_KEY_ENV,
659
+ "source_exhausted": False, "page_count": 0, "request_count": 0,
660
+ "max_pages": self.max_pages, "max_requests": self.max_requests,
661
+ "playlist_item_count": 0, "duplicate_item_count": 0,
662
+ "resume_policy": "restart_discovery_and_reuse_verified_packages"}
663
+
664
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
665
+ normalized_url = normalize_youtube_collection_url(request.input_url)
666
+ lookup = youtube_data_api_channel_lookup(request.input_url)
667
+ if request.platform != "youtube" or normalized_url is None or lookup is None:
668
+ return self._failure(error_code="youtube_data_api_input_not_supported",
669
+ error_summary="Data API channel discovery requires a channel, handle or legacy user URL.")
670
+ metadata = self._metadata(request, normalized_url)
671
+ if not self.api_key:
672
+ return self._failure(error_code="youtube_data_api_key_missing",
673
+ error_summary=f"YouTube Data API key is required via {YOUTUBE_DATA_API_KEY_ENV}.", metadata=metadata)
674
+ lookup_key, lookup_value = lookup
675
+ payload, failure = self._request_json("channels", {"part": "snippet,contentDetails", lookup_key: lookup_value},
676
+ failure_code="youtube_data_api_channel_request_failed", metadata=metadata)
677
+ if failure is not None:
678
+ return failure
679
+ channel = _first_item(payload)
680
+ if channel is None:
681
+ return self._failure(error_code="youtube_data_api_channel_not_found", error_summary="No matching channel.", metadata=metadata)
682
+ playlist_id = _uploads_playlist_id(channel)
683
+ if not playlist_id:
684
+ return self._failure(error_code="youtube_data_api_uploads_playlist_missing", error_summary="Channel has no uploads playlist.", metadata=metadata)
685
+ metadata.update(channel_id=str(channel.get("id") or ""), channel_title=_optional_str(_as_dict(channel.get("snippet")).get("title")), uploads_playlist_id=playlist_id)
686
+ return self._discover_playlist(playlist_id, request=request, metadata=metadata)
687
+
688
+ def _discover_playlist(self, playlist_id: str, *, request: DiscoveryRequest, metadata: dict[str, Any]) -> DiscoveryResult:
689
+ metadata["playlist_id"] = playlist_id
690
+ records, failure = self._playlist_records(playlist_id, limit=metadata["limit"], metadata=metadata)
691
+ enriched: dict[str, dict[str, Any]] = {}
692
+ if failure is None and records:
693
+ enriched, failure = self._video_metadata([record["video_id"] for record in records], metadata=metadata)
694
+ items = _data_api_records_to_items(records, enriched, collection_url=metadata["normalized_url"],
695
+ channel_id=metadata.get("channel_id", ""), channel_title=metadata.get("channel_title"),
696
+ uploads_playlist_id=playlist_id, discovery_provider=self.name)
697
+ metadata["item_count"] = len(items)
698
+ if failure is not None:
699
+ return replace(failure, items=items, metadata={**metadata, **failure.metadata, "item_count": len(items)})
700
+ if not records and not (request.allow_all and metadata["source_exhausted"]):
701
+ return self._failure(error_code=self.empty_error_code, error_summary="YouTube Data API returned no video items.", metadata=metadata)
702
+ return DiscoveryResult.success(provider=self.name, items=items, metadata=dict(metadata),
703
+ source_notes=["YouTube Data API resolved playlist video records; transcripts were not extracted.",
704
+ "Pagination completion is reported separately from extracted content coverage.",
705
+ "No browser cookies or session state were used."])
706
+
707
+ def _playlist_records(self, playlist_id: str, *, limit: int | None, metadata: dict[str, Any]) -> tuple[list[dict[str, str]], DiscoveryResult | None]:
708
+ records: list[dict[str, str]] = []
709
+ next_token: str | None = None
710
+ seen_tokens: set[str] = set()
711
+ seen_ids: set[str] = set()
712
+ def stop(code: str) -> tuple[list[dict[str, str]], DiscoveryResult]:
713
+ return records, self._failure(error_code=code, error_summary="YouTube pagination stopped before safe source exhaustion.", metadata=metadata)
714
+ while limit is None or len(records) < limit:
715
+ if metadata["page_count"] >= self.max_pages:
716
+ return stop("youtube_data_api_page_budget_exceeded")
717
+ size = 50 if limit is None else min(50, limit - len(records))
718
+ params = {"part": "snippet,contentDetails", "playlistId": playlist_id, "maxResults": str(size)}
719
+ if next_token:
720
+ params["pageToken"] = next_token
721
+ payload, failure = self._request_json("playlistItems", params, failure_code="youtube_data_api_playlist_request_failed", metadata=metadata)
722
+ if failure is not None:
723
+ metadata["stop_reason"] = failure.error_code
724
+ return records, replace(failure, metadata=dict(metadata))
725
+ metadata["page_count"] += 1
726
+ rows = payload.get("items")
727
+ if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
728
+ return stop("youtube_data_api_invalid_items")
729
+ token = payload.get("nextPageToken")
730
+ if token is not None and (not isinstance(token, str) or not token.strip()):
731
+ return stop("youtube_data_api_invalid_cursor")
732
+ if not rows and token:
733
+ return stop("youtube_data_api_empty_page_with_cursor")
734
+ before = len(records)
735
+ for item in rows:
736
+ metadata["playlist_item_count"] += 1
737
+ video_id = _playlist_video_id(item)
738
+ if not video_id:
739
+ return stop("youtube_data_api_missing_video_id")
740
+ if video_id in seen_ids:
741
+ metadata["duplicate_item_count"] += 1
742
+ continue
743
+ seen_ids.add(video_id)
744
+ snippet = _as_dict(item.get("snippet"))
745
+ details = _as_dict(item.get("contentDetails"))
746
+ records.append({"video_id": video_id, "title": _optional_str(snippet.get("title")) or "",
747
+ "published_at": _optional_str(snippet.get("publishedAt")) or "",
748
+ "video_published_at": _optional_str(details.get("videoPublishedAt")) or "",
749
+ "channel_title": _optional_str(snippet.get("videoOwnerChannelTitle")) or _optional_str(snippet.get("channelTitle")) or "",
750
+ "playlist_channel_title": _optional_str(snippet.get("channelTitle")) or ""})
751
+ if limit is not None and len(records) >= limit:
752
+ break
753
+ if rows and len(records) == before:
754
+ return stop("youtube_data_api_repeated_page")
755
+ if token and token in seen_tokens:
756
+ return stop("youtube_data_api_cursor_loop")
757
+ if not token:
758
+ metadata.update(source_exhausted=True, stop_reason="source_exhausted")
759
+ return records, None
760
+ seen_tokens.add(token)
761
+ next_token = token
762
+ metadata["stop_reason"] = "limit_reached"
763
+ return records, None
764
+
765
+ def _video_metadata(self, video_ids: list[str], *, metadata: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], DiscoveryResult | None]:
766
+ enriched: dict[str, dict[str, Any]] = {}
767
+ for chunk in _chunks(video_ids, 50):
768
+ payload, failure = self._request_json("videos", {"part": "snippet,contentDetails,statistics,status", "id": ",".join(chunk)},
769
+ failure_code="youtube_data_api_videos_request_failed", metadata=metadata)
770
+ if failure is not None:
771
+ return enriched, failure
772
+ for item in _items(payload):
773
+ video_id = _optional_str(item.get("id"))
774
+ if video_id:
775
+ enriched[video_id] = item
776
+ return enriched, None
777
+
778
+ def _request_json(self, endpoint: str, params: dict[str, str], *, failure_code: str, metadata: dict[str, Any]) -> tuple[dict[str, Any], DiscoveryResult | None]:
779
+ if metadata["request_count"] >= self.max_requests:
780
+ return {}, self._failure(error_code="youtube_data_api_request_budget_exceeded", error_summary="YouTube request budget exhausted.", metadata=metadata)
781
+ try:
782
+ if metadata["request_count"] and self.request_interval:
783
+ self._sleep(self.request_interval)
784
+ metadata["request_count"] += 1
785
+ status, body = self._fetcher(endpoint, {**params, "key": self.api_key or ""}, timeout=self.timeout)
786
+ except (Exception, KeyboardInterrupt) as exc:
787
+ code = "youtube_data_api_interrupted" if isinstance(exc, KeyboardInterrupt) else failure_code
788
+ return {}, self._failure(error_code=code, error_summary="YouTube request interrupted or transport failed.", metadata=metadata)
789
+ if status != 200:
790
+ return {}, self._failure(error_code=self._classify_http_failure(body, status=status, default=failure_code), error_summary=_youtube_data_api_error_summary(body, status=status, api_key=self.api_key),
791
+ metadata={**metadata, "endpoint": endpoint, "status_code": status})
792
+ try:
793
+ payload = json.loads(body)
794
+ except (json.JSONDecodeError, TypeError):
795
+ payload = None
796
+ if not isinstance(payload, dict):
797
+ return {}, self._failure(error_code="youtube_data_api_invalid_json", error_summary="YouTube response must be a valid JSON object.", metadata=metadata)
798
+ return payload, None
799
+
800
+ @staticmethod
801
+ def _classify_http_failure(body: str, *, status: int, default: str) -> str:
802
+ return default
803
+
804
+ def _failure(self, *, error_code: str, error_summary: object, metadata: dict[str, Any] | None = None) -> DiscoveryResult:
805
+ return DiscoveryResult.failure(provider=self.name, error_code=error_code,
806
+ error_summary=sanitize_error_summary(error_summary),
807
+ source_notes=["YouTube Data API discovery returned structured failure"],
808
+ metadata={**(metadata or {}), "stop_reason": error_code})
809
+
810
+
811
+ class YouTubeDataApiPlaylistProvider(YouTubeDataApiChannelProvider):
812
+ """Opt-in Data API playlist discovery; shares channel uploads pagination."""
813
+
814
+ name = YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND
815
+ empty_error_code = "youtube_data_api_playlist_empty"
816
+
817
+ @staticmethod
818
+ def _classify_http_failure(body: str, *, status: int, default: str) -> str:
819
+ return _classify_youtube_data_api_playlist_failure(body, status=status, default=default)
820
+
821
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
822
+ playlist_id = youtube_data_api_playlist_id(request.input_url)
823
+ normalized_url = normalize_youtube_collection_url(request.input_url)
824
+ if request.platform != "youtube" or not playlist_id or not normalized_url:
825
+ return self._failure(error_code="youtube_data_api_playlist_input_not_supported", error_summary="Input must identify a YouTube playlist.")
826
+ metadata = self._metadata(request, normalized_url)
827
+ if not self.api_key:
828
+ return self._failure(error_code="youtube_data_api_key_missing", error_summary=f"YouTube Data API key is required via {YOUTUBE_DATA_API_KEY_ENV}.", metadata=metadata)
829
+ return self._discover_playlist(playlist_id, request=request, metadata=metadata)
830
+
831
+
832
+ def _extract_watch_url(value: str) -> str | None:
833
+ return _canonical_watch_url_from_text(value)
834
+
835
+
836
+ def _data_api_records_to_items(
837
+ records: list[dict[str, str]],
838
+ video_metadata: dict[str, dict[str, Any]],
839
+ *,
840
+ collection_url: str,
841
+ channel_id: str,
842
+ channel_title: str | None,
843
+ uploads_playlist_id: str,
844
+ discovery_provider: str,
845
+ ) -> list[ContentItem]:
846
+ items: list[ContentItem] = []
847
+ for record in records:
848
+ video_id = record["video_id"]
849
+ metadata = video_metadata.get(video_id, {})
850
+ snippet = _as_dict(metadata.get("snippet"))
851
+ content_details = _as_dict(metadata.get("contentDetails"))
852
+ statistics = _as_dict(metadata.get("statistics"))
853
+ status = _as_dict(metadata.get("status"))
854
+
855
+ title = _optional_str(snippet.get("title")) or record.get("title") or None
856
+ item_channel_title = channel_title or _optional_str(snippet.get("channelTitle")) or record.get("channel_title") or None
857
+ published_at = (
858
+ _optional_str(snippet.get("publishedAt"))
859
+ or record.get("video_published_at")
860
+ or record.get("published_at")
861
+ or None
862
+ )
863
+ raw_meta: dict[str, Any] = {
864
+ "collection_url": collection_url,
865
+ "discovery_provider": discovery_provider,
866
+ "playlist_id": uploads_playlist_id,
867
+ "caption_status": "unknown",
868
+ "process_status": "discovered",
869
+ }
870
+ if channel_id:
871
+ raw_meta["channel_id"] = channel_id
872
+ raw_meta["uploads_playlist_id"] = uploads_playlist_id
873
+ if item_channel_title:
874
+ raw_meta["channel_title"] = item_channel_title
875
+ playlist_channel_title = record.get("playlist_channel_title")
876
+ if playlist_channel_title:
877
+ raw_meta["playlist_channel_title"] = playlist_channel_title
878
+ duration = _optional_str(content_details.get("duration"))
879
+ if duration:
880
+ raw_meta["duration"] = duration
881
+ view_count = _optional_str(statistics.get("viewCount"))
882
+ if view_count:
883
+ raw_meta["view_count"] = view_count
884
+ privacy_status = _optional_str(status.get("privacyStatus"))
885
+ if privacy_status:
886
+ raw_meta["privacy_status"] = privacy_status
887
+
888
+ items.append(
889
+ ContentItem(
890
+ platform="youtube",
891
+ input_type="collection",
892
+ url=f"https://www.youtube.com/watch?v={video_id}",
893
+ id=video_id,
894
+ title=title,
895
+ author=item_channel_title,
896
+ published_at=published_at,
897
+ source_type="video",
898
+ raw_meta=raw_meta,
899
+ )
900
+ )
901
+ return items
902
+
903
+
904
+ def classify_youtube_playlist_discovery_failure(message: object) -> str:
905
+ """Map known OpenCLI playlist discovery blockers to stable failure codes."""
906
+ text = str(message or "").lower()
907
+ if "empty_result" in text or "returned no data" in text or "no data" in text:
908
+ return "opencli_youtube_playlist_empty"
909
+ if "timed out" in text or "timeout" in text:
910
+ return "opencli_youtube_playlist_timeout"
911
+ if "confirm you're not a bot" in text or "not a bot" in text or "bot check" in text:
912
+ return "youtube_bot_check"
913
+ if "cookie" in text or "cookies" in text or "login required" in text or "log in" in text or "sign in" in text:
914
+ return "youtube_cookie_or_auth_required"
915
+ if "private" in text or "unavailable" in text or "not found" in text:
916
+ return "youtube_playlist_not_found"
917
+ if "blocked" in text:
918
+ return "youtube_request_blocked"
919
+ return "opencli_youtube_playlist_failed"
920
+
921
+
922
+ def _playlist_rows_from_json(stdout: str) -> list[dict[str, Any]] | None:
923
+ try:
924
+ payload = load_opencli_json(stdout)
925
+ except json.JSONDecodeError:
926
+ return None
927
+ rows = _json_video_rows(payload)
928
+ if rows is None:
929
+ raise ValueError("OpenCLI YouTube playlist JSON output must contain video rows.")
930
+ return rows
931
+
932
+
933
+ def _json_video_rows(payload: Any) -> list[dict[str, Any]] | None:
934
+ if isinstance(payload, list):
935
+ return [row for row in payload if isinstance(row, dict) and _row_has_video_reference(row)]
936
+ if not isinstance(payload, dict):
937
+ return None
938
+ if _row_has_video_reference(payload):
939
+ return [payload]
940
+ for key in ("items", "videos", "entries", "rows", "data", "playlist"):
941
+ value = payload.get(key)
942
+ rows = _json_video_rows(value)
943
+ if rows is not None:
944
+ return rows
945
+ return None
946
+
947
+
948
+ def _playlist_rows_from_csv(stdout: str) -> list[dict[str, Any]] | None:
949
+ try:
950
+ reader = csv.DictReader(stdout.splitlines())
951
+ rows = [dict(row) for row in reader if row]
952
+ except csv.Error:
953
+ return None
954
+ if not rows or not reader.fieldnames:
955
+ return None
956
+ return rows if any(_row_has_video_reference(row) for row in rows) else None
957
+
958
+
959
+ def _playlist_rows_from_markdown_table(stdout: str) -> list[dict[str, Any]] | None:
960
+ table_lines = [line.strip() for line in stdout.splitlines() if "|" in line]
961
+ if len(table_lines) < 2:
962
+ return None
963
+
964
+ header = _markdown_cells(table_lines[0])
965
+ if len(header) < 2:
966
+ return None
967
+
968
+ body_lines = table_lines[1:]
969
+ if body_lines and all(set(cell) <= {"-", ":", " "} for cell in _markdown_cells(body_lines[0])):
970
+ body_lines = body_lines[1:]
971
+
972
+ rows: list[dict[str, Any]] = []
973
+ for line in body_lines:
974
+ cells = _markdown_cells(line)
975
+ if len(cells) < len(header):
976
+ continue
977
+ rows.append(dict(zip(header, cells[: len(header)])))
978
+ return rows if rows else None
979
+
980
+
981
+ def _markdown_cells(line: str) -> list[str]:
982
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
983
+
984
+
985
+ def _playlist_rows_from_text(stdout: str) -> list[dict[str, Any]]:
986
+ rows: list[dict[str, Any]] = []
987
+ for line in stdout.splitlines():
988
+ video_url = _canonical_watch_url_from_text(line)
989
+ if video_url is None:
990
+ continue
991
+ title = line.split(video_url, 1)[0].strip(" -|,\t") or None
992
+ rows.append({"title": title, "url": video_url, "raw": line})
993
+ return rows
994
+
995
+
996
+ def _playlist_row_to_item(
997
+ row: dict[str, Any],
998
+ *,
999
+ collection_url: str,
1000
+ discovery_provider: str,
1001
+ ) -> ContentItem | None:
1002
+ normalized = {_normalize_key(key): value for key, value in row.items()}
1003
+ video_url = _row_video_url(normalized)
1004
+ video_id = _row_video_id(normalized, video_url=video_url)
1005
+ if video_url is None and video_id:
1006
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
1007
+ if video_url is None:
1008
+ return None
1009
+ video_id = video_id or _extract_video_id(video_url)
1010
+
1011
+ title = _row_title(normalized, video_url=video_url)
1012
+ author = _first_present(normalized, "author", "channel", "channel_title", "uploader", "owner")
1013
+ published_at = _first_present(normalized, "published_at", "publishedat", "date")
1014
+ raw_meta: dict[str, Any] = {
1015
+ "collection_url": collection_url,
1016
+ "discovery_provider": discovery_provider,
1017
+ "caption_status": "unknown",
1018
+ "process_status": "discovered",
1019
+ }
1020
+ playlist_id = opencli_youtube_playlist_target(collection_url)
1021
+ if playlist_id:
1022
+ raw_meta["playlist_id"] = playlist_id
1023
+ if author:
1024
+ raw_meta["channel_title"] = author
1025
+ for output_key, input_keys in {
1026
+ "duration": ("duration", "length"),
1027
+ "views": ("views", "view_count", "viewcount"),
1028
+ "published_hint": ("published_hint", "published", "age"),
1029
+ "rank": ("rank", "index", "position", "#"),
1030
+ }.items():
1031
+ value = _first_present(normalized, *input_keys)
1032
+ if value:
1033
+ raw_meta[output_key] = value
1034
+
1035
+ return ContentItem(
1036
+ platform="youtube",
1037
+ input_type="collection",
1038
+ url=video_url,
1039
+ id=video_id,
1040
+ title=title,
1041
+ author=author,
1042
+ published_at=published_at,
1043
+ source_type="video",
1044
+ raw_meta=raw_meta,
1045
+ )
1046
+
1047
+
1048
+ def _row_has_video_reference(row: dict[str, Any]) -> bool:
1049
+ values = [str(value or "") for value in row.values()]
1050
+ if any(_canonical_watch_url_from_text(value) for value in values):
1051
+ return True
1052
+ video_id = _first_present({_normalize_key(key): value for key, value in row.items()}, "video_id", "videoid")
1053
+ return bool(video_id and not str(video_id).startswith("PL"))
1054
+
1055
+
1056
+ def _row_video_url(row: dict[str, Any]) -> str | None:
1057
+ for key in ("url", "video_url", "videourl", "link", "href", "watch_url", "watchurl"):
1058
+ value = row.get(key)
1059
+ if value:
1060
+ canonical = _canonical_watch_url_from_text(str(value))
1061
+ if canonical:
1062
+ return canonical
1063
+ for value in row.values():
1064
+ canonical = _canonical_watch_url_from_text(str(value or ""))
1065
+ if canonical:
1066
+ return canonical
1067
+ return None
1068
+
1069
+
1070
+ def _row_video_id(row: dict[str, Any], *, video_url: str | None) -> str | None:
1071
+ url_video_id = _extract_video_id(video_url) if video_url else None
1072
+ if url_video_id:
1073
+ return url_video_id
1074
+ for key in ("video_id", "videoid"):
1075
+ value = _optional_str(row.get(key))
1076
+ if value and not value.startswith("PL"):
1077
+ return value
1078
+ return None
1079
+
1080
+
1081
+ def _row_title(row: dict[str, Any], *, video_url: str) -> str | None:
1082
+ for key in ("title", "name", "video_title", "videotitle"):
1083
+ value = _optional_str(row.get(key))
1084
+ if value:
1085
+ return value
1086
+ field = _optional_str(row.get("field"))
1087
+ if field and not field.isdigit() and _canonical_watch_url_from_text(field) is None:
1088
+ return field
1089
+ value = _optional_str(row.get("value") or row.get("raw"))
1090
+ if value:
1091
+ cleaned = value.replace(video_url, "")
1092
+ parts = [part.strip(" -|,\t") for part in cleaned.split("|")]
1093
+ title = next((part for part in parts if part and "view" not in part.lower()), None)
1094
+ if title:
1095
+ return title
1096
+ return None
1097
+
1098
+
1099
+ def _first_present(row: dict[str, Any], *keys: str) -> str | None:
1100
+ for key in keys:
1101
+ value = _optional_str(row.get(key))
1102
+ if value:
1103
+ return value
1104
+ return None
1105
+
1106
+
1107
+ def _normalize_key(key: object) -> str:
1108
+ return str(key or "").strip().lower().replace(" ", "_").replace("-", "_")
1109
+
1110
+
1111
+ def _canonical_watch_url_from_text(value: str) -> str | None:
1112
+ for match in _URL_CANDIDATE_RE.finditer(value):
1113
+ canonical = _canonical_watch_url(match.group(0))
1114
+ if canonical:
1115
+ return canonical
1116
+ return _canonical_watch_url(value)
1117
+
1118
+
1119
+ def _canonical_watch_url(candidate: str) -> str | None:
1120
+ parsed = urlparse(candidate.strip())
1121
+ host = parsed.netloc.lower()
1122
+ path_parts = [part for part in parsed.path.split("/") if part]
1123
+ if host in YOUTUBE_HOSTS and path_parts[:1] == ["watch"]:
1124
+ video_ids = parse_qs(parsed.query).get("v")
1125
+ if video_ids and video_ids[0]:
1126
+ return f"https://www.youtube.com/watch?v={video_ids[0]}"
1127
+ if host == "youtu.be" and path_parts:
1128
+ return f"https://www.youtube.com/watch?v={path_parts[0]}"
1129
+ return None
1130
+
1131
+
1132
+ def _looks_like_playlist_id(value: str) -> bool:
1133
+ return _PLAYLIST_ID_RE.fullmatch(value) is not None and "://" not in value and "/" not in value
1134
+
1135
+
1136
+ def _classify_youtube_data_api_playlist_failure(body: str, *, status: int, default: str) -> str:
1137
+ text = body.lower()
1138
+ try:
1139
+ payload = json.loads(body)
1140
+ except json.JSONDecodeError:
1141
+ payload = None
1142
+ if isinstance(payload, dict):
1143
+ error = _as_dict(payload.get("error"))
1144
+ message = _optional_str(error.get("message")) or ""
1145
+ errors = error.get("errors")
1146
+ reasons = []
1147
+ if isinstance(errors, list):
1148
+ for item in errors:
1149
+ if isinstance(item, dict):
1150
+ reason = _optional_str(item.get("reason"))
1151
+ if reason:
1152
+ reasons.append(reason)
1153
+ text = " ".join([text, message.lower(), " ".join(reasons).lower()])
1154
+ if status in {401, 403, 404} and (
1155
+ "playlistnotfound" in text
1156
+ or "not found" in text
1157
+ or "private" in text
1158
+ or "forbidden" in text
1159
+ or "permission" in text
1160
+ ):
1161
+ return "youtube_data_api_playlist_not_found"
1162
+ return default
1163
+
1164
+
1165
+ def _youtube_data_api_error_summary(body: str, *, status: int, api_key: str | None = None) -> str:
1166
+ safe_body = body.replace(api_key, "[redacted]") if api_key else body
1167
+ try:
1168
+ payload = json.loads(safe_body)
1169
+ except json.JSONDecodeError:
1170
+ return sanitize_error_summary(f"YouTube Data API request failed with HTTP {status}.") or ""
1171
+ error = payload.get("error") if isinstance(payload, dict) else None
1172
+ message = error.get("message") if isinstance(error, dict) else None
1173
+ if message:
1174
+ return sanitize_error_summary(f"YouTube Data API request failed with HTTP {status}: {message}") or ""
1175
+ return sanitize_error_summary(f"YouTube Data API request failed with HTTP {status}.") or ""
1176
+
1177
+
1178
+ def _uploads_playlist_id(channel: dict[str, Any]) -> str | None:
1179
+ content_details = _as_dict(channel.get("contentDetails"))
1180
+ related_playlists = _as_dict(content_details.get("relatedPlaylists"))
1181
+ return _optional_str(related_playlists.get("uploads"))
1182
+
1183
+
1184
+ def _first_item(payload: dict[str, Any]) -> dict[str, Any] | None:
1185
+ items = _items(payload)
1186
+ return items[0] if items else None
1187
+
1188
+
1189
+ def _items(payload: dict[str, Any]) -> list[dict[str, Any]]:
1190
+ value = payload.get("items")
1191
+ if not isinstance(value, list):
1192
+ return []
1193
+ return [item for item in value if isinstance(item, dict)]
1194
+
1195
+
1196
+ def _playlist_video_id(item: dict[str, Any]) -> str | None:
1197
+ content_details = _as_dict(item.get("contentDetails"))
1198
+ video_id = _optional_str(content_details.get("videoId"))
1199
+ if video_id:
1200
+ return video_id
1201
+ snippet = _as_dict(item.get("snippet"))
1202
+ resource_id = _as_dict(snippet.get("resourceId"))
1203
+ return _optional_str(resource_id.get("videoId"))
1204
+
1205
+
1206
+ def _as_dict(value: object) -> dict[str, Any]:
1207
+ return value if isinstance(value, dict) else {}
1208
+
1209
+
1210
+ def _optional_str(value: object) -> str | None:
1211
+ if value is None:
1212
+ return None
1213
+ text = str(value).strip()
1214
+ return text or None
1215
+
1216
+
1217
+ def _chunks(values: list[str], size: int) -> list[list[str]]:
1218
+ return [values[index : index + size] for index in range(0, len(values), size)]
1219
+
1220
+
1221
+ def _extract_video_id(video_url: str) -> str | None:
1222
+ parsed = urlparse(video_url)
1223
+ path_parts = [part for part in parsed.path.split("/") if part]
1224
+ if parsed.netloc.lower() == "youtu.be" and path_parts:
1225
+ return path_parts[0]
1226
+ video_ids = parse_qs(parsed.query).get("v")
1227
+ return video_ids[0] if video_ids and video_ids[0] else None
1228
+
1229
+
1230
+ def _split_opencli_video_value(value: str, video_url: str) -> tuple[str | None, str | None, str | None]:
1231
+ parts = [part.strip() for part in value.split("|")]
1232
+ video_index = next((index for index, part in enumerate(parts) if video_url in part), len(parts))
1233
+ meta_parts = parts[:video_index]
1234
+ duration = meta_parts[0] if meta_parts else None
1235
+ views = next((part for part in meta_parts[1:] if "view" in part.lower()), None)
1236
+ published_hint = next((part for part in meta_parts[1:] if part != views), None)
1237
+ return duration, views, published_hint
1238
+
1239
+
1240
+ def _to_text(value: object) -> str:
1241
+ if value is None:
1242
+ return ""
1243
+ if isinstance(value, bytes):
1244
+ return value.decode("utf-8", errors="replace")
1245
+ return str(value)