Compare commits

..

2 Commits

Author SHA1 Message Date
pre-commit-ci[bot]
4e00c62af1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-10-16 17:25:32 +00:00
David
1567a33e51 Add the ability to specify a custom API server
Fixes #193

Add the ability to specify a custom SponsorBlock API server. (draft implementation by copilot-workspace)

* Add a new configuration option `api_server` in `config.json.template` to specify the custom API server URL.
* Remove the hardcoded `SponsorBlock_api` URL from `src/iSponsorBlockTV/constants.py`.
* Update the `ApiHelper` class in `src/iSponsorBlockTV/api_helpers.py` to use the `api_server` configuration option for API calls.
* Add an option to input a custom API server URL in the CLI setup in `src/iSponsorBlockTV/config_setup.py`.
* Add an option to input a custom API server URL in the graphical setup wizard in `src/iSponsorBlockTV/setup_wizard.py`.
* Set the default `api_server` to "https://sponsor.ajay.app" in `src/iSponsorBlockTV/helpers.py`.

---

For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/dmunozv04/iSponsorBlockTV/issues/193?shareId=XXXX-XXXX-XXXX-XXXX).
2024-10-16 19:25:06 +02:00
22 changed files with 352 additions and 425 deletions

View File

@@ -7,14 +7,12 @@ 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 '....'
@@ -27,14 +25,13 @@ 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.

View File

@@ -8,15 +8,13 @@ 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.

View File

@@ -1,19 +0,0 @@
# 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"

View File

@@ -21,8 +21,6 @@ permissions:
jobs:
build:
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
runs-on: ubuntu-latest
steps:
# Get the repository's code
@@ -34,9 +32,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
${{ env.DOCKERHUB_USERNAME && 'dmunozv04/isponsorblocktv' || '' }}
images: ghcr.io/dmunozv04/isponsorblocktv, 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
@@ -68,12 +64,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
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=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache
# Only cache if it's not a pull request
cache-to: ${{ github.event_name != 'pull_request' && 'type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache,mode=max' || '' }}

View File

@@ -57,7 +57,7 @@ jobs:
build-binaries:
name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }})
name: Build binaries for ${{ matrix.job.target }} (${{ matrix.job.os }})
needs:
- build-sdist-and-wheel
runs-on: ${{ matrix.job.os }}
@@ -70,26 +70,20 @@ jobs:
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
os: windows-2022
release_suffix: x86_64-windows
# macOS
- target: aarch64-apple-darwin
os: macos-latest
os: macos-14
release_suffix: aarch64-osx
- target: x86_64-apple-darwin
os: macos-latest
cross: true
os: macos-12
release_suffix: x86_64-osx
env:
@@ -98,9 +92,8 @@ jobs:
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
PYAPP_VERSION: v0.23.0
steps:
- name: Checkout

View File

@@ -1 +0,0 @@
LICENSE.md

View File

@@ -3,7 +3,7 @@
# Inspired by textual pre-commit config
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v4.3.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
@@ -18,14 +18,22 @@ repos:
- 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
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- id: ruff-format
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.44.0
- id: isort
name: isort (python)
language_version: '3.11'
args: ["--profile", "black", "--filter-files"]
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: markdownlint
args: ["--fix"]
- id: black
language_version: '3.11'
args: ["--preview"]
- repo: https://github.com/hadialqattan/pycln # removes unused imports
rev: v2.3.0
hooks:
- id: pycln
language_version: "3.11"
args: [--all]

View File

@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM python:3.13-alpine3.21 AS base
FROM python:3.11-alpine3.19 AS base
FROM base AS compiler
@@ -19,9 +19,9 @@ RUN apk add --no-cache gcc musl-dev && \
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 {} +
python3 -m compileall -b -f /usr/local/lib/python3.11/site-packages && \
find /usr/local/lib/python3.11/site-packages -name "*.py" -type f -delete && \
find /usr/local/lib/python3.11/ -name "__pycache__" -type d -exec rm -rf {} +
FROM base

View File

@@ -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".

View File

