Compare commits

...

6 Commits

Author SHA1 Message Date
c3fae689e1 feat: show current viewer count for live streams in search results
and related videos feed
previously it would fall back to 0
2025-09-17 01:55:35 +02:00
4cfb1db7d0 fix: handle url rewrite when querying wrong endpoint
materialious is guilty of this
2025-09-17 01:52:45 +02:00
5a1e772909 feat: add support for video livestreams 2025-09-17 00:13:20 +02:00
7c4991cea7 fix: fix for infinite recursion/deadlock for specific channels
this applies mainly for meta channels like UC4R8DWoMoI7CAwX8_LjQHig,
which ythdd can't parse and thus doesn't support
2025-09-16 23:36:53 +02:00
5f88d6f096 docs: update todos and error message when comment extraction fails 2025-09-16 23:35:07 +02:00
eaaa14c4d8 feat: treat verified artists as verified users 2025-09-16 23:28:46 +02:00
4 changed files with 41 additions and 13 deletions

View File

@@ -36,6 +36,10 @@ def ggphtProxy(received_request):
prefix = "https://yt3.ggpht.com/"
# fix for how materialious fetches avatars
if received_request.startswith("guc/"):
return gucProxy(received_request.removeprefix("guc/"))
ggpht = requests.get(prefix + received_request, headers=ythdd_globals.getHeaders(caller='proxy'), stream=True)
ggpht.raw.decode_content = True
response = Response(ggpht.raw, mimetype=ggpht.headers['content-type'], status=ggpht.status_code)

View File

@@ -276,8 +276,8 @@ def generateChannelAvatarsFromUrl(url: str, proxied: bool = True) -> list:
# Generates channel avatars at default sizes.
# avatar urls for channels in search results start with //yt3.ggpht.com/
if url.startswith("//yt3.ggpht.com/"):
url = url.replace("//yt3.ggpht.com/", "https://yt3.ggpht.com/")
if url.startswith("//"):
url = "https:" + url
avatars = []
if not url.startswith("https://yt3.ggpht.com/") and not url.startswith("https://yt3.googleusercontent.com/"):
@@ -308,7 +308,7 @@ def isVerified(response_json: dict) -> bool:
match safeTraverse(list(response_json.keys()), [0], default=""):
case "metadataBadgeRenderer": # channels in search results
verified = safeTraverse(response_json, ["metadataBadgeRenderer", "tooltip"], default="") in ("Verified") # room for support of artist channels
verified = safeTraverse(response_json, ["metadataBadgeRenderer", "tooltip"], default="") in ("Verified", "Official Artist Channel") # perhaps look for badge styles?
return verified
return False

View File

