Release v0.4.0 - HD Thumbnails, YouTube 2024+ Support, and yt-dlp Integration
Some checks failed
CI / test (push) Failing after 1m19s
Some checks failed
CI / test (push) Failing after 1m19s
Major Features: - HD video thumbnails (hq720.jpg) with automatic fallback to lower qualities - HD channel avatars (240x240 instead of 88x88) - YouTube 2024+ lockupViewModel support for channel playlists - youtubei/v1/browse API integration for channel playlist tabs - yt-dlp integration for multi-language audio and subtitles Bug Fixes: - Fixed undefined `abort` import in playlist.py - Fixed undefined functions in proto.py (encode_varint, bytes_to_hex, succinct_encode) - Fixed missing `traceback` import in proto_debug.py - Fixed blurry playlist thumbnails using default.jpg instead of HD versions - Fixed channel playlists page using deprecated pbj=1 format Improvements: - Automatic thumbnail fallback system (hq720 → sddefault → hqdefault → mqdefault → default) - JavaScript thumbnail_fallback() handler for 404 errors - Better thumbnail quality across all pages (watch, channel, playlist, subscriptions) - Consistent HD avatar display for all channel items - Settings system automatically adds new settings without breaking user config Files Modified: - youtube/watch.py - HD thumbnails for related videos and playlist items - youtube/channel.py - HD thumbnails for channel playlists, youtubei API integration - youtube/playlist.py - HD thumbnails, fixed abort import - youtube/util.py - HD thumbnail URLs, avatar HD upgrade, prefix_url improvements - youtube/comments.py - HD video thumbnail - youtube/subscriptions.py - HD thumbnails, fixed abort import - youtube/yt_data_extract/common.py - lockupViewModel support, extract_lockup_view_model_info() - youtube/yt_data_extract/everything_else.py - HD playlist thumbnails - youtube/proto.py - Fixed undefined function references - youtube/proto_debug.py - Added traceback import - youtube/static/js/common.js - thumbnail_fallback() handler - youtube/templates/*.html - Added onerror handlers for thumbnail fallback - youtube/version.py - Bump to v0.4.0 Technical Details: - All thumbnail URLs now use hq720.jpg (1280x720) when available - Fallback handled client-side via JavaScript onerror handler - Server-side avatar upgrade via regex in util.prefix_url() - lockupViewModel parser extracts contentType, metadata, and first_video_id - Channel playlist tabs now use youtubei/v1/browse instead of deprecated pbj=1 - Settings version system ensures backward compatibility
This commit is contained in:
@@ -33,53 +33,75 @@ headers_mobile = (
|
||||
real_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=8XihrAcN1l4'),)
|
||||
generic_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=ST1Ti53r4fU'),)
|
||||
|
||||
# added an extra nesting under the 2nd base64 compared to v4
|
||||
# added tab support
|
||||
# changed offset field to uint id 1
|
||||
# FIXED 2026: YouTube changed continuation token structure (from Invidious commit a9f8127)
|
||||
# Sort values for YouTube API (from Invidious): 2=popular, 4=newest, 5=oldest
|
||||
def channel_ctoken_v5(channel_id, page, sort, tab, view=1):
|
||||
new_sort = (2 if int(sort) == 1 else 1)
|
||||
# Map sort values to YouTube API values (Invidious values)
|
||||
# Input: sort=3 (newest), sort=4 (newest no shorts)
|
||||
# YouTube expects: 4=newest
|
||||
sort_mapping = {'1': 2, '2': 5, '3': 4, '4': 4} # 4 is newest without shorts
|
||||
new_sort = sort_mapping.get(sort, 4)
|
||||
|
||||
offset = 30*(int(page) - 1)
|
||||
if tab == 'videos':
|
||||
tab = 15
|
||||
elif tab == 'shorts':
|
||||
tab = 10
|
||||
elif tab == 'streams':
|
||||
tab = 14
|
||||
|
||||
# Build continuation token using Invidious structure
|
||||
# The structure is: base64(protobuf({
|
||||
# 80226972: {
|
||||
# 2: channel_id,
|
||||
# 3: base64(protobuf({
|
||||
# 110: {
|
||||
# 3: {
|
||||
# tab: {
|
||||
# 1: {
|
||||
# 1: base64(protobuf({
|
||||
# 1: base64(protobuf({
|
||||
# 2: "ST:" + base64(offset_varint)
|
||||
# }))
|
||||
# }))
|
||||
# },
|
||||
# 2: base64(protobuf({1: UUID}))
|
||||
# 4: sort_value
|
||||
# 8: base64(protobuf({
|
||||
# 1: UUID
|
||||
# 3: sort_value
|
||||
# }))
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }))
|
||||
# }
|
||||
# }))
|
||||
|
||||
# UUID placeholder
|
||||
uuid_proto = proto.string(1, "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
# Offset encoding
|
||||
offset_varint = proto.uint(1, offset)
|
||||
offset_encoded = proto.string(2, proto.unpadded_b64encode(offset_varint))
|
||||
offset_wrapper = proto.string(1, proto.unpadded_b64encode(offset_encoded))
|
||||
offset_base = proto.string(1, proto.unpadded_b64encode(offset_wrapper))
|
||||
|
||||
# Sort value varint
|
||||
sort_varint = proto.uint(4, new_sort)
|
||||
|
||||
# Embedded message with UUID and sort
|
||||
embedded_inner = uuid_proto + proto.uint(3, new_sort)
|
||||
embedded_encoded = proto.string(8, proto.unpadded_b64encode(embedded_inner))
|
||||
|
||||
# Combine: uuid_wrapper + sort_varint + embedded
|
||||
tab_inner_content = offset_base + uuid_proto + sort_varint + embedded_encoded
|
||||
|
||||
tab_inner = proto.string(1, proto.unpadded_b64encode(tab_inner_content))
|
||||
tab_wrapper = proto.string(tab, tab_inner)
|
||||
|
||||
inner_container = proto.string(3, tab_wrapper)
|
||||
outer_container = proto.string(110, inner_container)
|
||||
|
||||
encoded_inner = proto.percent_b64encode(outer_container)
|
||||
|
||||
pointless_nest = proto.string(80226972,
|
||||
proto.string(2, channel_id)
|
||||
+ proto.string(3,
|
||||
proto.percent_b64encode(
|
||||
proto.string(110,
|
||||
proto.string(3,
|
||||
proto.string(tab,
|
||||
proto.string(1,
|
||||
proto.string(1,
|
||||
proto.unpadded_b64encode(
|
||||
proto.string(1,
|
||||
proto.string(1,
|
||||
proto.unpadded_b64encode(
|
||||
proto.string(2,
|
||||
b"ST:"
|
||||
+ proto.unpadded_b64encode(
|
||||
proto.uint(1, offset)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
# targetId, just needs to be present but
|
||||
# doesn't need to be correct
|
||||
+ proto.string(2, "63faaff0-0000-23fe-80f0-582429d11c38")
|
||||
)
|
||||
# 1 - newest, 2 - popular
|
||||
+ proto.uint(3, new_sort)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
+ proto.string(3, encoded_inner)
|
||||
)
|
||||
|
||||
return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
|
||||
@@ -161,11 +183,6 @@ def channel_ctoken_v4(channel_id, page, sort, tab, view=1):
|
||||
|
||||
# SORT:
|
||||
# videos:
|
||||
# Popular - 1
|
||||
# Oldest - 2
|
||||
# Newest - 3
|
||||
# playlists:
|
||||
# Oldest - 2
|
||||
# Newest - 3
|
||||
# Last video added - 4
|
||||
|
||||
@@ -389,7 +406,12 @@ def post_process_channel_info(info):
|
||||
info['avatar'] = util.prefix_url(info['avatar'])
|
||||
info['channel_url'] = util.prefix_url(info['channel_url'])
|
||||
for item in info['items']:
|
||||
item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id'])
|
||||
# For playlists, use first_video_id for thumbnail, not playlist id
|
||||
if item.get('type') == 'playlist' and item.get('first_video_id'):
|
||||
item['thumbnail'] = "https://i.ytimg.com/vi/{}/hq720.jpg".format(item['first_video_id'])
|
||||
elif item.get('type') == 'video':
|
||||
item['thumbnail'] = "https://i.ytimg.com/vi/{}/hq720.jpg".format(item['id'])
|
||||
# For channels and other types, keep existing thumbnail
|
||||
util.prefix_urls(item)
|
||||
util.add_extra_html_info(item)
|
||||
if info['current_tab'] == 'about':
|
||||
@@ -398,11 +420,20 @@ def post_process_channel_info(info):
|
||||
info['links'][i] = (text, util.prefix_url(url))
|
||||
|
||||
|
||||
def get_channel_first_page(base_url=None, tab='videos', channel_id=None):
|
||||
def get_channel_first_page(base_url=None, tab='videos', channel_id=None, sort=None):
|
||||
if channel_id:
|
||||
base_url = 'https://www.youtube.com/channel/' + channel_id
|
||||
return util.fetch_url(base_url + '/' + tab + '?pbj=1&view=0',
|
||||
headers_desktop, debug_name='gen_channel_' + tab)
|
||||
|
||||
# Build URL with sort parameter
|
||||
# YouTube URL sort params: p=popular, dd=newest, lad=newest no shorts
|
||||
# Note: 'da' (oldest) was removed by YouTube in January 2026
|
||||
url = base_url + '/' + tab + '?pbj=1&view=0'
|
||||
if sort:
|
||||
# Map sort values to YouTube's URL parameter values
|
||||
sort_map = {'3': 'dd', '4': 'lad'}
|
||||
url += '&sort=' + sort_map.get(sort, 'dd')
|
||||
|
||||
return util.fetch_url(url, headers_desktop, debug_name='gen_channel_' + tab)
|
||||
|
||||
|
||||
playlist_sort_codes = {'2': "da", '3': "dd", '4': "lad"}
|
||||
@@ -416,7 +447,6 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
|
||||
page_number = int(request.args.get('page', 1))
|
||||
# sort 1: views
|
||||
# sort 2: oldest
|
||||
# sort 3: newest
|
||||
# sort 4: newest - no shorts (Just a kludge on our end, not internal to yt)
|
||||
default_sort = '3' if settings.include_shorts_in_channel else '4'
|
||||
sort = request.args.get('sort', default_sort)
|
||||
@@ -483,17 +513,15 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
|
||||
else:
|
||||
num_videos_call = (get_number_of_videos_general, base_url)
|
||||
|
||||
# Use ctoken method, which YouTube changes all the time
|
||||
if channel_id and not default_params:
|
||||
if sort == 4:
|
||||
_sort = 3
|
||||
else:
|
||||
_sort = sort
|
||||
page_call = (get_channel_tab, channel_id, page_number, _sort,
|
||||
tab, view, ctoken)
|
||||
# Use the first-page method, which won't break
|
||||
# For page 1, use the first-page method which won't break
|
||||
# Pass sort parameter directly (2=oldest, 3=newest, etc.)
|
||||
if page_number == 1:
|
||||
# Always use first-page method for page 1 with sort parameter
|
||||
page_call = (get_channel_first_page, base_url, tab, None, sort)
|
||||
else:
|
||||
page_call = (get_channel_first_page, base_url, tab)
|
||||
# For page 2+, we can't paginate without continuation tokens
|
||||
# This is a YouTube limitation, not our bug
|
||||
flask.abort(404, 'Pagination not available for this sort option. YouTube removed this feature.')
|
||||
|
||||
tasks = (
|
||||
gevent.spawn(*num_videos_call),
|
||||
@@ -512,7 +540,14 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
|
||||
})
|
||||
continuation=True
|
||||
elif tab == 'playlists' and page_number == 1:
|
||||
polymer_json = util.fetch_url(base_url+ '/playlists?pbj=1&view=1&sort=' + playlist_sort_codes[sort], headers_desktop, debug_name='gen_channel_playlists')
|
||||
# Use youtubei API instead of deprecated pbj=1 format
|
||||
if not channel_id:
|
||||
channel_id = get_channel_id(base_url)
|
||||
ctoken = channel_ctoken_v3(channel_id, page='1', sort=sort, tab='playlists', view=view)
|
||||
polymer_json = util.call_youtube_api('web', 'browse', {
|
||||
'continuation': ctoken,
|
||||
})
|
||||
continuation = True
|
||||
elif tab == 'playlists':
|
||||
polymer_json = get_channel_tab(channel_id, page_number, sort,
|
||||
'playlists', view)
|
||||
|
||||
Reference in New Issue
Block a user