diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d332368 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# Ignore files +.dockerignore +.gitignore +.deepsource.toml +.github + +Dockerfile +docker-compose.yml +README.md diff --git a/README.md b/README.md index 0857a48..218f78a 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,16 @@ Open an issue/pull request if you have tested a device that isn't listed here. | Chromecast | ❔ | | Google TV | ✅ | | Roku | ✅ | -| Fire TV | ❔ | +| Fire TV | ✅ | | CCwGTV | ✅ | | Nintendo Switch | ✅ | -| Xbox One/Series | ❔ | +| Xbox One/Series | ✅ | | Playstation 4/5 | ✅ | ## Usage Run iSponsorBlockTV on a computer that has network access. Auto discovery will require the computer to be on the same network as the device during setup. +The device can also be manually added to iSponsorBlockTV with a YouTube TV code. This code can be found in the settings page of your YouTube application. It connects to the device, watches its activity and skips any sponsor segment using the [SponsorBlock](https://sponsor.ajay.app/) API. It can also skip/mute YouTube ads. diff --git a/iSponsorBlockTV/api_helpers.py b/iSponsorBlockTV/api_helpers.py index c6f5dd2..b462aa1 100644 --- a/iSponsorBlockTV/api_helpers.py +++ b/iSponsorBlockTV/api_helpers.py @@ -1,8 +1,7 @@ -from cache import AsyncTTL, AsyncLRU +from cache import AsyncLRU from .conditional_ttl_cache import AsyncConditionalTTL from . import constants, dial_client from hashlib import sha256 -from asyncio import create_task from aiohttp import ClientSession import html @@ -39,17 +38,17 @@ class ApiHelper: return for i in data["items"]: - if (i["id"]["kind"] != "youtube#video"): + if i["id"]["kind"] != "youtube#video": continue title_api = html.unescape(i["snippet"]["title"]) artist_api = html.unescape(i["snippet"]["channelTitle"]) if title_api == title and artist_api == artist: - return (i["id"]["videoId"], i["snippet"]["channelId"]) + return i["id"]["videoId"], i["snippet"]["channelId"] return @AsyncLRU(maxsize=100) async def is_whitelisted(self, vid_id): - if (self.apikey and self.channel_whitelist): + if self.apikey and self.channel_whitelist: channel_id = await self.__get_channel_id(vid_id) # check if channel id is in whitelist for i in self.channel_whitelist: @@ -66,7 +65,7 @@ class ApiHelper: if "error" in data: return data = data["items"][0] - if (data["kind"] != "youtube#video"): + if data["kind"] != "youtube#video": return return data["snippet"]["channelId"] @@ -113,11 +112,21 @@ class ApiHelper: headers = {"Accept": "application/json"} url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed async with self.web_session.get(url, headers=headers, params=params) as response: - response = await response.json() - for i in response: + response_json = await response.json() + if response.status != 200: + response_text = await response.text() + print( + f"Error getting segments for video {vid_id}, hashed as {vid_id_hashed}. " + f"Code: {response.status} - {response_text}") + return ([], True) + for i in response_json: if str(i["videoID"]) == str(vid_id): - response = i + response_json = i break + return self.process_segments(response_json) + + @staticmethod + def process_segments(response): segments = [] ignore_ttl = True try: @@ -146,7 +155,8 @@ class ApiHelper: return (segments, ignore_ttl) async def mark_viewed_segments(self, UUID): - """Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled. Lets the contributor know that someone skipped the segment (thanks)""" + """Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled. + Lets the contributor know that someone skipped the segment (thanks)""" if self.skip_count_tracking: for i in UUID: url = constants.SponsorBlock_api + "viewedVideoSponsorTime/" diff --git a/iSponsorBlockTV/config_setup.py b/iSponsorBlockTV/config_setup.py index f9ae79d..8a034af 100644 --- a/iSponsorBlockTV/config_setup.py +++ b/iSponsorBlockTV/config_setup.py @@ -1,14 +1,13 @@ -import json import asyncio -import sys import aiohttp from . import api_helpers, ytlounge -async def pair_device(loop): + +async def pair_device(): try: lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV") pairing_code = input("Enter pairing code (found in Settings - Link with TV code): ") - pairing_code = int(pairing_code.replace("-", "").replace(" ","")) # remove dashes and spaces + pairing_code = int(pairing_code.replace("-", "").replace(" ", "")) # remove dashes and spaces print("Pairing...") paired = await lounge_controller.pair(pairing_code) if not paired: @@ -23,7 +22,8 @@ async def pair_device(loop): except Exception as e: print(f"Failed to pair device: {e}") return - + + def main(config, debug: bool) -> None: print("Welcome to the iSponsorBlockTV cli setup wizard") loop = asyncio.get_event_loop_policy().get_event_loop() @@ -31,12 +31,14 @@ def main(config, debug: bool) -> None: loop.set_debug(True) asyncio.set_event_loop(loop) if hasattr(config, "atvs"): - print("The atvs config option is deprecated and has stopped working. Please read this for more information on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2") + print( + "The atvs config option is deprecated and has stopped working. Please read this for more information on " + "how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2") if input("Do you want to remove the legacy 'atvs' entry (the app won't start with it present)? (y/n) ") == "y": del config["atvs"] devices = config.devices while not input(f"Paired with {len(devices)} Device(s). Add more? (y/n) ") == "n": - task = loop.create_task(pair_device(loop)) + task = loop.create_task(pair_device()) loop.run_until_complete(task) device = task.result() if device: @@ -56,41 +58,46 @@ def main(config, debug: bool) -> None: apikey = input("Enter your API key: ") config["apikey"] = apikey config.apikey = apikey - + skip_categories = config.skip_categories if skip_categories: if input("Skip categories already specified. Change them? (y/n) ") == "y": categories = input( - "Enter skip categories (space or comma sepparated) Options: [sponsor selfpromo exclusive_access interaction poi_highlight intro outro preview filler music_offtopic]:\n" + "Enter skip categories (space or comma sepparated) Options: [sponsor selfpromo exclusive_access " + "interaction poi_highlight intro outro preview filler music_offtopic]:\n" ) skip_categories = categories.replace(",", " ").split(" ") - skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings + skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings else: categories = input( - "Enter skip categories (space or comma sepparated) Options: [sponsor, selfpromo, exclusive_access, interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n" + "Enter skip categories (space or comma sepparated) Options: [sponsor, selfpromo, exclusive_access, " + "interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n" ) skip_categories = categories.replace(",", " ").split(" ") - skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings + skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings config.skip_categories = skip_categories channel_whitelist = config.channel_whitelist if input("Do you want to whitelist any channels from being ad-blocked? (y/n) ") == "y": - if(not apikey): - print("WARNING: You need to specify an API key to use this function, otherwise the program will fail to start.\nYou can add one by re-running this setup wizard.") + if not apikey: + print( + "WARNING: You need to specify an API key to use this function, otherwise the program will fail to " + "start.\nYou can add one by re-running this setup wizard.") web_session = aiohttp.ClientSession() + api_helper = api_helpers.ApiHelper(config, web_session) while True: channel_info = {} channel = input("Enter a channel name or \"/exit\" to exit: ") if channel == "/exit": break - task = loop.create_task(api_helpers.search_channels(channel, apikey, web_session)) + task = loop.create_task(api_helper.search_channels(channel, apikey, web_session)) loop.run_until_complete(task) results = task.result() if len(results) == 0: print("No channels found") continue - + for i in range(len(results)): print(f"{i}: {results[i][1]} - Subs: {results[i][2]}") print("5: Enter a custom channel ID") @@ -115,9 +122,10 @@ def main(config, debug: bool) -> None: channel_whitelist.append(channel_info) # Close web session asynchronously loop.run_until_complete(web_session.close()) - + config.channel_whitelist = channel_whitelist - - config.skip_count_tracking = not input("Do you want to report skipped segments to sponsorblock. Only the segment UUID will be sent? (y/n) ") == "n" + + config.skip_count_tracking = not input( + "Do you want to report skipped segments to sponsorblock. Only the segment UUID will be sent? (y/n) ") == "n" print("Config finished") config.save() diff --git a/iSponsorBlockTV/constants.py b/iSponsorBlockTV/constants.py index 1f3f75f..29117db 100644 --- a/iSponsorBlockTV/constants.py +++ b/iSponsorBlockTV/constants.py @@ -17,3 +17,5 @@ skip_categories = ( ('Preview', 'preview'), ('Filler', 'filler'), ) + +youtube_client_blacklist = ["TVHTML5_FOR_KIDS"] diff --git a/iSponsorBlockTV/dial_client.py b/iSponsorBlockTV/dial_client.py index aa553cd..8c0bd74 100644 --- a/iSponsorBlockTV/dial_client.py +++ b/iSponsorBlockTV/dial_client.py @@ -1,24 +1,19 @@ """Send out a M-SEARCH request and listening for responses.""" import asyncio import socket - -import aiohttp import ssdp from ssdp import network import xmltodict -''' -Redistribution and use of the DIAL DIscovery And Launch protocol specification (the “DIAL Specification”), with or without modification, -are permitted provided that the following conditions are met: -● Redistributions of the DIAL Specification must retain the above copyright notice, this list of conditions and the following -disclaimer. -● Redistributions of implementations of the DIAL Specification in source code form must retain the above copyright notice, this -list of conditions and the following disclaimer. -● Redistributions of implementations of the DIAL Specification in binary form must include the above copyright notice. -● The DIAL mark, the NETFLIX mark and the names of contributors to the DIAL Specification may not be used to endorse or -promote specifications, software, products, or any other materials derived from the DIAL Specification without specific prior -written permission. The DIAL mark is owned by Netflix and information on licensing the DIAL mark is available at -www.dial-multiscreen.org.''' +'''Redistribution and use of the DIAL DIscovery And Launch protocol specification (the “DIAL Specification”), +with or without modification, are permitted provided that the following conditions are met: ● Redistributions of the +DIAL Specification must retain the above copyright notice, this list of conditions and the following disclaimer. ● +Redistributions of implementations of the DIAL Specification in source code form must retain the above copyright +notice, this list of conditions and the following disclaimer. ● Redistributions of implementations of the DIAL +Specification in binary form must include the above copyright notice. ● The DIAL mark, the NETFLIX mark and the names +of contributors to the DIAL Specification may not be used to endorse or promote specifications, software, products, +or any other materials derived from the DIAL Specification without specific prior written permission. The DIAL mark +is owned by Netflix and information on licensing the DIAL mark is available at www.dial-multiscreen.org.''' ''' MIT License @@ -44,6 +39,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.''' '''Modified code from https://github.com/codingjoe/ssdp/blob/main/ssdp/__main__.py''' + def get_ip(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(0) @@ -69,6 +65,7 @@ class Handler(ssdp.aio.SSDP): def __call__(self): return self + def response_received(self, response: ssdp.messages.SSDPResponse, addr): headers = response.headers headers = {k.lower(): v for k, v in headers} diff --git a/iSponsorBlockTV/helpers.py b/iSponsorBlockTV/helpers.py index 9649bea..481e45d 100644 --- a/iSponsorBlockTV/helpers.py +++ b/iSponsorBlockTV/helpers.py @@ -43,7 +43,8 @@ class Config: def validate(self): if hasattr(self, "atvs"): print( - "The atvs config option is deprecated and has stopped working. Please read this for more information on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2", + "The atvs config option is deprecated and has stopped working. Please read this for more information " + "on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2", ) print("Exiting in 10 seconds...") time.sleep(10) diff --git a/iSponsorBlockTV/main.py b/iSponsorBlockTV/main.py index ac43df2..8e937ca 100644 --- a/iSponsorBlockTV/main.py +++ b/iSponsorBlockTV/main.py @@ -3,7 +3,6 @@ import aiohttp import time import logging from . import api_helpers, ytlounge -import traceback class DeviceListener: @@ -41,7 +40,6 @@ class DeviceListener: except: # traceback.print_exc() await asyncio.sleep(10) - while not self.cancelled: while not (await self.is_available()) and not self.cancelled: await asyncio.sleep(10) @@ -50,6 +48,7 @@ class DeviceListener: except: pass while not lounge_controller.connected() and not self.cancelled: + # Doesn't connect to the device if it's a kids profile (it's broken) await asyncio.sleep(10) try: await lounge_controller.connect() @@ -57,10 +56,10 @@ class DeviceListener: pass print(f"Connected to device {lounge_controller.screen_name} ({self.name})") try: - #print("Subscribing to lounge") + # print("Subscribing to lounge") sub = await lounge_controller.subscribe_monitored(self) await sub - #print("Subscription ended") + await asyncio.sleep(10) except: pass @@ -111,13 +110,12 @@ class DeviceListener: self.api_helper.mark_viewed_segments(UUID) ) # Don't wait for this to finish - # Stops the connection to the device async def cancel(self): self.cancelled = True try: self.task.cancel() - except Exception as e: + except Exception: pass @@ -143,7 +141,7 @@ def main(config, debug): tasks.append(loop.create_task(device.refresh_auth_loop())) try: loop.run_forever() - except KeyboardInterrupt as e: + except KeyboardInterrupt: print("Keyboard interrupt detected, cancelling tasks and exiting...") loop.run_until_complete(finish(devices)) finally: diff --git a/iSponsorBlockTV/setup_wizard.py b/iSponsorBlockTV/setup_wizard.py index 75a35cf..da1f7de 100644 --- a/iSponsorBlockTV/setup_wizard.py +++ b/iSponsorBlockTV/setup_wizard.py @@ -1,6 +1,7 @@ -import aiohttp import asyncio import copy + +import aiohttp # Textual imports (Textual is awesome!) from textual import on from textual.app import App, ComposeResult @@ -12,6 +13,7 @@ from textual.widgets import Button, Footer, Header, Static, Label, Input, Select RadioSet, RadioButton from textual.widgets.selection_list import Selection from textual_slider import Slider + # Local imports from . import api_helpers, ytlounge from .constants import skip_categories @@ -457,7 +459,7 @@ class DevicesManager(Vertical): @on(Button.Pressed, "#element-remove") def remove_channel(self, event: Button.Pressed): channel_to_remove: Element = event.button.parent - self.config.channel_whitelist.remove(channel_to_remove.element_data) + self.config.devices.remove(channel_to_remove.element_data) channel_to_remove.remove() @on(Button.Pressed, "#add-device") @@ -479,7 +481,7 @@ class ApiKeyManager(Vertical): def compose(self) -> ComposeResult: yield Label("YouTube Api Key", classes="title") yield Label( - "You can get a YouTube Api Key from the [link=https://console.developers.google.com/apis/credentials]Google Cloud Console[/link]") + "You can get a YouTube Data API v3 Key from the [link=https://console.developers.google.com/apis/credentials]Google Cloud Console[/link]. This key is only required if you're whitelisting channels.") with Grid(id="api-key-grid"): yield Input(placeholder="YouTube Api Key", id="api-key-input", password=True, value=self.config.apikey) yield Button("Show key", id="api-key-view") @@ -557,9 +559,9 @@ class AdSkipMuteManager(Vertical): "This feature allows you to automatically mute and/or skip native YouTube ads. Skipping ads only works if that ad shows the 'Skip Ad' button, if it doesn't then it will only be able to be muted.", classes="subtitle", id="skip-count-tracking-subtitle") with Horizontal(id="ad-skip-mute-container"): - yield Checkbox(value=self.config.mute_ads, id="mute-ads-switch", - label="Enable skipping ads") yield Checkbox(value=self.config.skip_ads, id="skip-ads-switch", + label="Enable skipping ads") + yield Checkbox(value=self.config.mute_ads, id="mute-ads-switch", label="Enable muting ads") @on(Checkbox.Changed, "#mute-ads-switch") diff --git a/iSponsorBlockTV/ytlounge.py b/iSponsorBlockTV/ytlounge.py index f0e3efe..ebedbcc 100644 --- a/iSponsorBlockTV/ytlounge.py +++ b/iSponsorBlockTV/ytlounge.py @@ -1,6 +1,12 @@ import asyncio +import json import aiohttp import pyytlounge +from .constants import youtube_client_blacklist + +# Temporary imports +from pyytlounge.api import api_base +from pyytlounge.wrapper import NotLinkedException, desync create_task = asyncio.create_task @@ -24,7 +30,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): await asyncio.sleep(35) # YouTube sends at least a message every 30 seconds (no-op or any other) try: self.subscribe_task.cancel() - except Exception as e: + except Exception: pass # Subscribe to the lounge and start the watchdog @@ -48,11 +54,10 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): pass finally: self.subscribe_task_watchdog = asyncio.create_task(self._watchdog()) - # A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we can get the segments) + # A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we + # can get the segments) if event_type == "onStateChange": data = args[0] - self.state.apply_state(data) - self._update_state() # print(data) # Unmute when the video starts playing if self.mute_ads and data["state"] == "1": @@ -63,12 +68,12 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): self._update_state() # Unmute when the video starts playing if self.mute_ads and data.get("state", "0") == "1": - #print("Ad has ended, unmuting") + # print("Ad has ended, unmuting") create_task(self.mute(False, override=True)) elif self.mute_ads and event_type == "onAdStateChange": data = args[0] if data["adState"] == '0': # Ad is not playing - #print("Ad has ended, unmuting") + # print("Ad has ended, unmuting") create_task(self.mute(False, override=True)) else: # Seen multiple other adStates, assuming they are all ads print("Ad has started, muting") @@ -78,10 +83,11 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): self.volume_state = args[0] pass # Gets segments for the next video before it starts playing - elif event_type == "autoplayUpNext": - if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty - print(f"Getting segments for next video: {vid_id}") - create_task(self.api_helper.get_segments(vid_id)) + # Comment "fix" since it doesn't seem to work + # elif event_type == "autoplayUpNext": + # if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty + # print(f"Getting segments for next video: {vid_id}") + # create_task(self.api_helper.get_segments(vid_id)) # #Used to know if an ad is skippable or not elif event_type == "adPlaying": @@ -97,8 +103,20 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): create_task(self.mute(False, override=True)) elif self.mute_ads: create_task(self.mute(True, override=True)) - else: - super()._process_event(event_id, event_type, args) + + elif event_type == "loungeStatus": + data = args[0] + devices = json.loads(data["devices"]) + for device in devices: + if device["type"] == "LOUNGE_SCREEN": + device_info = json.loads(device.get("deviceInfo", "")) + if device_info.get("clientName", "") in youtube_client_blacklist: + self._sid = None + self._gsession = None # Force disconnect + # elif event_type == "onAutoplayModeChanged": + # data = args[0] + # create_task(self.set_auto_play_mode(data["autoplayMode"] == "ENABLED")) + super()._process_event(event_id, event_type, args) # Set the volume to a specific value (0-100) async def set_volume(self, volume: int) -> None: @@ -117,3 +135,51 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): self.volume_state["muted"] = mute_str # YouTube wants the volume when unmuting, so we send it await super()._command("setVolume", {"volume": self.volume_state.get("volume", 100), "muted": mute_str}) + + async def set_auto_play_mode(self, enabled: bool): + await super()._command("setAutoplayMode", {"autoplayMode": "ENABLED" if enabled else "DISABLED"}) + + # Here just temporarily, will be removed once the PR is merged on YTlounge + async def connect(self) -> bool: + """Attempt to connect using the previously set tokens""" + if not self.linked(): + raise NotLinkedException("Not linked") + + connect_body = { + "app": "web", + "mdx-version": "3", + "name": self.device_name, + "id": self.auth.screen_id, + "device": "REMOTE_CONTROL", + "capabilities": "que,dsdtr,atp", + "method": "setPlaylist", + "magnaKey": "cloudPairedDevice", + "ui": "false", + "deviceContext": "user_agent=dunno&window_width_points=&window_height_points=&os_name=android&ms=", + "theme": "cl", + "loungeIdToken": self.auth.lounge_id_token, + } + connect_url = ( + f"{api_base}/bc/bind?RID=1&VER=8&CVER=1&auth_failure_option=send_error" + ) + async with aiohttp.ClientSession() as session: + async with session.post(url=connect_url, data=connect_body) as resp: + try: + text = await resp.text() + if resp.status == 401: + self.auth.lounge_id_token = None + return False + + if resp.status != 200: + self._logger.warning( + "Unknown reply to connect %i %s", resp.status, resp.reason + ) + return False + lines = text.splitlines() + async for events in self._parse_event_chunks(desync(lines)): + self._process_events(events) + self._command_offset = 1 + return self.connected() + except Exception as ex: + self._logger.exception(ex, resp.status, resp.reason) + return False diff --git a/main_tui.py b/main_tui.py index 4b7a9af..0b78dbe 100644 --- a/main_tui.py +++ b/main_tui.py @@ -2,4 +2,4 @@ from iSponsorBlockTV import setup_wizard from iSponsorBlockTV.helpers import Config config = Config("data/config.json") -setup_wizard.main(config) \ No newline at end of file +setup_wizard.main(config) diff --git a/requirements.txt b/requirements.txt index 66d01a0..fad23ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -aiohttp==3.8.6 +aiohttp==3.9.0 argparse==1.4.0 async-cache==1.1.1 -pyytlounge==1.6.2 +pyytlounge==1.6.3 rich==13.6.0 ssdp==1.3.0 textual==0.40.0