diff options
Diffstat (limited to 'youtube/channel.py')
| -rw-r--r-- | youtube/channel.py | 502 |
1 files changed, 422 insertions, 80 deletions
diff --git a/youtube/channel.py b/youtube/channel.py index 69092d3..8baf588 100644 --- a/youtube/channel.py +++ b/youtube/channel.py @@ -1,12 +1,12 @@ import base64 -from youtube import util, yt_data_extract, local_playlist, subscriptions +from youtube import (util, yt_data_extract, local_playlist, subscriptions, + playlist) from youtube import yt_app +import settings import urllib import json -from string import Template import youtube.proto as proto -import html import math import gevent import re @@ -31,13 +31,138 @@ headers_mobile = ( real_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=8XihrAcN1l4'),) generic_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=ST1Ti53r4fU'),) +# Sort values for YouTube API (from Invidious): 2=popular, 4=newest, 5=oldest +# include_shorts only applies to tab='videos'; tab='shorts'/'streams' always include their own content. +def channel_ctoken_v5(channel_id, page, sort, tab, view=1, include_shorts=True): + # Tab-specific protobuf field numbers (from Invidious source) + # Each tab uses different field numbers in the protobuf structure: + # videos: 110 -> 3 -> 15 -> { 2:{1:UUID}, 4:sort, 8:{1:UUID, 3:sort} } + # shorts: 110 -> 3 -> 10 -> { 2:{1:UUID}, 4:sort, 7:{1:UUID, 3:sort} } + # streams: 110 -> 3 -> 14 -> { 2:{1:UUID}, 5:sort, 8:{1:UUID, 3:sort} } + tab_config = { + 'videos': {'tab_field': 15, 'sort_field': 4, 'embedded_field': 8}, + 'shorts': {'tab_field': 10, 'sort_field': 4, 'embedded_field': 7}, + 'streams': {'tab_field': 14, 'sort_field': 5, 'embedded_field': 8}, + } + config = tab_config.get(tab, tab_config['videos']) + tab_field = config['tab_field'] + sort_field = config['sort_field'] + embedded_field = config['embedded_field'] + + # Map sort values to YouTube API values + if tab == 'streams': + sort_mapping = {'1': 14, '2': 13, '3': 12, '4': 12} + else: + sort_mapping = {'1': 2, '2': 5, '3': 4, '4': 4} + new_sort = sort_mapping.get(sort, sort_mapping['3']) + + # UUID placeholder (field 1) + uuid_str = "00000000-0000-0000-0000-000000000000" + + # Build the tab-level object matching Invidious structure exactly: + # { 2: embedded{1: UUID}, sort_field: sort_val, embedded_field: embedded{1: UUID, 3: sort_val} } + tab_content = ( + proto.string(2, proto.string(1, uuid_str)) + + proto.uint(sort_field, new_sort) + + proto.string(embedded_field, + proto.string(1, uuid_str) + proto.uint(3, new_sort)) + ) + + tab_wrapper = proto.string(tab_field, tab_content) + inner_container = proto.string(3, tab_wrapper) + outer_container = proto.string(110, inner_container) + + # Add shorts filter when include_shorts=False (field 104, same as playlist.py) + # This tells YouTube to exclude shorts from the results + if not include_shorts: + outer_container += proto.string(104, proto.uint(2, 1)) + + encoded_inner = proto.percent_b64encode(outer_container) + + pointless_nest = proto.string(80226972, + proto.string(2, channel_id) + + proto.string(3, encoded_inner) + ) + + return base64.urlsafe_b64encode(pointless_nest).decode('ascii') + + +def channel_about_ctoken(channel_id): + return proto.make_protobuf( + ('base64p', + [ + [2, 80226972, + [ + [2, 2, channel_id], + [2, 3, + ('base64p', + [ + [2, 110, + [ + [2, 3, + [ + [2, 19, + [ + [2, 1, b'66b0e9e9-0000-2820-9589-582429a83980'], + ] + ], + ] + ], + ] + ], + ] + ) + ], + ] + ], + ] + ) + ) + + +# https://github.com/user234683/youtube-local/issues/151 +def channel_ctoken_v4(channel_id, page, sort, tab, view=1): + new_sort = (2 if int(sort) == 1 else 1) + offset = str(30*(int(page) - 1)) + pointless_nest = proto.string(80226972, + proto.string(2, channel_id) + + proto.string(3, + proto.percent_b64encode( + proto.string(110, + proto.string(3, + proto.string(15, + proto.string(1, + proto.string(1, + proto.unpadded_b64encode( + proto.string(1, + proto.unpadded_b64encode( + proto.string(2, + b"ST:" + + proto.unpadded_b64encode( + proto.string(2, 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) + ) + ) + ) + ) + ) + ) + + return base64.urlsafe_b64encode(pointless_nest).decode('ascii') + # SORT: # videos: -# Popular - 1 -# Oldest - 2 -# Newest - 3 -# playlists: -# Oldest - 2 # Newest - 3 # Last video added - 4 @@ -75,15 +200,15 @@ def channel_ctoken_v2(channel_id, page, sort, tab, view=1): 2: 17254859483345278706, 1: 16570086088270825023, }[int(sort)] - page_token = proto.string(61, proto.unpadded_b64encode( - proto.string(1, proto.uint(1, schema_number) + proto.string( - 2, - proto.string(1, proto.unpadded_b64encode(proto.uint(1, offset))) - )))) + page_token = proto.string(61, proto.unpadded_b64encode(proto.string(1, + proto.uint(1, schema_number) + proto.string(2, + proto.string(1, proto.unpadded_b64encode(proto.uint(1,offset))) + ) + ))) tab = proto.string(2, tab) sort = proto.uint(3, int(sort)) - # page = proto.string(15, str(page) ) + #page = proto.string(15, str(page)) shelf_view = proto.uint(4, 0) view = proto.uint(6, int(view)) @@ -114,17 +239,21 @@ def channel_ctoken_v1(channel_id, page, sort, tab, view=1): def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1, - ctoken=None, print_status=True): + ctoken=None, print_status=True, include_shorts=True): message = 'Got channel tab' if print_status else None if not ctoken: - ctoken = channel_ctoken_v3(channel_id, page, sort, tab, view) + if tab in ('videos', 'shorts', 'streams'): + ctoken = channel_ctoken_v5(channel_id, page, sort, tab, view, include_shorts) + else: + ctoken = channel_ctoken_v3(channel_id, page, sort, tab, view) ctoken = ctoken.replace('=', '%3D') + # Not sure what the purpose of the key is or whether it will change # For now it seems to be constant for the API endpoint, not dependent # on the browsing session or channel key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' - url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key + url = f'https://www.youtube.com/youtubei/v1/browse?key={key}' data = { 'context': { @@ -132,7 +261,7 @@ def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1, 'hl': 'en', 'gl': 'US', 'clientName': 'WEB', - 'clientVersion': '2.20180830', + 'clientVersion': '2.20240327.00.00', }, }, 'continuation': ctoken, @@ -147,43 +276,59 @@ def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1, # cache entries expire after 30 minutes -@cachetools.func.ttl_cache(maxsize=128, ttl=30*60) +number_of_videos_cache = cachetools.TTLCache(128, 30*60) +# Cache for continuation tokens (shorts/streams pagination) +continuation_token_cache = cachetools.TTLCache(512, 15*60) +@cachetools.cached(number_of_videos_cache) def get_number_of_videos_channel(channel_id): if channel_id is None: return 1000 # Uploads playlist - playlist_id = 'UU' + channel_id[2:] - url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1' + playlist_id = f'UU{channel_id[2:]}' + url = f'https://m.youtube.com/playlist?list={playlist_id}&pbj=1' try: response = util.fetch_url(url, headers_mobile, debug_name='number_of_videos', report_text='Got number of videos') - except urllib.error.HTTPError as e: + except (urllib.error.HTTPError, util.FetchError): traceback.print_exc() print("Couldn't retrieve number of videos") return 1000 response = response.decode('utf-8') - # match = re.search(r'"numVideosText":\s*{\s*"runs":\s*\[{"text":\s*"([\d,]*) videos"', response) - match = re.search(r'"numVideosText".*?([,\d]+)', response) - if match: - return int(match.group(1).replace(',','')) - else: - return 0 + # Try several patterns since YouTube's format changes: + # "numVideosText":{"runs":[{"text":"1,234"},{"text":" videos"}]} + # "stats":[..., {"runs":[{"text":"1,234"},{"text":" videos"}]}] + for pattern in ( + r'"numVideosText".*?"text":\s*"([\d,]+)"', + r'"numVideosText".*?([\d,]+)\s*videos?', + r'"numVideosText".*?([,\d]+)', + r'([\d,]+)\s*videos?\s*</span>', + ): + match = re.search(pattern, response) + if match: + try: + return int(match.group(1).replace(',', '')) + except ValueError: + continue + # Fallback: unknown count + return 0 +def set_cached_number_of_videos(channel_id, num_videos): + @cachetools.cached(number_of_videos_cache) + def dummy_func_using_same_cache(channel_id): + return num_videos + dummy_func_using_same_cache(channel_id) channel_id_re = re.compile(r'videos\.xml\?channel_id=([a-zA-Z0-9_-]{24})"') - - @cachetools.func.lru_cache(maxsize=128) def get_channel_id(base_url): # method that gives the smallest possible response at ~4 kb # needs to be as fast as possible base_url = base_url.replace('https://www', 'https://m') # avoid redirect - response = util.fetch_url( - base_url + '/about?pbj=1', headers_mobile, + response = util.fetch_url(f'{base_url}/about?pbj=1', headers_mobile, debug_name='get_channel_id', report_text='Got channel id').decode('utf-8') match = channel_id_re.search(response) if match: @@ -191,6 +336,30 @@ def get_channel_id(base_url): return None +metadata_cache = cachetools.LRUCache(128) +@cachetools.cached(metadata_cache) +def get_metadata(channel_id): + # Use youtubei browse API to get channel metadata + polymer_json = util.call_youtube_api('web', 'browse', { + 'browseId': channel_id, + }) + info = yt_data_extract.extract_channel_info(json.loads(polymer_json), + 'about', + continuation=False) + return extract_metadata_for_caching(info) +def set_cached_metadata(channel_id, metadata): + @cachetools.cached(metadata_cache) + def dummy_func_using_same_cache(channel_id): + return metadata + dummy_func_using_same_cache(channel_id) +def extract_metadata_for_caching(channel_info): + metadata = {} + for key in ('approx_subscriber_count', 'short_description', 'channel_name', + 'avatar'): + metadata[key] = channel_info[key] + return metadata + + def get_number_of_videos_general(base_url): return get_number_of_videos_channel(get_channel_id(base_url)) @@ -203,7 +372,7 @@ def get_channel_search_json(channel_id, query, page): ctoken = base64.urlsafe_b64encode(proto.nested(80226972, ctoken)).decode('ascii') key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' - url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key + url = f'https://www.youtube.com/youtubei/v1/browse?key={key}' data = { 'context': { @@ -211,7 +380,7 @@ def get_channel_search_json(channel_id, query, page): 'hl': 'en', 'gl': 'US', 'clientName': 'WEB', - 'clientVersion': '2.20180830', + 'clientVersion': '2.20240327.00.00', }, }, 'continuation': ctoken, @@ -229,15 +398,34 @@ 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']: + # Only set thumbnail if YouTube didn't provide one + if not item.get('thumbnail'): + if item.get('type') == 'playlist' and item.get('first_video_id'): + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['first_video_id']) + elif item.get('type') == 'video' and item.get('id'): + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id']) util.prefix_urls(item) util.add_extra_html_info(item) + if info['current_tab'] == 'about': + for i, (text, url) in enumerate(info['links']): + if isinstance(url, str) and util.YOUTUBE_URL_RE.fullmatch(url): + info['links'][i] = (text, util.prefix_url(url)) -def get_channel_first_page(base_url=None, 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 + '/videos?pbj=1&view=0', headers_desktop, - debug_name='gen_channel_videos') + base_url = f'https://www.youtube.com/channel/{channel_id}' + + # 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 = f'{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 += f'&sort={sort_map.get(sort, "dd")}' + + return util.fetch_url(url, headers_desktop, debug_name=f'gen_channel_{tab}') playlist_sort_codes = {'2': "da", '3': "dd", '4': "lad"} @@ -246,63 +434,216 @@ playlist_sort_codes = {'2': "da", '3': "dd", '4': "lad"} # youtube.com/user/[username]/[tab] # youtube.com/c/[custom]/[tab] # youtube.com/[custom]/[tab] - - def get_channel_page_general_url(base_url, tab, request, channel_id=None): page_number = int(request.args.get('page', 1)) - sort = request.args.get('sort', '3') + # sort 1: views + # sort 2: oldest + # sort 3: newest (includes shorts, via UU uploads playlist) + # sort 4: newest - no shorts (uses channel Videos tab API directly, like Invidious) + default_sort = '3' if settings.include_shorts_in_channel else '4' + sort = request.args.get('sort', default_sort) view = request.args.get('view', '1') query = request.args.get('query', '') ctoken = request.args.get('ctoken', '') - default_params = (page_number == 1 and sort == '3' and view == '1') - - if tab == 'videos' and channel_id and not default_params: - tasks = ( - gevent.spawn(get_number_of_videos_channel, channel_id), - gevent.spawn(get_channel_tab, channel_id, page_number, sort, - 'videos', view, ctoken) - ) - gevent.joinall(tasks) - util.check_gevent_exceptions(*tasks) - number_of_videos, polymer_json = tasks[0].value, tasks[1].value - elif tab == 'videos': - if channel_id: - num_videos_call = (get_number_of_videos_channel, channel_id) + default_params = (page_number == 1 and sort in ('3', '4') and view == '1') + continuation = bool(ctoken) + page_size = 30 + polymer_json = None + number_of_videos = 0 + info = None + + # ------------------------------------------------------------------------- + # sort=3: use UU uploads playlist (includes shorts) + # ------------------------------------------------------------------------- + if tab == 'videos' and sort == '3': + if not channel_id: + channel_id = get_channel_id(base_url) + if page_number == 1: + tasks = ( + gevent.spawn(playlist.playlist_first_page, + f'UU{channel_id[2:]}', + report_text='Retrieved channel videos'), + gevent.spawn(get_metadata, channel_id), + ) + gevent.joinall(tasks) + util.check_gevent_exceptions(*tasks) + pl_json = tasks[0].value + pl_info = yt_data_extract.extract_playlist_info(pl_json) + number_of_videos = pl_info['metadata']['video_count'] + if number_of_videos is None: + number_of_videos = 1000 + else: + set_cached_number_of_videos(channel_id, number_of_videos) else: - num_videos_call = (get_number_of_videos_general, base_url) - tasks = ( - gevent.spawn(*num_videos_call), - gevent.spawn(get_channel_first_page, base_url=base_url), - ) - gevent.joinall(tasks) - util.check_gevent_exceptions(*tasks) - number_of_videos, polymer_json = tasks[0].value, tasks[1].value + tasks = ( + gevent.spawn(playlist.get_videos, f'UU{channel_id[2:]}', + page_number, include_shorts=True), + gevent.spawn(get_metadata, channel_id), + gevent.spawn(get_number_of_videos_channel, channel_id), + gevent.spawn(playlist.playlist_first_page, f'UU{channel_id[2:]}', + report_text='Retrieved channel video count'), + ) + gevent.joinall(tasks) + util.check_gevent_exceptions(*tasks) + pl_json = tasks[0].value + pl_info = yt_data_extract.extract_playlist_info(pl_json) + first_page_meta = yt_data_extract.extract_playlist_metadata(tasks[3].value) + number_of_videos = (tasks[2].value + or first_page_meta.get('video_count') + or 0) + + if pl_info['items']: + info = pl_info + info['channel_id'] = channel_id + info['current_tab'] = 'videos' + page_size = 100 + # else fall through to the channel browse API below + + # ------------------------------------------------------------------------- + # Channel browse API: sort=4 (videos tab, no shorts), shorts, streams, + # or fallback when the UU playlist returned no items. + # Uses channel_ctoken_v5 per-tab tokens, mirroring Invidious's approach. + # Pagination is driven by the continuation token YouTube returns each page. + # ------------------------------------------------------------------------- + used_channel_api = False + if info is None and ( + tab in ('shorts', 'streams') + or (tab == 'videos' and sort == '4') + or (tab == 'videos' and sort == '3') # UU-playlist fallback + ): + if not channel_id: + channel_id = get_channel_id(base_url) + used_channel_api = True + + # Determine what browse call to make + if ctoken: + browse_call = (util.call_youtube_api, 'web', 'browse', + {'continuation': ctoken}) + continuation = True + elif page_number > 1: + cache_key = (channel_id, tab, sort, page_number - 1) + cached_ctoken = continuation_token_cache.get(cache_key) + if cached_ctoken: + browse_call = (util.call_youtube_api, 'web', 'browse', + {'continuation': cached_ctoken}) + else: + # Cache miss — restart from page 1 (better than an error) + browse_call = (get_channel_tab, channel_id, '1', sort, tab, int(view)) + continuation = True + else: + browse_call = (get_channel_tab, channel_id, '1', sort, tab, int(view)) + continuation = True + + # Single browse call; number_of_videos is computed from items actually + # fetched so we don't mislead the user with a total that includes + # shorts (which this branch is explicitly excluding for sort=4). + task = gevent.spawn(*browse_call) + task.join() + util.check_gevent_exceptions(task) + polymer_json = task.value + elif tab == 'about': - polymer_json = util.fetch_url(base_url + '/about?pbj=1', headers_desktop, debug_name='gen_channel_about') + # polymer_json = util.fetch_url(base_url + '/about?pbj=1', headers_desktop, debug_name='gen_channel_about') + channel_id = get_channel_id(base_url) + ctoken = channel_about_ctoken(channel_id) + polymer_json = util.call_youtube_api('web', 'browse', { + 'continuation': ctoken, + }) + 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) + continuation = True elif tab == 'search' and channel_id: polymer_json = get_channel_search_json(channel_id, query, page_number) elif tab == 'search': - url = base_url + '/search?pbj=1&query=' + urllib.parse.quote(query, safe='') + url = f'{base_url}/search?pbj=1&query={urllib.parse.quote(query, safe="")}' polymer_json = util.fetch_url(url, headers_desktop, debug_name='gen_channel_search') - else: - flask.abort(404, 'Unknown channel tab: ' + tab) + elif tab != 'videos': + flask.abort(404, f'Unknown channel tab: {tab}') + + if polymer_json is not None and info is None: + info = yt_data_extract.extract_channel_info( + json.loads(polymer_json), tab, continuation=continuation + ) - info = yt_data_extract.extract_channel_info(json.loads(polymer_json), tab) + if info is None: + return flask.render_template('error.html', error_message='Could not retrieve channel data') if info['error'] is not None: return flask.render_template('error.html', error_message=info['error']) - post_process_channel_info(info) - if tab == 'videos': + if channel_id: + info['channel_url'] = f'https://www.youtube.com/channel/{channel_id}' + info['channel_id'] = channel_id + else: + channel_id = info['channel_id'] + + # Will have microformat present, cache metadata while we have it + if (channel_id and default_params and tab not in ('videos', 'about') + and info.get('channel_name') is not None): + metadata = extract_metadata_for_caching(info) + set_cached_metadata(channel_id, metadata) + # Otherwise, populate with our (hopefully cached) metadata + elif channel_id and info.get('channel_name') is None: + metadata = get_metadata(channel_id) + for key, value in metadata.items(): + yt_data_extract.conservative_update(info, key, value) + # need to add this metadata to the videos/playlists + additional_info = { + 'author': info['channel_name'], + 'author_id': info['channel_id'], + 'author_url': info['channel_url'], + } + for item in info['items']: + item.update(additional_info) + + if tab in ('videos', 'shorts', 'streams'): + # For any tab using the channel browse API (sort=4, shorts, streams), + # pagination is driven by the ctoken YouTube returns in the response. + # Cache it so the next page request can use it. + if info.get('ctoken'): + cache_key = (channel_id, tab, sort, page_number) + continuation_token_cache[cache_key] = info['ctoken'] + + # Determine is_last_page and final number_of_pages. + # For channel-API-driven tabs (sort=4, shorts, streams, UU fallback), + # YouTube doesn't give us a reliable total filtered count. So instead + # of displaying a misleading number (the total-including-shorts from + # get_number_of_videos_channel), we count only what we've actually + # paged through, and use the ctoken to know whether to show "next". + if used_channel_api: + info['is_last_page'] = (info.get('ctoken') is None) + items_on_page = len(info.get('items', [])) + items_seen_so_far = (page_number - 1) * page_size + items_on_page + + # Use accumulated count as the displayed total so "N videos" shown + # to the user always matches what they could actually reach. + number_of_videos = items_seen_so_far + + # If there's more content, bump by 1 so the Next-page button exists + if info.get('ctoken'): + number_of_videos = max(number_of_videos, + page_number * page_size + 1) + # For sort=3 via UU playlist (used_channel_api=False), number_of_videos + # was already set from playlist metadata above. + info['number_of_videos'] = number_of_videos - info['number_of_pages'] = math.ceil(number_of_videos/30) + info['number_of_pages'] = math.ceil(number_of_videos / page_size) if number_of_videos else 1 + # Never show fewer pages than the page the user is actually on + if info['number_of_pages'] < page_number: + info['number_of_pages'] = page_number info['header_playlist_names'] = local_playlist.get_playlist_names() - if tab in ('videos', 'playlists'): + if tab in ('videos', 'shorts', 'streams', 'playlists'): info['current_sort'] = sort elif tab == 'search': info['search_box_value'] = query @@ -311,9 +652,10 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None): info['page_number'] = page_number info['subscribed'] = subscriptions.is_subscribed(info['channel_id']) - return flask.render_template( - 'channel.html', - parameters_dictionary=request.args, + post_process_channel_info(info) + + return flask.render_template('channel.html', + parameters_dictionary = request.args, **info ) @@ -321,22 +663,22 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None): @yt_app.route('/channel/<channel_id>/') @yt_app.route('/channel/<channel_id>/<tab>') def get_channel_page(channel_id, tab='videos'): - return get_channel_page_general_url('https://www.youtube.com/channel/' + channel_id, tab, request, channel_id) + return get_channel_page_general_url(f'https://www.youtube.com/channel/{channel_id}', tab, request, channel_id) @yt_app.route('/user/<username>/') @yt_app.route('/user/<username>/<tab>') def get_user_page(username, tab='videos'): - return get_channel_page_general_url('https://www.youtube.com/user/' + username, tab, request) + return get_channel_page_general_url(f'https://www.youtube.com/user/{username}', tab, request) @yt_app.route('/c/<custom>/') @yt_app.route('/c/<custom>/<tab>') def get_custom_c_page(custom, tab='videos'): - return get_channel_page_general_url('https://www.youtube.com/c/' + custom, tab, request) + return get_channel_page_general_url(f'https://www.youtube.com/c/{custom}', tab, request) @yt_app.route('/<custom>') @yt_app.route('/<custom>/<tab>') def get_toplevel_custom_page(custom, tab='videos'): - return get_channel_page_general_url('https://www.youtube.com/' + custom, tab, request) + return get_channel_page_general_url(f'https://www.youtube.com/{custom}', tab, request) |
