diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1b460a0..5da646a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9] + python-version: ["3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 3f36f12..79764fc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ dist .vscode .coverage coverage.xml -test-results.xml \ No newline at end of file +test-results.xml +venv +build diff --git a/haphilipsjs/__init__.py b/haphilipsjs/__init__.py index ea0a742..f0c71fa 100644 --- a/haphilipsjs/__init__.py +++ b/haphilipsjs/__init__.py @@ -24,6 +24,7 @@ ChannelListType, ChannelsCurrentType, ChannelsType, + ChannelType, ContextType, FavoriteListType, RecordingsListed, @@ -186,17 +187,20 @@ def __init__(self, data): class NoneJsonData(GeneralFailure): + """API Returned non json data when json was expected.""" def __init__(self, data): super().__init__(f"Non json data received: {data}") self.data = data - """API Returned non json data when json was expected.""" - T = TypeVar("T") class PhilipsTV(object): + + channels: ChannelsType + """All available channels, with ccid as key.""" + def __init__( self, host=None, @@ -218,7 +222,7 @@ def __init__( self.sources = {} self.source_id = None self.audio_volume = None - self.channels: ChannelsType = {} + self.channels = {} self.channel: Optional[Union[ActivitesTVType, ChannelsCurrentType]] = None self.channel_lists: Dict[str, ChannelListType] = {} self.favorite_lists: Dict[str, FavoriteListType] = {} @@ -456,6 +460,44 @@ def channel_id(self): return r["id"][pos + 1 :] return r["id"] + @property + def channel_list_id(self) -> str: + if self.api_version >= 5: + if not self.channel: + return "all" + r = cast(ActivitesTVType, self.channel) + channel_list = r.get("channelList") + if not channel_list: + return "all" + return channel_list.get("id", "all") + + return "all" + + @property + def channels_current(self) -> List[ChannelType]: + """All channels in the current favorite list.""" + if self.api_version >= 5: + favorite_list = self.favorite_lists.get(self.channel_list_id) + if not favorite_list: + return list(self.channels.values()) + + return [ + { + **channel, + "preset": favorite.get("preset", "") + } + for favorite in favorite_list.get("channels", []) + if (channel := self.channels.get(str(favorite.get("ccid")))) + ] + else: + return [ + { + **channel, + "ccid": key + } + for key, channel in self.channels.items() + ] + @property def ambilight_modes(self): modes = ["internal", "manual", "expert"] @@ -724,13 +766,10 @@ async def getAudiodata(self): async def getChannels(self): if self.api_version >= 5: self.channels = {} - for list_id in self.channel_lists: - r = await self.getChannelList(list_id) - if r: - for channel in r: - if "ccid" in channel: - self.channels[str(channel["ccid"])] = channel - return r + for channel_list in self.channel_lists.values(): + for channel in channel_list.get("Channel", []): + self.channels[str(channel.get("ccid"))] = channel + return self.channels else: r = cast(Optional[ChannelsType], await self.getReq("channels")) if r: @@ -775,15 +814,15 @@ async def getContext(self) -> Optional[ContextType]: self.context = r return r - async def setChannel(self, ccid, list_id: str = "alltv"): + async def setChannel(self, ccid: Union[str, int], list_id: Optional[str] = None): channel: Union[ActivitesTVType, ChannelsCurrentType] if self.api_version >= 5: - channel = {"channelList": {"id": list_id}, "channel": {"ccid": ccid}} + channel = {"channelList": {"id": list_id or "all"}, "channel": {"ccid": int(ccid)}} if await self.postReq("activities/tv", cast(Dict, channel)) is not None: self.channel = channel return True else: - channel = {"id": ccid} + channel = {"id": str(ccid)} if await self.postReq("channels/current", cast(Dict, channel)) is not None: self.channel = channel return True @@ -793,15 +832,34 @@ async def getChannelLists(self): if self.api_version >= 5: r = cast(ChannelDbTv, await self.getReq("channeldb/tv")) if r: - self.channel_lists = { - data["id"]: data for data in r.get("channelLists", {}) - } - self.favorite_lists = { - data["id"]: data for data in r.get("favoriteLists", {}) - } + channel_lists = {} + favorite_lists = {} + + for data in r.get("channelLists", []): + list_id = data["id"] + channel_list = cast( + Optional[ChannelListType], + await self.getReq(f"channeldb/tv/channelLists/{list_id}"), + ) + if channel_list: + channel_lists[list_id] = channel_list + + + for data in r.get("favoriteLists", []): + list_id = data["id"] + favorite_list = cast( + Optional[FavoriteListType], + await self.getReq(f"channeldb/tv/favoriteLists/{list_id}"), + ) + if favorite_list: + favorite_lists[list_id] = favorite_list + + self.channel_lists = channel_lists + self.favorite_lists = favorite_lists else: self.channel_lists = {} self.favorite_lists = {} + return r async def getFavoriteList(self, list_id: str): diff --git a/haphilipsjs/__main__.py b/haphilipsjs/__main__.py index 1f965fa..e22b997 100644 --- a/haphilipsjs/__main__.py +++ b/haphilipsjs/__main__.py @@ -3,7 +3,6 @@ import json import sys -from setuptools import find_namespace_packages from . import PhilipsTV import asyncio from ast import literal_eval @@ -52,6 +51,12 @@ def get_application_name(): stdscr.addstr(3, 45, tv.context.get("level3", "")) stdscr.addstr(4, 45, tv.context.get("data", "")) + + stdscr.addstr(0, 70, "Channels") + for idx, channel in enumerate(tv.channels_current): + stdscr.addstr(1+idx, 70, channel.get("name", channel.get("ccid"))) + + def print_pixels(side, offset_y, offset_x): stdscr.addstr(offset_y, offset_x, "{}".format(side)) stdscr.addstr(offset_y + 1, offset_x, "-----------") diff --git a/haphilipsjs/data/v6.py b/haphilipsjs/data/v6.py index f9618f3..b79c5a1 100644 --- a/haphilipsjs/data/v6.py +++ b/haphilipsjs/data/v6.py @@ -322,34 +322,78 @@ } } -ACTIVITIES_TV: ActivitesTVType = {"channel": {"ccid": 1648}} +ACTIVITIES_TV_ANDROID: ActivitesTVType = { + "channel": {"ccid": 1648}, + "channelList": { + "id": "1", + "version": "10" + } +} + +ACTIVITIES_TV_SAPHI: ActivitesTVType = { + "channel": {"ccid": 1648}, +} + +ACTIVITIES_TV = { + "android": ACTIVITIES_TV_ANDROID, + "saphi": ACTIVITIES_TV_SAPHI +} CHANNELDB_TV_CHANNELLISTS_ALL: ChannelListType = { "id": "all", "version": 10, "listType": "MixedSources", "medium": "mixed", - "active": True, - "virtual": True, - "modifiable": False, "Channel": [ {"ccid": 1648, "preset": "1", "name": "Irdeto scrambled"}, {"ccid": 1649, "preset": "2"}, ], } + +CHANNELDB_TV_FAVORITELISTS_ALLTER: FavoriteListType = { + "id": "allter", + "version": 10, + "listType": "MixedSources", + "medium": "mixed", + "channels": [ + {"ccid": 1648, "preset": "1"}, + {"ccid": 1649, "preset": "2"}, + ], +} + + +CHANNELDB_TV_FAVORITELISTS_1: FavoriteListType = { + "id": "1", + "version": 10, + "listType": "MixedSources", + "medium": "mixed", + "channels": [ + {"ccid": 1649, "preset": "1"}, + ], +} + CHANNELDB_TV_ANDROID: ChannelDbTv = { - "channelLists": [CHANNELDB_TV_CHANNELLISTS_ALL], + "channelLists": [ { + "id": "all", + "version": 10, + "listType": "MixedSources", + "medium": "mixed", + "active": True, + "virtual": True, + "modifiable": False, + } + ], "favoriteLists": [ { - "id": "com.google.android.videos%2F.tv.usecase.tvinput.playback.TvInputService", + "id": "allter", "version": 1545826184134, "parentId": "all", "listType": "MixedSources", "medium": "mixed", "virtual": False, "modifiable": False, - "name": "Google Play Movies & TV", + "name": "All teresterial", }, { "id": "1", @@ -366,7 +410,15 @@ CHANNELDB_TV_SAPHI: ChannelDbTv = { - "channelLists": [CHANNELDB_TV_CHANNELLISTS_ALL], + "channelLists": [{ + "id": "all", + "version": 10, + "listType": "MixedSources", + "medium": "mixed", + "active": True, + "virtual": True, + "modifiable": False, + }], } diff --git a/haphilipsjs/typing.py b/haphilipsjs/typing.py index 5fe173f..dbc849b 100644 --- a/haphilipsjs/typing.py +++ b/haphilipsjs/typing.py @@ -15,7 +15,7 @@ class ActivitiesChannelType(TypedDict, total=False): class ActivitiesChannelListType(TypedDict, total=False): id: str - version: str + version: Union[int, str] class ActivitesTVType(TypedDict, total=False): @@ -51,7 +51,7 @@ class ApplicationsType(TypedDict): applications: List[ApplicationType] -class FavoriteChannelType(TypedDict): +class FavoriteChannelType(TypedDict, total=False): ccid: int preset: str @@ -59,19 +59,22 @@ class FavoriteChannelType(TypedDict): class FavoriteListType(TypedDict, total=False): id: str version: Union[int, str] - parentId: str listType: str medium: str - virtual: bool - modifiable: bool name: str channels: List[FavoriteChannelType] class ChannelType(TypedDict, total=False): - ccid: int + ccid: Union[int, str] preset: str name: str + onid: int + tsid: int + sid: int + serviceType: str + type: str + logoVersion: Union[int, str] class ChannelListType(TypedDict, total=False): @@ -79,18 +82,37 @@ class ChannelListType(TypedDict, total=False): version: Union[int, str] listType: str medium: str - active: bool - virtual: bool - modifiable: bool + operator: str + installCountry: str Channel: List[ChannelType] ChannelsType = Dict[str, ChannelType] +class ChannelDbTvListBase(TypedDict): + id: str + version: Union[int, str] + listType: str + + +class ChannelDbTvList(ChannelDbTvListBase, total=False): + medium: str + active: bool + virtual: bool + modifiable: bool + +class ChannelDbTvListFavorite(ChannelDbTvListBase, total=False): + medium: str + active: bool + virtual: bool + modifiable: bool + parentId: str + name: str + class ChannelDbTv(TypedDict, total=False): - channelLists: List[ChannelListType] - favoriteLists: List[FavoriteListType] + channelLists: List[ChannelDbTvList] + favoriteLists: List[ChannelDbTvListFavorite] class JsonFeaturesType(TypedDict, total=False): diff --git a/setup.cfg b/setup.cfg index 224a779..4ee33b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,6 @@ [metadata] -description-file = README.md \ No newline at end of file +description-file = README.md + +[tool:pytest] +testpaths = tests +asyncio_mode = auto diff --git a/setup.py b/setup.py index ad77044..9abe906 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ def readme(): PACKAGE_NAME = 'ha-philipsjs' HERE = os.path.abspath(os.path.dirname(__file__)) -VERSION = '2.9.0' +VERSION = '3.0.0' PACKAGES = find_packages(exclude=['tests', 'tests.*', 'dist', 'ccu', 'build']) @@ -43,7 +43,7 @@ def readme(): 'tests': [ 'pytest>3.6.4', 'pytest-cov<2.6', - 'pytest-aiohttp', + 'pytest-asyncio>=0.20.2', 'coveralls', 'pytest-mock', 'respx>=0.17.0', diff --git a/tests/test_v1.py b/tests/test_v1.py index 9374481..e983028 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -31,7 +31,7 @@ async def param_fixture(request): @pytest.fixture -async def client_mock(loop, param: Param): +async def client_mock(param: Param): with respx.mock: client = haphilipsjs.PhilipsTV("127.0.0.1", api_version=1) @@ -79,6 +79,13 @@ async def test_basic_data(client_mock: haphilipsjs.PhilipsTV, param: Param): assert client_mock.system == SYSTEM_DECRYPTED[param.type] assert client_mock.sources == SOURCES assert client_mock.channels == CHANNELS + assert client_mock.channels_current == [ + { + **channel, + "ccid": key + } + for key, channel in CHANNELS.items() + ] assert client_mock.ambilight_current_configuration is None assert client_mock.ambilight_styles == {} assert client_mock.powerstate == POWERSTATE.get(param.type, {}).get("powerstate") diff --git a/tests/test_v6.py b/tests/test_v6.py index 6de32fe..791fe3c 100644 --- a/tests/test_v6.py +++ b/tests/test_v6.py @@ -16,6 +16,8 @@ CHANNELDB_TV_ANDROID, CHANNELDB_TV_SAPHI, CHANNELDB_TV_CHANNELLISTS_ALL, + CHANNELDB_TV_FAVORITELISTS_ALLTER, + CHANNELDB_TV_FAVORITELISTS_1, ACTIVITIES_CURRENT, CONTEXT, MENUITEMS_SETTINGS_STRUCTURE, @@ -66,7 +68,7 @@ async def param_fixture(request): @pytest.fixture -async def client_mock(loop, param: Param): +async def client_mock(param: Param): with respx.mock: if param.type == "android": respx.get(f"{param.base}/system").respond( @@ -88,10 +90,19 @@ async def client_mock(loop, param: Param): respx.get(f"{param.base}/channeldb/tv/channelLists/all").respond( json=cast(Dict, CHANNELDB_TV_CHANNELLISTS_ALL) ) + + respx.get(f"{param.base}/channeldb/tv/favoriteLists/allter").respond( + json=cast(Dict, CHANNELDB_TV_FAVORITELISTS_ALLTER) + ) + + respx.get(f"{param.base}/channeldb/tv/favoriteLists/1").respond( + json=cast(Dict, CHANNELDB_TV_FAVORITELISTS_1) + ) + respx.get(f"{param.base}/activities/current").respond( json=cast(Dict, ACTIVITIES_CURRENT) ) - respx.get(f"{param.base}/activities/tv").respond(json=cast(Dict, ACTIVITIES_TV)) + respx.get(f"{param.base}/activities/tv").respond(json=cast(Dict, ACTIVITIES_TV[param.type])) respx.get(f"{param.base}/applications").respond(json=cast(Dict, APPLICATIONS)) respx.get(f"{param.base}/powerstate").respond(json=POWERSTATE) respx.get(f"{param.base}/screenstate").respond(json=SCREENSTATE) @@ -155,6 +166,11 @@ async def test_basic_data(client_mock, param: Param): assert client_mock.quirk_ambilight_mode_ignored == True assert client_mock.os_type == "MSAF_2019_P" + assert client_mock.channel_list_id == "1" + assert client_mock.channels_current == [ + {"ccid": 1649, "preset": "1"} + ] + elif param.type == "saphi": assert client_mock.system == SYSTEM_SAPHI_DECRYPTED assert client_mock.sources == MOCK_SAPHI_SOURCES @@ -163,6 +179,10 @@ async def test_basic_data(client_mock, param: Param): assert client_mock.quirk_ambilight_mode_ignored == True assert client_mock.os_type == "Linux" + assert client_mock.channel_list_id == "all" + assert client_mock.channels_current == list(client_mock.channels.values()) + + assert client_mock.channels == { "1648": {"ccid": 1648, "preset": "1", "name": "Irdeto scrambled"}, "1649": {"ccid": 1649, "preset": "2"}, @@ -294,7 +314,7 @@ async def test_set_channel(client_mock, param: Param): assert respx.calls[-1].request.url == f"{param.base}/activities/tv" assert json.loads(respx.calls[-1].request.content) == { - "channelList": {"id": "alltv"}, + "channelList": {"id": "all"}, "channel": {"ccid": 1649}, }