Compare commits

..

1 Commits

Author SHA1 Message Date
dmunozv04
ce58552a89 test debug commands 2025-03-08 18:45:26 +01:00
22 changed files with 466 additions and 620 deletions

View File

@@ -6,4 +6,3 @@ enabled = true
[analyzers.meta] [analyzers.meta]
runtime_version = "3.x.x" runtime_version = "3.x.x"
max_line_length = 100

View File

@@ -6,8 +6,7 @@ on:
branches: branches:
- '*' - '*'
tags: tags:
- 'v*.*.*' - 'v*'
- 'v*.*.*-*'
pull_request: pull_request:
branches: branches:
- '*' - '*'
@@ -28,7 +27,7 @@ jobs:
steps: steps:
# Get the repository's code # Get the repository's code
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
# Generate docker tags # Generate docker tags
- name: Docker meta - name: Docker meta
@@ -41,9 +40,7 @@ jobs:
tags: | tags: |
type=raw,value=develop,priority=900,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} 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,enable=true,priority=600,prefix=pr-,suffix=,event=pr
type=semver,pattern=v{{version}} type=ref,event=tag
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
type=ref,event=branch type=ref,event=branch
type=schedule type=schedule
@@ -56,7 +53,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name != 'pull_request' && env.DOCKERHUB_USERNAME != '' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -74,7 +71,7 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64, linux/arm64, linux/arm/v7, linux/386, linux/arm/v6 platforms: linux/amd64, linux/arm64, linux/arm/v7
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@@ -11,9 +11,12 @@ name: Release Package
on: on:
push: push:
branches: branches:
- 'main' - '*'
tags: tags:
- 'v*' - 'v*'
pull_request:
branches:
- '*'
release: release:
types: [published] types: [published]
@@ -30,10 +33,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
@@ -54,9 +57,6 @@ jobs:
build-binaries: build-binaries:
permissions:
id-token: write
attestations: write
name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }}) name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }})
needs: needs:
- build-sdist-and-wheel - build-sdist-and-wheel
@@ -76,7 +76,7 @@ jobs:
cpu_variant: v1 cpu_variant: v1
release_suffix: x86_64-linux-v1 release_suffix: x86_64-linux-v1
- target: aarch64-unknown-linux-gnu - target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm os: ubuntu-latest
cross: true cross: true
release_suffix: aarch64-linux release_suffix: aarch64-linux
# Windows # Windows
@@ -100,17 +100,17 @@ jobs:
CARGO_BUILD_TARGET: ${{ matrix.job.target }} CARGO_BUILD_TARGET: ${{ matrix.job.target }}
PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }} PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }}
PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling) PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling)
PYAPP_VERSION: v0.28.0 PYAPP_VERSION: v0.24.0
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Clone PyApp - name: Clone PyApp
run: git clone --depth 1 --branch $PYAPP_VERSION https://github.com/ofek/pyapp $PYAPP_REPO run: git clone --depth 1 --branch $PYAPP_VERSION https://github.com/ofek/pyapp $PYAPP_REPO
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
@@ -141,7 +141,7 @@ jobs:
hatch --version hatch --version
- name: Get artifact - name: Get artifact
uses: actions/download-artifact@v5 uses: actions/download-artifact@v4
with: with:
name: sdist-and-wheel name: sdist-and-wheel
path: ${{ github.workspace }}/dist path: ${{ github.workspace }}/dist
@@ -158,12 +158,6 @@ jobs:
run: |- run: |-
mv dist/binary/iSponsorBlockTV* dist/binary/iSponsorBlockTV-${{ matrix.job.release_suffix }} mv dist/binary/iSponsorBlockTV* dist/binary/iSponsorBlockTV-${{ matrix.job.release_suffix }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v3
with:
subject-path: dist/binary/*
continue-on-error: true # Continue if attestation fails (it will fail on forks)
- name: Upload built binary package - name: Upload built binary package
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -181,7 +175,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Get artifact - name: Get artifact
uses: actions/download-artifact@v5 uses: actions/download-artifact@v4
with: with:
name: sdist-and-wheel name: sdist-and-wheel
path: dist path: dist
@@ -200,7 +194,7 @@ jobs:
if: github.event_name == 'release' && github.event.action == 'published' if: github.event_name == 'release' && github.event.action == 'published'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/download-artifact@v5 - uses: actions/download-artifact@v4
name: Get artifact name: Get artifact
with: with:
path: dist path: dist

View File

@@ -18,11 +18,11 @@ jobs:
steps: steps:
# Get the repository's code # Get the repository's code
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
# Update description # Update description
- name: Update repo description - name: Update repo description
uses: peter-evans/dockerhub-description@v5 uses: peter-evans/dockerhub-description@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@@ -3,7 +3,7 @@
# Inspired by textual pre-commit config # Inspired by textual pre-commit config
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v5.0.0
hooks: hooks:
- id: check-ast # simply checks whether the files parse as valid python - 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-builtin-literals # requires literal syntax when initializing empty or zero python builtin types
@@ -19,13 +19,13 @@ repos:
- id: mixed-line-ending # replaces or checks mixed line ending - id: mixed-line-ending # replaces or checks mixed line ending
- id: trailing-whitespace # checks for trailing whitespace - id: trailing-whitespace # checks for trailing whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.12 rev: v0.9.6
hooks: hooks:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
- id: ruff-format - id: ruff-format
- repo: https://github.com/igorshubovych/markdownlint-cli - repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.45.0 rev: v0.44.0
hooks: hooks:
- id: markdownlint - id: markdownlint
args: ["--fix"] args: ["--fix"]

View File

@@ -5,11 +5,10 @@
[![GitHub Release](https://img.shields.io/github/v/release/dmunozv04/isponsorblocktv?logo=GitHub&style=flat)](https://github.com/dmunozv04/iSponsorBlockTV/releases/latest) [![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) [![GitHub Repo stars](https://img.shields.io/github/stars/dmunozv04/isponsorblocktv?style=flat)](https://github.com/dmunozv04/isponsorblocktv)
iSponsorBlockTV is a self-hosted application that connects to your YouTube TV Skip sponsor segments in YouTube videos playing on a YouTube TV device (see
app (see compatibility below) and automatically skips segments (like Sponsors below for compatibility details).
or intros) in YouTube videos using the [SponsorBlock](https://sponsor.ajay.app/)
API. It can also auto mute and press the "Skip Ad" button the moment it becomes This project is written in asynchronous python and should be pretty quick.
available on YouTube ads.
## Installation ## Installation
@@ -23,7 +22,7 @@ Open an issue/pull request if you have tested a device that isn't listed here.
| Device | Status | | Device | Status |
|:-------------------|:------:| |:-------------------|:------:|
| Apple TV | ✅* | | Apple TV | ✅ |
| Samsung TV (Tizen) | ✅ | | Samsung TV (Tizen) | ✅ |
| LG TV (WebOS) | ✅ | | LG TV (WebOS) | ✅ |
| Android TV | ✅ | | Android TV | ✅ |
@@ -36,17 +35,17 @@ Open an issue/pull request if you have tested a device that isn't listed here.
| Xbox One/Series | ✅ | | Xbox One/Series | ✅ |
| Playstation 4/5 | ✅ | | Playstation 4/5 | ✅ |
*Ad muting won't work when using AirPlay to send the audio to another speaker.
## Usage ## Usage
Run iSponsorBlockTV on a computer that has network access. It doesn't need to Run iSponsorBlockTV on a computer that has network access.
be on the same network as the device, only access to youtube.com is required.
Auto discovery will require the computer to be on the same network as the device Auto discovery will require the computer to be on the same network as the device
during setup. during setup.
The device can also be manually added to iSponsorBlockTV with a YouTube TV code. 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 TV application. 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 ## Libraries used
@@ -72,9 +71,11 @@ This code can be found in the settings page of your YouTube TV application.
## Contributors ## Contributors
[![Contributors](https://contrib.rocks/image?repo=dmunozv04/iSponsorBlockTV)](https://github.com/dmunozv04/iSponsorBlockTV/graphs/contributors) - [dmunozv04](https://github.com/dmunozv04) - creator and maintainer
- [HaltCatchFire](https://github.com/HaltCatchFire) - updated dependencies and
Made with [contrib.rocks](https://contrib.rocks). improved skip logic
- [Oxixes](https://github.com/oxixes) - added support for channel whitelist and
minor improvements
## License ## License

View File

@@ -12,7 +12,6 @@
"skip_count_tracking": true, "skip_count_tracking": true,
"mute_ads": true, "mute_ads": true,
"skip_ads": true, "skip_ads": true,
"minimum_skip_length": 1,
"auto_play": true, "auto_play": true,
"join_name": "iSponsorBlockTV", "join_name": "iSponsorBlockTV",
"apikey": "", "apikey": "",
@@ -20,6 +19,5 @@
{"id": "", {"id": "",
"name": "" "name": ""
} }
], ]
"use_proxy": false
} }

View File

@@ -1,7 +1,8 @@
version: '3.3'
services: services:
iSponsorBlockTV: iSponsorBlockTV:
image: ghcr.io/dmunozv04/isponsorblocktv image: ghcr.io/dmunozv04/isponsorblocktv
container_name: iSponsorBlockTV container_name: iSponsorBlockTV
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /PATH_TO_YOUR_DATA_DIR:/app/data - /PATH_TO_YOUR_DATA_DIR:/app/data

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "iSponsorBlockTV" name = "iSponsorBlockTV"
version = "2.6.1" version = "2.3.1"
authors = [ authors = [
{"name" = "dmunozv04"} {"name" = "dmunozv04"}
] ]
@@ -29,5 +29,5 @@ files = ["requirements.txt"]
requires = ["hatchling", "hatch-requirements-txt"] requires = ["hatchling", "hatch-requirements-txt"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.ruff] [tool.black]
line-length = 100 line-length = 88

View File

@@ -1,10 +1,10 @@
aiohttp==3.12.15 aiohttp==3.11.12
appdirs==1.4.4 appdirs==1.4.4
async-cache==1.1.1 async-cache==1.1.1
pyytlounge==2.3.0 pyytlounge==2.1.2
rich==14.1.0 rich==13.9.4
ssdp==1.3.1 ssdp==1.3.0
textual==5.3.0 textual==1.0.0
textual-slider==0.2.0 textual-slider==0.2.0
xmltodict==0.15.1 xmltodict==0.14.2
rich_click==1.9.4 rich_click==1.8.5

View File

@@ -27,7 +27,6 @@ class ApiHelper:
self.skip_count_tracking = config.skip_count_tracking self.skip_count_tracking = config.skip_count_tracking
self.web_session = web_session self.web_session = web_session
self.num_devices = len(config.devices) self.num_devices = len(config.devices)
self.minimum_skip_length = config.minimum_skip_length
# Not used anymore, maybe it can stay here a little longer # Not used anymore, maybe it can stay here a little longer
@AsyncLRU(maxsize=10) @AsyncLRU(maxsize=10)
@@ -102,21 +101,26 @@ class ApiHelper:
if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]: if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]:
sub_count = "Hidden" sub_count = "Hidden"
else: else:
sub_count = int(channel_data["items"][0]["statistics"]["subscriberCount"]) sub_count = int(
channel_data["items"][0]["statistics"]["subscriberCount"]
)
sub_count = format(sub_count, "_") sub_count = format(sub_count, "_")
channels.append((i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count)) channels.append(
(i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count)
)
return channels return channels
@list_to_tuple # Convert list to tuple so it can be used as a key in the cache @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 @AsyncConditionalTTL(
time_to_live=300, maxsize=10
) # 5 minutes for non-locked segments
async def get_segments(self, vid_id): async def get_segments(self, vid_id):
if await self.is_whitelisted(vid_id): if await self.is_whitelisted(vid_id):
return ( return (
[], [],
True, True,
) # Return empty list and True to indicate ) # Return empty list and True to indicate that the cache should last forever
# that the cache should last forever
vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[ vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[
:4 :4
] # Hashes video id and gets the first 4 characters ] # Hashes video id and gets the first 4 characters
@@ -127,7 +131,9 @@ class ApiHelper:
} }
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed
async with self.web_session.get(url, headers=headers, params=params) as response: async with self.web_session.get(
url, headers=headers, params=params
) as response:
response_json = await response.json() response_json = await response.json()
if response.status != 200: if response.status != 200:
response_text = await response.text() response_text = await response.text()
@@ -140,10 +146,10 @@ class ApiHelper:
if str(i["videoID"]) == str(vid_id): if str(i["videoID"]) == str(vid_id):
response_json = i response_json = i
break break
return self.process_segments(response_json, self.minimum_skip_length) return self.process_segments(response_json)
@staticmethod @staticmethod
def process_segments(response, minimum_skip_length): def process_segments(response):
segments = [] segments = []
ignore_ttl = True ignore_ttl = True
try: try:
@@ -177,7 +183,7 @@ class ApiHelper:
segment_before_start = segments[-1]["start"] segment_before_start = segments[-1]["start"]
segment_before_UUID = segments[-1]["UUID"] segment_before_UUID = segments[-1]["UUID"]
except IndexError: except Exception:
segment_before_end = -10 segment_before_end = -10
if ( if (
segment_dict["start"] - segment_before_end < 1 segment_dict["start"] - segment_before_end < 1
@@ -185,16 +191,13 @@ class ApiHelper:
segment_dict["start"] = segment_before_start segment_dict["start"] = segment_before_start
segment_dict["UUID"].extend(segment_before_UUID) segment_dict["UUID"].extend(segment_before_UUID)
segments.pop() segments.pop()
# Only add segments greater than minimum skip length segments.append(segment_dict)
if segment_dict["end"] - segment_dict["start"] > minimum_skip_length: except Exception:
segments.append(segment_dict)
except BaseException:
pass pass
return segments, ignore_ttl return segments, ignore_ttl
async def mark_viewed_segments(self, uuids): async def mark_viewed_segments(self, uuids):
"""Marks the segments as viewed in the SponsorBlock API """Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled.
if skip_count_tracking is enabled.
Lets the contributor know that someone skipped the segment (thanks)""" Lets the contributor know that someone skipped the segment (thanks)"""
if self.skip_count_tracking: if self.skip_count_tracking:
for i in uuids: for i in uuids:

View File

@@ -3,29 +3,28 @@ import datetime
from cache.key import KEY from cache.key import KEY
from cache.lru import LRU from cache.lru import LRU
# MIT License """MIT License
# Copyright (c) 2020 Rajat Singh Copyright (c) 2020 Rajat Singh
# Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software. copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. SOFTWARE."""
"""Modified code from https://github.com/iamsinghrajat/async-cache"""
# Modified code from https://github.com/iamsinghrajat/async-cache
class AsyncConditionalTTL: class AsyncConditionalTTL:
@@ -33,7 +32,9 @@ class AsyncConditionalTTL:
def __init__(self, time_to_live, maxsize): def __init__(self, time_to_live, maxsize):
super().__init__(maxsize=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 self.maxsize = maxsize

View File

@@ -5,14 +5,16 @@ import aiohttp
from . import api_helpers, ytlounge from . import api_helpers, ytlounge
# Constants for user input prompts # Constants for user input prompts
USE_PROXY_PROMPT = "Do you want to use system-wide proxy? (y/N)"
ATVS_REMOVAL_PROMPT = ( ATVS_REMOVAL_PROMPT = (
"Do you want to remove the legacy 'atvs' entry (the app won't start with it present)? (y/N) " "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): " 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) " 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) " 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) " 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: " ENTER_API_KEY_PROMPT = "Enter your API key: "
CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) " CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) "
ENTER_SKIP_CATEGORIES_PROMPT = ( ENTER_SKIP_CATEGORIES_PROMPT = (
@@ -20,15 +22,13 @@ ENTER_SKIP_CATEGORIES_PROMPT = (
" selfpromo, exclusive_access, interaction, poi_highlight, intro, outro," " selfpromo, exclusive_access, interaction, poi_highlight, intro, outro,"
" preview, filler, music_offtopic]:\n" " preview, filler, music_offtopic]:\n"
) )
WHITELIST_CHANNELS_PROMPT = "Do you want to whitelist any channels from being ad-blocked? (y/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: ' SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: '
SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: " SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: "
ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: " ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: "
ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: " ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: "
MINIMUM_SKIP_PROMPT = "Do you want to specify a minimum length of segment to skip? (y/N)"
MINIMUM_SKIP_SPECIFICATION_PROMPT = (
"Enter minimum length of segment to skip in seconds (enter 0 to disable):"
)
REPORT_SKIPPED_SEGMENTS_PROMPT = ( REPORT_SKIPPED_SEGMENTS_PROMPT = (
"Do you want to report skipped segments to sponsorblock. Only the segment" "Do you want to report skipped segments to sponsorblock. Only the segment"
" UUID will be sent? (Y/n) " " UUID will be sent? (Y/n) "
@@ -43,11 +43,10 @@ def get_yn_input(prompt):
if choice.lower() in ["y", "n"]: if choice.lower() in ["y", "n"]:
return choice.lower() return choice.lower()
print("Invalid input. Please enter 'y' or 'n'.") print("Invalid input. Please enter 'y' or 'n'.")
return None
async def create_web_session(use_proxy): async def create_web_session():
return aiohttp.ClientSession(trust_env=use_proxy) return aiohttp.ClientSession()
async def pair_device(web_session: aiohttp.ClientSession): async def pair_device(web_session: aiohttp.ClientSession):
@@ -76,12 +75,8 @@ async def pair_device(web_session: aiohttp.ClientSession):
def main(config, debug: bool) -> None: def main(config, debug: bool) -> None:
print("Welcome to the iSponsorBlockTV cli setup wizard") print("Welcome to the iSponsorBlockTV cli setup wizard")
choice = get_yn_input(USE_PROXY_PROMPT)
config.use_proxy = choice == "y"
loop = asyncio.get_event_loop_policy().get_event_loop() loop = asyncio.get_event_loop_policy().get_event_loop()
web_session = loop.run_until_complete(create_web_session(config.use_proxy)) web_session = loop.run_until_complete(create_web_session())
if debug: if debug:
loop.set_debug(True) loop.set_debug(True)
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
@@ -125,11 +120,15 @@ def main(config, debug: bool) -> None:
if choice == "y": if choice == "y":
categories = input(ENTER_SKIP_CATEGORIES_PROMPT) categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
skip_categories = categories.replace(",", " ").split(" ") skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings skip_categories = [
x for x in skip_categories if x != ""
] # Remove empty strings
else: else:
categories = input(ENTER_SKIP_CATEGORIES_PROMPT) categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
skip_categories = categories.replace(",", " ").split(" ") skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings skip_categories = [
x for x in skip_categories if x != ""
] # Remove empty strings
config.skip_categories = skip_categories config.skip_categories = skip_categories
channel_whitelist = config.channel_whitelist channel_whitelist = config.channel_whitelist
@@ -148,7 +147,9 @@ def main(config, debug: bool) -> None:
if channel == "/exit": if channel == "/exit":
break break
task = loop.create_task(api_helper.search_channels(channel, apikey, web_session)) task = loop.create_task(
api_helper.search_channels(channel, apikey, web_session)
)
loop.run_until_complete(task) loop.run_until_complete(task)
results = task.result() results = task.result()
if len(results) == 0: if len(results) == 0:
@@ -180,21 +181,6 @@ def main(config, debug: bool) -> None:
config.channel_whitelist = channel_whitelist config.channel_whitelist = channel_whitelist
# Ask for minimum skip length. Confirm input is an integer
minimum_skip_length = config.minimum_skip_length
choice = get_yn_input(MINIMUM_SKIP_PROMPT)
if choice == "y":
while True:
try:
minimum_skip_length = int(input(MINIMUM_SKIP_SPECIFICATION_PROMPT))
break
except ValueError:
print("You entered a non integer value, try again.")
continue
config.minimum_skip_length = minimum_skip_length
choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT) choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT)
config.skip_count_tracking = choice != "n" config.skip_count_tracking = choice != "n"

View File

@@ -22,5 +22,3 @@ youtube_client_blacklist = ["TVHTML5_FOR_KIDS"]
config_file_blacklist_keys = ["config_file", "data_dir"] config_file_blacklist_keys = ["config_file", "data_dir"]
github_wiki_base_url = "https://github.com/dmunozv04/iSponsorBlockTV/wiki"

View File

@@ -1,25 +0,0 @@
class AiohttpTracer:
def __init__(self, logger):
self.logger = logger
async def on_request_start(self, session, context, params):
self.logger.debug(f"Request started ({id(context):#x}): {params.method} {params.url}")
async def on_request_end(self, session, context, params):
self.logger.debug(f"Request ended ({id(context):#x}): {params.response.status}")
async def on_request_exception(self, session, context, params):
self.logger.debug(f"Request exception ({id(context):#x}): {params.exception}")
async def on_response_chunk_received(self, session, context, params):
chunk_size = len(params.chunk)
try:
# Try to decode as text
text = params.chunk.decode("utf-8")
self.logger.debug(f"Response chunk ({id(context):#x}) {chunk_size} bytes: {text}")
except UnicodeDecodeError:
# If not valid UTF-8, show as hex
hex_data = params.chunk.hex()
self.logger.debug(
f"Response chunk ({id(context):#x}) ({chunk_size} bytes) [HEX]: {hex_data}"
)

View File

@@ -7,47 +7,46 @@ import ssdp
import xmltodict import xmltodict
from ssdp import network from ssdp import network
# Redistribution and use of the DIAL DIscovery And Launch protocol """Redistribution and use of the DIAL DIscovery And Launch protocol
# specification (the “DIAL Specification”), with or without modification, specification (the “DIAL Specification”), with or without modification,
# are permitted provided that the following conditions are met: ● are permitted provided that the following conditions are met: ●
# Redistributions of the DIAL Specification must retain the above copyright Redistributions of the DIAL Specification must retain the above copyright
# notice, this list of conditions and the following disclaimer. ● notice, this list of conditions and the following disclaimer. ●
# Redistributions of implementations of the DIAL Specification in source code Redistributions of implementations of the DIAL Specification in source code
# form must retain the above copyright notice, this list of conditions and the form must retain the above copyright notice, this list of conditions and the
# following disclaimer. ● Redistributions of implementations of the DIAL following disclaimer. ● Redistributions of implementations of the DIAL
# Specification in binary form must include the above copyright notice. ● The Specification in binary form must include the above copyright notice. ● The
# DIAL mark, the NETFLIX mark and the names of contributors to the DIAL DIAL mark, the NETFLIX mark and the names of contributors to the DIAL
# Specification may not be used to endorse or promote specifications, software, Specification may not be used to endorse or promote specifications, software,
# products, or any other materials derived from the DIAL Specification without products, or any other materials derived from the DIAL Specification without
# specific prior written permission. The DIAL mark is owned by Netflix and specific prior written permission. The DIAL mark is owned by Netflix and
# information on licensing the DIAL mark is available at information on licensing the DIAL mark is available at
# www.dial-multiscreen.org. www.dial-multiscreen.org."""
"""
MIT License
# MIT License Copyright (c) 2018 Johannes Hoppe
# Copyright (c) 2018 Johannes Hoppe Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
# Permission is hereby granted, free of charge, to any person obtaining a copy The above copyright notice and this permission notice shall be included in all
# of this software and associated documentation files (the "Software"), to deal copies or substantial portions of the Software.
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# copies or substantial portions of the Software. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER SOFTWARE."""
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, """Modified code from
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 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(): def get_ip():
@@ -82,9 +81,6 @@ class Handler(ssdp.aio.SSDP):
if "location" in headers: if "location" in headers:
self.devices.append(headers["location"]) self.devices.append(headers["location"])
def request_received(self, request: ssdp.messages.SSDPRequest, addr):
raise NotImplementedError("Request received is not implemented, this is a client")
async def find_youtube_app(web_session, url_location): async def find_youtube_app(web_session, url_location):
async with web_session.get(url_location) as response: async with web_session.get(url_location) as response:
@@ -115,19 +111,21 @@ async def discover(web_session):
search_target = "urn:dial-multiscreen-org:service:dial:1" search_target = "urn:dial-multiscreen-org:service:dial:1"
max_wait = 10 max_wait = 10
handler = Handler() handler = Handler()
# Send out an M-SEARCH request and listening for responses """Send out an M-SEARCH request and listening for responses."""
family, _ = network.get_best_family(bind, network.PORT) family, addr = network.get_best_family(bind, network.PORT)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
ip_address = get_ip() ip_address = get_ip()
connect = loop.create_datagram_endpoint(handler, family=family, local_addr=(ip_address, None)) connect = loop.create_datagram_endpoint(
transport, _ = await connect handler, family=family, local_addr=(ip_address, None)
)
transport, protocol = await connect
target = network.MULTICAST_ADDRESS_IPV4, network.PORT target = network.MULTICAST_ADDRESS_IPV4, network.PORT
search_request = ssdp.messages.SSDPRequest( search_request = ssdp.messages.SSDPRequest(
"M-SEARCH", "M-SEARCH",
headers={ headers={
"HOST": f"{target[0]}:{target[1]}", "HOST": "%s:%d" % target,
"MAN": '"ssdp:discover"', "MAN": '"ssdp:discover"',
"MX": str(max_wait), # seconds to delay response [1..5] "MX": str(max_wait), # seconds to delay response [1..5]
"ST": search_target, "ST": search_target,
@@ -138,6 +136,7 @@ async def discover(web_session):
search_request.sendto(transport, target) search_request.sendto(transport, target)
# print(search_request, addr[:2])
try: try:
await asyncio.sleep(4) await asyncio.sleep(4)
finally: finally:

View File

@@ -8,7 +8,7 @@ import rich_click as click
from appdirs import user_data_dir from appdirs import user_data_dir
from . import config_setup, main, setup_wizard from . import config_setup, main, setup_wizard
from .constants import config_file_blacklist_keys, github_wiki_base_url from .constants import config_file_blacklist_keys
class Device: class Device:
@@ -41,10 +41,8 @@ class Config:
self.skip_count_tracking = True self.skip_count_tracking = True
self.mute_ads = False self.mute_ads = False
self.skip_ads = False self.skip_ads = False
self.minimum_skip_length = 1
self.auto_play = True self.auto_play = True
self.join_name = "iSponsorBlockTV" self.join_name = "iSponsorBlockTV"
self.use_proxy = False
self.__load() self.__load()
def validate(self): def validate(self):
@@ -53,8 +51,8 @@ class Config:
( (
"The atvs config option is deprecated and has stopped working." "The atvs config option is deprecated and has stopped working."
" Please read this for more information " " Please read this for more information "
"on how to upgrade to V2:\n" "on how to upgrade to V2:"
f"{github_wiki_base_url}/Migrate-from-V1-to-V2" " \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
), ),
) )
print("Exiting in 10 seconds...") print("Exiting in 10 seconds...")
@@ -67,7 +65,9 @@ class Config:
sys.exit() sys.exit()
self.devices = [Device(i) for i in self.devices] self.devices = [Device(i) for i in self.devices]
if not self.apikey and self.channel_whitelist: if not self.apikey and self.channel_whitelist:
raise ValueError("No youtube API key found and channel whitelist is not empty") raise ValueError(
"No youtube API key found and channel whitelist is not empty"
)
if not self.skip_categories: if not self.skip_categories:
self.skip_categories = ["sponsor"] self.skip_categories = ["sponsor"]
print("No categories found, using default: sponsor") print("No categories found, using default: sponsor")
@@ -90,12 +90,18 @@ class Config:
print( print(
"Running in docker without mounting the data dir, check the" "Running in docker without mounting the data dir, check the"
" wiki for more information: " " wiki for more information: "
f"{github_wiki_base_url}/Installation#Docker" "https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker"
) )
print( print(
("This image has recently been updated to v2, and requires changes."), (
("Please read this for more information on how to upgrade to V2:"), "This image has recently been updated to v2, and requires"
f"{github_wiki_base_url}/Migrate-from-V1-to-V2", " 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...") print("Exiting in 10 seconds...")
time.sleep(10) time.sleep(10)
@@ -120,21 +126,20 @@ class Config:
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
return False return False
def __hash__(self):
return hash(tuple(sorted(self.items())))
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@click.option( @click.option(
"--data", "--data",
"-d", "-d",
default=lambda: os.getenv("iSPBTV_data_dir") or user_data_dir("iSponsorBlockTV", "dmunozv04"), default=lambda: os.getenv("iSPBTV_data_dir")
or user_data_dir("iSponsorBlockTV", "dmunozv04"),
help="data directory", help="data directory",
) )
@click.option("--debug", is_flag=True, help="debug mode") @click.option("--debug", is_flag=True, help="debug mode")
@click.option("--http-tracing", is_flag=True, help="Enable HTTP request/response tracing")
# legacy commands as arguments # legacy commands as arguments
@click.option("--setup", is_flag=True, help="Setup the program graphically", hidden=True) @click.option(
"--setup", is_flag=True, help="Setup the program graphically", hidden=True
)
@click.option( @click.option(
"--setup-cli", "--setup-cli",
is_flag=True, is_flag=True,
@@ -142,24 +147,13 @@ class Config:
hidden=True, hidden=True,
) )
@click.pass_context @click.pass_context
def cli(ctx, data, debug, http_tracing, setup, setup_cli): def cli(ctx, data, debug, setup, setup_cli):
"""iSponsorblockTV""" """iSponsorblockTV"""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["data_dir"] = data ctx.obj["data_dir"] = data
ctx.obj["debug"] = debug ctx.obj["debug"] = debug
ctx.obj["http_tracing"] = http_tracing
logger = logging.getLogger()
ctx.obj["logger"] = logger
sh = logging.StreamHandler()
sh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger.addHandler(sh)
if debug: if debug:
logger.setLevel(logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
else:
logger.setLevel(logging.INFO)
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
if setup: if setup:
ctx.invoke(setup_command) ctx.invoke(setup_command)
@@ -169,30 +163,36 @@ def cli(ctx, data, debug, http_tracing, setup, setup_cli):
ctx.invoke(start) ctx.invoke(start)
@cli.command(name="setup") @cli.command()
@click.pass_context @click.pass_context
def setup_command(ctx): def setup(ctx):
"""Setup the program graphically""" """Setup the program graphically"""
config = Config(ctx.obj["data_dir"]) config = Config(ctx.obj["data_dir"])
setup_wizard.main(config) setup_wizard.main(config)
sys.exit() sys.exit()
@cli.command(name="setup-cli") setup_command = setup
@cli.command()
@click.pass_context @click.pass_context
def setup_cli_command(ctx): def setup_cli(ctx):
"""Setup the program in the command line""" """Setup the program in the command line"""
config = Config(ctx.obj["data_dir"]) config = Config(ctx.obj["data_dir"])
config_setup.main(config, ctx.obj["debug"]) config_setup.main(config, ctx.obj["debug"])
setup_cli_command = setup_cli
@cli.command() @cli.command()
@click.pass_context @click.pass_context
def start(ctx): def start(ctx):
"""Start the main program""" """Start the main program"""
config = Config(ctx.obj["data_dir"]) config = Config(ctx.obj["data_dir"])
config.validate() config.validate()
main.main(config, ctx.obj["debug"], ctx.obj["http_tracing"]) main.main(config, ctx.obj["debug"])
# Create fake "self" group to show pyapp options in help menu # Create fake "self" group to show pyapp options in help menu
@@ -202,7 +202,9 @@ pyapp_group.add_command(
click.RichCommand("update", help="Update the package to the latest version") click.RichCommand("update", help="Update the package to the latest version")
) )
pyapp_group.add_command( pyapp_group.add_command(
click.Command("remove", help="Remove the package, wiping the installation but not the data") click.Command(
"remove", help="Remove the package, wiping the installation but not the data"
)
) )
pyapp_group.add_command( pyapp_group.add_command(
click.RichCommand( click.RichCommand(

View File

@@ -0,0 +1,57 @@
import os
import plistlib
from . import config_setup
"""Not updated to V2 yet, should still work. Here be dragons"""
default_plist = {
"Label": "com.dmunozv04iSponsorBlockTV",
"RunAtLoad": True,
"StartInterval": 20,
"EnvironmentVariables": {"PYTHONUNBUFFERED": "YES"},
"StandardErrorPath": "", # Fill later
"StandardOutPath": "",
"ProgramArguments": "",
"WorkingDirectory": "",
}
def create_plist(path):
plist = default_plist
plist["ProgramArguments"] = [path + "/iSponsorBlockTV-macos"]
plist["StandardErrorPath"] = path + "/iSponsorBlockTV.error.log"
plist["StandardOutPath"] = path + "/iSponsorBlockTV.out.log"
plist["WorkingDirectory"] = path
launchd_path = os.path.expanduser("~/Library/LaunchAgents/")
path_to_save = launchd_path + "com.dmunozv04.iSponsorBlockTV.plist"
with open(path_to_save, "wb") as fp:
plistlib.dump(plist, fp)
def run_setup(file):
config = {}
config_setup.main(config, file, debug=False)
def main():
correct_path = os.path.expanduser("~/iSponsorBlockTV")
if os.path.isfile(correct_path + "/iSponsorBlockTV-macos"):
print("Program is on the right path")
print("The launch daemon will now be installed")
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"
)
else:
if not os.path.exists(correct_path):
os.makedirs(correct_path)
print(
"Please move the program to the correct path: "
+ correct_path
+ "opening now on finder..."
)
os.system("open -R " + correct_path)

View File

@@ -7,7 +7,6 @@ from typing import Optional
import aiohttp import aiohttp
from . import api_helpers, ytlounge from . import api_helpers, ytlounge
from .debug_helpers import AiohttpTracer
class DeviceListener: class DeviceListener:
@@ -19,6 +18,16 @@ class DeviceListener:
self.cancelled = False self.cancelled = False
self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}") self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}")
self.web_session = web_session 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( self.lounge_controller = ytlounge.YtLoungeApi(
device.screen_id, config, api_helper, self.logger device.screen_id, config, api_helper, self.logger
) )
@@ -30,12 +39,14 @@ class DeviceListener:
try: try:
await self.lounge_controller.refresh_auth() await self.lounge_controller.refresh_auth()
except BaseException: except BaseException:
# traceback.print_exc()
pass pass
async def is_available(self): async def is_available(self):
try: try:
return await self.lounge_controller.is_available() return await self.lounge_controller.is_available()
except BaseException: except BaseException:
# traceback.print_exc()
return False return False
# Main subscription loop # Main subscription loop
@@ -49,7 +60,6 @@ class DeviceListener:
except BaseException: except BaseException:
await asyncio.sleep(10) await asyncio.sleep(10)
while not (await self.is_available()) and not self.cancelled: while not (await self.is_available()) and not self.cancelled:
self.logger.debug("Waiting for device to be available")
await asyncio.sleep(10) await asyncio.sleep(10)
try: try:
await lounge_controller.connect() await lounge_controller.connect()
@@ -57,7 +67,6 @@ class DeviceListener:
pass pass
while not lounge_controller.connected() and not self.cancelled: while not lounge_controller.connected() and not self.cancelled:
# Doesn't connect to the device if it's a kids profile (it's broken) # Doesn't connect to the device if it's a kids profile (it's broken)
self.logger.debug("Waiting for device to be connected")
await asyncio.sleep(10) await asyncio.sleep(10)
try: try:
await lounge_controller.connect() await lounge_controller.connect()
@@ -67,7 +76,7 @@ class DeviceListener:
"Connected to device %s (%s)", lounge_controller.screen_name, self.name "Connected to device %s (%s)", lounge_controller.screen_name, self.name
) )
try: try:
self.logger.debug("Subscribing to lounge") self.logger.info("Subscribing to lounge")
sub = await lounge_controller.subscribe_monitored(self) sub = await lounge_controller.subscribe_monitored(self)
await sub await sub
except BaseException: except BaseException:
@@ -75,11 +84,11 @@ class DeviceListener:
# Method called on playback state change # Method called on playback state change
async def __call__(self, state): async def __call__(self, state):
time_start = time.monotonic()
try: try:
self.task.cancel() self.task.cancel()
except BaseException: except BaseException:
pass pass
time_start = time.time()
self.task = asyncio.create_task(self.process_playstatus(state, time_start)) self.task = asyncio.create_task(self.process_playstatus(state, time_start))
# Processes the playback state change # Processes the playback state change
@@ -88,7 +97,9 @@ class DeviceListener:
if state.videoId: if state.videoId:
segments = await self.api_helper.get_segments(state.videoId) segments = await self.api_helper.get_segments(state.videoId)
if state.state.value == 1: # Playing if state.state.value == 1: # Playing
self.logger.info("Playing video %s with %d segments", state.videoId, len(segments)) self.logger.info(
f"Playing video {state.videoId} with {len(segments)} segments"
)
if segments: # If there are segments if segments: # If there are segments
await self.time_to_segment(segments, state.currentTime, time_start) await self.time_to_segment(segments, state.currentTime, time_start)
@@ -97,32 +108,28 @@ class DeviceListener:
start_next_segment = None start_next_segment = None
next_segment = None next_segment = None
for segment in segments: for segment in segments:
segment_start = segment["start"] if position < 2 and (segment["start"] <= position < segment["end"]):
segment_end = segment["end"]
is_within_start_range = (
position < 1 < segment_end and segment_start <= position < segment_end
)
is_beyond_current_position = segment_start > position
if is_within_start_range or is_beyond_current_position:
next_segment = segment next_segment = segment
start_next_segment = position if is_within_start_range else segment_start 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 break
if start_next_segment: if start_next_segment:
time_to_next = ( time_to_next = (
(start_next_segment - position - (time.monotonic() - time_start)) start_next_segment - position - (time.time() - time_start) - self.offset
/ self.lounge_controller.playback_speed )
) - self.offset
await self.skip(time_to_next, next_segment["end"], next_segment["UUID"]) await self.skip(time_to_next, next_segment["end"], next_segment["UUID"])
# Skips to the next segment (waits for the time to pass) # Skips to the next segment (waits for the time to pass)
async def skip(self, time_to, position, uuids): async def skip(self, time_to, position, uuids):
await asyncio.sleep(time_to) await asyncio.sleep(time_to)
self.logger.info("Skipping segment: seeking to %s", position) self.logger.info("Skipping segment: seeking to %s", position)
await asyncio.gather( await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids))
asyncio.create_task(self.lounge_controller.seek_to(position)), await asyncio.create_task(self.lounge_controller.seek_to(position))
asyncio.create_task(self.api_helper.mark_viewed_segments(uuids)),
)
async def cancel(self): async def cancel(self):
self.cancelled = True self.cancelled = True
@@ -145,7 +152,9 @@ class DeviceListener:
async def finish(devices, web_session, tcp_connector): async def finish(devices, web_session, tcp_connector):
await asyncio.gather(*(device.cancel() for device in devices), return_exceptions=True) await asyncio.gather(
*(device.cancel() for device in devices), return_exceptions=True
)
await web_session.close() await web_session.close()
await tcp_connector.close() await tcp_connector.close()
@@ -154,30 +163,14 @@ def handle_signal(signum, frame):
raise KeyboardInterrupt() raise KeyboardInterrupt()
async def main_async(config, debug, http_tracing): async def main_async(config, debug):
loop = asyncio.get_event_loop_policy().get_event_loop() loop = asyncio.get_event_loop_policy().get_event_loop()
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
devices = [] # Save the devices to close them later devices = [] # Save the devices to close them later
if debug: if debug:
loop.set_debug(True) loop.set_debug(True)
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300) tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300)
web_session = aiohttp.ClientSession(connector=tcp_connector)
# Configure session with tracing if enabled
if http_tracing:
root_logger = logging.getLogger("aiohttp_trace")
tracer = AiohttpTracer(root_logger)
trace_config = aiohttp.TraceConfig()
trace_config.on_request_start.append(tracer.on_request_start)
trace_config.on_response_chunk_received.append(tracer.on_response_chunk_received)
trace_config.on_request_end.append(tracer.on_request_end)
trace_config.on_request_exception.append(tracer.on_request_exception)
web_session = aiohttp.ClientSession(
trust_env=config.use_proxy, connector=tcp_connector, trace_configs=[trace_config]
)
else:
web_session = aiohttp.ClientSession(trust_env=config.use_proxy, connector=tcp_connector)
api_helper = api_helpers.ApiHelper(config, web_session) api_helper = api_helpers.ApiHelper(config, web_session)
for i in config.devices: for i in config.devices:
device = DeviceListener(api_helper, config, i, debug, web_session) device = DeviceListener(api_helper, config, i, debug, web_session)
@@ -198,8 +191,11 @@ async def main_async(config, debug, http_tracing):
finally: finally:
await web_session.close() await web_session.close()
await tcp_connector.close() await tcp_connector.close()
loop.close()
print("Exited") print("Exited")
def main(config, debug, http_tracing): def main(config, debug):
asyncio.run(main_async(config, debug, http_tracing)) loop = asyncio.get_event_loop()
loop.run_until_complete(main_async(config, debug))
loop.close()

View File

@@ -383,9 +383,3 @@ MigrationScreen {
padding: 1; padding: 1;
height: auto; height: auto;
} }
/* Use Proxy */
#useproxy-container{
padding: 1;
height: auto;
}