@@ -1,21 +1,14 @@
# iSponsorBlockTV
[![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).
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)
## Compatibility
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.
@@ -36,33 +29,24 @@ Open an issue/pull request if you have tested a device that isn't listed here.
| Playstation 4/5 | ✅ |
## Usage
Run iSponsorBlockTV on a computer that has network access.
Auto discovery will require the computer to be on the same network as the device
during setup.
The device can also be manually added to iSponsorBlockTV with a YouTube TV code.
This code can be found in the settings page of your YouTube application.
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 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 (<https://github.com/dmunozv04/iSponsorBlockTV/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
@@ -70,13 +54,8 @@ 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)

View File

@@ -13,11 +13,11 @@
"mute_ads": true,
"skip_ads": true,
"auto_play": true,
"join_name": "iSponsorBlockTV",
"apikey": "",
"channel_whitelist": [
{"id": "",
"name": ""
}
]
],
"api_server": "https://sponsor.ajay.app"
}

View File

@@ -1,12 +1,12 @@
[project]
name = "iSponsorBlockTV"
version = "2.3.0"
version = "2.2.1"
authors = [
{"name" = "dmunozv04"}
]
description = "SponsorBlock client for all YouTube TV clients"
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.7"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",

View File

@@ -1,10 +1,11 @@
aiohttp==3.11.12
aiohttp==3.9.5
appdirs==1.4.4
argparse==1.4.0
async-cache==1.1.1
pyytlounge==2.1.2
rich==13.9.4
pyytlounge==2.0.0
rich==13.7.1
ssdp==1.3.0
textual==1.0.0
textual-slider==0.2.0
xmltodict==0.14.2
rich_click==1.8.5
textual==0.58.0
textual-slider==0.1.1
xmltodict==0.13.0
rich_click==1.8.3

View File

