Skip to content

Commit

Permalink
First PoC of parsing guide data
Browse files Browse the repository at this point in the history
  • Loading branch information
tombulled committed Jan 6, 2024
1 parent 2df4eeb commit 4b7f6ab
Show file tree
Hide file tree
Showing 7 changed files with 416 additions and 159 deletions.
71 changes: 66 additions & 5 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,74 @@
from innertube import InnerTube
from innertube.raw_models import ResponseContext
from typing import Any, Mapping, Optional, Sequence, Set, Union
from typing_extensions import TypeAlias
import rich
from pydantic import TypeAdapter
from innertube import InnerTube, utils
from innertube.renderers import parse_renderable
from innertube.raw_models import Response, ResponseContext
from innertube.renderers import RENDERERS

IGNORE_FIELDS: Set[str] = {"responseContext", "trackingParams"}

# response_context: ResponseContext = ResponseContext.model_validate(
# data["responseContext"]
# )
# tracking_params: str = TypeAdapter(str).validate_python(
# data.get("trackingParams")
# )

"""
{
"responseContext": {
"visitorData": "...",
"...": "...",
},
"items": [
{
"SomeRenderer": {
"...": "...",
},
"SomeOtherRenderer": {
"...": "...",
},
}
],
"trackingParams": "CAAQumkiEwiYnZL5osmDAxVOBwYAHQe9Czw="
}
"""


def parse_response(response: dict, /):
parsed = {}

key: str
value: Mapping[str, Any]
for key, value in response.items():
if key in IGNORE_FIELDS:
rich.print(f"{key} -> [IGNORE]")
# parsed[key] = value
continue

parsed[key] = parse_renderable(value, parent=key)

return parsed


# Clients
WEB = InnerTube("WEB", "2.20230728.00.00")
WEB_REMIX = InnerTube("WEB_REMIX", "1.20220607.03.01")
IOS = InnerTube("IOS", "17.14.2")
IOS_MUSIC = InnerTube("IOS_MUSIC", "4.16.1")

channel_id: str = "UC8Yu1_yfN5qPh601Y4btsYw" # Arctic Monkeys
# Arctic Monkeys
channel_id: str = "UC8Yu1_yfN5qPh601Y4btsYw"

# data_browse_channel = WEB_REMIX.adaptor.dispatch(
# "browse", body={"browseId": channel_id}
# )
response = WEB_REMIX.adaptor.dispatch("guide")

d = data = WEB_REMIX.adaptor.dispatch("browse", body={"browseId": channel_id})
# rc_1 = ResponseContext.model_validate(data_browse_channel["responseContext"])
# rc_2 = ResponseContext.model_validate(data_guide["responseContext"])

rc = ResponseContext.parse_obj(data["responseContext"])
# d = parse(data_browse_channel)
d = parse_response(response)
22 changes: 22 additions & 0 deletions innertube/raw_enums.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
from enum import Enum, auto


class StrEnum(str, Enum):
pass


class ButtonStyle(StrEnum):
STYLE_DEFAULT: str = "STYLE_DEFAULT"


# A "service" as listed under "responseContext.serviceTrackingParams"
class Service(StrEnum):
CSI: str = "CSI"
GFEEDBACK: str = "GFEEDBACK"
ECATCHER: str = "ECATCHER"


# E.g., {"icon": {"iconType": "TAB_HOME"}}
class IconType(StrEnum):
LIBRARY_MUSIC = "LIBRARY_MUSIC"
TAB_EXPLORE = "TAB_EXPLORE"
TAB_HOME = "TAB_HOME"


class SharePanelType(Enum):
SHARE_PANEL_TYPE_UNIFIED_SHARE_PANEL = auto()
14 changes: 9 additions & 5 deletions innertube/raw_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,21 @@ class WebResponseContextExtensionData(BaseModel):
class ResponseContext(BaseModel):
visitor_data: str
service_tracking_params: Sequence[ServiceTrackingParams]
max_age_seconds: int
max_age_seconds: Optional[int] = None
main_app_web_response_context: Optional[MainAppWebResponseContext] = None
web_response_context_extension_data: Optional[
WebResponseContextExtensionData
] = None


class Response(BaseModel):
response_context: ResponseContext


class ShareEntityEndpoint(BaseModel):
serialized_share_entity: str
share_panel_type: SharePanelType

class Response(BaseModel):
response_context: ResponseContext
tracking_params: str

