aboutsummaryrefslogtreecommitdiffstats
path: root/youtube/channel.py
diff options
context:
space:
mode:
Diffstat (limited to 'youtube/channel.py')
-rw-r--r--youtube/channel.py163
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)