diff --git a/.deepsource.toml b/.deepsource.toml index aebeb0f..25bc3d7 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -5,4 +5,4 @@ name = "python" enabled = true [analyzers.meta] - runtime_version = "3.x.x" \ No newline at end of file + runtime_version = "3.x.x" 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/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6d375ab..e3e4ab7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,12 +7,14 @@ assignees: '' --- -Before opening an issue make sure that there are no duplicates and that you are on the latest version. +Before opening an issue make sure that there are no duplicates and that you are +on the latest version. **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -25,13 +27,14 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **iSponsorBlockTV server (please complete the following information):** - - OS: [e.g. Docker on linux Arm64, windows] - - Python version [e.g. 3.7] (no need to fill if running on docker + +- OS: [e.g. Docker on linux Arm64, windows] +- Python version [e.g. 3.7] (no need to fill if running on docker **Apple TV (please complete the following information):** - - Device: [e.g. Apple TV 4] - - OS: [e.g. tvOS 15.4] +- Device: [e.g. Apple TV 4] +- OS: [e.g. tvOS 15.4] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..d5a8d55 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -8,13 +8,15 @@ assignees: '' --- **Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +A clear and concise description of what the problem is. Ex. I'm always +frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +A clear and concise description of any alternative solutions or features you've +considered. **Additional context** Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..08aefd2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/main.yml b/.github/workflows/build_docker_images.yml similarity index 71% rename from .github/workflows/main.yml rename to .github/workflows/build_docker_images.yml index 26af9c7..b55f5f9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/build_docker_images.yml @@ -15,61 +15,65 @@ on: workflow_dispatch: -permissions: +permissions: contents: read packages: write - + jobs: build: + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} runs-on: ubuntu-latest 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 + images: | + ghcr.io/${{ github.repository }} + ${{ env.DOCKERHUB_USERNAME && 'dmunozv04/isponsorblocktv' || '' }} tags: | type=raw,value=develop,priority=900,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr type=ref,event=tag type=ref,event=branch type=schedule - + # 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@v6 with: context: . - platforms: linux/amd64, linux/arm64 + platforms: linux/amd64, linux/arm64, linux/arm/v7 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache - cache-to: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache,mode=max + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..53db252 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,205 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Release Package + +on: + push: + branches: + - '*' + tags: + - 'v*' + pull_request: + branches: + - '*' + release: + types: [published] + +defaults: + run: + shell: bash + +env: + PYTHON_VERSION: "3.11" +permissions: + contents: read +jobs: + build-sdist-and-wheel: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Hatch + run: | + python -m pip install --upgrade pip + pip install hatch + + - name: Build package + run: python -m hatch build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: sdist-and-wheel + path: dist/* + if-no-files-found: error + + + build-binaries: + name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }}) + needs: + - build-sdist-and-wheel + runs-on: ${{ matrix.job.os }} + strategy: + fail-fast: false + matrix: + job: + # Linux + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + cross: true + release_suffix: x86_64-linux + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + cross: true + cpu_variant: v1 + release_suffix: x86_64-linux-v1 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + cross: true + release_suffix: aarch64-linux + # Windows + - target: x86_64-pc-windows-msvc + os: windows-latest + release_suffix: x86_64-windows + # macOS + - target: aarch64-apple-darwin + os: macos-latest + release_suffix: aarch64-osx + - target: x86_64-apple-darwin + os: macos-latest + cross: true + release_suffix: x86_64-osx + + env: + PYAPP_PASS_LOCATION: "1" + PYAPP_UV_ENABLED: "1" + HATCH_BUILD_LOCATION: dist + CARGO: cargo + CARGO_BUILD_TARGET: ${{ matrix.job.target }} + PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }} + PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling) + PYAPP_VERSION: v0.24.0 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Clone PyApp + run: git clone --depth 1 --branch $PYAPP_VERSION https://github.com/ofek/pyapp $PYAPP_REPO + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Hatch + run: | + python -m pip install --upgrade pip + pip install hatch + + - name: Install Rust toolchain + if: ${{ !matrix.job.cross }} + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.job.target }} + + - name: Set up cross compiling tools + if: matrix.job.cross + uses: taiki-e/setup-cross-toolchain-action@v1 + with: + target: ${{ matrix.job.target}} + + - name: Show toolchain information + run: |- + rustup toolchain list + rustup default + rustup -V + rustc -V + cargo -V + hatch --version + + - name: Get artifact + uses: actions/download-artifact@v4 + with: + name: sdist-and-wheel + path: ${{ github.workspace }}/dist + merge-multiple: true + + - name: Build Binary + working-directory: ${{ github.workspace }} + run: |- + current_version=$(hatch version) + PYAPP_PROJECT_PATH="${{ github.workspace }}/dist/isponsorblocktv-${current_version}-py3-none-any.whl" hatch -v build -t binary + + - name: Rename binary + working-directory: ${{ github.workspace }} + run: |- + mv dist/binary/iSponsorBlockTV* dist/binary/iSponsorBlockTV-${{ matrix.job.release_suffix }} + + - name: Upload built binary package + uses: actions/upload-artifact@v4 + with: + name: binaries-${{ matrix.job.release_suffix }} + path: dist/binary/* + if-no-files-found: error + + + publish-to-pypi: + needs: build-sdist-and-wheel + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + # only run step if the event is a published release + if: github.event_name == 'release' && github.event.action == 'published' + runs-on: ubuntu-latest + steps: + - name: Get artifact + uses: actions/download-artifact@v4 + with: + name: sdist-and-wheel + path: dist + merge-multiple: true + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + + + publish-to-release: + permissions: + contents: write + needs: + - build-sdist-and-wheel + - build-binaries + if: github.event_name == 'release' && github.event.action == 'published' + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + name: Get artifact + with: + path: dist + merge-multiple: true + - name: Add assets to release + uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/.github/workflows/update_docker_readme.yml b/.github/workflows/update_docker_readme.yml index a1d1b80..12a3e62 100644 --- a/.github/workflows/update_docker_readme.yml +++ b/.github/workflows/update_docker_readme.yml @@ -22,9 +22,9 @@ 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 }} repository: dmunozv04/isponsorblocktv - short-description: ${{ github.event.repository.description }} \ No newline at end of file + short-description: ${{ github.event.repository.description }} diff --git a/.gitignore b/.gitignore index 47669b1..1c1206b 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,4 @@ data/config.json .DS_Store -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..0e4b780 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1 @@ +LICENSE.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9a6feed --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +# Inspired by textual pre-commit config +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-ast # simply checks whether the files parse as valid python + - id: check-builtin-literals # requires literal syntax when initializing empty or zero python builtin types + - id: check-case-conflict # checks for files that would conflict in case-insensitive filesystems + - id: check-merge-conflict # checks for files that contain merge conflict strings + - id: check-json # checks json files for parseable syntax + - id: check-toml # checks toml files for parseable syntax + - id: check-yaml # checks yaml files for parseable syntax + args: [ '--unsafe' ] # Instead of loading the files, parse them for syntax. + - id: check-shebang-scripts-are-executable # ensures that (non-binary) files with a shebang are executable + - id: check-vcs-permalinks # ensures that links to vcs websites are permalinks + - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline + - id: mixed-line-ending # replaces or checks mixed line ending + - id: trailing-whitespace # checks for trailing whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.6 + hooks: + - id: ruff + args: [ --fix, --exit-non-zero-on-fix ] + - id: ruff-format + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.44.0 + hooks: + - id: markdownlint + args: ["--fix"] diff --git a/Dockerfile b/Dockerfile index 653475f..94b6fc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,38 @@ # syntax=docker/dockerfile:1 +FROM python:3.13-alpine3.21 AS base -FROM python:alpine3.11 - -ENV PIP_NO_CACHE_DIR=off iSPBTV_docker=True TERM=xterm-256color COLORTERM=truecolor - -COPY requirements.txt . - -RUN pip install --upgrade pip wheel && \ - pip install -r requirements.txt - -COPY requirements.txt . +FROM base AS compiler WORKDIR /app -COPY . . +COPY src . -ENTRYPOINT ["python3", "-u", "main.py"] \ No newline at end of file +RUN python3 -m compileall -b -f . && \ + find . -name "*.py" -type f -delete + +FROM base AS dep_installer + +COPY requirements.txt . + +RUN apk add --no-cache gcc musl-dev && \ + pip install --upgrade pip wheel && \ + pip install -r requirements.txt && \ + pip uninstall -y pip wheel && \ + apk del gcc musl-dev && \ + python3 -m compileall -b -f /usr/local/lib/python3.13/site-packages && \ + find /usr/local/lib/python3.13/site-packages -name "*.py" -type f -delete && \ + find /usr/local/lib/python3.13/ -name "__pycache__" -type d -exec rm -rf {} + + +FROM base + +ENV PIP_NO_CACHE_DIR=off iSPBTV_docker=True iSPBTV_data_dir=data TERM=xterm-256color COLORTERM=truecolor + +COPY requirements.txt . + +COPY --from=dep_installer /usr/local /usr/local + +WORKDIR /app + +COPY --from=compiler /app . + +ENTRYPOINT ["python3", "-u", "main.pyc"] diff --git a/LICENSE.md b/LICENSE.md index f288702..78651f6 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -657,7 +657,7 @@ notice like this when it starts in an interactive mode: This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. -The hypothetical commands `show w' and `show c' should show the appropriate +The hypothetical commands `show w' and`show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". diff --git a/README.md b/README.md index 0857a48..3e61a0d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,21 @@ # iSponsorBlockTV -Skip sponsor segments in YouTube videos playing on a YouTube TV device (see below for compatibility details). + +[![ghcr.io Pulls](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fipitio.github.io%2Fbackage%2Fdmunozv04%2FiSponsorBlockTV%2Fisponsorblocktv.json&query=downloads&logo=github&label=ghcr.io%20pulls&style=flat)](https://ghcr.io/dmunozv04/isponsorblocktv) +[![Docker Pulls](https://img.shields.io/docker/pulls/dmunozv04/isponsorblocktv?logo=docker&style=flat)](https://hub.docker.com/r/dmunozv04/isponsorblocktv/) +[![GitHub Release](https://img.shields.io/github/v/release/dmunozv04/isponsorblocktv?logo=GitHub&style=flat)](https://github.com/dmunozv04/iSponsorBlockTV/releases/latest) +[![GitHub Repo stars](https://img.shields.io/github/stars/dmunozv04/isponsorblocktv?style=flat)](https://github.com/dmunozv04/isponsorblocktv) + +Skip sponsor segments in YouTube videos playing on a YouTube TV device (see +below for compatibility details). This project is written in asynchronous python and should be pretty quick. ## Installation + Check the [wiki](https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation) -Warning: docker armv7 builds have been deprecated. Amd64 and arm64 builds are still available. - ## Compatibility + Legend: ✅ = Working, ❌ = Not working, ❔ = Not tested Open an issue/pull request if you have tested a device that isn't listed here. @@ -19,33 +26,43 @@ Open an issue/pull request if you have tested a device that isn't listed here. | Samsung TV (Tizen) | ✅ | | LG TV (WebOS) | ✅ | | Android TV | ✅ | -| Chromecast | ❔ | +| 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. -It connects to the device, watches its activity and skips any sponsor segment using the [SponsorBlock](https://sponsor.ajay.app/) API. +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. ## Libraries used -- [pyytlounge](https://github.com/FabioGNR/pyytlounge) Used to interact with the device + +- [pyytlounge](https://github.com/FabioGNR/pyytlounge) Used to interact with the + device - asyncio and [aiohttp](https://github.com/aio-libs/aiohttp) - [async-cache](https://github.com/iamsinghrajat/async-cache) -- [Textual](https://github.com/textualize/textual/) Used for the amazing new graphical configurator +- [Textual](https://github.com/textualize/textual/) Used for the amazing new + graphical configurator - [ssdp](https://github.com/codingjoe/ssdp) Used for auto discovery ## Projects using this project + - [Home Assistant Addon](https://github.com/bertybuttface/addons/tree/main/isponsorblocktv) ## Contributing + 1. Fork it () 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) @@ -53,8 +70,13 @@ It can also skip/mute YouTube ads. 5. Create a new Pull Request ## Contributors + - [dmunozv04](https://github.com/dmunozv04) - creator and maintainer -- [HaltCatchFire](https://github.com/HaltCatchFire) - updated dependencies and improved skip logic -- [Oxixes](https://github.com/oxixes) - added support for channel whitelist and minor improvements +- [HaltCatchFire](https://github.com/HaltCatchFire) - updated dependencies and + improved skip logic +- [Oxixes](https://github.com/oxixes) - added support for channel whitelist and + minor improvements + ## License + [![GNU GPLv3](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) diff --git a/config.json.template b/config.json.template index 302e981..c650126 100644 --- a/config.json.template +++ b/config.json.template @@ -12,12 +12,12 @@ "skip_count_tracking": true, "mute_ads": true, "skip_ads": true, - "device_name": "iSponsorBlockTV", + "auto_play": true, + "join_name": "iSponsorBlockTV", "apikey": "", "channel_whitelist": [ {"id": "", "name": "" } ] -} - +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5632a0b..9be37a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,4 @@ services: container_name: iSponsorBlockTV restart: unless-stopped volumes: - - /PATH_TO_YOUR_DATA_DIR:/app/data \ No newline at end of file + - /PATH_TO_YOUR_DATA_DIR:/app/data diff --git a/iSponsorBlockTV/config_setup.py b/iSponsorBlockTV/config_setup.py deleted file mode 100644 index f9ae79d..0000000 --- a/iSponsorBlockTV/config_setup.py +++ /dev/null @@ -1,123 +0,0 @@ -import json -import asyncio -import sys -import aiohttp -from . import api_helpers, ytlounge - -async def pair_device(loop): - 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 - print("Pairing...") - paired = await lounge_controller.pair(pairing_code) - if not paired: - print("Failed to pair device") - return - device = { - "screen_id": lounge_controller.auth.screen_id, - "name": lounge_controller.screen_name, - } - print(f"Paired device: {device['name']}") - return device - 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() - if debug: - 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") - 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)) - loop.run_until_complete(task) - device = task.result() - if device: - devices.append(device) - config.devices = devices - - apikey = config.apikey - if apikey: - if input("API key already specified. Change it? (y/n) ") == "y": - apikey = input("Enter your API key: ") - config["apikey"] = apikey - else: - if input("API key only needed for the channel whitelist function. Add it? (y/n) ") == "y": - print( - "Get youtube apikey here: https://developers.google.com/youtube/registering_an_application" - ) - 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" - ) - skip_categories = categories.replace(",", " ").split(" ") - 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" - ) - skip_categories = categories.replace(",", " ").split(" ") - 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.") - web_session = aiohttp.ClientSession() - 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)) - 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") - print("6: Go back") - - choice = -1 - choice = input("Select one option of the above [0-6]: ") - while choice not in [str(x) for x in range(7)]: - print("Invalid choice") - choice = input("Select one option of the above [0-6]: ") - - if choice == "5": - channel_info["id"] = input("Enter a channel ID: ") - channel_info["name"] = input("Enter the channel name: ") - channel_whitelist.append(channel_info) - continue - elif choice == "6": - continue - - channel_info["id"] = results[int(choice)][0] - 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 - - 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 deleted file mode 100644 index 1f3f75f..0000000 --- a/iSponsorBlockTV/constants.py +++ /dev/null @@ -1,19 +0,0 @@ -userAgent = "iSponsorBlockTV/0.1" -SponsorBlock_service = "youtube" -SponsorBlock_actiontype = "skip" - -SponsorBlock_api = "https://sponsor.ajay.app/api/" -Youtube_api = "https://www.googleapis.com/youtube/v3/" - -skip_categories = ( - ('Sponsor', 'sponsor'), - ('Self Promotion', 'selfpromo'), - ('Intro', 'intro'), - ('Outro', 'outro'), - ('Music Offtopic', 'music_offtopic'), - ('Interaction', 'interaction'), - ('Exclusive Access', 'exclusive_access'), - ('POI Highlight', 'poi_highlight'), - ('Preview', 'preview'), - ('Filler', 'filler'), -) diff --git a/iSponsorBlockTV/helpers.py b/iSponsorBlockTV/helpers.py deleted file mode 100644 index c862231..0000000 --- a/iSponsorBlockTV/helpers.py +++ /dev/null @@ -1,122 +0,0 @@ -import argparse -import json -import logging -import os -import sys -import time - -from . import config_setup, main, setup_wizard - - -class Device: - def __init__(self, args_dict): - self.screen_id = "" - self.offset = 0 - self.__load(args_dict) - self.__validate() - - def __load(self, args_dict): - for i in args_dict: - setattr(self, i, args_dict[i]) - # Change offset to seconds (from milliseconds) - self.offset = self.offset / 1000 - - def __validate(self): - if not self.screen_id: - raise ValueError("No screen id found") - - -class Config: - def __init__(self, data_dir): - self.data_dir = data_dir - self.config_file = data_dir + "/config.json" - - self.devices = [] - self.apikey = "" - self.skip_categories = [] - self.channel_whitelist = [] - self.skip_count_tracking = True - self.mute_ads = False - self.skip_ads = False - self.device_name = "iSponsorBlockTV" - self.__load() - - 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", - ) - print("Exiting in 10 seconds...") - time.sleep(10) - sys.exit() - if not self.devices: - print("No devices found, please add at least one device") - print("Exiting in 10 seconds...") - time.sleep(10) - sys.exit() - self.devices = [Device(i) for i in self.devices] - if not self.apikey and self.channel_whitelist: - raise ValueError("No youtube API key found and channel whitelist is not empty") - if not self.skip_categories: - self.categories = ["sponsor"] - print("No categories found, using default: sponsor") - - def __load(self): - try: - with open(self.config_file, "r") as f: - config = json.load(f) - for i in config: - setattr(self, i, config[i]) - except FileNotFoundError: - print("Could not load config file") - # Create data directory if it doesn't exist (if we're not running in docker) - if not os.path.exists(self.data_dir): - if not os.getenv("iSPBTV_docker"): - print("Creating data directory") - os.makedirs(self.data_dir) - else: # Running in docker without mounting the data dir - print("Running in docker without mounting the data dir, check the wiki for more information: " - "https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker") - print("This image has recently been updated to v2, and requires changes.", - "Please read this for more information on how to upgrade to V2:", - "https://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2") - print("Exiting in 10 seconds...") - time.sleep(10) - sys.exit() - else: - print("Blank config file created") - - def save(self): - with open(self.config_file, "w") as f: - config_dict = self.__dict__ - # Don't save the config file name - config_file = self.config_file - del config_dict["config_file"] - json.dump(config_dict, f, indent=4) - self.config_file = config_file - - def __eq__(self, other): - if isinstance(other, Config): - return self.__dict__ == other.__dict__ - return False - - -def app_start(): - parser = argparse.ArgumentParser(description="iSponsorblockTV") - parser.add_argument("--data-dir", "-d", default="data", help="data directory") - parser.add_argument("--setup", "-s", action="store_true", help="setup the program graphically") - parser.add_argument("--setup-cli", "-sc", action="store_true", help="setup the program in the command line") - parser.add_argument("--debug", action="store_true", help="debug mode") - args = parser.parse_args() - - config = Config(args.data_dir) - if args.debug: - logging.basicConfig(level=logging.DEBUG) - if args.setup: # Set up the config file graphically - setup_wizard.main(config) - sys.exit() - if args.setup_cli: # Set up the config file - config_setup.main(config, args.debug) - else: - config.validate() - main.main(config, args.debug) diff --git a/iSponsorBlockTV/main.py b/iSponsorBlockTV/main.py deleted file mode 100644 index eb9fd29..0000000 --- a/iSponsorBlockTV/main.py +++ /dev/null @@ -1,149 +0,0 @@ -import asyncio -import aiohttp -import time -import logging -from . import api_helpers, ytlounge -import traceback - - -class DeviceListener: - def __init__(self, api_helper, config, screen_id, offset): - self.task: asyncio.Task = None - self.api_helper = api_helper - self.lounge_controller = ytlounge.YtLoungeApi(screen_id, config, api_helper) - self.offset = offset - self.cancelled = False - - # Ensures that we have a valid auth token - async def refresh_auth_loop(self): - while True: - await asyncio.sleep(60 * 60 * 24) # Refresh every 24 hours - try: - await self.lounge_controller.refresh_auth() - except: - # traceback.print_exc() - pass - - async def is_available(self): - try: - return await self.lounge_controller.is_available() - except: - # traceback.print_exc() - return False - - # Main subscription loop - async def loop(self): - lounge_controller = self.lounge_controller - while not lounge_controller.linked(): - try: - await lounge_controller.refresh_auth() - 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) - try: - await lounge_controller.connect() - except: - pass - while not lounge_controller.connected() and not self.cancelled: - await asyncio.sleep(10) - try: - await lounge_controller.connect() - except: - pass - print(f"Connected to device {lounge_controller.screen_name}") - try: - #print("Subscribing to lounge") - sub = await lounge_controller.subscribe_monitored(self) - await sub - #print("Subscription ended") - except: - pass - - # Method called on playback state change - async def __call__(self, state): - logging.debug("Playstatus update" + str(state)) - try: - self.task.cancel() - except: - pass - time_start = time.time() - self.task = asyncio.create_task(self.process_playstatus(state, time_start)) - - # Processes the playback state change - async def process_playstatus(self, state, time_start): - segments = [] - if state.videoId: - segments = await self.api_helper.get_segments(state.videoId) - if state.state.value == 1: # Playing - print(f"Playing {state.videoId} with {len(segments)} segments") - if segments: # If there are segments - await self.time_to_segment(segments, state.currentTime, time_start) - - # Finds the next segment to skip to and skips to it - async def time_to_segment(self, segments, position, time_start): - start_next_segment = None - next_segment = None - for segment in segments: - if position < 2 and ( - segment["start"] <= position < segment["end"] - ): - next_segment = segment - start_next_segment = position # different variable so segment doesn't change - break - if segment["start"] > position: - next_segment = segment - start_next_segment = next_segment["start"] - break - if start_next_segment: - time_to_next = start_next_segment - position - (time.time() - time_start) - self.offset - await self.skip(time_to_next, next_segment["end"], next_segment["UUID"]) - - # Skips to the next segment (waits for the time to pass) - async def skip(self, time_to, position, UUID): - await asyncio.sleep(time_to) - asyncio.create_task(self.lounge_controller.seek_to(position)) - asyncio.create_task( - 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: - pass - - -async def finish(devices): - for i in devices: - await i.cancel() - - -def main(config, debug): - loop = asyncio.get_event_loop_policy().get_event_loop() - tasks = [] # Save the tasks so the interpreter doesn't garbage collect them - devices = [] # Save the devices to close them later - if debug: - loop.set_debug(True) - asyncio.set_event_loop(loop) - tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300) - 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.screen_id, i.offset) - devices.append(device) - tasks.append(loop.create_task(device.loop())) - tasks.append(loop.create_task(device.refresh_auth_loop())) - try: - loop.run_forever() - except KeyboardInterrupt as e: - print("Keyboard interrupt detected, cancelling tasks and exiting...") - loop.run_until_complete(finish(devices)) - finally: - loop.run_until_complete(web_session.close()) diff --git a/iSponsorBlockTV/ytlounge.py b/iSponsorBlockTV/ytlounge.py deleted file mode 100644 index 8c9f204..0000000 --- a/iSponsorBlockTV/ytlounge.py +++ /dev/null @@ -1,119 +0,0 @@ -import asyncio -import aiohttp -import pyytlounge - -create_task = asyncio.create_task - - -class YtLoungeApi(pyytlounge.YtLoungeApi): - def __init__(self, screen_id, config=None, api_helper=None): - super().__init__(config.device_name if config else "iSponsorBlockTV") - self.auth.screen_id = screen_id - self.auth.lounge_id_token = None - self.api_helper = api_helper - self.volume_state = {} - self.subscribe_task = None - self.subscribe_task_watchdog = None - self.callback = None - if config: - self.mute_ads = config.mute_ads - self.skip_ads = config.skip_ads - - # Ensures that we still are subscribed to the lounge - async def _watchdog(self): - 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: - pass - - # Subscribe to the lounge and start the watchdog - async def subscribe_monitored(self, callback): - self.callback = callback - try: - self.subscribe_task_watchdog.cancel() - except: - pass # No watchdog task - self.subscribe_task = asyncio.create_task(super().subscribe(callback)) - self.subscribe_task_watchdog = asyncio.create_task(self._watchdog()) - return self.subscribe_task - - # Process a lounge subscription event - def _process_event(self, event_id: int, event_type: str, args): - # print(f"YtLoungeApi.__process_event({event_id}, {event_type}, {args})") - # (Re)start the watchdog - try: - self.subscribe_task_watchdog.cancel() - except: - 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) - 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": - create_task(self.mute(False, override=True)) - elif event_type == "nowPlaying": - data = args[0] - self.state = pyytlounge.PlaybackState(self._logger, data) - self._update_state() - # Unmute when the video starts playing - if self.mute_ads and data.get("state", "0") == "1": - #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") - create_task(self.mute(False, override=True)) - else: # Seen multiple other adStates, assuming they are all ads - print("Ad has started, muting") - create_task(self.mute(True, override=True)) - # Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it) - elif event_type == "onVolumeChanged": - 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)) - - # #Used to know if an ad is skippable or not - elif event_type == "adPlaying": - data = args[0] - # Gets segments for the next video (after the ad) before it starts playing - if vid_id := data["contentVideoId"]: - print(f"Getting segments for next video: {vid_id}") - create_task(self.api_helper.get_segments(vid_id)) - - if data["isSkippable"] == "true": # YouTube uses strings for booleans - if self.skip_ads: - create_task(self.skip_ad()) - 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) - - # Set the volume to a specific value (0-100) - async def set_volume(self, volume: int) -> None: - await super()._command("setVolume", {"volume": volume}) - - # Mute or unmute the device (if the device already is in the desired state, nothing happens) - # mute: True to mute, False to unmute - # override: If True, the command is sent even if the device already is in the desired state - # TODO: Only works if the device is subscribed to the lounge - async def mute(self, mute: bool, override: bool = False) -> None: - if mute: - mute_str = "true" - else: - mute_str = "false" - if override or not (self.volume_state.get("muted", "false") == mute_str): - 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}) diff --git a/main-macos.py b/main-macos.py deleted file mode 100644 index ac7e117..0000000 --- a/main-macos.py +++ /dev/null @@ -1,7 +0,0 @@ -from iSponsorBlockTV import helpers -import sys -import os - -if getattr(sys, "frozen", False): - os.environ["SSL_CERT_FILE"] = os.path.join(sys._MEIPASS, "lib", "cert.pem") -helpers.app_start() diff --git a/main-macos.spec b/main-macos.spec deleted file mode 100644 index f0ee8d9..0000000 --- a/main-macos.spec +++ /dev/null @@ -1,47 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -from PyInstaller.utils.hooks import exec_statement -cert_datas = exec_statement(""" - import ssl - print(ssl.get_default_verify_paths().cafile)""").strip().split() -cert_datas = [(f, 'lib') for f in cert_datas] - -block_cipher = None - -options = [ ('u', None, 'OPTION') ] - -a = Analysis(['main-macos.py'], - pathex=[], - binaries=[], - datas=cert_datas, - hiddenimports=['certifi'], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False) -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) - -exe = EXE(pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - options, - name='iSponsorBlockTV-macos', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..69bffe4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "iSponsorBlockTV" +version = "2.2.1" +authors = [ + {"name" = "dmunozv04"} +] +description = "SponsorBlock client for all YouTube TV clients" +readme = "README.md" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Topic :: Home Automation" +] +dynamic = ["dependencies"] + +[tool.hatch.metadata.hooks.requirements_txt] +files = ["requirements.txt"] + +[project.urls] +"Homepage" = "https://github.com/dmunozv04/iSponsorBlockTV" +"Bug Tracker" = "https://github.com/dmunozv04/iSponsorBlockTV/issues" + +[project.scripts] +"iSponsorBlockTV" = "iSponsorBlockTV.__main__:main" + +[build-system] +requires = ["hatchling", "hatch-requirements-txt"] +build-backend = "hatchling.build" + +[tool.black] +line-length = 88 diff --git a/requirements.txt b/requirements.txt index 66d01a0..7d5ace6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -aiohttp==3.8.6 -argparse==1.4.0 +aiohttp==3.9.5 +appdirs==1.4.4 async-cache==1.1.1 -pyytlounge==1.6.2 -rich==13.6.0 +pyytlounge==2.1.1 +rich==13.9.4 ssdp==1.3.0 -textual==0.40.0 -textual-slider==0.1.1 -xmltodict==0.13.0 - +textual==1.0.0 +textual-slider==0.2.0 +xmltodict==0.14.2 +rich_click==1.8.5 diff --git a/iSponsorBlockTV/__init__.py b/src/iSponsorBlockTV/__init__.py similarity index 100% rename from iSponsorBlockTV/__init__.py rename to src/iSponsorBlockTV/__init__.py diff --git a/src/iSponsorBlockTV/__main__.py b/src/iSponsorBlockTV/__main__.py new file mode 100644 index 0000000..12e16bf --- /dev/null +++ b/src/iSponsorBlockTV/__main__.py @@ -0,0 +1,9 @@ +from . import helpers + + +def main(): + helpers.app_start() + + +if __name__ == "__main__": + main() diff --git a/iSponsorBlockTV/api_helpers.py b/src/iSponsorBlockTV/api_helpers.py similarity index 55% rename from iSponsorBlockTV/api_helpers.py rename to src/iSponsorBlockTV/api_helpers.py index c6f5dd2..8cfe3e8 100644 --- a/iSponsorBlockTV/api_helpers.py +++ b/src/iSponsorBlockTV/api_helpers.py @@ -1,17 +1,18 @@ -from cache import AsyncTTL, 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 +from hashlib import sha256 + +from aiohttp import ClientSession +from cache import AsyncLRU + +from . import constants, dial_client +from .conditional_ttl_cache import AsyncConditionalTTL -def listToTuple(function): +def list_to_tuple(function): def wrapper(*args): - args = [tuple(x) if type(x) == list else x for x in args] + args = [tuple(x) if isinstance(x, list) else x for x in args] result = function(*args) - result = tuple(result) if type(result) == list else result + result = tuple(result) if isinstance(result, list) else result return result return wrapper @@ -39,17 +40,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,14 +67,20 @@ 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"] @AsyncLRU(maxsize=10) async def search_channels(self, channel): channels = [] - params = {"q": channel, "key": self.apikey, "part": "snippet", "type": "channel", "maxResults": "5"} + params = { + "q": channel, + "key": self.apikey, + "part": "snippet", + "type": "channel", + "maxResults": "5", + } url = constants.Youtube_api + "search" async with self.web_session.get(url, params=params) as resp: data = await resp.json() @@ -81,30 +88,42 @@ class ApiHelper: return channels for i in data["items"]: - # Get channel subcription number - params = {"id": i["snippet"]["channelId"], "key": self.apikey, "part": "statistics"} + # Get channel subscription number + params = { + "id": i["snippet"]["channelId"], + "key": self.apikey, + "part": "statistics", + } url = constants.Youtube_api + "channels" async with self.web_session.get(url, params=params) as resp: - channelData = await resp.json() + channel_data = await resp.json() - if channelData["items"][0]["statistics"]["hiddenSubscriberCount"]: - subCount = "Hidden" + if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]: + sub_count = "Hidden" else: - subCount = int(channelData["items"][0]["statistics"]["subscriberCount"]) - subCount = format(subCount, "_") + sub_count = int( + channel_data["items"][0]["statistics"]["subscriberCount"] + ) + sub_count = format(sub_count, "_") - channels.append((i["snippet"]["channelId"], i["snippet"]["channelTitle"], subCount)) + channels.append( + (i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count) + ) return channels - @listToTuple # Convert list to tuple so it can be used as a key in the cache - @AsyncConditionalTTL(time_to_live=300, maxsize=10) # 5 minutes for non-locked segments + @list_to_tuple # Convert list to tuple so it can be used as a key in the cache + @AsyncConditionalTTL( + time_to_live=300, maxsize=10 + ) # 5 minutes for non-locked segments async def get_segments(self, vid_id): if await self.is_whitelisted(vid_id): - print("Video is whitelisted") - return ([], True) # Return empty list and True to indicate that the cache should last forever + return ( + [], + True, + ) # Return empty list and True to indicate that the cache should last forever vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[ - :4 - ] # Hashes video id and gets the first 4 characters + :4 + ] # Hashes video id and gets the first 4 characters params = { "category": self.skip_categories, "actionType": constants.SponsorBlock_actiontype, @@ -112,17 +131,49 @@ 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: + async with self.web_session.get( + url, headers=headers, params=params + ) as 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: - for i in response["segments"]: - ignore_ttl = ignore_ttl and i["locked"] == 1 # If all segments are locked, ignore ttl + response_segments = response["segments"] + # sort by end + response_segments.sort(key=lambda x: x["segment"][1]) + # extend ends of overlapping segments to make one big segment + for i in response_segments: + for j in response_segments: + if j["segment"][0] <= i["segment"][1] <= j["segment"][1]: + i["segment"][1] = j["segment"][1] + + # sort by start + response_segments.sort(key=lambda x: x["segment"][0]) + # extend starts of overlapping segments to make one big segment + for i in reversed(response_segments): + for j in reversed(response_segments): + if j["segment"][0] <= i["segment"][0] <= j["segment"][1]: + i["segment"][0] = j["segment"][0] + + for i in response_segments: + ignore_ttl = ( + ignore_ttl and i["locked"] == 1 + ) # If all segments are locked, ignore ttl segment = i["segment"] UUID = i["UUID"] segment_dict = {"start": segment[0], "end": segment[1], "UUID": [UUID]} @@ -135,20 +186,21 @@ class ApiHelper: except Exception: segment_before_end = -10 if ( - segment_dict["start"] - segment_before_end < 1 - ): # Less than 1 second appart, combine them and skip them together + segment_dict["start"] - segment_before_end < 1 + ): # Less than 1 second apart, combine them and skip them together segment_dict["start"] = segment_before_start segment_dict["UUID"].extend(segment_before_UUID) segments.pop() segments.append(segment_dict) except Exception: pass - return (segments, ignore_ttl) + 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)""" + async def mark_viewed_segments(self, uuids): + """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: + for i in uuids: url = constants.SponsorBlock_api + "viewedVideoSponsorTime/" params = {"UUID": i} await self.web_session.post(url, params=params) diff --git a/iSponsorBlockTV/conditional_ttl_cache.py b/src/iSponsorBlockTV/conditional_ttl_cache.py similarity index 75% rename from iSponsorBlockTV/conditional_ttl_cache.py rename to src/iSponsorBlockTV/conditional_ttl_cache.py index 49fd0e7..de5b852 100644 --- a/iSponsorBlockTV/conditional_ttl_cache.py +++ b/src/iSponsorBlockTV/conditional_ttl_cache.py @@ -1,3 +1,8 @@ +import datetime + +from cache.key import KEY +from cache.lru import LRU + """MIT License Copyright (c) 2020 Rajat Singh @@ -19,11 +24,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" -'''Modified code from https://github.com/iamsinghrajat/async-cache''' - -from cache.key import KEY -from cache.lru import LRU -import datetime +"""Modified code from https://github.com/iamsinghrajat/async-cache""" class AsyncConditionalTTL: @@ -31,22 +32,20 @@ class AsyncConditionalTTL: def __init__(self, time_to_live, maxsize): super().__init__(maxsize=maxsize) - self.time_to_live = datetime.timedelta( - seconds=time_to_live - ) if time_to_live else None + self.time_to_live = ( + datetime.timedelta(seconds=time_to_live) if time_to_live else None + ) self.maxsize = maxsize def __contains__(self, key): if key not in self.keys(): return False - else: - key_expiration = super().__getitem__(key)[1] - if key_expiration and key_expiration < datetime.datetime.now(): - del self[key] - return False - else: - return True + key_expiration = super().__getitem__(key)[1] + if key_expiration and key_expiration < datetime.datetime.now(): + del self[key] + return False + return True def __getitem__(self, key): value = super().__getitem__(key)[0] @@ -55,13 +54,13 @@ class AsyncConditionalTTL: def __setitem__(self, key, value): value, ignore_ttl = value # unpack tuple ttl_value = ( - datetime.datetime.now() + self.time_to_live - ) if (self.time_to_live and not ignore_ttl) else None # ignore ttl if ignore_ttl is True + (datetime.datetime.now() + self.time_to_live) + if (self.time_to_live and not ignore_ttl) + else None + ) # ignore ttl if ignore_ttl is True super().__setitem__(key, (value, ttl_value)) - def __init__( - self, time_to_live=60, maxsize=1024, skip_args: int = 0 - ): + def __init__(self, time_to_live=60, maxsize=1024, skip_args: int = 0): """ :param time_to_live: Use time_to_live as None for non expiring cache @@ -73,7 +72,7 @@ class AsyncConditionalTTL: def __call__(self, func): async def wrapper(*args, **kwargs): - key = KEY(args[self.skip_args:], kwargs) + key = KEY(args[self.skip_args :], kwargs) if key in self.ttl: val = self.ttl[key] else: diff --git a/src/iSponsorBlockTV/config_setup.py b/src/iSponsorBlockTV/config_setup.py new file mode 100644 index 0000000..a46015e --- /dev/null +++ b/src/iSponsorBlockTV/config_setup.py @@ -0,0 +1,195 @@ +import asyncio + +import aiohttp + +from . import api_helpers, ytlounge + +# Constants for user input prompts +ATVS_REMOVAL_PROMPT = ( + "Do you want to remove the legacy 'atvs' entry (the app won't start" + " with it present)? (y/N) " +) +PAIRING_CODE_PROMPT = "Enter pairing code (found in Settings - Link with TV code): " +ADD_MORE_DEVICES_PROMPT = "Paired with {num_devices} Device(s). Add more? (y/N) " +CHANGE_API_KEY_PROMPT = "API key already specified. Change it? (y/N) " +ADD_API_KEY_PROMPT = ( + "API key only needed for the channel whitelist function. Add it? (y/N) " +) +ENTER_API_KEY_PROMPT = "Enter your API key: " +CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) " +ENTER_SKIP_CATEGORIES_PROMPT = ( + "Enter skip categories (space or comma sepparated) Options: [sponsor," + " selfpromo, exclusive_access, interaction, poi_highlight, intro, outro," + " preview, filler, music_offtopic]:\n" +) +WHITELIST_CHANNELS_PROMPT = ( + "Do you want to whitelist any channels from being ad-blocked? (y/N) " +) +SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: ' +SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: " +ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: " +ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: " +REPORT_SKIPPED_SEGMENTS_PROMPT = ( + "Do you want to report skipped segments to sponsorblock. Only the segment" + " UUID will be sent? (Y/n) " +) +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): + if choice.lower() in ["y", "n"]: + return choice.lower() + print("Invalid input. Please enter 'y' or 'n'.") + + +async def pair_device(): + try: + lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV") + pairing_code = input(PAIRING_CODE_PROMPT) + pairing_code = int( + pairing_code.replace("-", "").replace(" ", "") + ) # remove dashes and spaces + print("Pairing...") + paired = await lounge_controller.pair(pairing_code) + if not paired: + print("Failed to pair device") + return + device = { + "screen_id": lounge_controller.auth.screen_id, + "name": lounge_controller.screen_name, + } + print(f"Paired device: {device['name']}") + return device + 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() + web_session = aiohttp.ClientSession() + if debug: + 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" + ) + choice = get_yn_input(ATVS_REMOVAL_PROMPT) + if choice == "y": + del config["atvs"] + + devices = config.devices + choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices))) + while choice == "y": + task = loop.create_task(pair_device()) + loop.run_until_complete(task) + device = task.result() + if device: + devices.append(device) + choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices))) + config.devices = devices + + apikey = config.apikey + if apikey: + choice = get_yn_input(CHANGE_API_KEY_PROMPT) + if choice == "y": + apikey = input(ENTER_API_KEY_PROMPT) + else: + choice = get_yn_input(ADD_API_KEY_PROMPT) + if choice == "y": + print( + "Get youtube apikey here:" + " https://developers.google.com/youtube/registering_an_application" + ) + apikey = input(ENTER_API_KEY_PROMPT) + config.apikey = apikey + + skip_categories = config.skip_categories + if skip_categories: + choice = get_yn_input(CHANGE_SKIP_CATEGORIES_PROMPT) + if choice == "y": + categories = input(ENTER_SKIP_CATEGORIES_PROMPT) + skip_categories = categories.replace(",", " ").split(" ") + skip_categories = [ + x for x in skip_categories if x != "" + ] # Remove empty strings + else: + categories = input(ENTER_SKIP_CATEGORIES_PROMPT) + skip_categories = categories.replace(",", " ").split(" ") + skip_categories = [ + x for x in skip_categories if x != "" + ] # Remove empty strings + config.skip_categories = skip_categories + + channel_whitelist = config.channel_whitelist + choice = get_yn_input(WHITELIST_CHANNELS_PROMPT) + if choice == "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." + ) + api_helper = api_helpers.ApiHelper(config, web_session) + while True: + channel_info = {} + channel = input(SEARCH_CHANNEL_PROMPT) + if channel == "/exit": + break + + 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, item in enumerate(results): + print(f"{i}: {item[1]} - Subs: {item[2]}") + print("5: Enter a custom channel ID") + print("6: Go back") + + while choice := input(SELECT_CHANNEL_PROMPT): + if choice in [str(x) for x in range(7)]: + break + print("Invalid choice") + + if choice == "5": + channel_info["id"] = input(ENTER_CHANNEL_ID_PROMPT) + channel_info["name"] = input(ENTER_CUSTOM_CHANNEL_NAME_PROMPT) + channel_whitelist.append(channel_info) + continue + if choice == "6": + continue + + channel_info["id"] = results[int(choice)][0] + channel_info["name"] = results[int(choice)][1] + channel_whitelist.append(channel_info) + # Close web session asynchronously + + config.channel_whitelist = channel_whitelist + + choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT) + config.skip_count_tracking = choice != "n" + + choice = get_yn_input(MUTE_ADS_PROMPT) + config.mute_ads = choice == "y" + + choice = get_yn_input(SKIP_ADS_PROMPT) + config.skip_ads = choice == "y" + + choice = get_yn_input(AUTOPLAY_PROMPT) + config.auto_play = choice != "n" + + print("Config finished") + config.save() + loop.run_until_complete(web_session.close()) diff --git a/src/iSponsorBlockTV/constants.py b/src/iSponsorBlockTV/constants.py new file mode 100644 index 0000000..daaf36e --- /dev/null +++ b/src/iSponsorBlockTV/constants.py @@ -0,0 +1,24 @@ +userAgent = "iSponsorBlockTV/0.1" +SponsorBlock_service = "youtube" +SponsorBlock_actiontype = "skip" + +SponsorBlock_api = "https://sponsor.ajay.app/api/" +Youtube_api = "https://www.googleapis.com/youtube/v3/" + +skip_categories = ( + ("Sponsor", "sponsor"), + ("Self Promotion", "selfpromo"), + ("Intro", "intro"), + ("Outro", "outro"), + ("Music Offtopic", "music_offtopic"), + ("Interaction", "interaction"), + ("Exclusive Access", "exclusive_access"), + ("POI Highlight", "poi_highlight"), + ("Preview", "preview"), + ("Filler", "filler"), +) + +youtube_client_blacklist = ["TVHTML5_FOR_KIDS"] + + +config_file_blacklist_keys = ["config_file", "data_dir"] diff --git a/iSponsorBlockTV/dial_client.py b/src/iSponsorBlockTV/dial_client.py similarity index 72% rename from iSponsorBlockTV/dial_client.py rename to src/iSponsorBlockTV/dial_client.py index aa553cd..75695ea 100644 --- a/iSponsorBlockTV/dial_client.py +++ b/src/iSponsorBlockTV/dial_client.py @@ -1,26 +1,29 @@ -"""Send out a M-SEARCH request and listening for responses.""" +"""Send out an M-SEARCH request and listening for responses.""" + import asyncio import socket -import aiohttp import ssdp -from ssdp import network import xmltodict +from ssdp import network -''' -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 Copyright (c) 2018 Johannes Hoppe @@ -41,25 +44,26 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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''' +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) try: # doesn't even have to be reachable - s.connect(('10.254.254.254', 1)) - IP = s.getsockname()[0] + s.connect(("10.254.254.254", 1)) + ip = s.getsockname()[0] except Exception: - IP = '127.0.0.1' + ip = "127.0.0.1" finally: s.close() - return IP + return ip class Handler(ssdp.aio.SSDP): - def __init__(self): super().__init__() self.devices = [] @@ -69,6 +73,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} @@ -110,7 +115,9 @@ async def discover(web_session): family, addr = network.get_best_family(bind, network.PORT) loop = asyncio.get_event_loop() ip_address = get_ip() - connect = loop.create_datagram_endpoint(handler, family=family, local_addr=(ip_address, None)) + connect = loop.create_datagram_endpoint( + handler, family=family, local_addr=(ip_address, None) + ) transport, protocol = await connect target = network.MULTICAST_ADDRESS_IPV4, network.PORT diff --git a/src/iSponsorBlockTV/helpers.py b/src/iSponsorBlockTV/helpers.py new file mode 100644 index 0000000..6b4880e --- /dev/null +++ b/src/iSponsorBlockTV/helpers.py @@ -0,0 +1,218 @@ +import json +import logging +import os +import sys +import time + +import rich_click as click +from appdirs import user_data_dir + +from . import config_setup, main, setup_wizard +from .constants import config_file_blacklist_keys + + +class Device: + def __init__(self, args_dict): + self.screen_id = "" + self.offset = 0 + self.__load(args_dict) + self.__validate() + + def __load(self, args_dict): + for i in args_dict: + setattr(self, i, args_dict[i]) + # Change offset to seconds (from milliseconds) + self.offset = self.offset / 1000 + + def __validate(self): + if not self.screen_id: + raise ValueError("No screen id found") + + +class Config: + def __init__(self, data_dir): + self.data_dir = data_dir + self.config_file = data_dir + "/config.json" + + self.devices = [] + self.apikey = "" + self.skip_categories = [] # These are the categories on the config file + self.channel_whitelist = [] + self.skip_count_tracking = True + self.mute_ads = False + self.skip_ads = False + self.auto_play = True + self.__load() + + 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" + ), + ) + print("Exiting in 10 seconds...") + time.sleep(10) + sys.exit() + if not self.devices: + print("No devices found, please add at least one device") + print("Exiting in 10 seconds...") + time.sleep(10) + sys.exit() + self.devices = [Device(i) for i in self.devices] + if not self.apikey and self.channel_whitelist: + raise ValueError( + "No youtube API key found and channel whitelist is not empty" + ) + if not self.skip_categories: + self.skip_categories = ["sponsor"] + print("No categories found, using default: sponsor") + + def __load(self): + try: + with open(self.config_file, "r", encoding="utf-8") as f: + config = json.load(f) + for i in config: + if i not in config_file_blacklist_keys: + setattr(self, i, config[i]) + except FileNotFoundError: + print("Could not load config file") + # Create data directory if it doesn't exist (if we're not running in docker) + if not os.path.exists(self.data_dir): + if not os.getenv("iSPBTV_docker"): + print("Creating data directory") + os.makedirs(self.data_dir) + else: # Running in docker without mounting the data dir + print( + "Running in docker without mounting the data dir, check the" + " wiki for more information: " + "https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker" + ) + print( + ( + "This image has recently been updated to v2, and requires" + " changes." + ), + ( + "Please read this for more information on how to upgrade" + " to V2:" + ), + "https://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2", + ) + print("Exiting in 10 seconds...") + time.sleep(10) + sys.exit() + else: + print("Blank config file created") + + def save(self): + with open(self.config_file, "w", encoding="utf-8") as f: + config_dict = self.__dict__ + # Don't save the config file name + config_file = self.config_file + data_dir = self.data_dir + del config_dict["config_file"] + del config_dict["data_dir"] + json.dump(config_dict, f, indent=4) + self.config_file = config_file + self.data_dir = data_dir + + def __eq__(self, other): + if isinstance(other, Config): + return self.__dict__ == other.__dict__ + return False + + +@click.group(invoke_without_command=True) +@click.option( + "--data", + "-d", + default=lambda: os.getenv("iSPBTV_data_dir") + or user_data_dir("iSponsorBlockTV", "dmunozv04"), + help="data directory", +) +@click.option("--debug", is_flag=True, help="debug mode") +# legacy commands as arguments +@click.option( + "--setup", is_flag=True, help="Setup the program graphically", hidden=True +) +@click.option( + "--setup-cli", + is_flag=True, + help="Setup the program in the command line", + hidden=True, +) +@click.pass_context +def cli(ctx, data, debug, setup, setup_cli): + """iSponsorblockTV""" + ctx.ensure_object(dict) + ctx.obj["data_dir"] = data + ctx.obj["debug"] = debug + if debug: + logging.basicConfig(level=logging.DEBUG) + if ctx.invoked_subcommand is None: + if setup: + ctx.invoke(setup_command) + elif setup_cli: + ctx.invoke(setup_cli_command) + else: + ctx.invoke(start) + + +@cli.command() +@click.pass_context +def setup(ctx): + """Setup the program graphically""" + config = Config(ctx.obj["data_dir"]) + setup_wizard.main(config) + sys.exit() + + +setup_command = setup + + +@cli.command() +@click.pass_context +def setup_cli(ctx): + """Setup the program in the command line""" + config = Config(ctx.obj["data_dir"]) + config_setup.main(config, ctx.obj["debug"]) + + +setup_cli_command = setup_cli + + +@cli.command() +@click.pass_context +def start(ctx): + """Start the main program""" + config = Config(ctx.obj["data_dir"]) + config.validate() + main.main(config, ctx.obj["debug"]) + + +# Create fake "self" group to show pyapp options in help menu +# Subcommands remove, restore, update +pyapp_group = click.RichGroup("self", help="pyapp options (update, remove, restore)") +pyapp_group.add_command( + click.RichCommand("update", help="Update the package to the latest version") +) +pyapp_group.add_command( + click.Command( + "remove", help="Remove the package, wiping the installation but not the data" + ) +) +pyapp_group.add_command( + click.RichCommand( + "restore", help="Restore the package to its original state by reinstalling it" + ) +) +if os.getenv("PYAPP"): + cli.add_command(pyapp_group) + + +def app_start(): + cli(obj={}) diff --git a/iSponsorBlockTV/macos_install.py b/src/iSponsorBlockTV/macos_install.py similarity index 90% rename from iSponsorBlockTV/macos_install.py rename to src/iSponsorBlockTV/macos_install.py index 04be8be..2dc723e 100644 --- a/iSponsorBlockTV/macos_install.py +++ b/src/iSponsorBlockTV/macos_install.py @@ -1,6 +1,8 @@ -import plistlib import os +import plistlib + from . import config_setup + """Not updated to V2 yet, should still work. Here be dragons""" default_plist = { "Label": "com.dmunozv04iSponsorBlockTV", @@ -40,7 +42,9 @@ def main(): create_plist(correct_path) run_setup(correct_path + "/config.json") print( - "Launch daemon installed. Please restart the computer to enable it or use:\n launchctl load ~/Library/LaunchAgents/com.dmunozv04.iSponsorBlockTV.plist" + "Launch daemon installed. Please restart the computer to enable it or" + " use:\n launchctl load" + " ~/Library/LaunchAgents/com.dmunozv04.iSponsorBlockTV.plist" ) else: if not os.path.exists(correct_path): @@ -48,6 +52,6 @@ def main(): print( "Please move the program to the correct path: " + correct_path - + "opeing now on finder..." + + "opening now on finder..." ) os.system("open -R " + correct_path) diff --git a/src/iSponsorBlockTV/main.py b/src/iSponsorBlockTV/main.py new file mode 100644 index 0000000..dd67b45 --- /dev/null +++ b/src/iSponsorBlockTV/main.py @@ -0,0 +1,190 @@ +import asyncio +import logging +import time +from signal import SIGINT, SIGTERM, signal +from typing import Optional + +import aiohttp + +from . import api_helpers, ytlounge + + +class DeviceListener: + 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: + self.logger.setLevel(logging.INFO) + sh = logging.StreamHandler() + sh.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + self.logger.addHandler(sh) + self.logger.info("Starting device") + self.lounge_controller = ytlounge.YtLoungeApi( + device.screen_id, config, api_helper, self.logger, self.web_session + ) + + # Ensures that we have a valid auth token + async def refresh_auth_loop(self): + while True: + await asyncio.sleep(60 * 60 * 24) # Refresh every 24 hours + try: + await self.lounge_controller.refresh_auth() + except BaseException: + # traceback.print_exc() + pass + + async def is_available(self): + try: + return await self.lounge_controller.is_available() + except BaseException: + # traceback.print_exc() + return False + + # Main subscription loop + async def loop(self): + lounge_controller = self.lounge_controller + while not self.cancelled: + while not lounge_controller.linked(): + try: + self.logger.debug("Refreshing auth") + await lounge_controller.refresh_auth() + except BaseException: + await asyncio.sleep(10) + while not (await self.is_available()) and not self.cancelled: + await asyncio.sleep(10) + try: + await lounge_controller.connect() + except BaseException: + 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() + except BaseException: + pass + self.logger.info( + "Connected to device %s (%s)", lounge_controller.screen_name, self.name + ) + try: + self.logger.info("Subscribing to lounge") + sub = await lounge_controller.subscribe_monitored(self) + await sub + except BaseException: + pass + + # Method called on playback state change + async def __call__(self, state): + try: + self.task.cancel() + except BaseException: + pass + time_start = time.time() + self.task = asyncio.create_task(self.process_playstatus(state, time_start)) + + # Processes the playback state change + async def process_playstatus(self, state, time_start): + segments = [] + if state.videoId: + segments = await self.api_helper.get_segments(state.videoId) + if state.state.value == 1: # Playing + self.logger.info( + f"Playing video {state.videoId} with {len(segments)} segments" + ) + if segments: # If there are segments + await self.time_to_segment(segments, state.currentTime, time_start) + + # Finds the next segment to skip to and skips to it + async def time_to_segment(self, segments, position, time_start): + start_next_segment = None + next_segment = None + for segment in segments: + if position < 2 and (segment["start"] <= position < segment["end"]): + next_segment = segment + start_next_segment = ( + position # different variable so segment doesn't change + ) + break + if segment["start"] > position: + next_segment = segment + start_next_segment = next_segment["start"] + break + if start_next_segment: + time_to_next = ( + start_next_segment - position - (time.time() - time_start) - self.offset + ) + await self.skip(time_to_next, next_segment["end"], next_segment["UUID"]) + + # Skips to the next segment (waits for the time to pass) + async def skip(self, time_to, position, uuids): + await asyncio.sleep(time_to) + self.logger.info("Skipping segment: seeking to %s", position) + await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids)) + await asyncio.create_task(self.lounge_controller.seek_to(position)) + + async def cancel(self): + self.cancelled = True + await self.lounge_controller.disconnect() + if self.task: + self.task.cancel() + if self.lounge_controller.subscribe_task_watchdog: + self.lounge_controller.subscribe_task_watchdog.cancel() + if self.lounge_controller.subscribe_task: + self.lounge_controller.subscribe_task.cancel() + await asyncio.gather( + self.task, + self.lounge_controller.subscribe_task_watchdog, + self.lounge_controller.subscribe_task, + return_exceptions=True, + ) + + +async def finish(devices, web_session, tcp_connector): + await asyncio.gather( + *(device.cancel() for device in devices), return_exceptions=True + ) + await web_session.close() + await tcp_connector.close() + + +def handle_signal(signum, frame): + raise KeyboardInterrupt() + + +def main(config, debug): + loop = asyncio.get_event_loop_policy().get_event_loop() + tasks = [] # Save the tasks so the interpreter doesn't garbage collect them + devices = [] # Save the devices to close them later + if debug: + loop.set_debug(True) + asyncio.set_event_loop(loop) + tcp_connector = aiohttp.TCPConnector(loop=loop, ttl_dns_cache=300) + 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, web_session) + devices.append(device) + tasks.append(loop.create_task(device.loop())) + tasks.append(loop.create_task(device.refresh_auth_loop())) + signal(SIGTERM, handle_signal) + signal(SIGINT, handle_signal) + try: + loop.run_forever() + except KeyboardInterrupt: + print("Cancelling tasks and exiting...") + loop.run_until_complete(finish(devices, web_session, tcp_connector)) + for task in tasks: + task.cancel() + loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) + finally: + loop.close() + print("Exited") diff --git a/iSponsorBlockTV/setup-wizard-style.tcss b/src/iSponsorBlockTV/setup-wizard-style.tcss similarity index 91% rename from iSponsorBlockTV/setup-wizard-style.tcss rename to src/iSponsorBlockTV/setup-wizard-style.tcss index 503b359..cde381d 100644 --- a/iSponsorBlockTV/setup-wizard-style.tcss +++ b/src/iSponsorBlockTV/setup-wizard-style.tcss @@ -21,9 +21,13 @@ scrollbar-gutter: stable; } -.small-button{ +.button-small { height: 3; - + border: none; + border-top: none; + border-bottom: none; + offset: 0 -1; + padding: 0; } .button-100 { @@ -106,13 +110,14 @@ EditDevice { } Element { - background: $panel; + background: $panel-darken-1; border-top: solid $panel-lighten-2; layout: horizontal; height: 2; width: 100%; margin: 0 1 0 1; padding: 0; + } Element > .element-name { offset: 0 -1; @@ -120,7 +125,11 @@ Element > .element-name { width: 100%; align: left middle; text-align: left; - + background: $panel-darken-1; + &:hover { + background: $panel-lighten-1; + border-top: tall $panel-lighten-3; + } } Element > .element-remove { dock: right; @@ -132,7 +141,7 @@ Element > .element-remove { margin: 0 1 0 0; } -#add-device { +#add-device, #add-channel { text-style: bold; width: 100%; align: left middle; @@ -140,6 +149,11 @@ Element > .element-remove { dock: left; text-align: left; + background: $panel-darken-1; + &:hover { + background: $panel-lighten-1; + border-top: tall $panel-lighten-3; + } } #add-device-button-container{ height: 1; @@ -362,4 +376,10 @@ MigrationScreen { height: 1fr; width: 1fr; content-align: center middle; -} \ No newline at end of file +} + +/* Autoplay */ +#autoplay-container{ + padding: 1; + height: auto; +} diff --git a/iSponsorBlockTV/setup_wizard.py b/src/iSponsorBlockTV/setup_wizard.py similarity index 57% rename from iSponsorBlockTV/setup_wizard.py rename to src/iSponsorBlockTV/setup_wizard.py index 75a35cf..de5cd21 100644 --- a/iSponsorBlockTV/setup_wizard.py +++ b/src/iSponsorBlockTV/setup_wizard.py @@ -1,17 +1,37 @@ -import aiohttp import asyncio import copy + +import aiohttp + # Textual imports (Textual is awesome!) from textual import on from textual.app import App, ComposeResult -from textual.containers import ScrollableContainer, Grid, Container, Vertical, Horizontal +from textual.containers import ( + Container, + Grid, + Horizontal, + ScrollableContainer, + Vertical, +) from textual.events import Click from textual.screen import Screen from textual.validation import Function -from textual.widgets import Button, Footer, Header, Static, Label, Input, SelectionList, Checkbox, ContentSwitcher, \ - RadioSet, RadioButton +from textual.widgets import ( + Button, + Checkbox, + ContentSwitcher, + Footer, + Header, + Input, + Label, + RadioButton, + RadioSet, + SelectionList, + Static, +) from textual.widgets.selection_list import Selection from textual_slider import Slider + # Local imports from . import api_helpers, ytlounge from .constants import skip_categories @@ -28,7 +48,9 @@ def _validate_pairing_code(pairing_code: str) -> bool: class ModalWithClickExit(Screen): """A modal screen that exits when clicked outside its bounds. - https://discord.com/channels/1026214085173461072/1033754296224841768/1136015817356611604""" + https://discord.com/channels/1026214085173461072/1033754296224841768/1136015817356611604 + """ + DEFAULT_CSS = """ ModalWithClickExit { align: center middle; @@ -60,8 +82,18 @@ class Element(Static): pass def compose(self) -> ComposeResult: - yield Button(label=self.element_name, classes="element-name", disabled=True, id="element-name") - yield Button("Remove", classes="element-remove", variant="error", id="element-remove") + yield Button( + label=self.element_name, + classes="element-name button-small", + disabled=True, + id="element-name", + ) + yield Button( + "Remove", + classes="element-remove button-small", + variant="error", + id="element-remove", + ) def on_mount(self) -> None: if self.tooltip: @@ -73,12 +105,14 @@ class Device(Element): """A device element.""" def process_values_from_data(self): - print("HIIII") - print(self.element_data) if "name" in self.element_data and self.element_data["name"]: self.element_name = self.element_data["name"] else: - self.element_name = f"Unnamed device with id {self.element_data['screen_id'][:5]}...{self.element_data['screen_id'][-5:]}" + self.element_name = ( + "Unnamed device with id " + f"{self.element_data['screen_id'][:5]}..." + f"{self.element_data['screen_id'][-5:]}" + ) class Channel(Element): @@ -88,7 +122,9 @@ class Channel(Element): if "name" in self.element_data: self.element_name = self.element_data["name"] else: - self.element_name = f"Unnamed channel with id {self.element_data['channel_id']}" + self.element_name = ( + f"Unnamed channel with id {self.element_data['channel_id']}" + ) class ChannelRadio(RadioButton): @@ -102,18 +138,37 @@ class ChannelRadio(RadioButton): class MigrationScreen(ModalWithClickExit): """Screen with a dialog to remove old ATVS config.""" - BINDINGS = [("escape", "dismiss()", "Cancel"), - ("s", "remove_and_save", "Remove and save"), - ("q,ctrl+c", "exit", "Exit")] + + BINDINGS = [ + ("escape", "dismiss()", "Cancel"), + ("s", "remove_and_save", "Remove and save"), + ("q,ctrl+c", "exit", "Exit"), + ] AUTO_FOCUS = "#exit-save" def compose(self) -> ComposeResult: yield Grid( Label( - "Welcome to the new configurator! You seem to have the legacy 'atvs' entry on your config file, do you want to remove it?\n(The app won't start with it present)", - id="question", classes="button-100"), - Button("Remove and save", variant="primary", id="migrate-remove-save", classes="button-100"), - Button("Don't remove", variant="error", id="migrate-no-change", classes="button-100"), + ( + "Welcome to the new configurator! You seem to have the legacy" + " 'atvs' entry on your config file, do you want to remove it?\n(The" + " app won't start with it present)" + ), + id="question", + classes="button-100", + ), + Button( + "Remove and save", + variant="primary", + id="migrate-remove-save", + classes="button-100", + ), + Button( + "Don't remove", + variant="error", + id="migrate-no-change", + classes="button-100", + ), id="dialog-migration", ) @@ -133,16 +188,25 @@ class MigrationScreen(ModalWithClickExit): class ExitScreen(ModalWithClickExit): """Screen with a dialog to exit.""" - BINDINGS = [("escape", "dismiss()", "Cancel"), - ("s", "save", "Save"), - ("q,ctrl+c", "exit", "Exit")] + + BINDINGS = [ + ("escape", "dismiss()", "Cancel"), + ("s", "save", "Save"), + ("q,ctrl+c", "exit", "Exit"), + ] AUTO_FOCUS = "#exit-save" def compose(self) -> ComposeResult: yield Grid( - Label("Are you sure you want to exit without saving?", id="question", classes="button-100"), + Label( + "Are you sure you want to exit without saving?", + id="question", + classes="button-100", + ), Button("Save", variant="success", id="exit-save", classes="button-100"), - Button("Don't save", variant="error", id="exit-no-save", classes="button-100"), + Button( + "Don't save", variant="error", id="exit-no-save", classes="button-100" + ), Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"), id="dialog-exit", ) @@ -166,39 +230,79 @@ class ExitScreen(ModalWithClickExit): class AddDevice(ModalWithClickExit): """Screen with a dialog to add a device, either with a pairing code or with lan discovery.""" + BINDINGS = [("escape", "dismiss({})", "Return")] 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: with Container(id="add-device-container"): yield Label("Add Device", classes="title") with Grid(id="add-device-switch-buttons"): - yield Button("Add with pairing code", id="add-device-pin-button", classes="button-switcher") - yield Button("Add with lan discovery", id="add-device-dial-button", classes="button-switcher") - with ContentSwitcher(id="add-device-switcher", initial="add-device-pin-container"): + yield Button( + "Add with pairing code", + id="add-device-pin-button", + classes="button-switcher", + ) + yield Button( + "Add with lan discovery", + id="add-device-dial-button", + classes="button-switcher", + ) + with ContentSwitcher( + id="add-device-switcher", initial="add-device-pin-container" + ): with Container(id="add-device-pin-container"): - yield Input(placeholder="Pairing Code (found in Settings - Link with TV code)", - id="pairing-code-input", - validators=[ - Function(_validate_pairing_code, "Invalid pairing code format") - ] - ) - yield Input(placeholder="Device Name (auto filled if empty/optional)", id="device-name-input") - yield Button("Add", id="add-device-pin-add-button", variant="success", disabled=True) + yield Input( + placeholder=( + "Pairing Code (found in Settings - Link with TV code)" + ), + id="pairing-code-input", + validators=[ + Function( + _validate_pairing_code, "Invalid pairing code format" + ) + ], + ) + yield Input( + placeholder="Device Name (auto filled if empty/optional)", + id="device-name-input", + ) + yield Button( + "Add", + id="add-device-pin-add-button", + variant="success", + disabled=True, + ) yield Label(id="add-device-info") with Container(id="add-device-dial-container"): yield Label( - "Make sure your device is on the same network as this computer\nIf it isn't showing up, try restarting the app.\nIf running in docker, make sure to use `--network=host`\nTo refresh the list, close and open the dialog again", - classes="subtitle") - yield SelectionList(("Searching for devices...", "", False), id="dial-devices-list", disabled=True) - yield Button("Add selected devices", id="add-device-dial-add-button", variant="success", - disabled=True) + ( + "Make sure your device is on the same network as this" + " computer\nIf it isn't showing up, try restarting the" + " app.\nIf running in docker, make sure to use" + " `--network=host`\nTo refresh the list, close and open the" + " dialog again\n[b][u]If it still doesn't work, " + "pair using a pairing code (it's much more reliable)" + ), + classes="subtitle", + ) + yield SelectionList( + ("Searching for devices...", "", False), + id="dial-devices-list", + disabled=True, + ) + yield Button( + "Add selected devices", + id="add-device-dial-add-button", + variant="success", + disabled=True, + ) async def on_mount(self) -> None: self.devices_discovered_dial = [] @@ -221,25 +325,32 @@ class AddDevice(ModalWithClickExit): @on(Button.Pressed, "#add-device-switch-buttons > *") def handle_switch_buttons(self, event: Button.Pressed) -> None: - button_ = event.button.id - self.query_one("#add-device-switcher").current = event.button.id.replace("-button", "-container") + self.query_one("#add-device-switcher").current = event.button.id.replace( + "-button", "-container" + ) @on(Input.Changed, "#pairing-code-input") def changed_pairing_code(self, event: Input.Changed): - self.query_one("#add-device-pin-add-button").disabled = not event.validation_result.is_valid + self.query_one( + "#add-device-pin-add-button" + ).disabled = not event.validation_result.is_valid @on(Input.Submitted, "#pairing-code-input") @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(" ", "")) # remove dashes and spaces + pairing_code = int( + pairing_code.replace("-", "").replace(" ", "") + ) # remove dashes and spaces device_name = self.parent.query_one("#device-name-input").value paired = False try: paired = await lounge_controller.pair(pairing_code) - except: + except BaseException: pass if paired: device = { @@ -249,7 +360,9 @@ class AddDevice(ModalWithClickExit): } self.query_one("#pairing-code-input").value = "" self.query_one("#device-name-input").value = "" - self.query_one("#add-device-info").update(f"[#00ff00][b]Successfully added {device['name']}") + self.query_one("#add-device-info").update( + f"[#00ff00][b]Successfully added {device['name']}" + ) self.dismiss([device]) else: self.query_one("#pairing-code-input").value = "" @@ -267,11 +380,14 @@ class AddDevice(ModalWithClickExit): @on(SelectionList.SelectedChanged, "#dial-devices-list") def changed_device_list(self, event: SelectionList.SelectedChanged): - self.query_one("#add-device-dial-add-button").disabled = not event.selection_list.selected + self.query_one( + "#add-device-dial-add-button" + ).disabled = not event.selection_list.selected class AddChannel(ModalWithClickExit): """Screen with a dialog to add a channel, either using search or with a channel id.""" + BINDINGS = [("escape", "dismiss(())", "Return")] def __init__(self, config, **kwargs) -> None: @@ -284,32 +400,78 @@ class AddChannel(ModalWithClickExit): with Container(id="add-channel-container"): yield Label("Add Channel", classes="title") yield Label( - "Select a method to add a channel. Adding via search only works if a YouTube api key has been set", - id="add-channel-label", classes="subtitle") + ( + "Select a method to add a channel. Adding via search only works if" + " a YouTube api key has been set" + ), + id="add-channel-label", + classes="subtitle", + ) with Grid(id="add-channel-switch-buttons"): - yield Button("Add by channel name", id="add-channel-search-button", classes="button-switcher") - yield Button("Add by channel ID", id="add-channel-id-button", classes="button-switcher") + yield Button( + "Add by channel name", + id="add-channel-search-button", + classes="button-switcher", + ) + yield Button( + "Add by channel ID", + id="add-channel-id-button", + classes="button-switcher", + ) yield Label(id="add-channel-info", classes="subtitle") - with ContentSwitcher(id="add-channel-switcher", initial="add-channel-search-container"): + with ContentSwitcher( + id="add-channel-switcher", initial="add-channel-search-container" + ): with Vertical(id="add-channel-search-container"): if self.config.apikey: with Grid(id="add-channel-search-inputs"): - yield Input(placeholder="Enter channel name", id="channel-name-input-search") - yield Button("Search", id="search-channel-button", variant="success") - yield RadioSet(RadioButton(label="Search to see results", disabled=True), - id="channel-search-results") - yield Button("Add", id="add-channel-button-search", variant="success", disabled=True, - classes="button-100") + yield Input( + placeholder="Enter channel name", + id="channel-name-input-search", + ) + yield Button( + "Search", id="search-channel-button", variant="success" + ) + yield RadioSet( + RadioButton(label="Search to see results", disabled=True), + id="channel-search-results", + ) + yield Button( + "Add", + id="add-channel-button-search", + variant="success", + disabled=True, + classes="button-100", + ) else: yield Label( - "[#ff0000]No api key set, cannot search for channels. You can add it the config section below", - id="add-channel-search-no-key", classes="subtitle") + ( + "[#ff0000]No api key set, cannot search for channels." + " You can add it the config section below" + ), + id="add-channel-search-no-key", + classes="subtitle", + ) with Vertical(id="add-channel-id-container"): - yield Input(placeholder="Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)", - id="channel-id-input") - yield Input(placeholder="Enter channel name (only used to display in the config file)", - id="channel-name-input-id") - yield Button("Add", id="add-channel-button-id", variant="success", classes="button-100") + yield Input( + placeholder=( + "Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)" + ), + id="channel-id-input", + ) + yield Input( + placeholder=( + "Enter channel name (only used to display in the config" + " file)" + ), + id="channel-name-input-id", + ) + yield Button( + "Add", + id="add-channel-button-id", + variant="success", + classes="button-100", + ) @on(RadioSet.Changed, "#channel-search-results") def handle_radio_set_changed(self, event: RadioSet.Changed) -> None: @@ -317,15 +479,18 @@ class AddChannel(ModalWithClickExit): @on(Button.Pressed, "#add-channel-switch-buttons > *") def handle_switch_buttons(self, event: Button.Pressed) -> None: - button_ = event.button.id - self.query_one("#add-channel-switcher").current = event.button.id.replace("-button", "-container") + self.query_one("#add-channel-switcher").current = event.button.id.replace( + "-button", "-container" + ) @on(Button.Pressed, "#search-channel-button") @on(Input.Submitted, "#channel-name-input-search") async def handle_search_channel(self) -> None: channel_name = self.query_one("#channel-name-input-search").value if not channel_name: - self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel name") + self.query_one("#add-channel-info").update( + "[#ff0000]Please enter a channel name" + ) return self.query_one("#search-channel-button").disabled = True self.query_one("#add-channel-info").update("Searching...") @@ -333,8 +498,10 @@ class AddChannel(ModalWithClickExit): self.query_one("#channel-search-results").remove_children() try: channels_list = await self.api_helper.search_channels(channel_name) - except: - self.query_one("#add-channel-info").update("[#ff0000]Failed to search for channel") + except BaseException: + self.query_one("#add-channel-info").update( + "[#ff0000]Failed to search for channel" + ) self.query_one("#search-channel-button").disabled = False return for i in channels_list: @@ -347,7 +514,9 @@ class AddChannel(ModalWithClickExit): def handle_add_channel_search(self) -> None: channel = self.query_one("#channel-search-results").pressed_button.channel_data if not channel: - self.query_one("#add-channel-info").update("[#ff0000]Please select a channel") + self.query_one("#add-channel-info").update( + "[#ff0000]Please select a channel" + ) return self.query_one("#add-channel-info").update("Adding...") self.dismiss(channel) @@ -359,7 +528,9 @@ class AddChannel(ModalWithClickExit): channel_id = self.query_one("#channel-id-input").value channel_name = self.query_one("#channel-name-input-id").value if not channel_id: - self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel ID") + self.query_one("#add-channel-info").update( + "[#ff0000]Please enter a channel ID" + ) return if not channel_name: channel_name = channel_id @@ -370,6 +541,7 @@ class AddChannel(ModalWithClickExit): class EditDevice(ModalWithClickExit): """Screen with a dialog to edit a device. Used by the DevicesManager.""" + BINDINGS = [("escape", "close_screen_saving", "Return")] def __init__(self, device: Element, **kwargs) -> None: @@ -395,18 +567,29 @@ class EditDevice(ModalWithClickExit): yield Input(placeholder="Device name", id="device-name-input", value=name) yield Label("Device screen id") with Grid(id="device-id-container"): - yield Input(placeholder="Device id", id="device-id-input", value=self.device_data["screen_id"], - password=True) + yield Input( + placeholder="Device id", + id="device-id-input", + value=self.device_data["screen_id"], + password=True, + ) yield Button("Show id", id="device-id-view") yield Label("Device offset (in milliseconds)") with Horizontal(id="device-offset-container"): yield Input(id="device-offset-input", value=str(offset)) - yield Slider(name="Device offset", id="device-offset-slider", min=0, max=2000, step=100, value=offset) + yield Slider( + name="Device offset", + id="device-offset-slider", + min=0, + max=2000, + step=100, + value=offset, + ) def on_slider_changed(self, event: Slider.Changed) -> None: - input = self.query_one("#device-offset-input") - with input.prevent(Input.Changed): - input.value = str(event.slider.value) + offset_input = self.query_one("#device-offset-input") + with offset_input.prevent(Input.Changed): + offset_input.value = str(event.slider.value) def on_input_changed(self, event: Input.Changed): if event.input.id == "device-offset-input": @@ -428,7 +611,8 @@ class EditDevice(ModalWithClickExit): class DevicesManager(Vertical): - """Manager for devices, allows to add, edit and remove devices.""" + """Manager for devices, allows adding, edit and removing devices.""" + def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config @@ -437,7 +621,9 @@ class DevicesManager(Vertical): def compose(self) -> ComposeResult: yield Label("Devices", classes="title") with Horizontal(id="add-device-button-container"): - yield Button("Add Device", id="add-device", classes="button-100") + yield Button( + "Add Device", id="add-device", classes="button-100 button-small" + ) for device in self.devices: yield Device(device, tooltip="Click to edit") @@ -450,14 +636,15 @@ class DevicesManager(Vertical): self.mount(device_widget) device_widget.focus(scroll_visible=True) - def edit_device(self, device_widget: Element) -> None: + @staticmethod + def edit_device(device_widget: Element) -> None: device_widget.process_values_from_data() device_widget.query_one("#element-name").label = device_widget.element_name @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") @@ -472,6 +659,7 @@ class DevicesManager(Vertical): class ApiKeyManager(Vertical): """Manager for the YouTube Api Key.""" + def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config @@ -479,18 +667,23 @@ 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 Input( + placeholder="YouTube Api Key", + id="api-key-input", + password=True, + value=self.config.apikey, + ) yield Button("Show key", id="api-key-view") @on(Input.Changed, "#api-key-input") def changed_api_key(self, event: Input.Changed): self.config.apikey = event.input.value - # try: # ChannelWhitelist might not be mounted - # self.app.query_one("#warning-no-key").display = not self.config.apikey - # except: - # pass @on(Button.Pressed, "#api-key-view") def pressed_api_key_view(self, event: Button.Pressed): @@ -503,7 +696,8 @@ class ApiKeyManager(Vertical): class SkipCategoriesManager(Vertical): - """Manager for skip categories, allows to select which categories to skip.""" + """Manager for skip categories, allows selecting which categories to skip.""" + def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config @@ -528,6 +722,7 @@ class SkipCategoriesManager(Vertical): class SkipCountTrackingManager(Vertical): """Manager for skip count tracking, allows to enable/disable skip count tracking.""" + def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config @@ -535,10 +730,22 @@ class SkipCountTrackingManager(Vertical): def compose(self) -> ComposeResult: yield Label("Skip count tracking", classes="title") yield Label( - "This feature tracks which segments you have skipped to let users know how much their submission has helped others and used as a metric along with upvotes to ensure that spam doesn't get into the database. The program sends a message to the sponsor block server each time you skip a segment. Hopefully most people don't change this setting so that the view numbers are accurate. :)", - classes="subtitle", id="skip-count-tracking-subtitle") - yield Checkbox(value=self.config.skip_count_tracking, id="skip-count-tracking-switch", - label="Enable skip count tracking") + ( + "This feature tracks which segments you have skipped to let users know" + " how much their submission has helped others and used as a metric" + " along with upvotes to ensure that spam doesn't get into the database." + " The program sends a message to the sponsor block server each time you" + " skip a segment. Hopefully most people don't change this setting so" + " that the view numbers are accurate. :)" + ), + classes="subtitle", + id="skip-count-tracking-subtitle", + ) + yield Checkbox( + value=self.config.skip_count_tracking, + id="skip-count-tracking-switch", + label="Enable skip count tracking", + ) @on(Checkbox.Changed, "#skip-count-tracking-switch") def changed_skip_tracking(self, event: Checkbox.Changed): @@ -547,6 +754,7 @@ class SkipCountTrackingManager(Vertical): class AdSkipMuteManager(Vertical): """Manager for ad skip/mute, allows to enable/disable ad skip/mute.""" + def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config @@ -554,13 +762,25 @@ class AdSkipMuteManager(Vertical): def compose(self) -> ComposeResult: yield Label("Skip/Mute ads", classes="title") yield Label( - "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") + ( + "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 muting 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") def changed_mute(self, event: Checkbox.Changed): @@ -572,7 +792,8 @@ class AdSkipMuteManager(Vertical): class ChannelWhitelistManager(Vertical): - """Manager for channel whitelist, allows to add/remove channels from the whitelist.""" + """Manager for channel whitelist, allows adding/removing channels from the whitelist.""" + def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config @@ -580,17 +801,33 @@ class ChannelWhitelistManager(Vertical): def compose(self) -> ComposeResult: yield Label("Channel Whitelist", classes="title") yield Label( - "This feature allows to whitelist channels from being skipped. This feature is automatically disabled when no channels have been specified.", - classes="subtitle", id="channel-whitelist-subtitle") - yield Label(":warning: [#FF0000]You need to set your YouTube Api Key in order to use this feature", - id="warning-no-key") + ( + "This feature allows to whitelist channels from being skipped. This" + " feature is automatically disabled when no channels have been" + " specified." + ), + classes="subtitle", + id="channel-whitelist-subtitle", + ) + yield Label( + ( + ":warning: [#FF0000]You need to set your YouTube Api Key in order to" + " use this feature" + ), + id="warning-no-key", + ) with Horizontal(id="add-channel-button-container"): - yield Button("Add Channel", id="add-channel", classes="button-100") + yield Button( + "Add Channel", id="add-channel", classes="button-100 button-small" + ) for channel in self.config.channel_whitelist: yield Channel(channel) def on_mount(self) -> None: - self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(self.config.channel_whitelist) + self.app.query_one("#warning-no-key").display = ( + not self.config.apikey + ) and bool(self.config.channel_whitelist) + def new_channel(self, channel: tuple) -> None: if channel: channel_dict = { @@ -601,30 +838,56 @@ class ChannelWhitelistManager(Vertical): channel_widget = Channel(channel_dict) self.mount(channel_widget) channel_widget.focus(scroll_visible=True) - self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool( - self.config.channel_whitelist) + self.app.query_one("#warning-no-key").display = ( + not self.config.apikey + ) and bool(self.config.channel_whitelist) @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) channel_to_remove.remove() - self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool( - self.config.channel_whitelist) + self.app.query_one("#warning-no-key").display = ( + not self.config.apikey + ) and bool(self.config.channel_whitelist) @on(Button.Pressed, "#add-channel") def add_channel(self, event: Button.Pressed): self.app.push_screen(AddChannel(self.config), callback=self.new_channel) -class iSponsorBlockTVSetupMainScreen(Screen): +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""" + TITLE = "iSponsorBlockTV" SUB_TITLE = "Setup Wizard" - BINDINGS = [ - ("q,ctrl+c", "exit_modal", "Exit"), - ("s", "save", "Save") - ] + BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")] AUTO_FOCUS = None def __init__(self, config, **kwargs) -> None: @@ -637,12 +900,27 @@ class iSponsorBlockTVSetupMainScreen(Screen): yield Header() yield Footer() with ScrollableContainer(id="setup-wizard"): - yield DevicesManager(config=self.config, id="devices-manager", classes="container") - yield SkipCategoriesManager(config=self.config, id="skip-categories-manager", classes="container") - yield SkipCountTrackingManager(config=self.config, id="count-segments-manager", classes="container") - yield AdSkipMuteManager(config=self.config, id="ad-skip-mute-manager", classes="container") - yield ChannelWhitelistManager(config=self.config, id="channel-whitelist-manager", classes="container") - yield ApiKeyManager(config=self.config, id="api-key-manager", classes="container") + yield DevicesManager( + config=self.config, id="devices-manager", classes="container" + ) + yield SkipCategoriesManager( + config=self.config, id="skip-categories-manager", classes="container" + ) + yield SkipCountTrackingManager( + config=self.config, id="count-segments-manager", classes="container" + ) + yield AdSkipMuteManager( + config=self.config, id="ad-skip-mute-manager", classes="container" + ) + yield ChannelWhitelistManager( + config=self.config, id="channel-whitelist-manager", classes="container" + ) + 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(): @@ -666,25 +944,26 @@ class iSponsorBlockTVSetupMainScreen(Screen): @on(Input.Changed, "#api-key-input") def changed_api_key(self, event: Input.Changed): - print("HIIII") try: # ChannelWhitelist might not be mounted # Show if no api key is set and at least one channel is in the whitelist - self.app.query_one("#warning-no-key").display = (not event.input.value) and self.config.channel_whitelist - except: + self.app.query_one("#warning-no-key").display = ( + not event.input.value + ) and self.config.channel_whitelist + except BaseException: pass -class iSponsorBlockTVSetup(App): - CSS_PATH = "setup-wizard-style.tcss" # tcss is the recommended extension for textual css files + +class ISponsorBlockTVSetup(App): + CSS_PATH = ( # tcss is the recommended extension for textual css files + "setup-wizard-style.tcss" + ) # Bindings for the whole app here, so they are available in all screens - BINDINGS = [ - ("q,ctrl+c", "exit_modal", "Exit"), - ("s", "save", "Save") - ] + BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")] def __init__(self, config, **kwargs) -> None: super().__init__(**kwargs) self.config = config - self.main_screen = iSponsorBlockTVSetupMainScreen(config=self.config) + self.main_screen = ISponsorBlockTVSetupMainScreen(config=self.config) def on_mount(self) -> None: self.push_screen(self.main_screen) @@ -697,5 +976,5 @@ class iSponsorBlockTVSetup(App): def main(config): - app = iSponsorBlockTVSetup(config) - app.run() \ No newline at end of file + app = ISponsorBlockTVSetup(config) + app.run() diff --git a/src/iSponsorBlockTV/ytlounge.py b/src/iSponsorBlockTV/ytlounge.py new file mode 100644 index 0000000..1b73690 --- /dev/null +++ b/src/iSponsorBlockTV/ytlounge.py @@ -0,0 +1,197 @@ +import asyncio +import json +from typing import Any, List + +import pyytlounge +from aiohttp import ClientSession + +from .constants import youtube_client_blacklist + +create_task = asyncio.create_task + + +class YtLoungeApi(pyytlounge.YtLoungeApi): + 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: + loop = asyncio.get_event_loop() + loop.run_until_complete(self.session.close()) + loop.run_until_complete(self.conn.close()) + 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 + self.volume_state = {} + self.subscribe_task = None + self.subscribe_task_watchdog = None + 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 + self._command_mutex = asyncio.Lock() + + # Ensures that we still are subscribed to the lounge + async def _watchdog(self): + await asyncio.sleep( + 35 + ) # YouTube sends at least a message every 30 seconds (no-op or any other) + try: + self.subscribe_task.cancel() + except BaseException: + pass + + # Subscribe to the lounge and start the watchdog + async def subscribe_monitored(self, callback): + self.callback = callback + try: + self.subscribe_task_watchdog.cancel() + except BaseException: + pass # No watchdog task + self.subscribe_task = asyncio.create_task(super().subscribe(callback)) + self.subscribe_task_watchdog = asyncio.create_task(self._watchdog()) + return self.subscribe_task + + # Process a lounge subscription event + def _process_event(self, event_type: str, args: List[Any]): + self.logger.debug(f"process_event({event_type}, {args})") + # (Re)start the watchdog + try: + self.subscribe_task_watchdog.cancel() + except BaseException: + 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) + if event_type == "onStateChange": + data = args[0] + # print(data) + # Unmute when the video starts playing + if self.mute_ads and data["state"] == "1": + create_task(self.mute(False, override=True)) + elif event_type == "nowPlaying": + data = args[0] + # Unmute when the video starts playing + if self.mute_ads and data.get("state", "0") == "1": + self.logger.info("Ad has ended, unmuting") + create_task(self.mute(False, override=True)) + elif event_type == "onAdStateChange": + data = args[0] + if data["adState"] == "0": # Ad is not playing + self.logger.info("Ad has ended, unmuting") + create_task(self.mute(False, override=True)) + elif ( + self.skip_ads and data["isSkipEnabled"] == "true" + ): # YouTube uses strings for booleans + self.logger.info("Ad can be skipped, skipping") + create_task(self.skip_ad()) + create_task(self.mute(False, override=True)) + elif ( + self.mute_ads + ): # Seen multiple other adStates, assuming they are all ads + self.logger.info("Ad has started, muting") + create_task(self.mute(True, override=True)) + # Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it) + elif event_type == "onVolumeChanged": + 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 + self.logger.info(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": + data = args[0] + # Gets segments for the next video (after the ad) before it starts playing + if vid_id := data["contentVideoId"]: + self.logger.info(f"Getting segments for next video: {vid_id}") + create_task(self.api_helper.get_segments(vid_id)) + elif ( + self.skip_ads and data["isSkipEnabled"] == "true" + ): # YouTube uses strings for booleans + self.logger.info("Ad can be skipped, skipping") + create_task(self.skip_ad()) + create_task(self.mute(False, override=True)) + elif ( + self.mute_ads + ): # Seen multiple other adStates, assuming they are all ads + self.logger.info("Ad has started, muting") + create_task(self.mute(True, override=True)) + + 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 == "onSubtitlesTrackChanged": + if self.shorts_disconnected: + data = args[0] + video_id_saved = data.get("videoId", None) + self.shorts_disconnected = False + create_task(self.play_video(video_id_saved)) + elif event_type == "loungeScreenDisconnected": + if args: # Sometimes it's empty + 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_type, args) + + # Set the volume to a specific value (0-100) + async def set_volume(self, volume: int) -> None: + await super()._command("setVolume", {"volume": volume}) + + # Mute or unmute the device (if the device already is in the desired state, nothing happens) + # mute: True to mute, False to unmute + # override: If True, the command is sent even if the device already is in the desired state + # TODO: Only works if the device is subscribed to the lounge + async def mute(self, mute: bool, override: bool = False) -> None: + if mute: + mute_str = "true" + else: + mute_str = "false" + if override or not (self.volume_state.get("muted", "false") == mute_str): + 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"} + ) + + async def play_video(self, video_id: str) -> bool: + return await self._command("setPlaylist", {"videoId": video_id}) + + # Test to wrap the command function in a mutex to avoid race conditions with + # the _command_offset (TODO: move to upstream if it works) + async def _command(self, command: str, command_parameters: dict = None) -> bool: + async with self._command_mutex: + return await super()._command(command, command_parameters) diff --git a/main.py b/src/main.py similarity index 100% rename from main.py rename to src/main.py diff --git a/main_tui.py b/src/main_tui.py similarity index 82% rename from main_tui.py rename to src/main_tui.py index 4b7a9af..0b78dbe 100644 --- a/main_tui.py +++ b/src/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)