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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|