@@ -23,13 +23,16 @@ import ythdd_struct_parser
# [✓] /vi/videoIdXXXX/maxresdefault.jpg (todo: add a fallback for 404s)
# [✓] /api/v1/search?q=... (videos and playlists)
# [✓] /api/v1/search/suggestions?q=...&pq=...
# [✓] /api/v1/channels/id
# [✓] /api/v1/channels/videos, shorts, playlists
# [✓] /api/v1/channels/:ucid
# [✓] /api/v1/channels/:ucid/videos, shorts, playlists
# [✓] /api/v1/comments/:videoid?continuation=...
# [✓] /api/v1/videos/videoIdXXXX
# [X] /api/v1/channels/:ucid/streams
# [X] /api/v1/playlists/:plid
# [X] /api/v1/storyboards/:videoIdXXXX
# [*] /api/v1/auth/subscriptions (stub? db?)
# [*] /api/v1/auth/feed?page=1 (stub? db?)
# [*] /api/v1/auth/playlists (stub? db?)
# [*] /api/v1/videos/videoIdXXXX
DEFAULT_AVATAR = "https://yt3.ggpht.com/a/default-user=s176-c-k-c0x00ffffff-no-rj"
@@ -434,10 +437,16 @@ def videos(data):
format_streams = []
# adaptive_formats, format_streams = rebuildFormats(adaptive_formats)
adaptive_formats, format_streams = rebuildFormatsFromYtdlpApi(ydata)
if not live_now:
adaptive_formats, format_streams = rebuildFormatsFromYtdlpApi(ydata)
hls_url = None
else:
adaptive_formats, format_streams = [{"url": f"http://a/?expire={int(time_start + 5.9 * 60 * 60)}", "itag": "18", "type": "", "clen": "0", "lmt": "", "projectionType": "RECTANGULAR"}], [] # freetube/clipious shenanigans, see: https://github.com/FreeTubeApp/FreeTube/pull/5997 and https://github.com/lamarios/clipious/blob/b9e7885/lib/videos/models/adaptive_format.g.dart
hls_url = safeTraverse(ydata, ["url"], default="ythdd: unable to retrieve stream url")
if live_now:
video_type = "livestream"
video_type = "livestream"
premiere_timestamp = published # ??? that works i guess
elif premiere_timestamp:
video_type = "scheduled"
published = dateToEpoch(premiere_timestamp) if premiere_timestamp else int(time())
@@ -493,7 +502,7 @@ def videos(data):
"isUpcoming": is_upcoming,
"dashUrl": ythdd_globals.config['general']['public_facing_url'] + "api/invidious/api/v1/manifest/" + video_id, # not implemented
"premiereTimestamp": premiere_timestamp,
#"hlsUrl": hls_url, # broken after a change in iOS player, only usable for livestreams
"hlsUrl": hls_url, # broken after a change in iOS player, only usable for livestreams
"adaptiveFormats": adaptive_formats, # same as hlsUrl
"formatStreams": format_streams,
"captions": [], # not implemented
@@ -740,7 +749,8 @@ def ensure_comment_continuation(video_id: str, wdata = None):
if comment_continuation is not None:
ythdd_globals.general_cache["continuations"]["comments"][video_id].append(comment_continuation)
else:
print(f"error: couldn't extract comment continuation token from video page ({video_id})")
print(f"error: couldn't extract comment continuation token from video page ({video_id}). this video likely has comments disabled.")
ythdd_globals.general_cache["continuations"]["comments"][video_id].append("")
def channels(data, req, only_json: bool = False):
@@ -767,7 +777,7 @@ def channels(data, req, only_json: bool = False):
verified = False # to be replaced later with ythdd_extractor.isVerified(...)
author_name = safeTraverse(channel_meta, ["title"], default="Unknown Channel")
author_ucid = safeTraverse(channel_meta, ["externalId"], default="UNKNOWNCHANNELID")
author_ucid = safeTraverse(channel_meta, ["externalId"], default=data[3]) # prevent recursion with fallback to provided ucid
ythdd_globals.general_cache["continuations"]["channels"][author_ucid] = {
"avatar": avatar,

View File

@@ -98,6 +98,20 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
else:
avatar_url = safeTraverse(entry, ["videoRenderer", "avatar", "decoratedAvatarViewModel", "avatar", "avatarViewModel", "image", "sources", 0, "url"], default="unknown")
views_or_viewers_model = safeTraverse(entry, ["videoRenderer", "viewCountText"])
if "simpleText" in views_or_viewers_model:
# means this is a video with X views
view_count = parseViewsFromViewText(entry["videoRenderer"]["viewCountText"]["simpleText"])
view_count_text = entry["videoRenderer"]["viewCountText"]["simpleText"]
elif "runs" in views_or_viewers_model:
# means this is a livestream with X concurrent viewers
view_count = parseViewsFromViewText(entry["videoRenderer"]["viewCountText"]["runs"][0]["text"] + " watching")
view_count_text = entry["videoRenderer"]["viewCountText"]["runs"][0]["text"] + " watching"
else:
# unknown model, assume no views
view_count = 0
view_count_text = "Unknown amount of views"
return {
"type": "video",
"title": safeTraverse(entry, ["videoRenderer", "title", "runs", 0, "text"]),
@@ -110,8 +124,8 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
"videoThumbnails": genThumbs(safeTraverse(entry, ["videoRenderer", "videoId"], default="unknown")),
"description": description,
"descriptionHtml": description_html,
"viewCount": parseViewsFromViewText(safeTraverse(entry, ["videoRenderer", "viewCountText", "simpleText"], default="No views")),
"viewCountText": safeTraverse(entry, ["videoRenderer", "viewCountText", "simpleText"], default="Unknown amount of views"),
"viewCount": view_count,
"viewCountText": view_count_text,
"published": int(dateparser.parse(published_date).timestamp()), # sadly best we can do, invidious does this too
"publishedText": published_date,
"lengthSeconds": parseLengthFromTimeBadge(safeTraverse(entry, ["videoRenderer", "lengthText", "simpleText"], default="0:0")),