@@ -27,6 +27,7 @@ class ApiHelper:
self.skip_count_tracking = config.skip_count_tracking
self.web_session = web_session
self.num_devices = len(config.devices)
self.api_server = config.api_server
# Not used anymore, maybe it can stay here a little longer
@AsyncLRU(maxsize=10)
@@ -130,7 +131,7 @@ class ApiHelper:
"service": constants.SponsorBlock_service,
}
headers = {"Accept": "application/json"}
url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed
url = self.api_server + "/api/skipSegments/" + vid_id_hashed
async with self.web_session.get(
url, headers=headers, params=params
) as response:
@@ -201,7 +202,7 @@ class ApiHelper:
Lets the contributor know that someone skipped the segment (thanks)"""
if self.skip_count_tracking:
for i in uuids:
url = constants.SponsorBlock_api + "viewedVideoSponsorTime/"
url = self.api_server + "/api/viewedVideoSponsorTime/"
params = {"UUID": i}
await self.web_session.post(url, params=params)

View File

@@ -1,195 +1,202 @@
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())
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) "
ENTER_API_SERVER_PROMPT = (
"Enter the custom API server URL (leave blank to use default): "
)
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"
api_server = input(ENTER_API_SERVER_PROMPT)
if api_server:
config.api_server = api_server
print("Config finished")
config.save()
loop.run_until_complete(web_session.close())

View File

@@ -2,7 +2,6 @@ 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 = (
@@ -20,5 +19,4 @@ skip_categories = (
youtube_client_blacklist = ["TVHTML5_FOR_KIDS"]
config_file_blacklist_keys = ["config_file", "data_dir"]

View File

@@ -1,5 +1,4 @@
"""Send out an M-SEARCH request and listening for responses."""
import asyncio
import socket

View File

@@ -42,7 +42,7 @@ class Config:
self.mute_ads = False
self.skip_ads = False
self.auto_play = True
self.join_name = "iSponsorBlockTV"
self.api_server = "https://sponsor.ajay.app"
self.__load()
def validate(self):

View File

@@ -27,7 +27,7 @@ class DeviceListener:
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
self.logger.addHandler(sh)
self.logger.info("Starting device")
self.logger.info(f"Starting device")
self.lounge_controller = ytlounge.YtLoungeApi(
device.screen_id, config, api_helper, self.logger, self.web_session
)
@@ -38,14 +38,14 @@ class DeviceListener:
await asyncio.sleep(60 * 60 * 24) # Refresh every 24 hours
try:
await self.lounge_controller.refresh_auth()
except BaseException:
except:
# traceback.print_exc()
pass
async def is_available(self):
try:
return await self.lounge_controller.is_available()
except BaseException:
except:
# traceback.print_exc()
return False
@@ -57,20 +57,20 @@ class DeviceListener:
try:
self.logger.debug("Refreshing auth")
await lounge_controller.refresh_auth()
except BaseException:
except:
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:
except:
pass
while not lounge_controller.connected() and not self.cancelled:
# Doesn't connect to the device if it's a kids profile (it's broken)
await asyncio.sleep(10)
try:
await lounge_controller.connect()
except BaseException:
except:
pass
self.logger.info(
"Connected to device %s (%s)", lounge_controller.screen_name, self.name
@@ -79,14 +79,14 @@ class DeviceListener:
self.logger.info("Subscribing to lounge")
sub = await lounge_controller.subscribe_monitored(self)
await sub
except BaseException:
except:
pass
# Method called on playback state change
async def __call__(self, state):
try:
self.task.cancel()
except BaseException:
except:
pass
time_start = time.time()
self.task = asyncio.create_task(self.process_playstatus(state, time_start))
@@ -131,71 +131,40 @@ class DeviceListener:
await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids))
await asyncio.create_task(self.lounge_controller.seek_to(position))
# Stops the connection to the device
async def cancel(self):
self.cancelled = True
await self.lounge_controller.disconnect()
if self.task:
try:
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 initialize_web_session(self):
await self.lounge_controller.change_web_session(self.web_session)
except Exception:
pass
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()
async def finish(devices):
for i in devices:
await i.cancel()
def handle_signal(signum, frame):
raise KeyboardInterrupt()
async def main_async(config, debug):
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(connector=tcp_connector)
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)
await device.initialize_web_session()
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:
await asyncio.gather(*tasks)
except KeyboardInterrupt:
print("Cancelling tasks and exiting...")
await finish(devices, web_session, tcp_connector)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
finally:
await web_session.close()
await tcp_connector.close()
loop.close()
print("Exited")
def main(config, debug):
loop = asyncio.get_event_loop()
loop.run_until_complete(main_async(config, debug))
signal(SIGINT, lambda s, f: loop.stop())
signal(SIGTERM, lambda s, f: loop.stop())
loop.run_forever()
print("Cancelling tasks and exiting...")
loop.run_until_complete(finish(devices))
loop.run_until_complete(web_session.close())
loop.run_until_complete(tcp_connector.close())
loop.close()

View File

@@ -21,13 +21,9 @@
scrollbar-gutter: stable;
}
.button-small {
.small-button{
height: 3;
border: none;
border-top: none;
border-bottom: none;
offset: 0 -1;
padding: 0;
}
.button-100 {
@@ -110,14 +106,13 @@ EditDevice {
}
Element {
background: $panel-darken-1;
background: $panel;
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;
@@ -125,11 +120,7 @@ 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;
@@ -141,7 +132,7 @@ Element > .element-remove {
margin: 0 1 0 0;
}
#add-device, #add-channel {
#add-device {
text-style: bold;
width: 100%;
align: left middle;
@@ -149,11 +140,6 @@ 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;

View File

@@ -84,15 +84,12 @@ class Element(Static):
def compose(self) -> ComposeResult:
yield Button(
label=self.element_name,
classes="element-name button-small",
classes="element-name",
disabled=True,
id="element-name",
)
yield Button(
"Remove",
classes="element-remove button-small",
variant="error",
id="element-remove",
"Remove", classes="element-remove", variant="error", id="element-remove"
)
def on_mount(self) -> None:
@@ -105,6 +102,7 @@ class Device(Element):
"""A device element."""
def process_values_from_data(self):
print(self.element_data)
if "name" in self.element_data and self.element_data["name"]:
self.element_name = self.element_data["name"]
else:
@@ -287,8 +285,7 @@ class AddDevice(ModalWithClickExit):
" 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)"
" dialog again"
),
classes="subtitle",
)
@@ -331,9 +328,9 @@ class AddDevice(ModalWithClickExit):
@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")
@@ -350,7 +347,7 @@ class AddDevice(ModalWithClickExit):
paired = False
try:
paired = await lounge_controller.pair(pairing_code)
except BaseException:
except:
pass
if paired:
device = {
@@ -380,9 +377,9 @@ 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):
@@ -479,6 +476,7 @@ 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"
)
@@ -498,7 +496,7 @@ class AddChannel(ModalWithClickExit):
self.query_one("#channel-search-results").remove_children()
try:
channels_list = await self.api_helper.search_channels(channel_name)
except BaseException:
except:
self.query_one("#add-channel-info").update(
"[#ff0000]Failed to search for channel"
)
@@ -587,7 +585,7 @@ class EditDevice(ModalWithClickExit):
)
def on_slider_changed(self, event: Slider.Changed) -> None:
offset_input = self.query_one("#device-offset-input")
offset_input = self.query_one("#device-offset-offset_input")
with offset_input.prevent(Input.Changed):
offset_input.value = str(event.slider.value)
@@ -621,9 +619,7 @@ 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 button-small"
)
yield Button("Add Device", id="add-device", classes="button-100")
for device in self.devices:
yield Device(device, tooltip="Click to edit")
@@ -817,9 +813,7 @@ class ChannelWhitelistManager(Vertical):
id="warning-no-key",
)
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")
for channel in self.config.channel_whitelist:
yield Channel(channel)
@@ -882,6 +876,31 @@ class AutoPlayManager(Vertical):
self.config.auto_play = event.checkbox.value
class ApiServerManager(Vertical):
"""Manager for the custom API server URL."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Custom API Server", classes="title")
yield Label(
"You can specify a custom SponsorBlock API server URL here.",
classes="subtitle",
)
with Grid(id="api-server-grid"):
yield Input(
placeholder="Custom API Server URL",
id="api-server-input",
value=self.config.api_server,
)
@on(Input.Changed, "#api-server-input")
def changed_api_server(self, event: Input.Changed):
self.config.api_server = event.input.value
class ISponsorBlockTVSetupMainScreen(Screen):
"""Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
@@ -921,6 +940,9 @@ class ISponsorBlockTVSetupMainScreen(Screen):
yield AutoPlayManager(
config=self.config, id="autoplay-manager", classes="container"
)
yield ApiServerManager(
config=self.config, id="api-server-manager", classes="container"
)
def on_mount(self) -> None:
if self.check_for_old_config_entries():
@@ -949,7 +971,7 @@ class ISponsorBlockTVSetupMainScreen(Screen):
self.app.query_one("#warning-no-key").display = (
not event.input.value
) and self.config.channel_whitelist
except BaseException:
except:
pass

View File

@@ -1,6 +1,5 @@
import asyncio
import json
from typing import Any, List
import pyytlounge
from aiohttp import ClientSession
@@ -19,9 +18,9 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
logger=None,
web_session: ClientSession = None,
):
super().__init__(
config.join_name if config else "iSponsorBlockTV", logger=logger
)
super().__init__("iSponsorBlockTV", logger=logger)
if web_session is not None:
self.session = web_session # And use the one we passed
self.auth.screen_id = screen_id
self.auth.lounge_id_token = None
self.api_helper = api_helper
@@ -45,7 +44,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
) # YouTube sends at least a message every 30 seconds (no-op or any other)
try:
self.subscribe_task.cancel()
except BaseException:
except Exception:
pass
# Subscribe to the lounge and start the watchdog
@@ -53,19 +52,19 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.callback = callback
try:
self.subscribe_task_watchdog.cancel()
except BaseException:
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_type: str, args: List[Any]):
self.logger.debug(f"process_event({event_type}, {args})")
def _process_event(self, event_id: int, event_type: str, args):
self.logger.debug(f"process_event({event_id}, {event_type}, {args})")
# (Re)start the watchdog
try:
self.subscribe_task_watchdog.cancel()
except BaseException:
except:
pass
finally:
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
@@ -156,7 +155,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
elif event_type == "onAutoplayModeChanged":
create_task(self.set_auto_play_mode(self.auto_play))
super()._process_event(event_type, args)
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:
@@ -192,10 +191,3 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
async def _command(self, command: str, command_parameters: dict = None) -> bool:
async with self._command_mutex:
return await super()._command(command, command_parameters)
async def change_web_session(self, web_session: ClientSession):
if self.session is not None:
await self.session.close()
if self.conn is not None:
await self.conn.close()
self.session = web_session