View File

@@ -13,7 +13,6 @@ from textual.containers import (
ScrollableContainer, ScrollableContainer,
Vertical, Vertical,
) )
from textual.css.query import NoMatches
from textual.events import Click from textual.events import Click
from textual.screen import Screen from textual.screen import Screen
from textual.validation import Function from textual.validation import Function
@@ -80,7 +79,7 @@ class Element(Static):
self.tooltip = tooltip self.tooltip = tooltip
def process_values_from_data(self): def process_values_from_data(self):
raise NotImplementedError("Subclasses must implement this method.") pass
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Button( yield Button(
@@ -123,7 +122,9 @@ class Channel(Element):
if "name" in self.element_data: if "name" in self.element_data:
self.element_name = self.element_data["name"] self.element_name = self.element_data["name"]
else: 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): class ChannelRadio(RadioButton):
@@ -203,7 +204,9 @@ class ExitScreen(ModalWithClickExit):
classes="button-100", classes="button-100",
), ),
Button("Save", variant="success", id="exit-save", 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"), Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"),
id="dialog-exit", id="dialog-exit",
) )
@@ -226,15 +229,14 @@ class ExitScreen(ModalWithClickExit):
class AddDevice(ModalWithClickExit): class AddDevice(ModalWithClickExit):
"""Screen with a dialog to add a device, either with a pairing code """Screen with a dialog to add a device, either with a pairing code or with lan discovery."""
or with lan discovery."""
BINDINGS = [("escape", "dismiss({})", "Return")] BINDINGS = [("escape", "dismiss({})", "Return")]
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.config = config self.config = config
self.web_session = aiohttp.ClientSession(trust_env=config.use_proxy) self.web_session = aiohttp.ClientSession()
self.api_helper = api_helpers.ApiHelper(config, self.web_session) self.api_helper = api_helpers.ApiHelper(config, self.web_session)
self.devices_discovered_dial = [] self.devices_discovered_dial = []
@@ -252,13 +254,19 @@ class AddDevice(ModalWithClickExit):
id="add-device-dial-button", id="add-device-dial-button",
classes="button-switcher", classes="button-switcher",
) )
with ContentSwitcher(id="add-device-switcher", initial="add-device-pin-container"): with ContentSwitcher(
id="add-device-switcher", initial="add-device-pin-container"
):
with Container(id="add-device-pin-container"): with Container(id="add-device-pin-container"):
yield Input( yield Input(
placeholder=("Pairing Code (found in Settings - Link with TV code)"), placeholder=(
"Pairing Code (found in Settings - Link with TV code)"
),
id="pairing-code-input", id="pairing-code-input",
validators=[ validators=[
Function(_validate_pairing_code, "Invalid pairing code format") Function(
_validate_pairing_code, "Invalid pairing code format"
)
], ],
) )
yield Input( yield Input(
@@ -302,11 +310,7 @@ class AddDevice(ModalWithClickExit):
async def task_discover_devices(self): async def task_discover_devices(self):
devices_found = await self.api_helper.discover_youtube_devices_dial() devices_found = await self.api_helper.discover_youtube_devices_dial()
try: list_widget: SelectionList = self.query_one("#dial-devices-list")
list_widget: SelectionList = self.query_one("#dial-devices-list")
except NoMatches:
# The widget was not found, probably the screen was dismissed
return
list_widget.clear_options() list_widget.clear_options()
if devices_found: if devices_found:
# print(devices_found) # print(devices_found)
@@ -327,7 +331,9 @@ class AddDevice(ModalWithClickExit):
@on(Input.Changed, "#pairing-code-input") @on(Input.Changed, "#pairing-code-input")
def changed_pairing_code(self, event: Input.Changed): 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(Input.Submitted, "#pairing-code-input")
@on(Button.Pressed, "#add-device-pin-add-button") @on(Button.Pressed, "#add-device-pin-add-button")
@@ -341,7 +347,7 @@ class AddDevice(ModalWithClickExit):
pairing_code = int( pairing_code = int(
pairing_code.replace("-", "").replace(" ", "") pairing_code.replace("-", "").replace(" ", "")
) # remove dashes and spaces ) # remove dashes and spaces
device_name = self.query_one("#device-name-input").value device_name = self.parent.query_one("#device-name-input").value
paired = False paired = False
try: try:
paired = await lounge_controller.pair(pairing_code) paired = await lounge_controller.pair(pairing_code)
@@ -375,19 +381,20 @@ class AddDevice(ModalWithClickExit):
@on(SelectionList.SelectedChanged, "#dial-devices-list") @on(SelectionList.SelectedChanged, "#dial-devices-list")
def changed_device_list(self, event: SelectionList.SelectedChanged): 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): class AddChannel(ModalWithClickExit):
"""Screen with a dialog to add a channel, """Screen with a dialog to add a channel, either using search or with a channel id."""
either using search or with a channel id."""
BINDINGS = [("escape", "dismiss(())", "Return")] BINDINGS = [("escape", "dismiss(())", "Return")]
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.config = config self.config = config
web_session = aiohttp.ClientSession(trust_env=config.use_proxy) web_session = aiohttp.ClientSession()
self.api_helper = api_helpers.ApiHelper(config, web_session) self.api_helper = api_helpers.ApiHelper(config, web_session)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@@ -413,7 +420,9 @@ class AddChannel(ModalWithClickExit):
classes="button-switcher", classes="button-switcher",
) )
yield Label(id="add-channel-info", classes="subtitle") 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"): with Vertical(id="add-channel-search-container"):
if self.config.apikey: if self.config.apikey:
with Grid(id="add-channel-search-inputs"): with Grid(id="add-channel-search-inputs"):
@@ -421,7 +430,9 @@ class AddChannel(ModalWithClickExit):
placeholder="Enter channel name", placeholder="Enter channel name",
id="channel-name-input-search", id="channel-name-input-search",
) )
yield Button("Search", id="search-channel-button", variant="success") yield Button(
"Search", id="search-channel-button", variant="success"
)
yield RadioSet( yield RadioSet(
RadioButton(label="Search to see results", disabled=True), RadioButton(label="Search to see results", disabled=True),
id="channel-search-results", id="channel-search-results",
@@ -444,12 +455,15 @@ class AddChannel(ModalWithClickExit):
) )
with Vertical(id="add-channel-id-container"): with Vertical(id="add-channel-id-container"):
yield Input( yield Input(
placeholder=("Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"), placeholder=(
"Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"
),
id="channel-id-input", id="channel-id-input",
) )
yield Input( yield Input(
placeholder=( placeholder=(
"Enter channel name (only used to display in the config file)" "Enter channel name (only used to display in the config"
" file)"
), ),
id="channel-name-input-id", id="channel-name-input-id",
) )
@@ -475,7 +489,9 @@ class AddChannel(ModalWithClickExit):
async def handle_search_channel(self) -> None: async def handle_search_channel(self) -> None:
channel_name = self.query_one("#channel-name-input-search").value channel_name = self.query_one("#channel-name-input-search").value
if not channel_name: 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 return
self.query_one("#search-channel-button").disabled = True self.query_one("#search-channel-button").disabled = True
self.query_one("#add-channel-info").update("Searching...") self.query_one("#add-channel-info").update("Searching...")
@@ -484,7 +500,9 @@ class AddChannel(ModalWithClickExit):
try: try:
channels_list = await self.api_helper.search_channels(channel_name) channels_list = await self.api_helper.search_channels(channel_name)
except BaseException: except BaseException:
self.query_one("#add-channel-info").update("[#ff0000]Failed to search for channel") self.query_one("#add-channel-info").update(
"[#ff0000]Failed to search for channel"
)
self.query_one("#search-channel-button").disabled = False self.query_one("#search-channel-button").disabled = False
return return
for i in channels_list: for i in channels_list:
@@ -497,7 +515,9 @@ class AddChannel(ModalWithClickExit):
def handle_add_channel_search(self) -> None: def handle_add_channel_search(self) -> None:
channel = self.query_one("#channel-search-results").pressed_button.channel_data channel = self.query_one("#channel-search-results").pressed_button.channel_data
if not channel: 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 return
self.query_one("#add-channel-info").update("Adding...") self.query_one("#add-channel-info").update("Adding...")
self.dismiss(channel) self.dismiss(channel)
@@ -509,7 +529,9 @@ class AddChannel(ModalWithClickExit):
channel_id = self.query_one("#channel-id-input").value channel_id = self.query_one("#channel-id-input").value
channel_name = self.query_one("#channel-name-input-id").value channel_name = self.query_one("#channel-name-input-id").value
if not channel_id: 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 return
if not channel_name: if not channel_name:
channel_name = channel_id channel_name = channel_id
@@ -531,7 +553,7 @@ class EditDevice(ModalWithClickExit):
def action_close_screen_saving(self) -> None: def action_close_screen_saving(self) -> None:
self.dismiss() self.dismiss()
def dismiss(self, _=None) -> None: def dismiss(self) -> None:
self.device_data["name"] = self.query_one("#device-name-input").value self.device_data["name"] = self.query_one("#device-name-input").value
self.device_data["screen_id"] = self.query_one("#device-id-input").value self.device_data["screen_id"] = self.query_one("#device-id-input").value
self.device_data["offset"] = int(self.query_one("#device-offset-input").value) self.device_data["offset"] = int(self.query_one("#device-offset-input").value)
@@ -600,7 +622,9 @@ class DevicesManager(Vertical):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("Devices", classes="title") yield Label("Devices", classes="title")
with Horizontal(id="add-device-button-container"): with Horizontal(id="add-device-button-container"):
yield Button("Add Device", id="add-device", classes="button-100 button-small") yield Button(
"Add Device", id="add-device", classes="button-100 button-small"
)
for device in self.devices: for device in self.devices:
yield Device(device, tooltip="Click to edit") yield Device(device, tooltip="Click to edit")
@@ -645,7 +669,7 @@ class ApiKeyManager(Vertical):
yield Label("YouTube Api Key", classes="title") yield Label("YouTube Api Key", classes="title")
yield Label( yield Label(
"You can get a YouTube Data API v3 Key from the" "You can get a YouTube Data API v3 Key from the"
" [link='https://console.developers.google.com/apis/credentials']Google Cloud" " [link=https://console.developers.google.com/apis/credentials]Google Cloud"
" Console[/link]. This key is only required if you're whitelisting" " Console[/link]. This key is only required if you're whitelisting"
" channels." " channels."
) )
@@ -664,7 +688,7 @@ class ApiKeyManager(Vertical):
@on(Button.Pressed, "#api-key-view") @on(Button.Pressed, "#api-key-view")
def pressed_api_key_view(self, event: Button.Pressed): def pressed_api_key_view(self, event: Button.Pressed):
if "Show" in str(event.button.label): if "Show" in event.button.label:
event.button.label = "Hide key" event.button.label = "Hide key"
self.query_one("#api-key-input").password = False self.query_one("#api-key-input").password = False
else: else:
@@ -697,43 +721,6 @@ class SkipCategoriesManager(Vertical):
self.config.skip_categories = event.selection_list.selected self.config.skip_categories = event.selection_list.selected
class MinimumSkipLengthManager(Vertical):
"""Manager for minimum skip length setting."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Minimum Skip Length", classes="title")
yield Label(
(
"Specify the minimum length a segment must meet in order to skip "
"it (in seconds). Default is 1 second; entering 0 will skip all "
"segments."
),
classes="subtitle",
)
yield Input(
placeholder="Minimum skip length (0 to skip all)",
id="minimum-skip-length-input",
value=str(getattr(self.config, "minimum_skip_length", 1)),
validators=[
Function(
lambda user_input: user_input.isdigit(),
"Please enter a valid non-negative number",
)
],
)
@on(Input.Changed, "#minimum-skip-length-input")
def changed_minimum_skip_length(self, event: Input.Changed):
try:
self.config.minimum_skip_length = int(event.input.value)
except ValueError:
self.config.minimum_skip_length = 1
class SkipCountTrackingManager(Vertical): class SkipCountTrackingManager(Vertical):
"""Manager for skip count tracking, allows to enable/disable skip count tracking.""" """Manager for skip count tracking, allows to enable/disable skip count tracking."""
@@ -806,8 +793,7 @@ class AdSkipMuteManager(Vertical):
class ChannelWhitelistManager(Vertical): class ChannelWhitelistManager(Vertical):
"""Manager for channel whitelist, """Manager for channel whitelist, allows adding/removing channels from the whitelist."""
allows adding/removing channels from the whitelist."""
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -825,18 +811,23 @@ class ChannelWhitelistManager(Vertical):
id="channel-whitelist-subtitle", id="channel-whitelist-subtitle",
) )
yield Label( yield Label(
("⚠️ [#FF0000]You need to set your YouTube Api Key in order to use this feature"), (
":warning: [#FF0000]You need to set your YouTube Api Key in order to"
" use this feature"
),
id="warning-no-key", id="warning-no-key",
) )
with Horizontal(id="add-channel-button-container"): with Horizontal(id="add-channel-button-container"):
yield Button("Add Channel", id="add-channel", classes="button-100 button-small") yield Button(
"Add Channel", id="add-channel", classes="button-100 button-small"
)
for channel in self.config.channel_whitelist: for channel in self.config.channel_whitelist:
yield Channel(channel) yield Channel(channel)
def on_mount(self) -> None: def on_mount(self) -> None:
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool( self.app.query_one("#warning-no-key").display = (
self.config.channel_whitelist not self.config.apikey
) ) and bool(self.config.channel_whitelist)
def new_channel(self, channel: tuple) -> None: def new_channel(self, channel: tuple) -> None:
if channel: if channel:
@@ -848,18 +839,18 @@ class ChannelWhitelistManager(Vertical):
channel_widget = Channel(channel_dict) channel_widget = Channel(channel_dict)
self.mount(channel_widget) self.mount(channel_widget)
channel_widget.focus(scroll_visible=True) channel_widget.focus(scroll_visible=True)
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool( self.app.query_one("#warning-no-key").display = (
self.config.channel_whitelist not self.config.apikey
) ) and bool(self.config.channel_whitelist)
@on(Button.Pressed, "#element-remove") @on(Button.Pressed, "#element-remove")
def remove_channel(self, event: Button.Pressed): def remove_channel(self, event: Button.Pressed):
channel_to_remove: Element = event.button.parent channel_to_remove: Element = event.button.parent
self.config.channel_whitelist.remove(channel_to_remove.element_data) self.config.channel_whitelist.remove(channel_to_remove.element_data)
channel_to_remove.remove() channel_to_remove.remove()
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool( self.app.query_one("#warning-no-key").display = (
self.config.channel_whitelist not self.config.apikey
) ) and bool(self.config.channel_whitelist)
@on(Button.Pressed, "#add-channel") @on(Button.Pressed, "#add-channel")
def add_channel(self, event: Button.Pressed): def add_channel(self, event: Button.Pressed):
@@ -892,45 +883,13 @@ class AutoPlayManager(Vertical):
self.config.auto_play = event.checkbox.value self.config.auto_play = event.checkbox.value
class UseProxyManager(Vertical): class ISponsorBlockTVSetupMainScreen(Screen):
"""Manager for proxy use, allows enabling/disabling use of proxy.""" """Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Use proxy", classes="title")
yield Label(
"This feature allows application to use system proxy,"
" if it is set in environment variables."
" This parameter will be passed in all [i]aiohttp.ClientSession[/i]"
' calls. For further information, see "[i]trust_env[/i]" section at'
" [link='https://docs.aiohttp.org/en/stable/client_reference.html']"
"aiohttp documentation[/link].",
classes="subtitle",
id="useproxy-subtitle",
)
with Horizontal(id="useproxy-container"):
yield Checkbox(
value=self.config.use_proxy,
id="useproxy-switch",
label="Use proxy",
)
@on(Checkbox.Changed, "#useproxy-switch")
def changed_skip(self, event: Checkbox.Changed):
self.config.use_proxy = event.checkbox.value
class ISponsorBlockTVSetup(App):
TITLE = "iSponsorBlockTV" TITLE = "iSponsorBlockTV"
SUB_TITLE = "Setup Wizard" 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 AUTO_FOCUS = None
CSS_PATH = ( # tcss is the recommended extension for textual css files
"setup-wizard-style.tcss"
)
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -942,15 +901,12 @@ class ISponsorBlockTVSetup(App):
yield Header() yield Header()
yield Footer() yield Footer()
with ScrollableContainer(id="setup-wizard"): with ScrollableContainer(id="setup-wizard"):
yield DevicesManager(config=self.config, id="devices-manager", classes="container") yield DevicesManager(
config=self.config, id="devices-manager", classes="container"
)
yield SkipCategoriesManager( yield SkipCategoriesManager(
config=self.config, id="skip-categories-manager", classes="container" config=self.config, id="skip-categories-manager", classes="container"
) )
yield MinimumSkipLengthManager(
config=self.config,
id="minimum-skip-length-manager",
classes="container",
)
yield SkipCountTrackingManager( yield SkipCountTrackingManager(
config=self.config, id="count-segments-manager", classes="container" config=self.config, id="count-segments-manager", classes="container"
) )
@@ -960,13 +916,17 @@ class ISponsorBlockTVSetup(App):
yield ChannelWhitelistManager( yield ChannelWhitelistManager(
config=self.config, id="channel-whitelist-manager", classes="container" config=self.config, id="channel-whitelist-manager", classes="container"
) )
yield ApiKeyManager(config=self.config, id="api-key-manager", classes="container") yield ApiKeyManager(
yield AutoPlayManager(config=self.config, id="autoplay-manager", classes="container") config=self.config, id="api-key-manager", classes="container"
yield UseProxyManager(config=self.config, id="useproxy-manager", classes="container") )
yield AutoPlayManager(
config=self.config, id="autoplay-manager", classes="container"
)
def on_mount(self) -> None: def on_mount(self) -> None:
if self.check_for_old_config_entries(): if self.check_for_old_config_entries():
self.app.push_screen(MigrationScreen()) self.app.push_screen(MigrationScreen())
pass
def action_save(self) -> None: def action_save(self) -> None:
self.config.save() self.config.save()
@@ -986,13 +946,36 @@ class ISponsorBlockTVSetup(App):
@on(Input.Changed, "#api-key-input") @on(Input.Changed, "#api-key-input")
def changed_api_key(self, event: Input.Changed): def changed_api_key(self, event: Input.Changed):
try: # ChannelWhitelist might not be mounted try: # ChannelWhitelist might not be mounted
self.app.query_one("#warning-no-key").display = bool( # Show if no api key is set and at least one channel is in the whitelist
(not event.input.value) and self.config.channel_whitelist self.app.query_one("#warning-no-key").display = (
) not event.input.value
except NoMatches: ) and self.config.channel_whitelist
except BaseException:
pass pass
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")]
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
self.main_screen = ISponsorBlockTVSetupMainScreen(config=self.config)
def on_mount(self) -> None:
self.push_screen(self.main_screen)
def action_save(self) -> None:
self.main_screen.action_save()
def action_exit_modal(self) -> None:
self.main_screen.action_exit_modal()
def main(config): def main(config):
app = ISponsorBlockTVSetup(config) app = ISponsorBlockTVSetup(config)
app.run() app.run()

