diff --git a/.github/workflows/build_docker_images.yml b/.github/workflows/build_docker_images.yml index ba8d1fc..8bbb573 100644 --- a/.github/workflows/build_docker_images.yml +++ b/.github/workflows/build_docker_images.yml @@ -25,12 +25,12 @@ jobs: steps: # Get the repository's code - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Generate docker tags - name: Docker meta id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ghcr.io/dmunozv04/isponsorblocktv, dmunozv04/isponsorblocktv tags: | @@ -42,29 +42,29 @@ jobs: # https://github.com/docker/setup-qemu-action - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64, linux/arm64, linux/arm/v7 diff --git a/.github/workflows/release_pypi.yml b/.github/workflows/release_pypi.yml index 43c9298..180d5b9 100644 --- a/.github/workflows/release_pypi.yml +++ b/.github/workflows/release_pypi.yml @@ -22,9 +22,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install dependencies diff --git a/.github/workflows/update_docker_readme.yml b/.github/workflows/update_docker_readme.yml index ccf6c37..12a3e62 100644 --- a/.github/workflows/update_docker_readme.yml +++ b/.github/workflows/update_docker_readme.yml @@ -22,7 +22,7 @@ jobs: # Update description - name: Update repo description - uses: peter-evans/dockerhub-description@v3 + uses: peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 5947838..e2067f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,12 +16,12 @@ COPY requirements.txt . RUN apk add --no-cache gcc musl-dev && \ pip install --upgrade pip wheel && \ - pip install --user -r requirements.txt && \ + pip install -r requirements.txt && \ pip uninstall -y pip wheel && \ apk del gcc musl-dev && \ - python3 -m compileall -b -f /root/.local/lib/python3.11/site-packages && \ - find /root/.local/lib/python3.11/site-packages -name "*.py" -type f -delete && \ - find /root/.local/lib/python3.11/ -name "__pycache__" -type d -exec rm -rf {} + + python3 -m compileall -b -f /usr/local/lib/python3.11/site-packages && \ + find /usr/local/lib/python3.11/site-packages -name "*.py" -type f -delete && \ + find /usr/local/lib/python3.11/ -name "__pycache__" -type d -exec rm -rf {} + FROM base @@ -29,7 +29,7 @@ ENV PIP_NO_CACHE_DIR=off iSPBTV_docker=True iSPBTV_data_dir=data TERM=xterm-256c COPY requirements.txt . -COPY --from=dep_installer /root/.local /root/.local +COPY --from=dep_installer /usr/local /usr/local WORKDIR /app diff --git a/config.json.template b/config.json.template index 59f2b9d..5e828f9 100644 --- a/config.json.template +++ b/config.json.template @@ -12,6 +12,7 @@ "skip_count_tracking": true, "mute_ads": true, "skip_ads": true, + "autoplay": true, "apikey": "", "channel_whitelist": [ {"id": "", diff --git a/pyproject.toml b/pyproject.toml index de6e83e..cd9e702 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "iSponsorBlockTV" -version = "2.0.6" +version = "2.0.8" authors = [ {"name" = "dmunozv04"} ] diff --git a/requirements.txt b/requirements.txt index 293b2ae..e30c014 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -aiohttp==3.9.0 +aiohttp==3.9.5 appdirs==1.4.4 argparse==1.4.0 async-cache==1.1.1 -pyytlounge==1.6.3 -rich==13.6.0 +pyytlounge==2.0.0 +rich==13.7.1 ssdp==1.3.0 -textual==0.40.0 +textual==0.58.0 textual-slider==0.1.1 xmltodict==0.13.0 diff --git a/src/iSponsorBlockTV/config_setup.py b/src/iSponsorBlockTV/config_setup.py index ca134d0..7948c8e 100644 --- a/src/iSponsorBlockTV/config_setup.py +++ b/src/iSponsorBlockTV/config_setup.py @@ -35,7 +35,7 @@ REPORT_SKIPPED_SEGMENTS_PROMPT = ( ) MUTE_ADS_PROMPT = "Do you want to mute native YouTube ads automatically? (y/n) " SKIP_ADS_PROMPT = "Do you want to skip native YouTube ads automatically? (y/n) " - +AUTOPLAY_PROMPT = "Do you want to enable autoplay? (y/n) " def get_yn_input(prompt): while choice := input(prompt): @@ -70,6 +70,7 @@ async def pair_device(): def main(config, debug: bool) -> None: print("Welcome to the iSponsorBlockTV cli setup wizard") loop = asyncio.get_event_loop_policy().get_event_loop() + web_session = aiohttp.ClientSession() if debug: loop.set_debug(True) asyncio.set_event_loop(loop) @@ -135,7 +136,6 @@ def main(config, debug: bool) -> None: " 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 = {} @@ -174,7 +174,6 @@ def main(config, debug: bool) -> None: channel_info["name"] = results[int(choice)][1] channel_whitelist.append(channel_info) # Close web session asynchronously - loop.run_until_complete(web_session.close()) config.channel_whitelist = channel_whitelist @@ -186,6 +185,10 @@ def main(config, debug: bool) -> None: choice = get_yn_input(SKIP_ADS_PROMPT) config.skip_ads = choice == "y" + + choice = get_yn_input(AUTOPLAY_PROMPT) + config.auto_play = choice == "y" print("Config finished") config.save() + loop.run_until_complete(web_session.close()) diff --git a/src/iSponsorBlockTV/helpers.py b/src/iSponsorBlockTV/helpers.py index 0627c29..3bce12c 100644 --- a/src/iSponsorBlockTV/helpers.py +++ b/src/iSponsorBlockTV/helpers.py @@ -41,6 +41,7 @@ class Config: self.skip_count_tracking = True self.mute_ads = False self.skip_ads = False + self.auto_play = True self.__load() def validate(self): diff --git a/src/iSponsorBlockTV/main.py b/src/iSponsorBlockTV/main.py index 8cc6e9d..bb8f1a2 100644 --- a/src/iSponsorBlockTV/main.py +++ b/src/iSponsorBlockTV/main.py @@ -10,13 +10,14 @@ from . import api_helpers, ytlounge class DeviceListener: - def __init__(self, api_helper, config, device, debug: bool): + def __init__(self, api_helper, config, device, debug: bool, web_session): self.task: Optional[asyncio.Task] = None self.api_helper = api_helper self.offset = device.offset self.name = device.name self.cancelled = False self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}") + self.web_session = web_session if debug: self.logger.setLevel(logging.DEBUG) else: @@ -28,7 +29,7 @@ class DeviceListener: self.logger.addHandler(sh) self.logger.info(f"Starting device") self.lounge_controller = ytlounge.YtLoungeApi( - device.screen_id, config, api_helper, self.logger + device.screen_id, config, api_helper, self.logger, self.web_session ) # Ensures that we have a valid auth token @@ -51,13 +52,13 @@ class DeviceListener: # Main subscription loop async def loop(self): lounge_controller = self.lounge_controller - while not lounge_controller.linked(): - try: - self.logger.debug("Refreshing auth") - await lounge_controller.refresh_auth() - except: - await asyncio.sleep(10) while not self.cancelled: + while not lounge_controller.linked(): + try: + self.logger.debug("Refreshing auth") + await lounge_controller.refresh_auth() + except: + await asyncio.sleep(10) while not (await self.is_available()) and not self.cancelled: await asyncio.sleep(10) try: @@ -155,7 +156,7 @@ def main(config, debug): web_session = aiohttp.ClientSession(loop=loop, connector=tcp_connector) api_helper = api_helpers.ApiHelper(config, web_session) for i in config.devices: - device = DeviceListener(api_helper, config, i, debug) + device = DeviceListener(api_helper, config, i, debug, web_session) devices.append(device) tasks.append(loop.create_task(device.loop())) tasks.append(loop.create_task(device.refresh_auth_loop())) @@ -165,3 +166,5 @@ def main(config, debug): print("Cancelling tasks and exiting...") loop.run_until_complete(finish(devices)) loop.run_until_complete(web_session.close()) + loop.run_until_complete(tcp_connector.close()) + loop.close() diff --git a/src/iSponsorBlockTV/setup-wizard-style.tcss b/src/iSponsorBlockTV/setup-wizard-style.tcss index e6dd886..4f6d25e 100644 --- a/src/iSponsorBlockTV/setup-wizard-style.tcss +++ b/src/iSponsorBlockTV/setup-wizard-style.tcss @@ -363,3 +363,9 @@ MigrationScreen { width: 1fr; content-align: center middle; } + +/* Autoplay */ +#autoplay-container{ + padding: 1; + height: auto; +} diff --git a/src/iSponsorBlockTV/setup_wizard.py b/src/iSponsorBlockTV/setup_wizard.py index e692abe..669c581 100644 --- a/src/iSponsorBlockTV/setup_wizard.py +++ b/src/iSponsorBlockTV/setup_wizard.py @@ -234,8 +234,8 @@ class AddDevice(ModalWithClickExit): def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config - web_session = aiohttp.ClientSession() - self.api_helper = api_helpers.ApiHelper(config, web_session) + self.web_session = aiohttp.ClientSession() + self.api_helper = api_helpers.ApiHelper(config, self.web_session) self.devices_discovered_dial = [] def compose(self) -> ComposeResult: @@ -336,7 +336,9 @@ class AddDevice(ModalWithClickExit): @on(Button.Pressed, "#add-device-pin-add-button") async def handle_add_device_pin(self) -> None: self.query_one("#add-device-pin-add-button").disabled = True - lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV") + lounge_controller = ytlounge.YtLoungeApi( + "iSponsorBlockTV", web_session=self.web_session + ) pairing_code = self.query_one("#pairing-code-input").value pairing_code = int( pairing_code.replace("-", "").replace(" ", "") @@ -848,6 +850,32 @@ class ChannelWhitelistManager(Vertical): self.app.push_screen(AddChannel(self.config), callback=self.new_channel) +class AutoPlayManager(Vertical): + """Manager for autoplay, allows enabling/disabling autoplay.""" + + def __init__(self, config, **kwargs) -> None: + super().__init__(**kwargs) + self.config = config + + def compose(self) -> ComposeResult: + yield Label("Autoplay", classes="title") + yield Label( + "This feature allows you to enable/disable autoplay", + classes="subtitle", + id="autoplay-subtitle", + ) + with Horizontal(id="autoplay-container"): + yield Checkbox( + value=self.config.auto_play, + id="autoplay-switch", + label="Enable autoplay", + ) + + @on(Checkbox.Changed, "#autoplay-switch") + def changed_skip(self, event: Checkbox.Changed): + self.config.auto_play = event.checkbox.value + + class ISponsorBlockTVSetupMainScreen(Screen): """Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221""" @@ -884,6 +912,9 @@ class ISponsorBlockTVSetupMainScreen(Screen): yield ApiKeyManager( config=self.config, id="api-key-manager", classes="container" ) + yield AutoPlayManager( + config=self.config, id="autoplay-manager", classes="container" + ) def on_mount(self) -> None: if self.check_for_old_config_entries(): diff --git a/src/iSponsorBlockTV/ytlounge.py b/src/iSponsorBlockTV/ytlounge.py index 18087f7..ce5f398 100644 --- a/src/iSponsorBlockTV/ytlounge.py +++ b/src/iSponsorBlockTV/ytlounge.py @@ -2,6 +2,7 @@ import asyncio import json import pyytlounge +from aiohttp import ClientSession from .constants import youtube_client_blacklist @@ -9,8 +10,17 @@ create_task = asyncio.create_task class YtLoungeApi(pyytlounge.YtLoungeApi): - def __init__(self, screen_id, config=None, api_helper=None, logger=None): + def __init__( + self, + screen_id, + config=None, + api_helper=None, + logger=None, + web_session: ClientSession = None, + ): super().__init__("iSponsorBlockTV", logger=logger) + if web_session is not None: + self.session = web_session # And use the one we passed self.auth.screen_id = screen_id self.auth.lounge_id_token = None self.api_helper = api_helper @@ -20,9 +30,11 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): self.callback = None self.logger = logger self.shorts_disconnected = False + self.auto_play = True if config: self.mute_ads = config.mute_ads self.skip_ads = config.skip_ads + self.auto_play = config.auto_play # Ensures that we still are subscribed to the lounge async def _watchdog(self): @@ -136,6 +148,8 @@ class YtLoungeApi(pyytlounge.YtLoungeApi): data = args[0] if data["reason"] == "disconnectedByUserScreenInitiated": # Short playing? self.shorts_disconnected = True + elif event_type == "onAutoplayModeChanged": + create_task(self.set_auto_play_mode(self.auto_play)) super()._process_event(event_id, event_type, args)