diff options
Diffstat (limited to 'youtube/channel.py')
| -rw-r--r-- | youtube/channel.py | 163 |
1 files changed, 99 insertions, 64 deletions
diff --git a/youtube/channel.py b/youtube/channel.py index 81881eb..72fac07 100644 --- a/youtube/channel.py +++ b/youtube/channel.py @@ -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) |
