cli-web-youtube 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.
@@ -0,0 +1,49 @@
1
+ # cli-web-youtube
2
+
3
+ > Generated by [CLI-Anything-Web](../../../../cli-anything-web-plugin/) from [youtube.com](https://www.youtube.com)
4
+
5
+ Search YouTube videos, get video details, browse trending content, and explore channels — all from the command line.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install cli-web-youtube
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # Search for videos
17
+ cli-web-youtube search videos "python tutorial" --limit 10
18
+
19
+ # Get video details
20
+ cli-web-youtube video get dQw4w9WgXcQ --json
21
+
22
+ # Browse trending
23
+ cli-web-youtube trending list --category music --limit 10
24
+
25
+ # Channel info
26
+ cli-web-youtube channel get @MrBeast --json
27
+
28
+ # Interactive REPL
29
+ cli-web-youtube
30
+ ```
31
+
32
+ ## Auth
33
+
34
+ No authentication required — all commands access public YouTube content.
35
+
36
+ ## JSON Output
37
+
38
+ All commands support `--json` for structured output:
39
+
40
+ ```bash
41
+ cli-web-youtube search videos "AI" --limit 3 --json
42
+ cli-web-youtube video get dQw4w9WgXcQ --json
43
+ ```
44
+
45
+ ## Testing
46
+
47
+ ```bash
48
+ python -m pytest cli_web/youtube/tests/ -v
49
+ ```
@@ -0,0 +1,3 @@
1
+ """cli-web-youtube — CLI for YouTube video search and browsing."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m cli_web.youtube."""
2
+
3
+ from cli_web.youtube.youtube_cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
File without changes
@@ -0,0 +1,42 @@
1
+ """Channel command for cli-web-youtube."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import YouTubeClient
8
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
9
+ from ..utils.output import print_channel_detail
10
+
11
+
12
+ @click.group("channel")
13
+ def channel_group():
14
+ """Browse YouTube channels."""
15
+
16
+
17
+ @channel_group.command("get")
18
+ @click.argument("handle")
19
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
20
+ def channel_get(handle, use_json):
21
+ """Get channel info and recent videos.
22
+
23
+ HANDLE can be @username, channel ID (UC...), or URL.
24
+
25
+ Example: channel get @MrBeast
26
+ """
27
+ # Extract handle from URL if needed
28
+ if "youtube.com" in handle:
29
+ if "/@" in handle:
30
+ handle = handle.split("/@")[1].split("/")[0]
31
+ handle = f"@{handle}"
32
+ elif "/channel/" in handle:
33
+ handle = handle.split("/channel/")[1].split("/")[0]
34
+
35
+ use_json = resolve_json_mode(use_json)
36
+ with handle_errors(json_mode=use_json):
37
+ client = YouTubeClient()
38
+ result = client.channel(handle)
39
+ if use_json:
40
+ print_json(result)
41
+ else:
42
+ print_channel_detail(result)
@@ -0,0 +1,34 @@
1
+ """Search command for cli-web-youtube."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import YouTubeClient
8
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
9
+ from ..utils.output import print_videos_table
10
+
11
+
12
+ @click.group("search")
13
+ def search_group():
14
+ """Search YouTube videos."""
15
+
16
+
17
+ @search_group.command("videos")
18
+ @click.argument("query")
19
+ @click.option("--limit", "-l", default=10, type=int, help="Max results (default 10).")
20
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
21
+ def search_videos(query, limit, use_json):
22
+ """Search for videos by query.
23
+
24
+ Example: search videos "python tutorial"
25
+ """
26
+ use_json = resolve_json_mode(use_json)
27
+ with handle_errors(json_mode=use_json):
28
+ client = YouTubeClient()
29
+ result = client.search(query, limit=limit)
30
+ if use_json:
31
+ print_json(result)
32
+ else:
33
+ click.echo(f'\n Search: "{query}" ({result["estimated_results"]:,} results)\n')
34
+ print_videos_table(result["videos"])
@@ -0,0 +1,41 @@
1
+ """Trending command for cli-web-youtube."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import YouTubeClient
8
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
9
+ from ..utils.output import print_videos_table
10
+
11
+
12
+ @click.group("trending")
13
+ def trending_group():
14
+ """Browse trending YouTube videos."""
15
+
16
+
17
+ @trending_group.command("list")
18
+ @click.option(
19
+ "--category",
20
+ "-c",
21
+ default="now",
22
+ type=click.Choice(["now", "music", "gaming", "movies"], case_sensitive=False),
23
+ help="Category filter (default: now).",
24
+ )
25
+ @click.option("--limit", "-l", default=20, type=int, help="Max results (default 20).")
26
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
27
+ def trending_list(category, limit, use_json):
28
+ """List trending/popular videos.
29
+
30
+ Example: trending list --category music --limit 10
31
+ """
32
+ use_json = resolve_json_mode(use_json)
33
+ with handle_errors(json_mode=use_json):
34
+ client = YouTubeClient()
35
+ videos = client.trending(category=category)
36
+ videos = videos[:limit]
37
+ if use_json:
38
+ print_json({"videos": videos, "count": len(videos), "category": category})
39
+ else:
40
+ click.echo(f"\n Trending on YouTube — {category.title()} ({len(videos)} videos)\n")
41
+ print_videos_table(videos)
@@ -0,0 +1,41 @@
1
+ """Video command for cli-web-youtube."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import YouTubeClient
8
+ from ..utils.helpers import handle_errors, print_json, resolve_json_mode
9
+ from ..utils.output import print_video_detail
10
+
11
+
12
+ @click.group("video")
13
+ def video_group():
14
+ """Get video details."""
15
+
16
+
17
+ @video_group.command("get")
18
+ @click.argument("video_id")
19
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
20
+ def video_get(video_id, use_json):
21
+ """Get details for a specific video.
22
+
23
+ VIDEO_ID can be the 11-character ID or a full YouTube URL.
24
+
25
+ Example: video get dQw4w9WgXcQ
26
+ """
27
+ # Extract video ID from URL if needed
28
+ if "youtube.com" in video_id or "youtu.be" in video_id:
29
+ if "v=" in video_id:
30
+ video_id = video_id.split("v=")[1].split("&")[0]
31
+ elif "youtu.be/" in video_id:
32
+ video_id = video_id.split("youtu.be/")[1].split("?")[0]
33
+
34
+ use_json = resolve_json_mode(use_json)
35
+ with handle_errors(json_mode=use_json):
36
+ client = YouTubeClient()
37
+ result = client.video_detail(video_id)
38
+ if use_json:
39
+ print_json(result)
40
+ else:
41
+ print_video_detail(result)
File without changes
@@ -0,0 +1,244 @@
1
+ """HTTP client for YouTube InnerTube API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from .exceptions import (
8
+ NetworkError,
9
+ NotFoundError,
10
+ ParseError,
11
+ RateLimitError,
12
+ ServerError,
13
+ YouTubeError,
14
+ )
15
+ from .models import (
16
+ format_channel,
17
+ format_video_detail,
18
+ format_video_from_renderer,
19
+ )
20
+
21
+ INNERTUBE_URL = "https://www.youtube.com/youtubei/v1"
22
+ INNERTUBE_CONTEXT = {
23
+ "client": {
24
+ "clientName": "WEB",
25
+ "clientVersion": "2.20260326.01.00",
26
+ "hl": "en",
27
+ "gl": "US",
28
+ }
29
+ }
30
+
31
+
32
+ class YouTubeClient:
33
+ """Client for YouTube's InnerTube API."""
34
+
35
+ def __init__(self):
36
+ self._session = httpx.Client(
37
+ timeout=15.0,
38
+ headers={
39
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
40
+ "Content-Type": "application/json",
41
+ "Accept": "application/json",
42
+ },
43
+ follow_redirects=True,
44
+ )
45
+
46
+ def __enter__(self):
47
+ return self
48
+
49
+ def __exit__(self, *args):
50
+ self._session.close()
51
+
52
+ def close(self):
53
+ self._session.close()
54
+
55
+ # ── Internal ──────────────────────────────────────────────
56
+
57
+ def _post(self, endpoint: str, body: dict) -> dict:
58
+ """POST to InnerTube API with context."""
59
+ url = f"{INNERTUBE_URL}/{endpoint}?prettyPrint=false"
60
+ payload = {"context": INNERTUBE_CONTEXT, **body}
61
+ try:
62
+ resp = self._session.post(url, json=payload)
63
+ except httpx.TimeoutException as exc:
64
+ raise NetworkError(f"Request timed out: {exc}") from exc
65
+ except Exception as exc:
66
+ raise NetworkError(f"Request failed: {exc}") from exc
67
+ return self._handle_response(resp, endpoint)
68
+
69
+ def _handle_response(self, resp: httpx.Response, endpoint: str = "") -> dict:
70
+ if resp.status_code == 404:
71
+ raise NotFoundError(f"Not found: {endpoint}")
72
+ if resp.status_code == 429:
73
+ retry = resp.headers.get("retry-after")
74
+ raise RateLimitError(
75
+ retry_after=float(retry) if retry else None,
76
+ )
77
+ if resp.status_code >= 500:
78
+ raise ServerError(
79
+ f"YouTube server error: HTTP {resp.status_code}",
80
+ status_code=resp.status_code,
81
+ )
82
+ if resp.status_code >= 400:
83
+ raise YouTubeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
84
+ try:
85
+ return resp.json()
86
+ except Exception as exc:
87
+ raise ParseError(f"Failed to parse response: {exc}") from exc
88
+
89
+ # ── Search ────────────────────────────────────────────────
90
+
91
+ def search(self, query: str, limit: int = 20) -> dict:
92
+ """Search YouTube videos."""
93
+ resp = self._post("search", {"query": query})
94
+
95
+ contents = (
96
+ resp.get("contents", {})
97
+ .get("twoColumnSearchResultsRenderer", {})
98
+ .get("primaryContents", {})
99
+ .get("sectionListRenderer", {})
100
+ .get("contents", [])
101
+ )
102
+
103
+ videos = []
104
+ for section in contents:
105
+ items = section.get("itemSectionRenderer", {}).get("contents", [])
106
+ for item in items:
107
+ renderer = item.get("videoRenderer")
108
+ if renderer:
109
+ videos.append(format_video_from_renderer(renderer))
110
+ if len(videos) >= limit:
111
+ break
112
+ if len(videos) >= limit:
113
+ break
114
+
115
+ estimated = resp.get("estimatedResults", "0")
116
+
117
+ return {
118
+ "query": query,
119
+ "estimated_results": int(estimated),
120
+ "videos": videos,
121
+ }
122
+
123
+ # ── Video detail ──────────────────────────────────────────
124
+
125
+ def video_detail(self, video_id: str) -> dict:
126
+ """Get video details via player endpoint."""
127
+ resp = self._post("player", {"videoId": video_id})
128
+ video_details = resp.get("videoDetails")
129
+ if not video_details:
130
+ raise NotFoundError(f"Video not found: {video_id}")
131
+ microformat = resp.get("microformat")
132
+ return format_video_detail(video_details, microformat)
133
+
134
+ # ── Trending ──────────────────────────────────────────────
135
+
136
+ def trending(self, category: str = "now") -> list[dict]:
137
+ """Get trending/popular videos via search with sort by view count.
138
+
139
+ YouTube's trending page requires auth for non-logged-in users,
140
+ so we use the search API with popular sort filters as a proxy.
141
+ """
142
+ # Map categories to search queries that surface popular content
143
+ queries = {
144
+ "now": "", # empty query with sort=viewCount returns popular recent videos
145
+ "music": "music",
146
+ "gaming": "gaming",
147
+ "movies": "movies",
148
+ }
149
+ query = queries.get(category, category)
150
+
151
+ # Use search with empty/broad query — returns popular videos
152
+ resp = self._post(
153
+ "search",
154
+ {
155
+ "query": query if query else "trending",
156
+ "params": "CAMSAhAB", # sort by upload date, filter videos only
157
+ },
158
+ )
159
+
160
+ contents = (
161
+ resp.get("contents", {})
162
+ .get("twoColumnSearchResultsRenderer", {})
163
+ .get("primaryContents", {})
164
+ .get("sectionListRenderer", {})
165
+ .get("contents", [])
166
+ )
167
+
168
+ videos = []
169
+ for section in contents:
170
+ items = section.get("itemSectionRenderer", {}).get("contents", [])
171
+ for item in items:
172
+ renderer = item.get("videoRenderer")
173
+ if renderer:
174
+ videos.append(format_video_from_renderer(renderer))
175
+
176
+ return videos
177
+
178
+ # ── Channel ───────────────────────────────────────────────
179
+
180
+ def channel(self, handle: str) -> dict:
181
+ """Get channel info via browse endpoint.
182
+
183
+ Accepts @handle, channel ID (UC...), or /c/name format.
184
+ """
185
+ # For @handles, scrape the channel page HTML to get ytInitialData
186
+ # This is more reliable than the browse API which needs an API key
187
+ import json as _json
188
+
189
+ clean = handle.lstrip("@")
190
+ if handle.startswith("UC"):
191
+ channel_url = f"https://www.youtube.com/channel/{handle}"
192
+ else:
193
+ channel_url = f"https://www.youtube.com/@{clean}"
194
+
195
+ try:
196
+ page_resp = self._session.get(channel_url)
197
+ except Exception as exc:
198
+ raise NetworkError(f"Failed to fetch channel: {exc}") from exc
199
+
200
+ if page_resp.status_code == 404:
201
+ raise NotFoundError(f"Channel not found: {handle}")
202
+
203
+ text = page_resp.text
204
+ marker = "var ytInitialData = "
205
+ idx = text.find(marker)
206
+ if idx < 0:
207
+ raise ParseError(f"Could not find channel data for: {handle}")
208
+
209
+ start = idx + len(marker)
210
+ end = text.find(";</script>", start)
211
+ if end < 0:
212
+ end = text.find(";\n", start)
213
+
214
+ try:
215
+ resp = _json.loads(text[start:end])
216
+ except _json.JSONDecodeError as exc:
217
+ raise ParseError(f"Failed to parse channel data: {exc}") from exc
218
+ header = resp.get("header", {})
219
+ metadata = resp.get("metadata", {}).get("channelMetadataRenderer", {})
220
+ result = format_channel(header)
221
+ if "channel_id" in result and not result["channel_id"]:
222
+ result["channel_id"] = metadata.get("externalId", "")
223
+ if not result.get("url"):
224
+ result["url"] = metadata.get("channelUrl", channel_url)
225
+
226
+ # Extract recent videos from tabs
227
+ tabs = resp.get("contents", {}).get("twoColumnBrowseResultsRenderer", {}).get("tabs", [])
228
+ recent_videos = []
229
+ for tab in tabs:
230
+ tab_content = tab.get("tabRenderer", {}).get("content", {})
231
+ sections = tab_content.get("sectionListRenderer", {}).get(
232
+ "contents", []
233
+ ) or tab_content.get("richGridRenderer", {}).get("contents", [])
234
+ for section in sections[:10]:
235
+ # richItemRenderer wraps videoRenderer in newer layouts
236
+ rich = section.get("richItemRenderer", {}).get("content", {})
237
+ renderer = rich.get("videoRenderer") or section.get("videoRenderer")
238
+ if renderer:
239
+ recent_videos.append(format_video_from_renderer(renderer))
240
+ if recent_videos:
241
+ break
242
+
243
+ result["recent_videos"] = recent_videos[:10]
244
+ return result
@@ -0,0 +1,63 @@
1
+ """Domain exception hierarchy for cli-web-youtube."""
2
+
3
+
4
+ class YouTubeError(Exception):
5
+ """Base for all YouTube CLI errors."""
6
+
7
+ def __init__(self, message: str, code: str = "YOUTUBE_ERROR"):
8
+ self.message = message
9
+ self.code = code
10
+ super().__init__(message)
11
+
12
+ def to_dict(self) -> dict:
13
+ return {"error": True, "code": self.code, "message": self.message}
14
+
15
+
16
+ class AuthError(YouTubeError):
17
+ """Authentication required or expired."""
18
+
19
+ def __init__(self, message: str = "Authentication required.", recoverable: bool = False):
20
+ self.recoverable = recoverable
21
+ super().__init__(message, "AUTH_EXPIRED")
22
+
23
+
24
+ class RateLimitError(YouTubeError):
25
+ """429 — retry with backoff."""
26
+
27
+ def __init__(self, message: str = "Rate limited by YouTube.", retry_after: float | None = None):
28
+ self.retry_after = retry_after
29
+ super().__init__(message, "RATE_LIMITED")
30
+
31
+ def to_dict(self) -> dict:
32
+ d = super().to_dict()
33
+ d["retry_after"] = self.retry_after
34
+ return d
35
+
36
+
37
+ class NetworkError(YouTubeError):
38
+ """Connection/DNS/timeout errors."""
39
+
40
+ def __init__(self, message: str):
41
+ super().__init__(message, "NETWORK_ERROR")
42
+
43
+
44
+ class ServerError(YouTubeError):
45
+ """5xx responses."""
46
+
47
+ def __init__(self, message: str, status_code: int = 500):
48
+ self.status_code = status_code
49
+ super().__init__(message, "SERVER_ERROR")
50
+
51
+
52
+ class NotFoundError(YouTubeError):
53
+ """404 — resource not found."""
54
+
55
+ def __init__(self, message: str = "Resource not found"):
56
+ super().__init__(message, "NOT_FOUND")
57
+
58
+
59
+ class ParseError(YouTubeError):
60
+ """Failed to parse YouTube response."""
61
+
62
+ def __init__(self, message: str = "Failed to parse YouTube response"):
63
+ super().__init__(message, "PARSE_ERROR")
@@ -0,0 +1,149 @@
1
+ """Data models for cli-web-youtube — normalize InnerTube responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def format_video_from_renderer(renderer: dict) -> dict:
7
+ """Extract video data from a videoRenderer object."""
8
+ title_runs = renderer.get("title", {}).get("runs", [])
9
+ title = title_runs[0].get("text", "") if title_runs else ""
10
+
11
+ owner_runs = renderer.get("ownerText", {}).get("runs", [])
12
+ channel = owner_runs[0].get("text", "") if owner_runs else ""
13
+ channel_id = ""
14
+ if owner_runs:
15
+ nav = owner_runs[0].get("navigationEndpoint", {})
16
+ channel_id = nav.get("browseEndpoint", {}).get("browseId", "")
17
+
18
+ views_text = renderer.get("viewCountText", {}).get("simpleText", "")
19
+ length_text = renderer.get("lengthText", {}).get("simpleText", "")
20
+ published = renderer.get("publishedTimeText", {}).get("simpleText", "")
21
+
22
+ thumbs = renderer.get("thumbnail", {}).get("thumbnails", [])
23
+ thumbnail = thumbs[-1].get("url", "") if thumbs else ""
24
+
25
+ desc_runs = renderer.get("detailedMetadataSnippets", [])
26
+ description = ""
27
+ if desc_runs:
28
+ snippet_runs = desc_runs[0].get("snippetText", {}).get("runs", [])
29
+ description = "".join(r.get("text", "") for r in snippet_runs)
30
+
31
+ return {
32
+ "id": renderer.get("videoId", ""),
33
+ "title": title,
34
+ "channel": channel,
35
+ "channel_id": channel_id,
36
+ "views": views_text,
37
+ "duration": length_text,
38
+ "published": published,
39
+ "thumbnail": thumbnail,
40
+ "description": description,
41
+ "url": f"https://www.youtube.com/watch?v={renderer.get('videoId', '')}",
42
+ }
43
+
44
+
45
+ def format_video_detail(video_details: dict, microformat: dict | None = None) -> dict:
46
+ """Extract full video details from player response."""
47
+ thumbs = video_details.get("thumbnail", {}).get("thumbnails", [])
48
+ thumbnail = thumbs[-1].get("url", "") if thumbs else ""
49
+
50
+ result = {
51
+ "id": video_details.get("videoId", ""),
52
+ "title": video_details.get("title", ""),
53
+ "channel": video_details.get("author", ""),
54
+ "channel_id": video_details.get("channelId", ""),
55
+ "views": int(video_details.get("viewCount", 0)),
56
+ "duration_seconds": int(video_details.get("lengthSeconds", 0)),
57
+ "description": video_details.get("shortDescription", ""),
58
+ "keywords": video_details.get("keywords", []),
59
+ "thumbnail": thumbnail,
60
+ "is_live": video_details.get("isLiveContent", False),
61
+ "url": f"https://www.youtube.com/watch?v={video_details.get('videoId', '')}",
62
+ }
63
+
64
+ if microformat:
65
+ mf = microformat.get("playerMicroformatRenderer", {})
66
+ result["publish_date"] = mf.get("publishDate", "")
67
+ result["category"] = mf.get("category", "")
68
+ result["is_family_safe"] = mf.get("isFamilySafe", True)
69
+
70
+ return result
71
+
72
+
73
+ def format_channel(header: dict, metadata: dict | None = None) -> dict:
74
+ """Extract channel info from browse response."""
75
+ # Try c4TabbedHeaderRenderer (standard channels)
76
+ c4 = header.get("c4TabbedHeaderRenderer", {})
77
+ if c4:
78
+ thumbs = c4.get("avatar", {}).get("thumbnails", [])
79
+ avatar = thumbs[-1].get("url", "") if thumbs else ""
80
+ banner_thumbs = c4.get("banner", {}).get("thumbnails", [])
81
+ banner = banner_thumbs[-1].get("url", "") if banner_thumbs else ""
82
+
83
+ sub_text = c4.get("subscriberCountText", {}).get("simpleText", "")
84
+
85
+ return {
86
+ "channel_id": c4.get("channelId", ""),
87
+ "title": c4.get("title", ""),
88
+ "subscriber_count": sub_text,
89
+ "avatar": avatar,
90
+ "banner": banner,
91
+ "url": f"https://www.youtube.com/channel/{c4.get('channelId', '')}",
92
+ }
93
+
94
+ # Try pageHeaderRenderer (newer layout)
95
+ ph = header.get("pageHeaderRenderer", {})
96
+ if ph:
97
+ title = ph.get("pageTitle", "")
98
+ content = ph.get("content", {}).get("pageHeaderViewModel", {})
99
+ desc = content.get("description", {}).get("descriptionPreviewViewModel", {})
100
+ desc_text = desc.get("description", {}).get("content", "")
101
+
102
+ image = (
103
+ content.get("image", {})
104
+ .get("decoratedAvatarViewModel", {})
105
+ .get("avatar", {})
106
+ .get("avatarViewModel", {})
107
+ .get("image", {})
108
+ .get("sources", [])
109
+ )
110
+ avatar = image[-1].get("url", "") if image else ""
111
+
112
+ metadata_row = (
113
+ content.get("metadata", {}).get("contentMetadataViewModel", {}).get("metadataRows", [])
114
+ )
115
+ subs = ""
116
+ videos = ""
117
+ for row in metadata_row:
118
+ for part in row.get("metadataParts", []):
119
+ text = part.get("text", {}).get("content", "")
120
+ if "subscriber" in text.lower():
121
+ subs = text
122
+ elif "video" in text.lower():
123
+ videos = text
124
+
125
+ return {
126
+ "channel_id": "",
127
+ "title": title,
128
+ "description": desc_text,
129
+ "subscriber_count": subs,
130
+ "video_count": videos,
131
+ "avatar": avatar,
132
+ "url": "",
133
+ }
134
+
135
+ return {"error": "Unknown header format", "keys": list(header.keys())}
136
+
137
+
138
+ def format_trending_videos(contents: list) -> list[dict]:
139
+ """Extract videos from trending browse response."""
140
+ videos = []
141
+ for section in contents:
142
+ items = section.get("itemSectionRenderer", {}).get("contents", []) or section.get(
143
+ "shelfRenderer", {}
144
+ ).get("content", {}).get("expandedShelfContentsRenderer", {}).get("items", [])
145
+ for item in items:
146
+ renderer = item.get("videoRenderer")
147
+ if renderer:
148
+ videos.append(format_video_from_renderer(renderer))
149
+ return videos