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/" 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 = requests.get(prefix + received_request, headers=ythdd_globals.getHeaders(caller='proxy'), stream=True)
ggpht.raw.decode_content = True ggpht.raw.decode_content = True
response = Response(ggpht.raw, mimetype=ggpht.headers['content-type'], status=ggpht.status_code) 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. # Generates channel avatars at default sizes.
# avatar urls for channels in search results start with //yt3.ggpht.com/ # avatar urls for channels in search results start with //yt3.ggpht.com/
if url.startswith("//yt3.ggpht.com/"): if url.startswith("//"):
url = url.replace("//yt3.ggpht.com/", "https://yt3.ggpht.com/") url = "https:" + url
avatars = [] avatars = []
if not url.startswith("https://yt3.ggpht.com/") and not url.startswith("https://yt3.googleusercontent.com/"): 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=""): match safeTraverse(list(response_json.keys()), [0], default=""):
case "metadataBadgeRenderer": # channels in search results 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 verified
return False return False

View File

@@ -23,13 +23,16 @@ import ythdd_struct_parser
# [✓] /vi/videoIdXXXX/maxresdefault.jpg (todo: add a fallback for 404s) # [✓] /vi/videoIdXXXX/maxresdefault.jpg (todo: add a fallback for 404s)
# [✓] /api/v1/search?q=... (videos and playlists) # [✓] /api/v1/search?q=... (videos and playlists)
# [✓] /api/v1/search/suggestions?q=...&pq=... # [✓] /api/v1/search/suggestions?q=...&pq=...
# [✓] /api/v1/channels/id # [✓] /api/v1/channels/:ucid
# [✓] /api/v1/channels/videos, shorts, playlists # [✓] /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/playlists/:plid
# [X] /api/v1/storyboards/:videoIdXXXX
# [*] /api/v1/auth/subscriptions (stub? db?) # [*] /api/v1/auth/subscriptions (stub? db?)
# [*] /api/v1/auth/feed?page=1 (stub? db?) # [*] /api/v1/auth/feed?page=1 (stub? db?)
# [*] /api/v1/auth/playlists (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" DEFAULT_AVATAR = "https://yt3.ggpht.com/a/default-user=s176-c-k-c0x00ffffff-no-rj"
@@ -434,10 +437,16 @@ def videos(data):
format_streams = [] format_streams = []
# adaptive_formats, format_streams = rebuildFormats(adaptive_formats) # 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: if live_now:
video_type = "livestream" video_type = "livestream"
premiere_timestamp = published # ??? that works i guess
elif premiere_timestamp: elif premiere_timestamp:
video_type = "scheduled" video_type = "scheduled"
published = dateToEpoch(premiere_timestamp) if premiere_timestamp else int(time()) published = dateToEpoch(premiere_timestamp) if premiere_timestamp else int(time())
@@ -493,7 +502,7 @@ def videos(data):
"isUpcoming": is_upcoming, "isUpcoming": is_upcoming,
"dashUrl": ythdd_globals.config['general']['public_facing_url'] + "api/invidious/api/v1/manifest/" + video_id, # not implemented "dashUrl": ythdd_globals.config['general']['public_facing_url'] + "api/invidious/api/v1/manifest/" + video_id, # not implemented
"premiereTimestamp": premiere_timestamp, "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 "adaptiveFormats": adaptive_formats, # same as hlsUrl
"formatStreams": format_streams, "formatStreams": format_streams,
"captions": [], # not implemented "captions": [], # not implemented
@@ -740,7 +749,8 @@ def ensure_comment_continuation(video_id: str, wdata = None):
if comment_continuation is not None: if comment_continuation is not None:
ythdd_globals.general_cache["continuations"]["comments"][video_id].append(comment_continuation) ythdd_globals.general_cache["continuations"]["comments"][video_id].append(comment_continuation)
else: 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): 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(...) verified = False # to be replaced later with ythdd_extractor.isVerified(...)
author_name = safeTraverse(channel_meta, ["title"], default="Unknown Channel") 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] = { ythdd_globals.general_cache["continuations"]["channels"][author_ucid] = {
"avatar": avatar, "avatar": avatar,

View File

@@ -98,6 +98,20 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
else: else:
avatar_url = safeTraverse(entry, ["videoRenderer", "avatar", "decoratedAvatarViewModel", "avatar", "avatarViewModel", "image", "sources", 0, "url"], default="unknown") 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 { return {
"type": "video", "type": "video",
"title": safeTraverse(entry, ["videoRenderer", "title", "runs", 0, "text"]), "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")), "videoThumbnails": genThumbs(safeTraverse(entry, ["videoRenderer", "videoId"], default="unknown")),
"description": description, "description": description,
"descriptionHtml": description_html, "descriptionHtml": description_html,
"viewCount": parseViewsFromViewText(safeTraverse(entry, ["videoRenderer", "viewCountText", "simpleText"], default="No views")), "viewCount": view_count,
"viewCountText": safeTraverse(entry, ["videoRenderer", "viewCountText", "simpleText"], default="Unknown amount of views"), "viewCountText": view_count_text,
"published": int(dateparser.parse(published_date).timestamp()), # sadly best we can do, invidious does this too "published": int(dateparser.parse(published_date).timestamp()), # sadly best we can do, invidious does this too
"publishedText": published_date, "publishedText": published_date,
"lengthSeconds": parseLengthFromTimeBadge(safeTraverse(entry, ["videoRenderer", "lengthText", "simpleText"], default="0:0")), "lengthSeconds": parseLengthFromTimeBadge(safeTraverse(entry, ["videoRenderer", "lengthText", "simpleText"], default="0:0")),