import base64 from dataclasses import dataclass from typing import Optional from pure_protobuf.dataclasses_ import field, message from pure_protobuf.types import int64 def b64e(b, padding=True): return base64.urlsafe_b64encode(b).decode('ascii') \ .replace("=", "%3D" if padding else "") # SEARCH {{{ @message @dataclass class Extras: verbatim: Optional[bool] = field(1, default=None) # don't fix spelling @message @dataclass class Filters: # adapted from invidious date: Optional[int64] = field(1, default=None) type: Optional[int64] = field(2, default=None) length: Optional[int64] = field(3, default=None) is_hd: Optional[bool] = field(4, default=None) subtitles: Optional[bool] = field(5, default=None) ccommons: Optional[bool] = field(6, default=None) is_3d: Optional[bool] = field(7, default=None) live: Optional[bool] = field(8, default=None) purchased: Optional[bool] = field(9, default=None) is_4k: Optional[bool] = field(14, default=None) is_360: Optional[bool] = field(15, default=None) location: Optional[bool] = field(23, default=None) is_hdr: Optional[bool] = field(25, default=None) @message @dataclass class SearchRequest: sorted: Optional[int64] = field(1, default=None) filter: Optional[Filters] = field(2, default=None) extras: Optional[Extras] = field(8, default=None) def make_sp(sort=None, date=None, type=None, len=None, features=[], extras=[]): sortorder = dict(relevance=0, rating=1, date=2, views=3) datefilter = dict(hour=1, day=2, week=3, month=4, year=5) typefilter = dict(video=1, channel=2, playlist=3, movie=4, show=5) lenfilter = dict(short=1, long=2) return b64e(SearchRequest( sorted=sortorder.get(sort), filter=Filters( date=datefilter.get(date), type=typefilter.get(type), length=lenfilter.get(len), **{f:True for f in features}, ) if date or type or len or features else None, extras=Extras(**{f:True for f in extras}), ).dumps()) # }}} SEARCH # CHANNEL v1/v3 {{{ @message @dataclass class SearchOffset: offset: int64 = field(3, default=0) @message @dataclass class ChannelDataInner: offset: int64 = field(1) @message @dataclass class ChannelDataContainer: data: str = field(1) # base64 of ChannelDataInner @message @dataclass class NewChannelUserInfoInner: field1: int = field(1, default=0) @message @dataclass class NewChannelUserInfo: offset: int = field(7) field2: NewChannelUserInfoInner = field(2, default=NewChannelUserInfoInner()) # otherwise page>=2 fails field10: int = field(10, default=0) # otherwise 'newest' fails # youtube sets those, but works without (values from invidious, yt's are dynamic): #field5: int = field(5, default=50) #field6: int = field(6, default=1) #field9: int = field(9, default=1) @message @dataclass class NewChannelDataUser: data: str = field(1) # b64 uuid: str = field(2) @message @dataclass class NewChannelDataSort: user: NewChannelDataUser = field(1) sort: int64 = field(3, default=1) # newest @message @dataclass class NewChannelDataInner: data: NewChannelDataSort = field(15) @message @dataclass class NewChannelDataContainer: data: NewChannelDataInner = field(3) @message @dataclass class NewChannelDataContainerOuter: data: NewChannelDataContainer = field(110) @message @dataclass class Subparams: type_s: str = field(2) type_i: Optional[int64] = field(4) page: Optional[str] = field(15) sort: Optional[int64] = field(3, default=None) unknown_const1: int64 = field(7, default=1) unknown_const2: int64 = field(23, default=0) # usually returns gridResponses. to switch to listResponses (cargo-culting # invidious, playlist continuations (not yet supported) require list): list_or_grid: Optional[int64] = field(6,default=2) # 2=list, None/1=grid # invidious sets those, but no idea why: #field12:int64 = field(12,default=1) #field13:str = field(13,default="") # playlists in list mode don't work without this field61: Optional[str] = field(61, default=None) # base64 channelData @message @dataclass class Params: subject: str = field(2) # ucid/plid params: str = field(3) # b64e encoded query: Optional[str] = field(11, default=None) # channel search # defined by invidious, but not sent by youtube with NewChannel* structs: #param35: Optional[str] = field(35, default=None) # "browse-feed#{ucid}videos102" @message @dataclass class Continuation: params: Params = field(80226972) def make_channel_params(subject, typ="videos", page=1, sort=None, query=None, v3=False): typestr = dict(videos="videos", playlists="playlists", search="search") typeint = dict(videos=0, playlists=1, search=None) # not supporting autogen'd sortorder = dict(newest=1, popular=2) if typ == "playlists": sortorder = dict(newest=3, modified=4) elif typ == "search": sortorder = dict() elif typ == "videos": # XXX: should be better integrated return b64e(Continuation( params=Params( subject=subject, params=b64e(NewChannelDataContainerOuter( data=NewChannelDataContainer( data=NewChannelDataInner( data=NewChannelDataSort( user=NewChannelDataUser( data=b64e(NewChannelUserInfo( offset= page*30, ).dumps(), padding=False), uuid="00000000-0000-0000-0000-000000000000" ), sort=sortorder.get(sort, 1) ) ) ) ).dumps()) ) ).dumps()) if page and typ=="search": _page = b64e(SearchOffset(offset=(page-1)*30).dumps(), padding=False) else: _page = None return b64e(Continuation( params=Params( subject=subject, params=b64e(Subparams( type_s=typestr.get(typ), type_i=typeint.get(typ), sort=sortorder.get(sort), page=_page, field61=b64e( ChannelDataContainer( data=b64e( ChannelDataInner( offset=(page-1)*30 ).dumps(), padding=False ) ).dumps(), padding=False ) if v3 else None ).dumps()), query=query, ), ).dumps()) # }}} CHANNEL v1/v3 # PLAYLIST {{{ @message @dataclass class PlaylistData: offset: Optional[int64] = field(1) # normal playlists after: Optional[str] = field(2) # mix playlist (RDCL*, RDCM*): video id of last loaded item (how to get first page!?) @message @dataclass class PlaylistSubparams: data: str = field(15) def make_playlist_params(playlist_id, offset): # TODO: special type of playlists: (see https://github.com/nlitsme/youtube_tool) # - PL*: normal playlists # - special channel playlists (suffix=channel_id without UC): # - UU*: newest uploads (works) # - PU*: popular uploads (works) # Note: youtubekids (e.g. PLMYEtVRpaqY00V9W81Cwmzp6N6vZqfUKD4) fallback-only (youtube webui 404's) # - FL*: favourited uploads (works; 404's if private) # - LL*: liked uploads (untested) # - RDCMUC*: mix for channel (fallback only; e.g. RDCMUCYO_jab_esuFRV4b17AJtAw, undisplayable on youtube (only works on watch)) # - CL, EL, MQ, TT: unknown # - OLAK5uy_ music album playlist (works, e.g. OLAK5uy_m4xAFdmMC5rX3Ji3g93pQe3hqLZw_9LhM) # - RD*: mix playlist (works with after=; e.g. RDCLAK5uy_kLWIr9gv1XLlPbaDS965-Db4TrBoUTxQ8, RDCLAK5uy_mx8mvGaCkyI12AedAu8yIbBpEYmg6Ayow, RDCLAK5uy_n340faD9pPGRVEVwozYTzIpfXuqmQwcgA, RDCLAK5uy_kQmVjdr90p2onIIAnijgU8u9iUUAOXM2I) # https://www.youtube.com/watch?v=FTQbiNvZqaY&list=RDCLAK5uy_khNGopKCT_t38MZ1W7z4kERrqprkXovxo&start_radio=1 # - EC, UL, TL: unknown # - own user playlists (obviously not working): # - RDMM: my mix # - WL: watch later # - LL: own liked videos # - LM: own liked youtube-music videos after = None if playlist_id.startswith("RD"): # NOTE: this only fixes RDCLAK5uy_, not RDCMUC after = '___________' # any video id that doesn't exist within the playlist requests the beginning offset = None return b64e(Continuation( params=Params( subject="VL" + playlist_id, params=b64e(PlaylistSubparams( data="PT:" + b64e(PlaylistData( offset=offset, after=after, ).dumps(), padding=False) ).dumps()), ), ).dumps()) # }}} PLAYLIST