diff --git a/.gitignore b/.gitignore index 3abda3d9..edd24fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ __pycache__ /env /cache /*.jar -log.txt +/*log.txt /lock.file settings.json #/lang/English.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 67a63140..9edcc18f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -64,5 +64,14 @@ "console": "integratedTerminal", "justMyCode": false }, + { + "name": "Run App (DEBUG+GQL+WS+Log)", + "type": "python", + "request": "launch", + "program": "main.py", + "args": ["-vvv", "--debug-gql", "--debug-ws", "--log"], + "console": "integratedTerminal", + "justMyCode": false + }, ] } diff --git a/channel.py b/channel.py index 7aa594bc..54c0bff8 100644 --- a/channel.py +++ b/channel.py @@ -377,7 +377,7 @@ def _payload(self) -> JsonType: ] return {"data": (b64encode(json_minify(payload).encode("utf8"))).decode("utf8")} - async def send_watch(self) -> bool: + async def send_watch(self) -> tuple[bool, bool]: """ Start of fix for 2024/5 API Change """ @@ -390,41 +390,64 @@ async def send_watch(self) -> bool: signature: JsonType | None = response["data"]['streamPlaybackAccessToken']["signature"] value: JsonType | None = response["data"]['streamPlaybackAccessToken']["value"] if not signature or not value: - return False + return False, False RequestBroadcastQualitiesURL = f"https://usher.ttvnw.net/api/channel/hls/{self._login}.m3u8?sig={signature}&token={value}" try: - async with self._twitch.request( # Gets list of streams + async with self._twitch.request( # Gets list of m3u8 playlists "GET", RequestBroadcastQualitiesURL ) as response1: - BroadcastQualities = await response1.text() + BroadcastQualitiesM3U = await response1.text() except RequestException: - return False + logger.error(f"Failed to recieve list of m3u8 playlists.") + return False, False - BroadcastLowestQualityURL = BroadcastQualities.split("\n")[-1] # Just takes the last line, this could probably be handled better in the future - if not validators.url(BroadcastLowestQualityURL): - return False - - try: - async with self._twitch.request( # Gets actual streams - "GET", BroadcastLowestQualityURL - ) as response2: - StreamURLList = await response2.text() - except RequestException: - return False - - StreamLowestQualityURL = StreamURLList.split("\n")[-2] # For whatever reason this includes a blank line at the end, this should probably be handled better in the future - if not validators.url(StreamLowestQualityURL): - return False + BroadcastQualitiesM3U = BroadcastQualitiesM3U.split("\n") + BroadcastQualitiesList = [] + for i in range(int(len(BroadcastQualitiesM3U)/3)): # gets all m3u8 playlists + BroadcastQualitiesList.append(BroadcastQualitiesM3U[4+3*i]) + + if not all(validators.url(url) for url in BroadcastQualitiesList): + logger.error(f"Couldn't parse list of m3u8 playlists.") + return False, False + + retries = -1 + for BroadcastQuality in BroadcastQualitiesList: + retries = retries + 1 + try: + async with self._twitch.request( # Gets actual streams + "GET", BroadcastQuality, return_error = True + ) as response2: + if response2.status == 200: + StreamURLList = await response2.text() + else: + logger.log(CALL,f"Request for streams from m3u8 returned: {response2}") + continue + except RequestException: + logger.error(f"Failed to recieve list of streams.") + return False, False + + StreamURL = StreamURLList.split("\n")[-2] # For whatever reason this includes a blank line at the end, this should probably be handled better in the future + if not validators.url(StreamURL): + logger.error(f"Failed to parse streamURL.") + return False, False - try: - async with self._twitch.request( # Downloads the stream - "HEAD", StreamLowestQualityURL - ) as response3: # I lied, well idk, but this code doesn't listen for the actual video data - return response3.status == 200 - except RequestException: - return False + try: + async with self._twitch.request( # The HEAD request is enough to advance drops + "HEAD", StreamURL, return_error = True + ) as response3: + if response3.status == 200: + logger.log(CALL,f"Successfully watched after {retries} retries.") + return True, False + else: + logger.error(f"Request for stream HEAD returned: {response2}") + except RequestException: + logger.error(f"Failed to recieve list of streams.") + return False, False + await asyncio.sleep(1) # Wait a second to not spam twitch API + logger.error(f"Failed to watch all of {len(BroadcastQualitiesList)} Broadcast qualities. Can be ignored if occuring up to ~15x/hour.") + return False, True """ End of fix for 2024/5 API Change. Old code below. diff --git a/inventory.py b/inventory.py index 3bd090ba..6e7ed557 100644 --- a/inventory.py +++ b/inventory.py @@ -220,7 +220,7 @@ def progress(self) -> float: if self.required_minutes: # Quick fix to prevent division by zero crash return self.current_minutes / self.required_minutes else: - self._manager.print(f'!!!required_minutes for "{self.name}" is 0 This could be due to a subscription requirement, tracked in Issue #101!!!') + self._manager.print(f'Required_minutes for "{self.name}" from "{self.campaign.game.name}" is 0. This could be due to a subscription requirement, tracked in Issue #101. Take a look at the drop, but this can likely be ignored.') self.preconditions_met = False return 0 diff --git a/patch_notes.txt b/patch_notes.txt index 01602845..538425f0 100644 --- a/patch_notes.txt +++ b/patch_notes.txt @@ -1,3 +1,14 @@ +## v15.9.0 + +18.8.2024 +- Added game name to `0 required minutes` error message + +27.8.2024 +- Clarified that 0 required minutes error is not critical +- Fixed "Twitch is down, retrying" for my case. It seems like some might still experience the issue. Tracked in #172 + + + ## v15.8.2 13.8.2024 diff --git a/twitch.py b/twitch.py index 90eefca6..a7d3778e 100644 --- a/twitch.py +++ b/twitch.py @@ -1046,11 +1046,14 @@ async def _watch_loop(self) -> NoReturn: interval: float = WATCH_INTERVAL.total_seconds() while True: channel: Channel = await self.watching_channel.get() - succeeded: bool = await channel.send_watch() + succeeded, repeat_now = await channel.send_watch() + logger.log(CALL,f"returned watch, succeeded: {succeeded}, repeat_new: {repeat_now}") if not succeeded: # this usually means the campaign expired in the middle of mining + # or the m3u8 playlists all returned a 500 Internal server error # NOTE: the maintenance task should switch the channel right after this happens - await self._watch_sleep(interval) + if not repeat_now: + await self._watch_sleep(interval) continue last_watch = time() self._drop_update = asyncio.Future() @@ -1480,7 +1483,7 @@ async def get_auth(self) -> _AuthState: @asynccontextmanager async def request( - self, method: str, url: URL | str, *, invalidate_after: datetime | None = None, **kwargs + self, method: str, url: URL | str, *, invalidate_after: datetime | None = None, return_error: bool = False, **kwargs ) -> abc.AsyncIterator[aiohttp.ClientResponse]: session = await self.get_session() method = method.upper() @@ -1505,12 +1508,12 @@ async def request( ) assert response is not None logger.debug(f"Response: {response.status}: {response}") - if response.status < 500: + if response.status < 500 or return_error: # pre-read the response to avoid getting errors outside of the context manager raw_response = await response.read() # noqa yield response return - self.print(_("error", "site_down").format(seconds=round(delay))) + self.print(_("error", "site_down").format(seconds=round(delay)) + f"\nResponse: {response}" + f"\nStatus: {response.status}") except aiohttp.ClientConnectorCertificateError: # type: ignore[unused-ignore] # for a case where SSL verification fails raise