View File

@@ -1,14 +1,10 @@
import asyncio import asyncio
import json import json
import sys
from typing import Any, List from typing import Any, List
import pyytlounge import pyytlounge
from aiohttp import ClientSession from aiohttp import ClientSession
from pyytlounge.wrapper import NotLinkedException, api_base, as_aiter, Dict
from uuid import uuid4
from .constants import youtube_client_blacklist from .constants import youtube_client_blacklist
create_task = asyncio.create_task create_task = asyncio.create_task
@@ -22,20 +18,19 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
api_helper=None, api_helper=None,
logger=None, logger=None,
): ):
super().__init__(config.join_name if config else "iSponsorBlockTV", logger=logger) super().__init__(
config.join_name if config else "iSponsorBlockTV", logger=logger
)
self.auth.screen_id = screen_id self.auth.screen_id = screen_id
self.auth.lounge_id_token = None self.auth.lounge_id_token = None
self.api_helper = api_helper self.api_helper = api_helper
self.volume_state = {} self.volume_state = {}
self.playback_speed = 1.0
self.subscribe_task = None self.subscribe_task = None
self.subscribe_task_watchdog = None self.subscribe_task_watchdog = None
self.callback = None self.callback = None
self.logger = logger self.logger = logger
self.shorts_disconnected = False self.shorts_disconnected = False
self.auto_play = True self.auto_play = True
self.watchdog_running = False
self.last_event_time = 0
if config: if config:
self.mute_ads = config.mute_ads self.mute_ads = config.mute_ads
self.skip_ads = config.skip_ads self.skip_ads = config.skip_ads
@@ -44,73 +39,40 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# Ensures that we still are subscribed to the lounge # Ensures that we still are subscribed to the lounge
async def _watchdog(self): async def _watchdog(self):
""" await asyncio.sleep(
Continuous watchdog that monitors for connection health. 35
If no events are received within the expected timeframe, ) # YouTube sends at least a message every 30 seconds (no-op or any other)
it cancels the current subscription.
"""
self.watchdog_running = True
self.last_event_time = asyncio.get_event_loop().time()
try: try:
while self.watchdog_running: self.subscribe_task.cancel()
await asyncio.sleep(10) except BaseException:
current_time = asyncio.get_event_loop().time() pass
time_since_last_event = current_time - self.last_event_time
# YouTube sends a message at least every 30 seconds
if time_since_last_event > 60:
self.logger.debug(
f"Watchdog triggered: No events for {time_since_last_event:.1f} seconds"
)
# Cancel current subscription
if self.subscribe_task and not self.subscribe_task.done():
self.subscribe_task.cancel()
await asyncio.sleep(1) # Give it time to cancel
except asyncio.CancelledError:
self.logger.debug("Watchdog task cancelled")
self.watchdog_running = False
except BaseException as e:
self.logger.error(f"Watchdog error: {e}")
self.watchdog_running = False
# Subscribe to the lounge and start the watchdog # Subscribe to the lounge and start the watchdog
async def subscribe_monitored(self, callback): async def subscribe_monitored(self, callback):
self.callback = callback self.callback = callback
try:
# Stop existing watchdog if running
if self.subscribe_task_watchdog and not self.subscribe_task_watchdog.done():
self.watchdog_running = False
self.subscribe_task_watchdog.cancel() self.subscribe_task_watchdog.cancel()
try: except BaseException:
await self.subscribe_task_watchdog pass # No watchdog task
except (asyncio.CancelledError, Exception):
pass
# Start new subscription
if self.subscribe_task and not self.subscribe_task.done():
self.subscribe_task.cancel()
try:
await self.subscribe_task
except (asyncio.CancelledError, Exception):
pass
self.subscribe_task = asyncio.create_task(super().subscribe(callback)) self.subscribe_task = asyncio.create_task(super().subscribe(callback))
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog()) self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
create_task(self.debug_command("bugchomp "))
return self.subscribe_task return self.subscribe_task
# Process a lounge subscription event # Process a lounge subscription event
# skipcq: PY-R1000
def _process_event(self, event_type: str, args: List[Any]): def _process_event(self, event_type: str, args: List[Any]):
self.logger.debug(f"process_event({event_type}, {args})") self.logger.debug(f"process_event({event_type}, {args})")
# Update last event time for the watchdog # (Re)start the watchdog
self.last_event_time = asyncio.get_event_loop().time() try:
self.subscribe_task_watchdog.cancel()
# A bunch of events useful to detect ads playing, except BaseException:
# and the next video before it starts playing pass
# (that way we can get the segments) 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": if event_type == "onStateChange":
create_task(self.debug_command("exp 0 "))
data = args[0] data = args[0]
# print(data) # print(data)
# Unmute when the video starts playing # Unmute when the video starts playing
@@ -124,7 +86,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif event_type == "onAdStateChange": elif event_type == "onAdStateChange":
data = args[0] data = args[0]
if data["adState"] == "0" and data["currentTime"] != "0": # Ad is not playing if data["adState"] == "0": # Ad is not playing
self.logger.info("Ad has ended, unmuting") self.logger.info("Ad has ended, unmuting")
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif ( elif (
@@ -133,16 +95,20 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.logger.info("Ad can be skipped, skipping") self.logger.info("Ad can be skipped, skipping")
create_task(self.skip_ad()) create_task(self.skip_ad())
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif self.mute_ads: # Seen multiple other adStates, assuming they are all ads elif (
self.mute_ads
): # Seen multiple other adStates, assuming they are all ads
self.logger.info("Ad has started, muting") self.logger.info("Ad has started, muting")
create_task(self.mute(True, override=True)) create_task(self.mute(True, override=True))
# Manages volume, useful since YouTube wants to know the volume # Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it)
# when unmuting (even if they already have it)
elif event_type == "onVolumeChanged": elif event_type == "onVolumeChanged":
self.volume_state = args[0] self.volume_state = args[0]
pass
# Gets segments for the next video before it starts playing # Gets segments for the next video before it starts playing
elif event_type == "autoplayUpNext": elif event_type == "autoplayUpNext":
if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty 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}") self.logger.info(f"Getting segments for next video: {vid_id}")
create_task(self.api_helper.get_segments(vid_id)) create_task(self.api_helper.get_segments(vid_id))
@@ -153,14 +119,15 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if vid_id := data["contentVideoId"]: if vid_id := data["contentVideoId"]:
self.logger.info(f"Getting segments for next video: {vid_id}") self.logger.info(f"Getting segments for next video: {vid_id}")
create_task(self.api_helper.get_segments(vid_id)) create_task(self.api_helper.get_segments(vid_id))
elif (
if (
self.skip_ads and data["isSkipEnabled"] == "true" self.skip_ads and data["isSkipEnabled"] == "true"
): # YouTube uses strings for booleans ): # YouTube uses strings for booleans
self.logger.info("Ad can be skipped, skipping") self.logger.info("Ad can be skipped, skipping")
create_task(self.skip_ad()) create_task(self.skip_ad())
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif self.mute_ads: # Seen multiple other adStates, assuming they are all ads elif (
self.mute_ads
): # Seen multiple other adStates, assuming they are all ads
self.logger.info("Ad has started, muting") self.logger.info("Ad has started, muting")
create_task(self.mute(True, override=True)) create_task(self.mute(True, override=True))
@@ -183,55 +150,51 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
elif event_type == "loungeScreenDisconnected": elif event_type == "loungeScreenDisconnected":
if args: # Sometimes it's empty if args: # Sometimes it's empty
data = args[0] data = args[0]
if data["reason"] == "disconnectedByUserScreenInitiated": # Short playing? if (
data["reason"] == "disconnectedByUserScreenInitiated"
): # Short playing?
self.shorts_disconnected = True self.shorts_disconnected = True
elif event_type == "onAutoplayModeChanged": elif event_type == "onAutoplayModeChanged":
create_task(self.set_auto_play_mode(self.auto_play)) create_task(self.set_auto_play_mode(self.auto_play))
elif event_type == "onPlaybackSpeedChanged":
data = args[0]
self.playback_speed = float(data.get("playbackSpeed", "1"))
create_task(self.get_now_playing())
super()._process_event(event_type, args) super()._process_event(event_type, args)
# Set the volume to a specific value (0-100) # Set the volume to a specific value (0-100)
async def set_volume(self, volume: int) -> None: async def set_volume(self, volume: int) -> None:
await self._command("setVolume", {"volume": volume}) 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: async def mute(self, mute: bool, override: bool = False) -> None:
"""
Mute or unmute the device (if the device already
is in the desired state, nothing happens)
:param bool mute: True to mute, False to unmute
:param bool 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
"""
if mute: if mute:
mute_str = "true" mute_str = "true"
else: else:
mute_str = "false" mute_str = "false"
if override or not self.volume_state.get("muted", "false") == mute_str: if override or not (self.volume_state.get("muted", "false") == mute_str):
self.volume_state["muted"] = mute_str self.volume_state["muted"] = mute_str
# YouTube wants the volume when unmuting, so we send it # YouTube wants the volume when unmuting, so we send it
await self._command( await super()._command(
"setVolume", "setVolume",
{"volume": self.volume_state.get("volume", 100), "muted": mute_str}, {"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: async def play_video(self, video_id: str) -> bool:
return await self._command("setPlaylist", {"videoId": video_id}) return await self._command("setPlaylist", {"videoId": video_id})
async def get_now_playing(self):
return await self._command("getNowPlaying")
# Test to wrap the command function in a mutex to avoid race conditions with # Test to wrap the command function in a mutex to avoid race conditions with
# the _command_offset (TODO: move to upstream if it works) # the _command_offset (TODO: move to upstream if it works)
async def _command(self, command: str, command_parameters: dict = None) -> bool: async def _command(self, command: str, command_parameters: dict = None) -> bool:
async with self._command_mutex: async with self._command_mutex:
self.logger.debug(
f"Send command: {command}, Parameters: {command_parameters}"
)
return await super()._command(command, command_parameters) return await super()._command(command, command_parameters)
async def change_web_session(self, web_session: ClientSession): async def change_web_session(self, web_session: ClientSession):
@@ -240,110 +203,9 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if self.conn is not None: if self.conn is not None:
await self.conn.close() await self.conn.close()
self.session = web_session self.session = web_session
def _common_connection_parameters(self) -> Dict[str, Any]: async def debug_command(self, debug_command: str):
return { await super()._command(
"name": self.device_name, "sendDebugCommand",
"loungeIdToken": self.auth.lounge_id_token, {"debugCommand": debug_command},
"SID": self._sid, )
"AID": self._last_event_id,
"gsessionid": self._gsession,
"device": "REMOTE_CONTROL",
"app": "ytios-phone-20.15.1",
"VER": "8",
"v": "2",
}
async def connect(self) -> bool:
"""Attempt to connect using the previously set tokens"""
if not self.linked():
raise NotLinkedException("Not linked")
connect_body = {
"id": self.auth.screen_id,
"mdx-version": "3",
"TYPE": "xmlhttp",
"theme": "cl",
"sessionSource": "MDX_SESSION_SOURCE_UNKNOWN",
"connectParams": '{"setStatesParams": "{"playbackSpeed":0}"}',
"RID": "1",
"CVER": "1",
"capabilities": "que,dsdtr,atp,vsp",
"ui": "false",
"app": "ytios-phone-20.15.1",
"pairing_type": "manual",
"VER": "8",
"loungeIdToken": self.auth.lounge_id_token,
"device": "REMOTE_CONTROL",
"name": self.device_name,
}
connect_url = f"{api_base}/bc/bind"
async with self.session.post(url=connect_url, data=connect_body) as resp:
try:
text = await resp.text()
if resp.status == 401:
if "Connection denied" in text:
self._logger.warning(
"Connection denied, attempting to circumvent the issue"
)
await self.connect_as_screen()
# self._lounge_token_expired()
return False
if resp.status != 200:
self._logger.warning("Unknown reply to connect %i %s", resp.status, resp.reason)
return False
lines = text.splitlines()
async for events in self._parse_event_chunks(as_aiter(lines)):
self._process_events(events)
self._command_offset = 1
return self.connected()
except:
self._logger.exception(
"Handle connect failed, status %s reason %s",
resp.status,
resp.reason,
)
raise
async def connect_as_screen(self) -> bool:
"""Attempt to connect using the previously set tokens"""
if not self.linked():
raise NotLinkedException("Not linked")
connect_body = {
"id": str(uuid4()),
"mdx-version": "3",
"TYPE": "xmlhttp",
"theme": "cl",
"sessionSource": "MDX_SESSION_SOURCE_UNKNOWN",
"connectParams": '{"setStatesParams": "{"playbackSpeed":0}"}',
"sessionNonce": str(uuid4()),
"RID": "1",
"CVER": "1",
"capabilities": "que,dsdtr,atp,vsp",
"ui": "false",
"app": "ytios-phone-20.15.1",
"pairing_type": "manual",
"VER": "8",
"loungeIdToken": self.auth.lounge_id_token,
"device": "LOUNGE_SCREEN",
"name": self.device_name,
}
connect_url = f"{api_base}/bc/bind"
async with self.session.post(url=connect_url, data=connect_body) as resp:
try:
await resp.text()
self.logger.error(
"Connected as screen: please force close the app on the device for iSponsorBlockTV to work properly"
)
self.logger.warn("Exiting in 5 seconds")
await asyncio.sleep(5)
sys.exit(0)
except:
self._logger.exception(
"Handle connect failed, status %s reason %s",
resp.status,
resp.reason,
)
raise