# class GetBrowseArtistDetailPageResponse(Response):
# contents: SingleColumnBrowseResultsRenderer
# header: MusicImmersiveHeaderRenderer
165 changes: 165 additions & 0 deletions innertube/renderers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from typing import Any, Mapping, MutableMapping, Optional, Sequence, TypeVar, Union
from typing_extensions import Annotated, TypeAlias

import humps
import pydantic
import rich

from .raw_enums import ButtonStyle, IconType

from . import utils


def build_abs_key(field: Union[str, int], parent: Optional[str]) -> str:
sep: str = "." if isinstance(field, str) else ""

if isinstance(field, int):
field = f"[{field}]"

if parent is None:
return field

return f"{parent}{sep}{field}"


DataMap: TypeAlias = Mapping[str, Mapping[str, Any]]
DataSeq: TypeAlias = Sequence[DataMap]
Data: TypeAlias = Union[DataMap, DataSeq]


def parse_renderable(data: Data, /, *, parent: Optional[str] = None):
assert utils.is_renderable(data)

if isinstance(data, Sequence):
return [
parse_renderable(item, parent=build_abs_key(index, parent))
for index, item in enumerate(data)
]

key = next(iter(data))
value = data[key]

abs_key: str = build_abs_key(key, parent)

if key not in RENDERERS:
raise Exception(f"No renderer available for {key!r} ({abs_key})")

render_cls: type = RENDERERS[key]

rich.print(f"{abs_key} -> render({key!r}, {set(value.keys())})")

return render_cls.model_validate(value)


Renderable = Annotated[Any, pydantic.BeforeValidator(parse_renderable)]


C = TypeVar("C", bound=type)

RENDERERS: MutableMapping[str, type] = {}


def renderer(cls: C) -> C:
renderer_id: str = humps.camelize(cls.__name__) + "Renderer"

RENDERERS[renderer_id] = cls

return cls


class BaseModel(pydantic.BaseModel):
model_config = pydantic.ConfigDict(
extra="forbid",
alias_generator=humps.camelize,
)


class ComplexTextRun(BaseModel):
text: str


class ComplexText(BaseModel):
runs: Sequence[ComplexTextRun]

# def join(self) -> str:
# return "".join(run for run in self.runs)


class SimpleText(BaseModel):
simple_text: str


Text: TypeAlias = Union[ComplexText, SimpleText]


class AccessibilityData(BaseModel):
label: str


class Accessibility(BaseModel):
accessibility_data: AccessibilityData


class BrowseEndpoint(BaseModel):
browse_id: str


class Icon(BaseModel):
icon_type: IconType


class SignInEndpoint(BaseModel):
hack: bool


class NavigationEndpoint(BaseModel):
click_tracking_params: str
browse_endpoint: Optional[BrowseEndpoint] = None
sign_in_endpoint: Optional[SignInEndpoint] = None


@renderer
class Button(BaseModel):
style: ButtonStyle
is_disabled: bool
text: SimpleText
navigation_endpoint: NavigationEndpoint
tracking_params: str


@renderer
class SingleColumnBrowseResultsRenderer:
pass


@renderer
class SingleColumnBrowseResultsRenderer:
pass


@renderer
class MusicImmersiveHeaderRenderer:
pass


@renderer
class GuideEntry(BaseModel):
navigation_endpoint: NavigationEndpoint
icon: Icon
tracking_params: str
formatted_title: ComplexText
accessibility: Accessibility
is_primary: bool


@renderer
class GuideSection(BaseModel):
tracking_params: str
items: Sequence[Renderable]


@renderer
class GuideSigninPromo(BaseModel):
action_text: ComplexText
descriptiveText: ComplexText
signInButton: Renderable
37 changes: 37 additions & 0 deletions innertube/utils.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,39 @@
import re
from typing import Any, Mapping, Sequence


def filter(dictionary: dict, /) -> dict:
return {key: value for key, value in dictionary.items() if value is not None}

def is_renderable(data: Any, /) -> bool:
"""
>>> is_renderable({"FooRenderer": {}})
True
>>> is_renderable({})
False
"""

# print("is_renderable", data)

if isinstance(data, Sequence):
return all(map(is_renderable, data))

if not isinstance(data, Mapping):
return False

if len(data) != 1:
return False

key: Any = next(iter(data))
value: Any = data[key]

if not isinstance(key, str):
return False

if not re.match(r"(.+)Renderer", key):
return False

if not isinstance(value, Mapping):
return False

return True
Loading

0 comments on commit 4b7f6ab

Please sign in to comment.