mirror of
https://github.com/dmunozv04/iSponsorBlockTV.git
synced 2025-12-07 12:26:45 +03:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d17e59bf0d | ||
|
|
205191f442 | ||
|
|
810cd5eec3 | ||
|
|
e2ace8629f | ||
|
|
e54ead26d2 | ||
|
|
49fba2f28f | ||
|
|
b1333a2f61 | ||
|
|
cfef219d32 | ||
|
|
338e0479ba | ||
|
|
bfefa94a7b | ||
|
|
783e3d4240 | ||
|
|
015f5a79c9 | ||
|
|
dc72db0609 | ||
|
|
8de38cc92b | ||
|
|
94ba642af1 | ||
|
|
6e09db9994 | ||
|
|
b56d7443d1 | ||
|
|
e92ba897c4 | ||
|
|
5214190fd0 | ||
|
|
d3341009a6 | ||
|
|
adc0f5b95d | ||
|
|
5dbd16ddd5 | ||
|
|
faa0379b89 | ||
|
|
fb3ed6b39a | ||
|
|
1ab7e73b52 | ||
|
|
d310e4c817 | ||
|
|
d21bb6320f | ||
|
|
dd42e20dc4 | ||
|
|
213ae97bf2 | ||
|
|
865f5469a2 | ||
|
|
daa7026221 | ||
|
|
582b9bf725 | ||
|
|
ce95b6dbf0 | ||
|
|
80196b19aa | ||
|
|
4dd6aa1c4d | ||
|
|
1a5f29fe2a | ||
|
|
13fe1f69ae | ||
|
|
3b1ce5297f | ||
|
|
e689a713ef | ||
|
|
e6b1e14d80 | ||
|
|
4934eff8b7 | ||
|
|
674a13e43a | ||
|
|
152ba104a6 | ||
|
|
265c56f3d6 | ||
|
|
7e954478f2 | ||
|
|
4f39f64ed0 | ||
|
|
8208a51176 | ||
|
|
ab6b67f88b | ||
|
|
9068b58bf6 | ||
|
|
a9f9a5b31c | ||
|
|
a75dd83548 | ||
|
|
4e3c9d115c | ||
|
|
04533162cb | ||
|
|
bee2a9c80f | ||
|
|
f620ed2fcc | ||
|
|
db26bff3d2 | ||
|
|
d205848132 | ||
|
|
29445e678f | ||
|
|
35453bc49e | ||
|
|
88875a82d3 | ||
|
|
446393b078 | ||
|
|
784d54c4e2 | ||
|
|
66b39c4cac | ||
|
|
321a9e6e9b | ||
|
|
ecb3583c35 | ||
|
|
3cee674e91 | ||
|
|
d575a296e7 | ||
|
|
7bf52b6df1 | ||
|
|
846ea444d2 | ||
|
|
1671d7841b | ||
|
|
a4f0b5fffe | ||
|
|
dae27e7aa3 | ||
|
|
5df90a234d | ||
|
|
c3fd67df27 | ||
|
|
35652b6247 | ||
|
|
8ab9cf9519 | ||
|
|
f4fbbcdff5 | ||
|
|
385ed8268c | ||
|
|
c196e76205 | ||
|
|
863ec5e163 | ||
|
|
128e1f72cb | ||
|
|
fede94e973 | ||
|
|
0d62f69460 | ||
|
|
8d76bdd1c1 | ||
|
|
8c7c2cc206 | ||
|
|
7a0a264caa | ||
|
|
b1f1bd1851 | ||
|
|
ab3285048d | ||
|
|
799e0a6f77 | ||
|
|
c95ab4897d | ||
|
|
23e90caefc | ||
|
|
7b6f9bd8a0 | ||
|
|
f3a2c82a56 | ||
|
|
d0506304fd | ||
|
|
a01994e2b0 | ||
|
|
bc2c4727dc | ||
|
|
9ebd39d491 | ||
|
|
2e6a0af8ce | ||
|
|
4aa5b1e08c | ||
|
|
2cc8fa128f | ||
|
|
4ab49ea61e | ||
|
|
3d8fa562b4 | ||
|
|
20c6870e4c | ||
|
|
b90a5e317c | ||
|
|
93e3fd5720 | ||
|
|
b88d2b61be | ||
|
|
5a5ebcfeb7 | ||
|
|
7a66d1acd5 | ||
|
|
de8d03285f | ||
|
|
d850ec8162 | ||
|
|
81f5b6568d | ||
|
|
28b6ac3218 | ||
|
|
138d8bd51c | ||
|
|
db647362c6 | ||
|
|
58ee703501 | ||
|
|
4f4d990544 | ||
|
|
6e1ee572d5 | ||
|
|
df118c967d | ||
|
|
262e3c606d | ||
|
|
d72b24c95b | ||
|
|
88f5e5d4bf | ||
|
|
aa26e8fb04 | ||
|
|
3bbabdb26e | ||
|
|
064bdcd93c | ||
|
|
50b71d9f5c | ||
|
|
9461d6516f | ||
|
|
f69d6d04cf | ||
|
|
ace8f3564f | ||
|
|
70cba12efa | ||
|
|
b35d9fd60e | ||
|
|
518b1d9b2e | ||
|
|
4460aaf35c | ||
|
|
d3260d17f1 | ||
|
|
ac6f15042c | ||
|
|
7e45f623f7 | ||
|
|
0bcb70979f |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Ignore files
|
||||||
|
.dockerignore
|
||||||
|
.gitignore
|
||||||
|
.deepsource.toml
|
||||||
|
.github
|
||||||
|
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
@@ -25,12 +25,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Get the repository's code
|
# Get the repository's code
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Generate docker tags
|
# Generate docker tags
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/dmunozv04/isponsorblocktv, dmunozv04/isponsorblocktv
|
images: ghcr.io/dmunozv04/isponsorblocktv, dmunozv04/isponsorblocktv
|
||||||
tags: |
|
tags: |
|
||||||
@@ -42,34 +42,35 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/docker/setup-qemu-action
|
# https://github.com/docker/setup-qemu-action
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
# https://github.com/docker/setup-buildx-action
|
# https://github.com/docker/setup-buildx-action
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64, linux/arm64
|
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 }}
|
||||||
cache-from: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache
|
cache-from: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache
|
||||||
cache-to: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache,mode=max
|
# 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' || '' }}
|
||||||
37
.github/workflows/release_pypi.yml
vendored
Normal file
37
.github/workflows/release_pypi.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# This workflow will upload a Python Package using Twine when a release is created
|
||||||
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
|
||||||
|
|
||||||
|
# This workflow uses actions that are not certified by GitHub.
|
||||||
|
# They are provided by a third-party and are governed by
|
||||||
|
# separate terms of service, privacy policy, and support
|
||||||
|
# documentation.
|
||||||
|
|
||||||
|
name: Upload Python Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install build wheel
|
||||||
|
- name: Build package
|
||||||
|
run: python -m build
|
||||||
|
- name: Publish package
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
7
.github/workflows/update_docker_readme.yml
vendored
7
.github/workflows/update_docker_readme.yml
vendored
@@ -18,12 +18,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Get the repository's code
|
# Get the repository's code
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Update description
|
# Update description
|
||||||
- name: Update repo description
|
- name: Update repo description
|
||||||
uses: peter-evans/dockerhub-description@v2
|
uses: peter-evans/dockerhub-description@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
repository: dmunozv04/isponsorblocktv
|
repository: dmunozv04/isponsorblocktv
|
||||||
|
short-description: ${{ github.event.repository.description }}
|
||||||
|
|||||||
39
.pre-commit-config.yaml
Normal file
39
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# See https://pre-commit.com for more information
|
||||||
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
|
# Inspired by textual pre-commit config
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: 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
|
||||||
|
- id: check-case-conflict # checks for files that would conflict in case-insensitive filesystems
|
||||||
|
- id: check-merge-conflict # checks for files that contain merge conflict strings
|
||||||
|
- id: check-json # checks json files for parseable syntax
|
||||||
|
- id: check-toml # checks toml files for parseable syntax
|
||||||
|
- id: check-yaml # checks yaml files for parseable syntax
|
||||||
|
args: [ '--unsafe' ] # Instead of loading the files, parse them for syntax.
|
||||||
|
- id: check-shebang-scripts-are-executable # ensures that (non-binary) files with a shebang are executable
|
||||||
|
- id: check-vcs-permalinks # ensures that links to vcs websites are permalinks
|
||||||
|
- id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline
|
||||||
|
- id: mixed-line-ending # replaces or checks mixed line ending
|
||||||
|
- id: trailing-whitespace # checks for trailing whitespace
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.12.0
|
||||||
|
hooks:
|
||||||
|
- 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: 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]
|
||||||
44
Dockerfile
44
Dockerfile
@@ -1,18 +1,38 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM python:3.11-alpine3.19 as BASE
|
||||||
|
|
||||||
FROM python:alpine3.11
|
FROM base as compiler
|
||||||
|
|
||||||
ENV PIP_NO_CACHE_DIR=off iSPBTV_docker=True TERM=xterm-256color COLORTERM=truecolor
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
RUN pip install --upgrade pip wheel && \
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY src .
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "-u", "main.py"]
|
RUN python3 -m compileall -b -f . && \
|
||||||
|
find . -name "*.py" -type f -delete
|
||||||
|
|
||||||
|
FROM base as DEP_INSTALLER
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
RUN apk add --no-cache gcc musl-dev && \
|
||||||
|
pip install --upgrade pip wheel && \
|
||||||
|
pip install -r requirements.txt && \
|
||||||
|
pip uninstall -y pip wheel && \
|
||||||
|
apk del gcc musl-dev && \
|
||||||
|
python3 -m compileall -b -f /usr/local/lib/python3.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
|
||||||
|
|
||||||
|
ENV PIP_NO_CACHE_DIR=off iSPBTV_docker=True iSPBTV_data_dir=data TERM=xterm-256color COLORTERM=truecolor
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
COPY --from=dep_installer /usr/local /usr/local
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=compiler /app .
|
||||||
|
|
||||||
|
ENTRYPOINT ["python3", "-u", "main.pyc"]
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -18,17 +18,20 @@ Open an issue/pull request if you have tested a device that isn't listed here.
|
|||||||
| Apple TV | ✅ |
|
| Apple TV | ✅ |
|
||||||
| Samsung TV (Tizen) | ✅ |
|
| Samsung TV (Tizen) | ✅ |
|
||||||
| LG TV (WebOS) | ✅ |
|
| LG TV (WebOS) | ✅ |
|
||||||
| Android TV | ❔ |
|
| Android TV | ✅ |
|
||||||
| Chromecast | ❔ |
|
| Chromecast | ✅ |
|
||||||
| Roku | ❔ |
|
| Google TV | ✅ |
|
||||||
| Fire TV | ❔ |
|
| Roku | ✅ |
|
||||||
|
| Fire TV | ✅ |
|
||||||
|
| CCwGTV | ✅ |
|
||||||
| Nintendo Switch | ✅ |
|
| Nintendo Switch | ✅ |
|
||||||
| Xbox One/Series | ❔ |
|
| Xbox One/Series | ✅ |
|
||||||
| Playstation 4/5 | ✅ |
|
| Playstation 4/5 | ✅ |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
Run iSponsorBlockTV on a computer that has network access.
|
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.
|
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.
|
It can also skip/mute YouTube ads.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"skip_count_tracking": true,
|
"skip_count_tracking": true,
|
||||||
"mute_ads": true,
|
"mute_ads": true,
|
||||||
"skip_ads": true,
|
"skip_ads": true,
|
||||||
|
"autoplay": true,
|
||||||
"apikey": "",
|
"apikey": "",
|
||||||
"channel_whitelist": [
|
"channel_whitelist": [
|
||||||
{"id": "",
|
{"id": "",
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import json
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
import aiohttp
|
|
||||||
from . import api_helpers, ytlounge
|
|
||||||
|
|
||||||
async def pair_device(loop):
|
|
||||||
try:
|
|
||||||
lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV")
|
|
||||||
pairing_code = input("Enter pairing code (found in Settings - Link with TV code): ")
|
|
||||||
pairing_code = int(pairing_code.replace("-", "").replace(" ","")) # remove dashes and spaces
|
|
||||||
print("Pairing...")
|
|
||||||
paired = await lounge_controller.pair(pairing_code)
|
|
||||||
if not paired:
|
|
||||||
print("Failed to pair device")
|
|
||||||
return
|
|
||||||
device = {
|
|
||||||
"screen_id": lounge_controller.auth.screen_id,
|
|
||||||
"name": lounge_controller.screen_name,
|
|
||||||
}
|
|
||||||
print(f"Paired device: {device['name']}")
|
|
||||||
return device
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to pair device: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
def main(config, debug: bool) -> None:
|
|
||||||
print("Welcome to the iSponsorBlockTV cli setup wizard")
|
|
||||||
loop = asyncio.get_event_loop_policy().get_event_loop()
|
|
||||||
if debug:
|
|
||||||
loop.set_debug(True)
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
if hasattr(config, "atvs"):
|
|
||||||
print("The atvs config option is deprecated and has stopped working. Please read this for more information on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2")
|
|
||||||
if input("Do you want to remove the legacy 'atvs' entry (the app won't start with it present)? (y/n) ") == "y":
|
|
||||||
del config["atvs"]
|
|
||||||
devices = config.devices
|
|
||||||
while not input(f"Paired with {len(devices)} Device(s). Add more? (y/n) ") == "n":
|
|
||||||
task = loop.create_task(pair_device(loop))
|
|
||||||
loop.run_until_complete(task)
|
|
||||||
device = task.result()
|
|
||||||
if device:
|
|
||||||
devices.append(device)
|
|
||||||
config.devices = devices
|
|
||||||
|
|
||||||
apikey = config.apikey
|
|
||||||
if apikey:
|
|
||||||
if input("API key already specified. Change it? (y/n) ") == "y":
|
|
||||||
apikey = input("Enter your API key: ")
|
|
||||||
config["apikey"] = apikey
|
|
||||||
else:
|
|
||||||
if input("API key only needed for the channel whitelist function. Add it? (y/n) ") == "y":
|
|
||||||
print(
|
|
||||||
"Get youtube apikey here: https://developers.google.com/youtube/registering_an_application"
|
|
||||||
)
|
|
||||||
apikey = input("Enter your API key: ")
|
|
||||||
config["apikey"] = apikey
|
|
||||||
config.apikey = apikey
|
|
||||||
|
|
||||||
skip_categories = config.skip_categories
|
|
||||||
if skip_categories:
|
|
||||||
if input("Skip categories already specified. Change them? (y/n) ") == "y":
|
|
||||||
categories = input(
|
|
||||||
"Enter skip categories (space or comma sepparated) Options: [sponsor selfpromo exclusive_access interaction poi_highlight intro outro preview filler music_offtopic]:\n"
|
|
||||||
)
|
|
||||||
skip_categories = categories.replace(",", " ").split(" ")
|
|
||||||
skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings
|
|
||||||
else:
|
|
||||||
categories = input(
|
|
||||||
"Enter skip categories (space or comma sepparated) Options: [sponsor, selfpromo, exclusive_access, interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n"
|
|
||||||
)
|
|
||||||
skip_categories = categories.replace(",", " ").split(" ")
|
|
||||||
skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings
|
|
||||||
config.skip_categories = skip_categories
|
|
||||||
|
|
||||||
channel_whitelist = config.channel_whitelist
|
|
||||||
if input("Do you want to whitelist any channels from being ad-blocked? (y/n) ") == "y":
|
|
||||||
if(not apikey):
|
|
||||||
print("WARNING: You need to specify an API key to use this function, otherwise the program will fail to start.\nYou can add one by re-running this setup wizard.")
|
|
||||||
web_session = aiohttp.ClientSession()
|
|
||||||
while True:
|
|
||||||
channel_info = {}
|
|
||||||
channel = input("Enter a channel name or \"/exit\" to exit: ")
|
|
||||||
if channel == "/exit":
|
|
||||||
break
|
|
||||||
|
|
||||||
task = loop.create_task(api_helpers.search_channels(channel, apikey, web_session))
|
|
||||||
loop.run_until_complete(task)
|
|
||||||
results = task.result()
|
|
||||||
if len(results) == 0:
|
|
||||||
print("No channels found")
|
|
||||||
continue
|
|
||||||
|
|
||||||
for i in range(len(results)):
|
|
||||||
print(f"{i}: {results[i][1]} - Subs: {results[i][2]}")
|
|
||||||
print("5: Enter a custom channel ID")
|
|
||||||
print("6: Go back")
|
|
||||||
|
|
||||||
choice = -1
|
|
||||||
choice = input("Select one option of the above [0-6]: ")
|
|
||||||
while choice not in [str(x) for x in range(7)]:
|
|
||||||
print("Invalid choice")
|
|
||||||
choice = input("Select one option of the above [0-6]: ")
|
|
||||||
|
|
||||||
if choice == "5":
|
|
||||||
channel_info["id"] = input("Enter a channel ID: ")
|
|
||||||
channel_info["name"] = input("Enter the channel name: ")
|
|
||||||
channel_whitelist.append(channel_info)
|
|
||||||
continue
|
|
||||||
elif choice == "6":
|
|
||||||
continue
|
|
||||||
|
|
||||||
channel_info["id"] = results[int(choice)][0]
|
|
||||||
channel_info["name"] = results[int(choice)][1]
|
|
||||||
channel_whitelist.append(channel_info)
|
|
||||||
# Close web session asynchronously
|
|
||||||
loop.run_until_complete(web_session.close())
|
|
||||||
|
|
||||||
config.channel_whitelist = channel_whitelist
|
|
||||||
|
|
||||||
config.skip_count_tracking = not input("Do you want to report skipped segments to sponsorblock. Only the segment UUID will be sent? (y/n) ") == "n"
|
|
||||||
print("Config finished")
|
|
||||||
config.save()
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
userAgent = "iSponsorBlockTV/0.1"
|
|
||||||
SponsorBlock_service = "youtube"
|
|
||||||
SponsorBlock_actiontype = "skip"
|
|
||||||
|
|
||||||
SponsorBlock_api = "https://sponsor.ajay.app/api/"
|
|
||||||
Youtube_api = "https://www.googleapis.com/youtube/v3/"
|
|
||||||
|
|
||||||
skip_categories = (
|
|
||||||
('Sponsor', 'sponsor'),
|
|
||||||
('Self Promotion', 'selfpromo'),
|
|
||||||
('Intro', 'intro'),
|
|
||||||
('Outro', 'outro'),
|
|
||||||
('Music Offtopic', 'music_offtopic'),
|
|
||||||
('Interaction', 'interaction'),
|
|
||||||
('Exclusive Access', 'exclusive_access'),
|
|
||||||
('POI Highlight', 'poi_highlight'),
|
|
||||||
('Preview', 'preview'),
|
|
||||||
('Filler', 'filler'),
|
|
||||||
)
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import aiohttp
|
|
||||||
import pyytlounge
|
|
||||||
|
|
||||||
create_task = asyncio.create_task
|
|
||||||
|
|
||||||
|
|
||||||
class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|
||||||
def __init__(self, screen_id, config=None, api_helper=None):
|
|
||||||
super().__init__("iSponsorBlockTV")
|
|
||||||
self.auth.screen_id = screen_id
|
|
||||||
self.auth.lounge_id_token = None
|
|
||||||
self.api_helper = api_helper
|
|
||||||
self.volume_state = {}
|
|
||||||
self.subscribe_task = None
|
|
||||||
self.subscribe_task_watchdog = None
|
|
||||||
self.callback = None
|
|
||||||
if config:
|
|
||||||
self.mute_ads = config.mute_ads
|
|
||||||
self.skip_ads = config.skip_ads
|
|
||||||
|
|
||||||
# Ensures that we still are subscribed to the lounge
|
|
||||||
async def _watchdog(self):
|
|
||||||
await asyncio.sleep(35) # YouTube sends at least a message every 30 seconds (no-op or any other)
|
|
||||||
try:
|
|
||||||
self.subscribe_task.cancel()
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Subscribe to the lounge and start the watchdog
|
|
||||||
async def subscribe_monitored(self, callback):
|
|
||||||
self.callback = callback
|
|
||||||
try:
|
|
||||||
self.subscribe_task_watchdog.cancel()
|
|
||||||
except:
|
|
||||||
pass # No watchdog task
|
|
||||||
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
|
|
||||||
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
|
|
||||||
return self.subscribe_task
|
|
||||||
|
|
||||||
# Process a lounge subscription event
|
|
||||||
def _process_event(self, event_id: int, event_type: str, args):
|
|
||||||
# print(f"YtLoungeApi.__process_event({event_id}, {event_type}, {args})")
|
|
||||||
# (Re)start the watchdog
|
|
||||||
try:
|
|
||||||
self.subscribe_task_watchdog.cancel()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
|
|
||||||
# A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we can get the segments)
|
|
||||||
if event_type == "onStateChange":
|
|
||||||
data = args[0]
|
|
||||||
self.state.apply_state(data)
|
|
||||||
self._update_state()
|
|
||||||
# print(data)
|
|
||||||
# Unmute when the video starts playing
|
|
||||||
if self.mute_ads and data["state"] == "1":
|
|
||||||
create_task(self.mute(False, override=True))
|
|
||||||
elif event_type == "nowPlaying":
|
|
||||||
data = args[0]
|
|
||||||
self.state = pyytlounge.PlaybackState(self._logger, data)
|
|
||||||
self._update_state()
|
|
||||||
# Unmute when the video starts playing
|
|
||||||
if self.mute_ads and data.get("state", "0") == "1":
|
|
||||||
#print("Ad has ended, unmuting")
|
|
||||||
create_task(self.mute(False, override=True))
|
|
||||||
elif self.mute_ads and event_type == "onAdStateChange":
|
|
||||||
data = args[0]
|
|
||||||
if data["adState"] == '0': # Ad is not playing
|
|
||||||
#print("Ad has ended, unmuting")
|
|
||||||
create_task(self.mute(False, override=True))
|
|
||||||
else: # Seen multiple other adStates, assuming they are all ads
|
|
||||||
print("Ad has started, muting")
|
|
||||||
create_task(self.mute(True, override=True))
|
|
||||||
# Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it)
|
|
||||||
elif event_type == "onVolumeChanged":
|
|
||||||
self.volume_state = args[0]
|
|
||||||
pass
|
|
||||||
# Gets segments for the next video before it starts playing
|
|
||||||
elif event_type == "autoplayUpNext":
|
|
||||||
if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty
|
|
||||||
print(f"Getting segments for next video: {vid_id}")
|
|
||||||
create_task(self.api_helper.get_segments(vid_id))
|
|
||||||
|
|
||||||
# #Used to know if an ad is skippable or not
|
|
||||||
elif event_type == "adPlaying":
|
|
||||||
data = args[0]
|
|
||||||
# Gets segments for the next video (after the ad) before it starts playing
|
|
||||||
if vid_id := data["contentVideoId"]:
|
|
||||||
print(f"Getting segments for next video: {vid_id}")
|
|
||||||
create_task(self.api_helper.get_segments(vid_id))
|
|
||||||
|
|
||||||
if data["isSkippable"] == "true": # YouTube uses strings for booleans
|
|
||||||
if self.skip_ads:
|
|
||||||
create_task(self.skip_ad())
|
|
||||||
create_task(self.mute(False, override=True))
|
|
||||||
elif self.mute_ads:
|
|
||||||
create_task(self.mute(True, override=True))
|
|
||||||
else:
|
|
||||||
super()._process_event(event_id, event_type, args)
|
|
||||||
|
|
||||||
# Set the volume to a specific value (0-100)
|
|
||||||
async def set_volume(self, volume: int) -> None:
|
|
||||||
await super()._command("setVolume", {"volume": volume})
|
|
||||||
|
|
||||||
# Mute or unmute the device (if the device already is in the desired state, nothing happens)
|
|
||||||
# mute: True to mute, False to unmute
|
|
||||||
# override: If True, the command is sent even if the device already is in the desired state
|
|
||||||
# TODO: Only works if the device is subscribed to the lounge
|
|
||||||
async def mute(self, mute: bool, override: bool = False) -> None:
|
|
||||||
if mute:
|
|
||||||
mute_str = "true"
|
|
||||||
else:
|
|
||||||
mute_str = "false"
|
|
||||||
if override or not (self.volume_state.get("muted", "false") == mute_str):
|
|
||||||
self.volume_state["muted"] = mute_str
|
|
||||||
# YouTube wants the volume when unmuting, so we send it
|
|
||||||
await super()._command("setVolume", {"volume": self.volume_state.get("volume", 100), "muted": mute_str})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from iSponsorBlockTV import helpers
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
os.environ["SSL_CERT_FILE"] = os.path.join(sys._MEIPASS, "lib", "cert.pem")
|
|
||||||
helpers.app_start()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
from PyInstaller.utils.hooks import exec_statement
|
|
||||||
cert_datas = exec_statement("""
|
|
||||||
import ssl
|
|
||||||
print(ssl.get_default_verify_paths().cafile)""").strip().split()
|
|
||||||
cert_datas = [(f, 'lib') for f in cert_datas]
|
|
||||||
|
|
||||||
block_cipher = None
|
|
||||||
|
|
||||||
options = [ ('u', None, 'OPTION') ]
|
|
||||||
|
|
||||||
a = Analysis(['main-macos.py'],
|
|
||||||
pathex=[],
|
|
||||||
binaries=[],
|
|
||||||
datas=cert_datas,
|
|
||||||
hiddenimports=['certifi'],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
win_no_prefer_redirects=False,
|
|
||||||
win_private_assemblies=False,
|
|
||||||
cipher=block_cipher,
|
|
||||||
noarchive=False)
|
|
||||||
pyz = PYZ(a.pure, a.zipped_data,
|
|
||||||
cipher=block_cipher)
|
|
||||||
|
|
||||||
exe = EXE(pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.zipfiles,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
options,
|
|
||||||
name='iSponsorBlockTV-macos',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None)
|
|
||||||
33
pyproject.toml
Normal file
33
pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[project]
|
||||||
|
name = "iSponsorBlockTV"
|
||||||
|
version = "2.1.0"
|
||||||
|
authors = [
|
||||||
|
{"name" = "dmunozv04"}
|
||||||
|
]
|
||||||
|
description = "SponsorBlock client for all YouTube TV clients"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.7"
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Topic :: Home Automation"
|
||||||
|
]
|
||||||
|
dynamic = ["dependencies"]
|
||||||
|
|
||||||
|
[tool.hatch.metadata.hooks.requirements_txt]
|
||||||
|
files = ["requirements.txt"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://github.com/dmunozv04/iSponsorBlockTV"
|
||||||
|
"Bug Tracker" = "https://github.com/dmunozv04/iSponsorBlockTV/issues"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
"iSponsorBlockTV" = "iSponsorBlockTV.__main__:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling", "hatch-requirements-txt"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
aiohttp==3.8.6
|
aiohttp==3.9.5
|
||||||
|
appdirs==1.4.4
|
||||||
argparse==1.4.0
|
argparse==1.4.0
|
||||||
async-cache==1.1.1
|
async-cache==1.1.1
|
||||||
pyytlounge==1.6.2
|
pyytlounge==2.0.0
|
||||||
rich==13.6.0
|
rich==13.7.1
|
||||||
ssdp==1.3.0
|
ssdp==1.3.0
|
||||||
textual==0.40.0
|
textual==0.58.0
|
||||||
textual-slider==0.1.1
|
textual-slider==0.1.1
|
||||||
xmltodict==0.13.0
|
xmltodict==0.13.0
|
||||||
|
|
||||||
|
|||||||
9
src/iSponsorBlockTV/__main__.py
Normal file
9
src/iSponsorBlockTV/__main__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from . import helpers
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
helpers.app_start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
from cache import AsyncTTL, AsyncLRU
|
|
||||||
from .conditional_ttl_cache import AsyncConditionalTTL
|
|
||||||
from . import constants, dial_client
|
|
||||||
from hashlib import sha256
|
|
||||||
from asyncio import create_task
|
|
||||||
from aiohttp import ClientSession
|
|
||||||
import html
|
import html
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from cache import AsyncLRU
|
||||||
|
|
||||||
|
from . import constants, dial_client
|
||||||
|
from .conditional_ttl_cache import AsyncConditionalTTL
|
||||||
|
|
||||||
|
|
||||||
def listToTuple(function):
|
def list_to_tuple(function):
|
||||||
def wrapper(*args):
|
def wrapper(*args):
|
||||||
args = [tuple(x) if type(x) == list else x for x in args]
|
args = [tuple(x) if isinstance(x, list) else x for x in args]
|
||||||
result = function(*args)
|
result = function(*args)
|
||||||
result = tuple(result) if type(result) == list else result
|
result = tuple(result) if isinstance(result, list) else result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -39,17 +40,17 @@ class ApiHelper:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for i in data["items"]:
|
for i in data["items"]:
|
||||||
if (i["id"]["kind"] != "youtube#video"):
|
if i["id"]["kind"] != "youtube#video":
|
||||||
continue
|
continue
|
||||||
title_api = html.unescape(i["snippet"]["title"])
|
title_api = html.unescape(i["snippet"]["title"])
|
||||||
artist_api = html.unescape(i["snippet"]["channelTitle"])
|
artist_api = html.unescape(i["snippet"]["channelTitle"])
|
||||||
if title_api == title and artist_api == artist:
|
if title_api == title and artist_api == artist:
|
||||||
return (i["id"]["videoId"], i["snippet"]["channelId"])
|
return i["id"]["videoId"], i["snippet"]["channelId"]
|
||||||
return
|
return
|
||||||
|
|
||||||
@AsyncLRU(maxsize=100)
|
@AsyncLRU(maxsize=100)
|
||||||
async def is_whitelisted(self, vid_id):
|
async def is_whitelisted(self, vid_id):
|
||||||
if (self.apikey and self.channel_whitelist):
|
if self.apikey and self.channel_whitelist:
|
||||||
channel_id = await self.__get_channel_id(vid_id)
|
channel_id = await self.__get_channel_id(vid_id)
|
||||||
# check if channel id is in whitelist
|
# check if channel id is in whitelist
|
||||||
for i in self.channel_whitelist:
|
for i in self.channel_whitelist:
|
||||||
@@ -66,14 +67,20 @@ class ApiHelper:
|
|||||||
if "error" in data:
|
if "error" in data:
|
||||||
return
|
return
|
||||||
data = data["items"][0]
|
data = data["items"][0]
|
||||||
if (data["kind"] != "youtube#video"):
|
if data["kind"] != "youtube#video":
|
||||||
return
|
return
|
||||||
return data["snippet"]["channelId"]
|
return data["snippet"]["channelId"]
|
||||||
|
|
||||||
@AsyncLRU(maxsize=10)
|
@AsyncLRU(maxsize=10)
|
||||||
async def search_channels(self, channel):
|
async def search_channels(self, channel):
|
||||||
channels = []
|
channels = []
|
||||||
params = {"q": channel, "key": self.apikey, "part": "snippet", "type": "channel", "maxResults": "5"}
|
params = {
|
||||||
|
"q": channel,
|
||||||
|
"key": self.apikey,
|
||||||
|
"part": "snippet",
|
||||||
|
"type": "channel",
|
||||||
|
"maxResults": "5",
|
||||||
|
}
|
||||||
url = constants.Youtube_api + "search"
|
url = constants.Youtube_api + "search"
|
||||||
async with self.web_session.get(url, params=params) as resp:
|
async with self.web_session.get(url, params=params) as resp:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
@@ -81,27 +88,39 @@ class ApiHelper:
|
|||||||
return channels
|
return channels
|
||||||
|
|
||||||
for i in data["items"]:
|
for i in data["items"]:
|
||||||
# Get channel subcription number
|
# Get channel subscription number
|
||||||
params = {"id": i["snippet"]["channelId"], "key": self.apikey, "part": "statistics"}
|
params = {
|
||||||
|
"id": i["snippet"]["channelId"],
|
||||||
|
"key": self.apikey,
|
||||||
|
"part": "statistics",
|
||||||
|
}
|
||||||
url = constants.Youtube_api + "channels"
|
url = constants.Youtube_api + "channels"
|
||||||
async with self.web_session.get(url, params=params) as resp:
|
async with self.web_session.get(url, params=params) as resp:
|
||||||
channelData = await resp.json()
|
channel_data = await resp.json()
|
||||||
|
|
||||||
if channelData["items"][0]["statistics"]["hiddenSubscriberCount"]:
|
if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]:
|
||||||
subCount = "Hidden"
|
sub_count = "Hidden"
|
||||||
else:
|
else:
|
||||||
subCount = int(channelData["items"][0]["statistics"]["subscriberCount"])
|
sub_count = int(
|
||||||
subCount = format(subCount, "_")
|
channel_data["items"][0]["statistics"]["subscriberCount"]
|
||||||
|
)
|
||||||
|
sub_count = format(sub_count, "_")
|
||||||
|
|
||||||
channels.append((i["snippet"]["channelId"], i["snippet"]["channelTitle"], subCount))
|
channels.append(
|
||||||
|
(i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count)
|
||||||
|
)
|
||||||
return channels
|
return channels
|
||||||
|
|
||||||
@listToTuple # 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):
|
||||||
print("Video is whitelisted")
|
return (
|
||||||
return ([], True) # Return empty list and True to indicate that the cache should last forever
|
[],
|
||||||
|
True,
|
||||||
|
) # Return empty list and True to indicate 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
|
||||||
@@ -112,17 +131,49 @@ 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(
|
||||||
response = await response.json()
|
url, headers=headers, params=params
|
||||||
for i in response:
|
) as response:
|
||||||
|
response_json = await response.json()
|
||||||
|
if response.status != 200:
|
||||||
|
response_text = await response.text()
|
||||||
|
print(
|
||||||
|
f"Error getting segments for video {vid_id}, hashed as {vid_id_hashed}."
|
||||||
|
f" Code: {response.status} - {response_text}"
|
||||||
|
)
|
||||||
|
return [], True
|
||||||
|
for i in response_json:
|
||||||
if str(i["videoID"]) == str(vid_id):
|
if str(i["videoID"]) == str(vid_id):
|
||||||
response = i
|
response_json = i
|
||||||
break
|
break
|
||||||
|
return self.process_segments(response_json)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_segments(response):
|
||||||
segments = []
|
segments = []
|
||||||
ignore_ttl = True
|
ignore_ttl = True
|
||||||
try:
|
try:
|
||||||
for i in response["segments"]:
|
response_segments = response["segments"]
|
||||||
ignore_ttl = ignore_ttl and i["locked"] == 1 # If all segments are locked, ignore ttl
|
# sort by end
|
||||||
|
response_segments.sort(key=lambda x: x["segment"][1])
|
||||||
|
# extend ends of overlapping segments to make one big segment
|
||||||
|
for i in response_segments:
|
||||||
|
for j in response_segments:
|
||||||
|
if j["segment"][0] <= i["segment"][1] <= j["segment"][1]:
|
||||||
|
i["segment"][1] = j["segment"][1]
|
||||||
|
|
||||||
|
# sort by start
|
||||||
|
response_segments.sort(key=lambda x: x["segment"][0])
|
||||||
|
# extend starts of overlapping segments to make one big segment
|
||||||
|
for i in reversed(response_segments):
|
||||||
|
for j in reversed(response_segments):
|
||||||
|
if j["segment"][0] <= i["segment"][0] <= j["segment"][1]:
|
||||||
|
i["segment"][0] = j["segment"][0]
|
||||||
|
|
||||||
|
for i in response_segments:
|
||||||
|
ignore_ttl = (
|
||||||
|
ignore_ttl and i["locked"] == 1
|
||||||
|
) # If all segments are locked, ignore ttl
|
||||||
segment = i["segment"]
|
segment = i["segment"]
|
||||||
UUID = i["UUID"]
|
UUID = i["UUID"]
|
||||||
segment_dict = {"start": segment[0], "end": segment[1], "UUID": [UUID]}
|
segment_dict = {"start": segment[0], "end": segment[1], "UUID": [UUID]}
|
||||||
@@ -136,19 +187,20 @@ class ApiHelper:
|
|||||||
segment_before_end = -10
|
segment_before_end = -10
|
||||||
if (
|
if (
|
||||||
segment_dict["start"] - segment_before_end < 1
|
segment_dict["start"] - segment_before_end < 1
|
||||||
): # Less than 1 second appart, combine them and skip them together
|
): # Less than 1 second apart, combine them and skip them together
|
||||||
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()
|
||||||
segments.append(segment_dict)
|
segments.append(segment_dict)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return (segments, ignore_ttl)
|
return segments, ignore_ttl
|
||||||
|
|
||||||
async def mark_viewed_segments(self, UUID):
|
async def mark_viewed_segments(self, uuids):
|
||||||
"""Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled. Lets the contributor know that someone skipped the segment (thanks)"""
|
"""Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled.
|
||||||
|
Lets the contributor know that someone skipped the segment (thanks)"""
|
||||||
if self.skip_count_tracking:
|
if self.skip_count_tracking:
|
||||||
for i in UUID:
|
for i in uuids:
|
||||||
url = constants.SponsorBlock_api + "viewedVideoSponsorTime/"
|
url = constants.SponsorBlock_api + "viewedVideoSponsorTime/"
|
||||||
params = {"UUID": i}
|
params = {"UUID": i}
|
||||||
await self.web_session.post(url, params=params)
|
await self.web_session.post(url, params=params)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from cache.key import KEY
|
||||||
|
from cache.lru import LRU
|
||||||
|
|
||||||
"""MIT License
|
"""MIT License
|
||||||
|
|
||||||
Copyright (c) 2020 Rajat Singh
|
Copyright (c) 2020 Rajat Singh
|
||||||
@@ -19,11 +24,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
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"""
|
||||||
|
|
||||||
from cache.key import KEY
|
|
||||||
from cache.lru import LRU
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncConditionalTTL:
|
class AsyncConditionalTTL:
|
||||||
@@ -31,21 +32,19 @@ 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(
|
self.time_to_live = (
|
||||||
seconds=time_to_live
|
datetime.timedelta(seconds=time_to_live) if time_to_live else None
|
||||||
) if time_to_live else None
|
)
|
||||||
|
|
||||||
self.maxsize = maxsize
|
self.maxsize = maxsize
|
||||||
|
|
||||||
def __contains__(self, key):
|
def __contains__(self, key):
|
||||||
if key not in self.keys():
|
if key not in self.keys():
|
||||||
return False
|
return False
|
||||||
else:
|
|
||||||
key_expiration = super().__getitem__(key)[1]
|
key_expiration = super().__getitem__(key)[1]
|
||||||
if key_expiration and key_expiration < datetime.datetime.now():
|
if key_expiration and key_expiration < datetime.datetime.now():
|
||||||
del self[key]
|
del self[key]
|
||||||
return False
|
return False
|
||||||
else:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
@@ -55,13 +54,13 @@ class AsyncConditionalTTL:
|
|||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key, value):
|
||||||
value, ignore_ttl = value # unpack tuple
|
value, ignore_ttl = value # unpack tuple
|
||||||
ttl_value = (
|
ttl_value = (
|
||||||
datetime.datetime.now() + self.time_to_live
|
(datetime.datetime.now() + self.time_to_live)
|
||||||
) if (self.time_to_live and not ignore_ttl) else None # ignore ttl if ignore_ttl is True
|
if (self.time_to_live and not ignore_ttl)
|
||||||
|
else None
|
||||||
|
) # ignore ttl if ignore_ttl is True
|
||||||
super().__setitem__(key, (value, ttl_value))
|
super().__setitem__(key, (value, ttl_value))
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, time_to_live=60, maxsize=1024, skip_args: int = 0):
|
||||||
self, time_to_live=60, maxsize=1024, skip_args: int = 0
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
:param time_to_live: Use time_to_live as None for non expiring cache
|
:param time_to_live: Use time_to_live as None for non expiring cache
|
||||||
195
src/iSponsorBlockTV/config_setup.py
Normal file
195
src/iSponsorBlockTV/config_setup.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from . import api_helpers, ytlounge
|
||||||
|
|
||||||
|
# Constants for user input prompts
|
||||||
|
ATVS_REMOVAL_PROMPT = (
|
||||||
|
"Do you want to remove the legacy 'atvs' entry (the app won't start"
|
||||||
|
" with it present)? (y/N) "
|
||||||
|
)
|
||||||
|
PAIRING_CODE_PROMPT = "Enter pairing code (found in Settings - Link with TV code): "
|
||||||
|
ADD_MORE_DEVICES_PROMPT = "Paired with {num_devices} Device(s). Add more? (y/N) "
|
||||||
|
CHANGE_API_KEY_PROMPT = "API key already specified. Change it? (y/N) "
|
||||||
|
ADD_API_KEY_PROMPT = (
|
||||||
|
"API key only needed for the channel whitelist function. Add it? (y/N) "
|
||||||
|
)
|
||||||
|
ENTER_API_KEY_PROMPT = "Enter your API key: "
|
||||||
|
CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) "
|
||||||
|
ENTER_SKIP_CATEGORIES_PROMPT = (
|
||||||
|
"Enter skip categories (space or comma sepparated) Options: [sponsor,"
|
||||||
|
" selfpromo, exclusive_access, interaction, poi_highlight, intro, outro,"
|
||||||
|
" preview, filler, music_offtopic]:\n"
|
||||||
|
)
|
||||||
|
WHITELIST_CHANNELS_PROMPT = (
|
||||||
|
"Do you want to whitelist any channels from being ad-blocked? (y/N) "
|
||||||
|
)
|
||||||
|
SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: '
|
||||||
|
SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: "
|
||||||
|
ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: "
|
||||||
|
ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: "
|
||||||
|
REPORT_SKIPPED_SEGMENTS_PROMPT = (
|
||||||
|
"Do you want to report skipped segments to sponsorblock. Only the segment"
|
||||||
|
" UUID will be sent? (Y/n) "
|
||||||
|
)
|
||||||
|
MUTE_ADS_PROMPT = "Do you want to mute native YouTube ads automatically? (y/N) "
|
||||||
|
SKIP_ADS_PROMPT = "Do you want to skip native YouTube ads automatically? (y/N) "
|
||||||
|
AUTOPLAY_PROMPT = "Do you want to enable autoplay? (Y/n) "
|
||||||
|
|
||||||
|
|
||||||
|
def get_yn_input(prompt):
|
||||||
|
while choice := input(prompt):
|
||||||
|
if choice.lower() in ["y", "n"]:
|
||||||
|
return choice.lower()
|
||||||
|
print("Invalid input. Please enter 'y' or 'n'.")
|
||||||
|
|
||||||
|
|
||||||
|
async def pair_device():
|
||||||
|
try:
|
||||||
|
lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV")
|
||||||
|
pairing_code = input(PAIRING_CODE_PROMPT)
|
||||||
|
pairing_code = int(
|
||||||
|
pairing_code.replace("-", "").replace(" ", "")
|
||||||
|
) # remove dashes and spaces
|
||||||
|
print("Pairing...")
|
||||||
|
paired = await lounge_controller.pair(pairing_code)
|
||||||
|
if not paired:
|
||||||
|
print("Failed to pair device")
|
||||||
|
return
|
||||||
|
device = {
|
||||||
|
"screen_id": lounge_controller.auth.screen_id,
|
||||||
|
"name": lounge_controller.screen_name,
|
||||||
|
}
|
||||||
|
print(f"Paired device: {device['name']}")
|
||||||
|
return device
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to pair device: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def main(config, debug: bool) -> None:
|
||||||
|
print("Welcome to the iSponsorBlockTV cli setup wizard")
|
||||||
|
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
|
web_session = aiohttp.ClientSession()
|
||||||
|
if debug:
|
||||||
|
loop.set_debug(True)
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
if hasattr(config, "atvs"):
|
||||||
|
print(
|
||||||
|
"The atvs config option is deprecated and has stopped working. Please read"
|
||||||
|
" this for more information on how to upgrade to V2:"
|
||||||
|
" \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
|
||||||
|
)
|
||||||
|
choice = get_yn_input(ATVS_REMOVAL_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
del config["atvs"]
|
||||||
|
|
||||||
|
devices = config.devices
|
||||||
|
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))
|
||||||
|
while choice == "y":
|
||||||
|
task = loop.create_task(pair_device())
|
||||||
|
loop.run_until_complete(task)
|
||||||
|
device = task.result()
|
||||||
|
if device:
|
||||||
|
devices.append(device)
|
||||||
|
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))
|
||||||
|
config.devices = devices
|
||||||
|
|
||||||
|
apikey = config.apikey
|
||||||
|
if apikey:
|
||||||
|
choice = get_yn_input(CHANGE_API_KEY_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
apikey = input(ENTER_API_KEY_PROMPT)
|
||||||
|
else:
|
||||||
|
choice = get_yn_input(ADD_API_KEY_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
print(
|
||||||
|
"Get youtube apikey here:"
|
||||||
|
" https://developers.google.com/youtube/registering_an_application"
|
||||||
|
)
|
||||||
|
apikey = input(ENTER_API_KEY_PROMPT)
|
||||||
|
config.apikey = apikey
|
||||||
|
|
||||||
|
skip_categories = config.skip_categories
|
||||||
|
if skip_categories:
|
||||||
|
choice = get_yn_input(CHANGE_SKIP_CATEGORIES_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
||||||
|
skip_categories = categories.replace(",", " ").split(" ")
|
||||||
|
skip_categories = [
|
||||||
|
x for x in skip_categories if x != ""
|
||||||
|
] # Remove empty strings
|
||||||
|
else:
|
||||||
|
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
||||||
|
skip_categories = categories.replace(",", " ").split(" ")
|
||||||
|
skip_categories = [
|
||||||
|
x for x in skip_categories if x != ""
|
||||||
|
] # Remove empty strings
|
||||||
|
config.skip_categories = skip_categories
|
||||||
|
|
||||||
|
channel_whitelist = config.channel_whitelist
|
||||||
|
choice = get_yn_input(WHITELIST_CHANNELS_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
if not apikey:
|
||||||
|
print(
|
||||||
|
"WARNING: You need to specify an API key to use this function,"
|
||||||
|
" otherwise the program will fail to start.\nYou can add one by"
|
||||||
|
" re-running this setup wizard."
|
||||||
|
)
|
||||||
|
api_helper = api_helpers.ApiHelper(config, web_session)
|
||||||
|
while True:
|
||||||
|
channel_info = {}
|
||||||
|
channel = input(SEARCH_CHANNEL_PROMPT)
|
||||||
|
if channel == "/exit":
|
||||||
|
break
|
||||||
|
|
||||||
|
task = loop.create_task(
|
||||||
|
api_helper.search_channels(channel, apikey, web_session)
|
||||||
|
)
|
||||||
|
loop.run_until_complete(task)
|
||||||
|
results = task.result()
|
||||||
|
if len(results) == 0:
|
||||||
|
print("No channels found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i, item in enumerate(results):
|
||||||
|
print(f"{i}: {item[1]} - Subs: {item[2]}")
|
||||||
|
print("5: Enter a custom channel ID")
|
||||||
|
print("6: Go back")
|
||||||
|
|
||||||
|
while choice := input(SELECT_CHANNEL_PROMPT):
|
||||||
|
if choice in [str(x) for x in range(7)]:
|
||||||
|
break
|
||||||
|
print("Invalid choice")
|
||||||
|
|
||||||
|
if choice == "5":
|
||||||
|
channel_info["id"] = input(ENTER_CHANNEL_ID_PROMPT)
|
||||||
|
channel_info["name"] = input(ENTER_CUSTOM_CHANNEL_NAME_PROMPT)
|
||||||
|
channel_whitelist.append(channel_info)
|
||||||
|
continue
|
||||||
|
if choice == "6":
|
||||||
|
continue
|
||||||
|
|
||||||
|
channel_info["id"] = results[int(choice)][0]
|
||||||
|
channel_info["name"] = results[int(choice)][1]
|
||||||
|
channel_whitelist.append(channel_info)
|
||||||
|
# Close web session asynchronously
|
||||||
|
|
||||||
|
config.channel_whitelist = channel_whitelist
|
||||||
|
|
||||||
|
choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT)
|
||||||
|
config.skip_count_tracking = choice != "n"
|
||||||
|
|
||||||
|
choice = get_yn_input(MUTE_ADS_PROMPT)
|
||||||
|
config.mute_ads = choice == "y"
|
||||||
|
|
||||||
|
choice = get_yn_input(SKIP_ADS_PROMPT)
|
||||||
|
config.skip_ads = choice == "y"
|
||||||
|
|
||||||
|
choice = get_yn_input(AUTOPLAY_PROMPT)
|
||||||
|
config.auto_play = choice != "n"
|
||||||
|
|
||||||
|
print("Config finished")
|
||||||
|
config.save()
|
||||||
|
loop.run_until_complete(web_session.close())
|
||||||
24
src/iSponsorBlockTV/constants.py
Normal file
24
src/iSponsorBlockTV/constants.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
userAgent = "iSponsorBlockTV/0.1"
|
||||||
|
SponsorBlock_service = "youtube"
|
||||||
|
SponsorBlock_actiontype = "skip"
|
||||||
|
|
||||||
|
SponsorBlock_api = "https://sponsor.ajay.app/api/"
|
||||||
|
Youtube_api = "https://www.googleapis.com/youtube/v3/"
|
||||||
|
|
||||||
|
skip_categories = (
|
||||||
|
("Sponsor", "sponsor"),
|
||||||
|
("Self Promotion", "selfpromo"),
|
||||||
|
("Intro", "intro"),
|
||||||
|
("Outro", "outro"),
|
||||||
|
("Music Offtopic", "music_offtopic"),
|
||||||
|
("Interaction", "interaction"),
|
||||||
|
("Exclusive Access", "exclusive_access"),
|
||||||
|
("POI Highlight", "poi_highlight"),
|
||||||
|
("Preview", "preview"),
|
||||||
|
("Filler", "filler"),
|
||||||
|
)
|
||||||
|
|
||||||
|
youtube_client_blacklist = ["TVHTML5_FOR_KIDS"]
|
||||||
|
|
||||||
|
|
||||||
|
config_file_blacklist_keys = ["config_file", "data_dir"]
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
"""Send out a M-SEARCH request and listening for responses."""
|
"""Send out an M-SEARCH request and listening for responses."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import ssdp
|
import ssdp
|
||||||
from ssdp import network
|
|
||||||
import xmltodict
|
import xmltodict
|
||||||
|
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 notice, this list of conditions and the following
|
Redistributions of the DIAL Specification must retain the above copyright
|
||||||
disclaimer.
|
notice, this list of conditions and the following disclaimer. ●
|
||||||
● Redistributions of implementations of the DIAL Specification in source code form must retain the above copyright notice, this
|
Redistributions of implementations of the DIAL Specification in source code
|
||||||
list of conditions and the following disclaimer.
|
form must retain the above copyright notice, this list of conditions and the
|
||||||
● Redistributions of implementations of the DIAL Specification in binary form must include the above copyright notice.
|
following disclaimer. ● Redistributions of implementations of the DIAL
|
||||||
● The DIAL mark, the NETFLIX mark and the names of contributors to the DIAL Specification may not be used to endorse or
|
Specification in binary form must include the above copyright notice. ● The
|
||||||
promote specifications, software, products, or any other materials derived from the DIAL Specification without specific prior
|
DIAL mark, the NETFLIX mark and the names of contributors to the DIAL
|
||||||
written permission. The DIAL mark is owned by Netflix and information on licensing the DIAL mark is available at
|
Specification may not be used to endorse or promote specifications, software,
|
||||||
www.dial-multiscreen.org.'''
|
products, or any other materials derived from the DIAL Specification without
|
||||||
|
specific prior written permission. The DIAL mark is owned by Netflix and
|
||||||
|
information on licensing the DIAL mark is available at
|
||||||
|
www.dial-multiscreen.org."""
|
||||||
|
|
||||||
'''
|
"""
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2018 Johannes Hoppe
|
Copyright (c) 2018 Johannes Hoppe
|
||||||
@@ -41,25 +43,26 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
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/codingjoe/ssdp/blob/main/ssdp/__main__.py'''
|
"""Modified code from
|
||||||
|
https://github.com/codingjoe/ssdp/blob/main/ssdp/__main__.py"""
|
||||||
|
|
||||||
|
|
||||||
def get_ip():
|
def get_ip():
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
s.settimeout(0)
|
s.settimeout(0)
|
||||||
try:
|
try:
|
||||||
# doesn't even have to be reachable
|
# doesn't even have to be reachable
|
||||||
s.connect(('10.254.254.254', 1))
|
s.connect(("10.254.254.254", 1))
|
||||||
IP = s.getsockname()[0]
|
ip = s.getsockname()[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
IP = '127.0.0.1'
|
ip = "127.0.0.1"
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
return IP
|
return ip
|
||||||
|
|
||||||
|
|
||||||
class Handler(ssdp.aio.SSDP):
|
class Handler(ssdp.aio.SSDP):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.devices = []
|
self.devices = []
|
||||||
@@ -69,6 +72,7 @@ class Handler(ssdp.aio.SSDP):
|
|||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def response_received(self, response: ssdp.messages.SSDPResponse, addr):
|
def response_received(self, response: ssdp.messages.SSDPResponse, addr):
|
||||||
headers = response.headers
|
headers = response.headers
|
||||||
headers = {k.lower(): v for k, v in headers}
|
headers = {k.lower(): v for k, v in headers}
|
||||||
@@ -110,7 +114,9 @@ async def discover(web_session):
|
|||||||
family, addr = 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(
|
||||||
|
handler, family=family, local_addr=(ip_address, None)
|
||||||
|
)
|
||||||
transport, protocol = await connect
|
transport, protocol = await connect
|
||||||
|
|
||||||
target = network.MULTICAST_ADDRESS_IPV4, network.PORT
|
target = network.MULTICAST_ADDRESS_IPV4, network.PORT
|
||||||
@@ -5,7 +5,10 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
class Device:
|
class Device:
|
||||||
@@ -33,17 +36,23 @@ class Config:
|
|||||||
|
|
||||||
self.devices = []
|
self.devices = []
|
||||||
self.apikey = ""
|
self.apikey = ""
|
||||||
self.skip_categories = []
|
self.skip_categories = [] # These are the categories on the config file
|
||||||
self.channel_whitelist = []
|
self.channel_whitelist = []
|
||||||
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.auto_play = True
|
||||||
self.__load()
|
self.__load()
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
if hasattr(self, "atvs"):
|
if hasattr(self, "atvs"):
|
||||||
print(
|
print(
|
||||||
"The atvs config option is deprecated and has stopped working. Please read this for more information on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2",
|
(
|
||||||
|
"The atvs config option is deprecated and has stopped working."
|
||||||
|
" Please read this for more information "
|
||||||
|
"on how to upgrade to V2:"
|
||||||
|
" \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
print("Exiting in 10 seconds...")
|
print("Exiting in 10 seconds...")
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
@@ -55,16 +64,19 @@ 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.categories = ["sponsor"]
|
self.skip_categories = ["sponsor"]
|
||||||
print("No categories found, using default: sponsor")
|
print("No categories found, using default: sponsor")
|
||||||
|
|
||||||
def __load(self):
|
def __load(self):
|
||||||
try:
|
try:
|
||||||
with open(self.config_file, "r") as f:
|
with open(self.config_file, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
for i in config:
|
for i in config:
|
||||||
|
if i not in config_file_blacklist_keys:
|
||||||
setattr(self, i, config[i])
|
setattr(self, i, config[i])
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print("Could not load config file")
|
print("Could not load config file")
|
||||||
@@ -74,11 +86,22 @@ class Config:
|
|||||||
print("Creating data directory")
|
print("Creating data directory")
|
||||||
os.makedirs(self.data_dir)
|
os.makedirs(self.data_dir)
|
||||||
else: # Running in docker without mounting the data dir
|
else: # Running in docker without mounting the data dir
|
||||||
print("Running in docker without mounting the data dir, check the wiki for more information: "
|
print(
|
||||||
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker")
|
"Running in docker without mounting the data dir, check the"
|
||||||
print("This image has recently been updated to v2, and requires changes.",
|
" wiki for more information: "
|
||||||
"Please read this for more information on how to upgrade to V2:",
|
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker"
|
||||||
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2")
|
)
|
||||||
|
print(
|
||||||
|
(
|
||||||
|
"This image has recently been updated to v2, and requires"
|
||||||
|
" changes."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Please read this for more information on how to upgrade"
|
||||||
|
" to V2:"
|
||||||
|
),
|
||||||
|
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2",
|
||||||
|
)
|
||||||
print("Exiting in 10 seconds...")
|
print("Exiting in 10 seconds...")
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
sys.exit()
|
sys.exit()
|
||||||
@@ -86,13 +109,16 @@ class Config:
|
|||||||
print("Blank config file created")
|
print("Blank config file created")
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
with open(self.config_file, "w") as f:
|
with open(self.config_file, "w", encoding="utf-8") as f:
|
||||||
config_dict = self.__dict__
|
config_dict = self.__dict__
|
||||||
# Don't save the config file name
|
# Don't save the config file name
|
||||||
config_file = self.config_file
|
config_file = self.config_file
|
||||||
|
data_dir = self.data_dir
|
||||||
del config_dict["config_file"]
|
del config_dict["config_file"]
|
||||||
|
del config_dict["data_dir"]
|
||||||
json.dump(config_dict, f, indent=4)
|
json.dump(config_dict, f, indent=4)
|
||||||
self.config_file = config_file
|
self.config_file = config_file
|
||||||
|
self.data_dir = data_dir
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if isinstance(other, Config):
|
if isinstance(other, Config):
|
||||||
@@ -101,10 +127,23 @@ class Config:
|
|||||||
|
|
||||||
|
|
||||||
def app_start():
|
def app_start():
|
||||||
|
# If env has a data dir use that, otherwise use the default
|
||||||
|
default_data_dir = os.getenv("iSPBTV_data_dir") or user_data_dir(
|
||||||
|
"iSponsorBlockTV", "dmunozv04"
|
||||||
|
)
|
||||||
parser = argparse.ArgumentParser(description="iSponsorblockTV")
|
parser = argparse.ArgumentParser(description="iSponsorblockTV")
|
||||||
parser.add_argument("--data-dir", "-d", default="data", help="data directory")
|
parser.add_argument(
|
||||||
parser.add_argument("--setup", "-s", action="store_true", help="setup the program graphically")
|
"--data-dir", "-d", default=default_data_dir, help="data directory"
|
||||||
parser.add_argument("--setup-cli", "-sc", action="store_true", help="setup the program in the command line")
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--setup", "-s", action="store_true", help="setup the program graphically"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--setup-cli",
|
||||||
|
"-sc",
|
||||||
|
action="store_true",
|
||||||
|
help="setup the program in the command line",
|
||||||
|
)
|
||||||
parser.add_argument("--debug", action="store_true", help="debug mode")
|
parser.add_argument("--debug", action="store_true", help="debug mode")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import plistlib
|
|
||||||
import os
|
import os
|
||||||
|
import plistlib
|
||||||
|
|
||||||
from . import config_setup
|
from . import config_setup
|
||||||
|
|
||||||
"""Not updated to V2 yet, should still work. Here be dragons"""
|
"""Not updated to V2 yet, should still work. Here be dragons"""
|
||||||
default_plist = {
|
default_plist = {
|
||||||
"Label": "com.dmunozv04iSponsorBlockTV",
|
"Label": "com.dmunozv04iSponsorBlockTV",
|
||||||
@@ -40,7 +42,9 @@ def main():
|
|||||||
create_plist(correct_path)
|
create_plist(correct_path)
|
||||||
run_setup(correct_path + "/config.json")
|
run_setup(correct_path + "/config.json")
|
||||||
print(
|
print(
|
||||||
"Launch daemon installed. Please restart the computer to enable it or use:\n launchctl load ~/Library/LaunchAgents/com.dmunozv04.iSponsorBlockTV.plist"
|
"Launch daemon installed. Please restart the computer to enable it or"
|
||||||
|
" use:\n launchctl load"
|
||||||
|
" ~/Library/LaunchAgents/com.dmunozv04.iSponsorBlockTV.plist"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if not os.path.exists(correct_path):
|
if not os.path.exists(correct_path):
|
||||||
@@ -48,6 +52,6 @@ def main():
|
|||||||
print(
|
print(
|
||||||
"Please move the program to the correct path: "
|
"Please move the program to the correct path: "
|
||||||
+ correct_path
|
+ correct_path
|
||||||
+ "opeing now on finder..."
|
+ "opening now on finder..."
|
||||||
)
|
)
|
||||||
os.system("open -R " + correct_path)
|
os.system("open -R " + correct_path)
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from signal import SIGINT, SIGTERM, signal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
from . import api_helpers, ytlounge
|
from . import api_helpers, ytlounge
|
||||||
import traceback
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceListener:
|
class DeviceListener:
|
||||||
def __init__(self, api_helper, config, screen_id, offset):
|
def __init__(self, api_helper, config, device, debug: bool, web_session):
|
||||||
self.task: asyncio.Task = None
|
self.task: Optional[asyncio.Task] = None
|
||||||
self.api_helper = api_helper
|
self.api_helper = api_helper
|
||||||
self.lounge_controller = ytlounge.YtLoungeApi(screen_id, config, api_helper)
|
self.offset = device.offset
|
||||||
self.offset = offset
|
self.name = device.name
|
||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
|
self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}")
|
||||||
|
self.web_session = web_session
|
||||||
|
if debug:
|
||||||
|
self.logger.setLevel(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
self.logger.setLevel(logging.INFO)
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
|
)
|
||||||
|
self.logger.addHandler(sh)
|
||||||
|
self.logger.info(f"Starting device")
|
||||||
|
self.lounge_controller = ytlounge.YtLoungeApi(
|
||||||
|
device.screen_id, config, api_helper, self.logger, self.web_session
|
||||||
|
)
|
||||||
|
|
||||||
# Ensures that we have a valid auth token
|
# Ensures that we have a valid auth token
|
||||||
async def refresh_auth_loop(self):
|
async def refresh_auth_loop(self):
|
||||||
@@ -34,14 +52,13 @@ class DeviceListener:
|
|||||||
# Main subscription loop
|
# Main subscription loop
|
||||||
async def loop(self):
|
async def loop(self):
|
||||||
lounge_controller = self.lounge_controller
|
lounge_controller = self.lounge_controller
|
||||||
|
while not self.cancelled:
|
||||||
while not lounge_controller.linked():
|
while not lounge_controller.linked():
|
||||||
try:
|
try:
|
||||||
|
self.logger.debug("Refreshing auth")
|
||||||
await lounge_controller.refresh_auth()
|
await lounge_controller.refresh_auth()
|
||||||
except:
|
except:
|
||||||
# traceback.print_exc()
|
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
while not self.cancelled:
|
|
||||||
while not (await self.is_available()) and not self.cancelled:
|
while not (await self.is_available()) and not self.cancelled:
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
try:
|
try:
|
||||||
@@ -49,23 +66,24 @@ class DeviceListener:
|
|||||||
except:
|
except:
|
||||||
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)
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
try:
|
try:
|
||||||
await lounge_controller.connect()
|
await lounge_controller.connect()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
print(f"Connected to device {lounge_controller.screen_name}")
|
self.logger.info(
|
||||||
|
"Connected to device %s (%s)", lounge_controller.screen_name, self.name
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
#print("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
|
||||||
#print("Subscription ended")
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Method called on playback state change
|
# Method called on playback state change
|
||||||
async def __call__(self, state):
|
async def __call__(self, state):
|
||||||
logging.debug("Playstatus update" + str(state))
|
|
||||||
try:
|
try:
|
||||||
self.task.cancel()
|
self.task.cancel()
|
||||||
except:
|
except:
|
||||||
@@ -79,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
|
||||||
print(f"Playing {state.videoId} with {len(segments)} 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)
|
||||||
|
|
||||||
@@ -88,35 +108,35 @@ class DeviceListener:
|
|||||||
start_next_segment = None
|
start_next_segment = None
|
||||||
next_segment = None
|
next_segment = None
|
||||||
for segment in segments:
|
for segment in segments:
|
||||||
if position < 2 and (
|
if position < 2 and (segment["start"] <= position < segment["end"]):
|
||||||
segment["start"] <= position < segment["end"]
|
|
||||||
):
|
|
||||||
next_segment = segment
|
next_segment = segment
|
||||||
start_next_segment = position # different variable so segment doesn't change
|
start_next_segment = (
|
||||||
|
position # different variable so segment doesn't change
|
||||||
|
)
|
||||||
break
|
break
|
||||||
if segment["start"] > position:
|
if segment["start"] > position:
|
||||||
next_segment = segment
|
next_segment = segment
|
||||||
start_next_segment = next_segment["start"]
|
start_next_segment = next_segment["start"]
|
||||||
break
|
break
|
||||||
if start_next_segment:
|
if start_next_segment:
|
||||||
time_to_next = start_next_segment - position - (time.time() - time_start) - self.offset
|
time_to_next = (
|
||||||
|
start_next_segment - position - (time.time() - time_start) - self.offset
|
||||||
|
)
|
||||||
await self.skip(time_to_next, next_segment["end"], next_segment["UUID"])
|
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, UUID):
|
async def skip(self, time_to, position, uuids):
|
||||||
await asyncio.sleep(time_to)
|
await asyncio.sleep(time_to)
|
||||||
asyncio.create_task(self.lounge_controller.seek_to(position))
|
self.logger.info("Skipping segment: seeking to %s", position)
|
||||||
asyncio.create_task(
|
await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids))
|
||||||
self.api_helper.mark_viewed_segments(UUID)
|
await asyncio.create_task(self.lounge_controller.seek_to(position))
|
||||||
) # Don't wait for this to finish
|
|
||||||
|
|
||||||
|
|
||||||
# Stops the connection to the device
|
# Stops the connection to the device
|
||||||
async def cancel(self):
|
async def cancel(self):
|
||||||
self.cancelled = True
|
self.cancelled = True
|
||||||
try:
|
try:
|
||||||
self.task.cancel()
|
self.task.cancel()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -136,14 +156,15 @@ def main(config, debug):
|
|||||||
web_session = aiohttp.ClientSession(loop=loop, connector=tcp_connector)
|
web_session = aiohttp.ClientSession(loop=loop, 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.screen_id, i.offset)
|
device = DeviceListener(api_helper, config, i, debug, web_session)
|
||||||
devices.append(device)
|
devices.append(device)
|
||||||
tasks.append(loop.create_task(device.loop()))
|
tasks.append(loop.create_task(device.loop()))
|
||||||
tasks.append(loop.create_task(device.refresh_auth_loop()))
|
tasks.append(loop.create_task(device.refresh_auth_loop()))
|
||||||
try:
|
signal(SIGINT, lambda s, f: loop.stop())
|
||||||
|
signal(SIGTERM, lambda s, f: loop.stop())
|
||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
except KeyboardInterrupt as e:
|
print("Cancelling tasks and exiting...")
|
||||||
print("Keyboard interrupt detected, cancelling tasks and exiting...")
|
|
||||||
loop.run_until_complete(finish(devices))
|
loop.run_until_complete(finish(devices))
|
||||||
finally:
|
|
||||||
loop.run_until_complete(web_session.close())
|
loop.run_until_complete(web_session.close())
|
||||||
|
loop.run_until_complete(tcp_connector.close())
|
||||||
|
loop.close()
|
||||||
@@ -363,3 +363,9 @@ MigrationScreen {
|
|||||||
width: 1fr;
|
width: 1fr;
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Autoplay */
|
||||||
|
#autoplay-container{
|
||||||
|
padding: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
@@ -1,17 +1,37 @@
|
|||||||
import aiohttp
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
# Textual imports (Textual is awesome!)
|
# Textual imports (Textual is awesome!)
|
||||||
from textual import on
|
from textual import on
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import ScrollableContainer, Grid, Container, Vertical, Horizontal
|
from textual.containers import (
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Horizontal,
|
||||||
|
ScrollableContainer,
|
||||||
|
Vertical,
|
||||||
|
)
|
||||||
from textual.events import Click
|
from textual.events import Click
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.validation import Function
|
from textual.validation import Function
|
||||||
from textual.widgets import Button, Footer, Header, Static, Label, Input, SelectionList, Checkbox, ContentSwitcher, \
|
from textual.widgets import (
|
||||||
RadioSet, RadioButton
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
ContentSwitcher,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
RadioButton,
|
||||||
|
RadioSet,
|
||||||
|
SelectionList,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
from textual.widgets.selection_list import Selection
|
from textual.widgets.selection_list import Selection
|
||||||
from textual_slider import Slider
|
from textual_slider import Slider
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from . import api_helpers, ytlounge
|
from . import api_helpers, ytlounge
|
||||||
from .constants import skip_categories
|
from .constants import skip_categories
|
||||||
@@ -28,7 +48,9 @@ def _validate_pairing_code(pairing_code: str) -> bool:
|
|||||||
|
|
||||||
class ModalWithClickExit(Screen):
|
class ModalWithClickExit(Screen):
|
||||||
"""A modal screen that exits when clicked outside its bounds.
|
"""A modal screen that exits when clicked outside its bounds.
|
||||||
https://discord.com/channels/1026214085173461072/1033754296224841768/1136015817356611604"""
|
https://discord.com/channels/1026214085173461072/1033754296224841768/1136015817356611604
|
||||||
|
"""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
ModalWithClickExit {
|
ModalWithClickExit {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
@@ -60,8 +82,15 @@ class Element(Static):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Button(label=self.element_name, classes="element-name", disabled=True, id="element-name")
|
yield Button(
|
||||||
yield Button("Remove", classes="element-remove", variant="error", id="element-remove")
|
label=self.element_name,
|
||||||
|
classes="element-name",
|
||||||
|
disabled=True,
|
||||||
|
id="element-name",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Remove", classes="element-remove", variant="error", id="element-remove"
|
||||||
|
)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
if self.tooltip:
|
if self.tooltip:
|
||||||
@@ -73,12 +102,15 @@ class Device(Element):
|
|||||||
"""A device element."""
|
"""A device element."""
|
||||||
|
|
||||||
def process_values_from_data(self):
|
def process_values_from_data(self):
|
||||||
print("HIIII")
|
|
||||||
print(self.element_data)
|
print(self.element_data)
|
||||||
if "name" in self.element_data and self.element_data["name"]:
|
if "name" in self.element_data and self.element_data["name"]:
|
||||||
self.element_name = self.element_data["name"]
|
self.element_name = self.element_data["name"]
|
||||||
else:
|
else:
|
||||||
self.element_name = f"Unnamed device with id {self.element_data['screen_id'][:5]}...{self.element_data['screen_id'][-5:]}"
|
self.element_name = (
|
||||||
|
"Unnamed device with id "
|
||||||
|
f"{self.element_data['screen_id'][:5]}..."
|
||||||
|
f"{self.element_data['screen_id'][-5:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Channel(Element):
|
class Channel(Element):
|
||||||
@@ -88,7 +120,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):
|
||||||
@@ -102,18 +136,37 @@ class ChannelRadio(RadioButton):
|
|||||||
|
|
||||||
class MigrationScreen(ModalWithClickExit):
|
class MigrationScreen(ModalWithClickExit):
|
||||||
"""Screen with a dialog to remove old ATVS config."""
|
"""Screen with a dialog to remove old ATVS config."""
|
||||||
BINDINGS = [("escape", "dismiss()", "Cancel"),
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "dismiss()", "Cancel"),
|
||||||
("s", "remove_and_save", "Remove and save"),
|
("s", "remove_and_save", "Remove and save"),
|
||||||
("q,ctrl+c", "exit", "Exit")]
|
("q,ctrl+c", "exit", "Exit"),
|
||||||
|
]
|
||||||
AUTO_FOCUS = "#exit-save"
|
AUTO_FOCUS = "#exit-save"
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Grid(
|
yield Grid(
|
||||||
Label(
|
Label(
|
||||||
"Welcome to the new configurator! You seem to have the legacy 'atvs' entry on your config file, do you want to remove it?\n(The app won't start with it present)",
|
(
|
||||||
id="question", classes="button-100"),
|
"Welcome to the new configurator! You seem to have the legacy"
|
||||||
Button("Remove and save", variant="primary", id="migrate-remove-save", classes="button-100"),
|
" 'atvs' entry on your config file, do you want to remove it?\n(The"
|
||||||
Button("Don't remove", variant="error", id="migrate-no-change", classes="button-100"),
|
" app won't start with it present)"
|
||||||
|
),
|
||||||
|
id="question",
|
||||||
|
classes="button-100",
|
||||||
|
),
|
||||||
|
Button(
|
||||||
|
"Remove and save",
|
||||||
|
variant="primary",
|
||||||
|
id="migrate-remove-save",
|
||||||
|
classes="button-100",
|
||||||
|
),
|
||||||
|
Button(
|
||||||
|
"Don't remove",
|
||||||
|
variant="error",
|
||||||
|
id="migrate-no-change",
|
||||||
|
classes="button-100",
|
||||||
|
),
|
||||||
id="dialog-migration",
|
id="dialog-migration",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,16 +186,25 @@ class MigrationScreen(ModalWithClickExit):
|
|||||||
|
|
||||||
class ExitScreen(ModalWithClickExit):
|
class ExitScreen(ModalWithClickExit):
|
||||||
"""Screen with a dialog to exit."""
|
"""Screen with a dialog to exit."""
|
||||||
BINDINGS = [("escape", "dismiss()", "Cancel"),
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "dismiss()", "Cancel"),
|
||||||
("s", "save", "Save"),
|
("s", "save", "Save"),
|
||||||
("q,ctrl+c", "exit", "Exit")]
|
("q,ctrl+c", "exit", "Exit"),
|
||||||
|
]
|
||||||
AUTO_FOCUS = "#exit-save"
|
AUTO_FOCUS = "#exit-save"
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Grid(
|
yield Grid(
|
||||||
Label("Are you sure you want to exit without saving?", id="question", classes="button-100"),
|
Label(
|
||||||
|
"Are you sure you want to exit without saving?",
|
||||||
|
id="question",
|
||||||
|
classes="button-100",
|
||||||
|
),
|
||||||
Button("Save", variant="success", id="exit-save", classes="button-100"),
|
Button("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",
|
||||||
)
|
)
|
||||||
@@ -166,39 +228,78 @@ class ExitScreen(ModalWithClickExit):
|
|||||||
|
|
||||||
class AddDevice(ModalWithClickExit):
|
class AddDevice(ModalWithClickExit):
|
||||||
"""Screen with a dialog to add a device, either with a pairing code or with lan discovery."""
|
"""Screen with a dialog to add a device, either with a pairing code 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
|
||||||
web_session = aiohttp.ClientSession()
|
self.web_session = aiohttp.ClientSession()
|
||||||
self.api_helper = api_helpers.ApiHelper(config, web_session)
|
self.api_helper = api_helpers.ApiHelper(config, self.web_session)
|
||||||
self.devices_discovered_dial = []
|
self.devices_discovered_dial = []
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Container(id="add-device-container"):
|
with Container(id="add-device-container"):
|
||||||
yield Label("Add Device", classes="title")
|
yield Label("Add Device", classes="title")
|
||||||
with Grid(id="add-device-switch-buttons"):
|
with Grid(id="add-device-switch-buttons"):
|
||||||
yield Button("Add with pairing code", id="add-device-pin-button", classes="button-switcher")
|
yield Button(
|
||||||
yield Button("Add with lan discovery", id="add-device-dial-button", classes="button-switcher")
|
"Add with pairing code",
|
||||||
with ContentSwitcher(id="add-device-switcher", initial="add-device-pin-container"):
|
id="add-device-pin-button",
|
||||||
|
classes="button-switcher",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Add with lan discovery",
|
||||||
|
id="add-device-dial-button",
|
||||||
|
classes="button-switcher",
|
||||||
|
)
|
||||||
|
with ContentSwitcher(
|
||||||
|
id="add-device-switcher", initial="add-device-pin-container"
|
||||||
|
):
|
||||||
with Container(id="add-device-pin-container"):
|
with Container(id="add-device-pin-container"):
|
||||||
yield Input(placeholder="Pairing Code (found in Settings - Link with TV code)",
|
yield Input(
|
||||||
|
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(
|
||||||
|
placeholder="Device Name (auto filled if empty/optional)",
|
||||||
|
id="device-name-input",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Add",
|
||||||
|
id="add-device-pin-add-button",
|
||||||
|
variant="success",
|
||||||
|
disabled=True,
|
||||||
)
|
)
|
||||||
yield Input(placeholder="Device Name (auto filled if empty/optional)", id="device-name-input")
|
|
||||||
yield Button("Add", id="add-device-pin-add-button", variant="success", disabled=True)
|
|
||||||
yield Label(id="add-device-info")
|
yield Label(id="add-device-info")
|
||||||
with Container(id="add-device-dial-container"):
|
with Container(id="add-device-dial-container"):
|
||||||
yield Label(
|
yield Label(
|
||||||
"Make sure your device is on the same network as this computer\nIf it isn't showing up, try restarting the app.\nIf running in docker, make sure to use `--network=host`\nTo refresh the list, close and open the dialog again",
|
(
|
||||||
classes="subtitle")
|
"Make sure your device is on the same network as this"
|
||||||
yield SelectionList(("Searching for devices...", "", False), id="dial-devices-list", disabled=True)
|
" computer\nIf it isn't showing up, try restarting the"
|
||||||
yield Button("Add selected devices", id="add-device-dial-add-button", variant="success",
|
" app.\nIf running in docker, make sure to use"
|
||||||
disabled=True)
|
" `--network=host`\nTo refresh the list, close and open the"
|
||||||
|
" dialog again"
|
||||||
|
),
|
||||||
|
classes="subtitle",
|
||||||
|
)
|
||||||
|
yield SelectionList(
|
||||||
|
("Searching for devices...", "", False),
|
||||||
|
id="dial-devices-list",
|
||||||
|
disabled=True,
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Add selected devices",
|
||||||
|
id="add-device-dial-add-button",
|
||||||
|
variant="success",
|
||||||
|
disabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
self.devices_discovered_dial = []
|
self.devices_discovered_dial = []
|
||||||
@@ -221,20 +322,27 @@ class AddDevice(ModalWithClickExit):
|
|||||||
|
|
||||||
@on(Button.Pressed, "#add-device-switch-buttons > *")
|
@on(Button.Pressed, "#add-device-switch-buttons > *")
|
||||||
def handle_switch_buttons(self, event: Button.Pressed) -> None:
|
def handle_switch_buttons(self, event: Button.Pressed) -> None:
|
||||||
button_ = event.button.id
|
self.query_one("#add-device-switcher").current = event.button.id.replace(
|
||||||
self.query_one("#add-device-switcher").current = event.button.id.replace("-button", "-container")
|
"-button", "-container"
|
||||||
|
)
|
||||||
|
|
||||||
@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")
|
||||||
async def handle_add_device_pin(self) -> None:
|
async def handle_add_device_pin(self) -> None:
|
||||||
self.query_one("#add-device-pin-add-button").disabled = True
|
self.query_one("#add-device-pin-add-button").disabled = True
|
||||||
lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV")
|
lounge_controller = ytlounge.YtLoungeApi(
|
||||||
|
"iSponsorBlockTV", web_session=self.web_session
|
||||||
|
)
|
||||||
pairing_code = self.query_one("#pairing-code-input").value
|
pairing_code = self.query_one("#pairing-code-input").value
|
||||||
pairing_code = int(pairing_code.replace("-", "").replace(" ", "")) # remove dashes and spaces
|
pairing_code = int(
|
||||||
|
pairing_code.replace("-", "").replace(" ", "")
|
||||||
|
) # remove dashes and spaces
|
||||||
device_name = self.parent.query_one("#device-name-input").value
|
device_name = self.parent.query_one("#device-name-input").value
|
||||||
paired = False
|
paired = False
|
||||||
try:
|
try:
|
||||||
@@ -249,7 +357,9 @@ class AddDevice(ModalWithClickExit):
|
|||||||
}
|
}
|
||||||
self.query_one("#pairing-code-input").value = ""
|
self.query_one("#pairing-code-input").value = ""
|
||||||
self.query_one("#device-name-input").value = ""
|
self.query_one("#device-name-input").value = ""
|
||||||
self.query_one("#add-device-info").update(f"[#00ff00][b]Successfully added {device['name']}")
|
self.query_one("#add-device-info").update(
|
||||||
|
f"[#00ff00][b]Successfully added {device['name']}"
|
||||||
|
)
|
||||||
self.dismiss([device])
|
self.dismiss([device])
|
||||||
else:
|
else:
|
||||||
self.query_one("#pairing-code-input").value = ""
|
self.query_one("#pairing-code-input").value = ""
|
||||||
@@ -267,11 +377,14 @@ 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, either using search or with a channel id."""
|
"""Screen with a dialog to add a channel, 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:
|
||||||
@@ -284,32 +397,78 @@ class AddChannel(ModalWithClickExit):
|
|||||||
with Container(id="add-channel-container"):
|
with Container(id="add-channel-container"):
|
||||||
yield Label("Add Channel", classes="title")
|
yield Label("Add Channel", classes="title")
|
||||||
yield Label(
|
yield Label(
|
||||||
"Select a method to add a channel. Adding via search only works if a YouTube api key has been set",
|
(
|
||||||
id="add-channel-label", classes="subtitle")
|
"Select a method to add a channel. Adding via search only works if"
|
||||||
|
" a YouTube api key has been set"
|
||||||
|
),
|
||||||
|
id="add-channel-label",
|
||||||
|
classes="subtitle",
|
||||||
|
)
|
||||||
with Grid(id="add-channel-switch-buttons"):
|
with Grid(id="add-channel-switch-buttons"):
|
||||||
yield Button("Add by channel name", id="add-channel-search-button", classes="button-switcher")
|
yield Button(
|
||||||
yield Button("Add by channel ID", id="add-channel-id-button", classes="button-switcher")
|
"Add by channel name",
|
||||||
|
id="add-channel-search-button",
|
||||||
|
classes="button-switcher",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Add by channel ID",
|
||||||
|
id="add-channel-id-button",
|
||||||
|
classes="button-switcher",
|
||||||
|
)
|
||||||
yield Label(id="add-channel-info", classes="subtitle")
|
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"):
|
||||||
yield Input(placeholder="Enter channel name", id="channel-name-input-search")
|
yield Input(
|
||||||
yield Button("Search", id="search-channel-button", variant="success")
|
placeholder="Enter channel name",
|
||||||
yield RadioSet(RadioButton(label="Search to see results", disabled=True),
|
id="channel-name-input-search",
|
||||||
id="channel-search-results")
|
)
|
||||||
yield Button("Add", id="add-channel-button-search", variant="success", disabled=True,
|
yield Button(
|
||||||
classes="button-100")
|
"Search", id="search-channel-button", variant="success"
|
||||||
|
)
|
||||||
|
yield RadioSet(
|
||||||
|
RadioButton(label="Search to see results", disabled=True),
|
||||||
|
id="channel-search-results",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Add",
|
||||||
|
id="add-channel-button-search",
|
||||||
|
variant="success",
|
||||||
|
disabled=True,
|
||||||
|
classes="button-100",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
yield Label(
|
yield Label(
|
||||||
"[#ff0000]No api key set, cannot search for channels. You can add it the config section below",
|
(
|
||||||
id="add-channel-search-no-key", classes="subtitle")
|
"[#ff0000]No api key set, cannot search for channels."
|
||||||
|
" You can add it the config section below"
|
||||||
|
),
|
||||||
|
id="add-channel-search-no-key",
|
||||||
|
classes="subtitle",
|
||||||
|
)
|
||||||
with Vertical(id="add-channel-id-container"):
|
with Vertical(id="add-channel-id-container"):
|
||||||
yield Input(placeholder="Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)",
|
yield Input(
|
||||||
id="channel-id-input")
|
placeholder=(
|
||||||
yield Input(placeholder="Enter channel name (only used to display in the config file)",
|
"Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"
|
||||||
id="channel-name-input-id")
|
),
|
||||||
yield Button("Add", id="add-channel-button-id", variant="success", classes="button-100")
|
id="channel-id-input",
|
||||||
|
)
|
||||||
|
yield Input(
|
||||||
|
placeholder=(
|
||||||
|
"Enter channel name (only used to display in the config"
|
||||||
|
" file)"
|
||||||
|
),
|
||||||
|
id="channel-name-input-id",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Add",
|
||||||
|
id="add-channel-button-id",
|
||||||
|
variant="success",
|
||||||
|
classes="button-100",
|
||||||
|
)
|
||||||
|
|
||||||
@on(RadioSet.Changed, "#channel-search-results")
|
@on(RadioSet.Changed, "#channel-search-results")
|
||||||
def handle_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
def handle_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
||||||
@@ -318,14 +477,18 @@ class AddChannel(ModalWithClickExit):
|
|||||||
@on(Button.Pressed, "#add-channel-switch-buttons > *")
|
@on(Button.Pressed, "#add-channel-switch-buttons > *")
|
||||||
def handle_switch_buttons(self, event: Button.Pressed) -> None:
|
def handle_switch_buttons(self, event: Button.Pressed) -> None:
|
||||||
button_ = event.button.id
|
button_ = event.button.id
|
||||||
self.query_one("#add-channel-switcher").current = event.button.id.replace("-button", "-container")
|
self.query_one("#add-channel-switcher").current = event.button.id.replace(
|
||||||
|
"-button", "-container"
|
||||||
|
)
|
||||||
|
|
||||||
@on(Button.Pressed, "#search-channel-button")
|
@on(Button.Pressed, "#search-channel-button")
|
||||||
@on(Input.Submitted, "#channel-name-input-search")
|
@on(Input.Submitted, "#channel-name-input-search")
|
||||||
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...")
|
||||||
@@ -334,7 +497,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:
|
except:
|
||||||
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:
|
||||||
@@ -347,7 +512,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)
|
||||||
@@ -359,7 +526,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
|
||||||
@@ -370,6 +539,7 @@ class AddChannel(ModalWithClickExit):
|
|||||||
|
|
||||||
class EditDevice(ModalWithClickExit):
|
class EditDevice(ModalWithClickExit):
|
||||||
"""Screen with a dialog to edit a device. Used by the DevicesManager."""
|
"""Screen with a dialog to edit a device. Used by the DevicesManager."""
|
||||||
|
|
||||||
BINDINGS = [("escape", "close_screen_saving", "Return")]
|
BINDINGS = [("escape", "close_screen_saving", "Return")]
|
||||||
|
|
||||||
def __init__(self, device: Element, **kwargs) -> None:
|
def __init__(self, device: Element, **kwargs) -> None:
|
||||||
@@ -395,18 +565,29 @@ class EditDevice(ModalWithClickExit):
|
|||||||
yield Input(placeholder="Device name", id="device-name-input", value=name)
|
yield Input(placeholder="Device name", id="device-name-input", value=name)
|
||||||
yield Label("Device screen id")
|
yield Label("Device screen id")
|
||||||
with Grid(id="device-id-container"):
|
with Grid(id="device-id-container"):
|
||||||
yield Input(placeholder="Device id", id="device-id-input", value=self.device_data["screen_id"],
|
yield Input(
|
||||||
password=True)
|
placeholder="Device id",
|
||||||
|
id="device-id-input",
|
||||||
|
value=self.device_data["screen_id"],
|
||||||
|
password=True,
|
||||||
|
)
|
||||||
yield Button("Show id", id="device-id-view")
|
yield Button("Show id", id="device-id-view")
|
||||||
yield Label("Device offset (in milliseconds)")
|
yield Label("Device offset (in milliseconds)")
|
||||||
with Horizontal(id="device-offset-container"):
|
with Horizontal(id="device-offset-container"):
|
||||||
yield Input(id="device-offset-input", value=str(offset))
|
yield Input(id="device-offset-input", value=str(offset))
|
||||||
yield Slider(name="Device offset", id="device-offset-slider", min=0, max=2000, step=100, value=offset)
|
yield Slider(
|
||||||
|
name="Device offset",
|
||||||
|
id="device-offset-slider",
|
||||||
|
min=0,
|
||||||
|
max=2000,
|
||||||
|
step=100,
|
||||||
|
value=offset,
|
||||||
|
)
|
||||||
|
|
||||||
def on_slider_changed(self, event: Slider.Changed) -> None:
|
def on_slider_changed(self, event: Slider.Changed) -> None:
|
||||||
input = self.query_one("#device-offset-input")
|
offset_input = self.query_one("#device-offset-offset_input")
|
||||||
with input.prevent(Input.Changed):
|
with offset_input.prevent(Input.Changed):
|
||||||
input.value = str(event.slider.value)
|
offset_input.value = str(event.slider.value)
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed):
|
def on_input_changed(self, event: Input.Changed):
|
||||||
if event.input.id == "device-offset-input":
|
if event.input.id == "device-offset-input":
|
||||||
@@ -428,7 +609,8 @@ class EditDevice(ModalWithClickExit):
|
|||||||
|
|
||||||
|
|
||||||
class DevicesManager(Vertical):
|
class DevicesManager(Vertical):
|
||||||
"""Manager for devices, allows to add, edit and remove devices."""
|
"""Manager for devices, allows adding, edit and removing devices."""
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -450,14 +632,15 @@ class DevicesManager(Vertical):
|
|||||||
self.mount(device_widget)
|
self.mount(device_widget)
|
||||||
device_widget.focus(scroll_visible=True)
|
device_widget.focus(scroll_visible=True)
|
||||||
|
|
||||||
def edit_device(self, device_widget: Element) -> None:
|
@staticmethod
|
||||||
|
def edit_device(device_widget: Element) -> None:
|
||||||
device_widget.process_values_from_data()
|
device_widget.process_values_from_data()
|
||||||
device_widget.query_one("#element-name").label = device_widget.element_name
|
device_widget.query_one("#element-name").label = device_widget.element_name
|
||||||
|
|
||||||
@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.devices.remove(channel_to_remove.element_data)
|
||||||
channel_to_remove.remove()
|
channel_to_remove.remove()
|
||||||
|
|
||||||
@on(Button.Pressed, "#add-device")
|
@on(Button.Pressed, "#add-device")
|
||||||
@@ -472,6 +655,7 @@ class DevicesManager(Vertical):
|
|||||||
|
|
||||||
class ApiKeyManager(Vertical):
|
class ApiKeyManager(Vertical):
|
||||||
"""Manager for the YouTube Api Key."""
|
"""Manager for the YouTube Api Key."""
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -479,18 +663,23 @@ class ApiKeyManager(Vertical):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Label("YouTube Api Key", classes="title")
|
yield Label("YouTube Api Key", classes="title")
|
||||||
yield Label(
|
yield Label(
|
||||||
"You can get a YouTube Api Key from the [link=https://console.developers.google.com/apis/credentials]Google Cloud Console[/link]")
|
"You can get a YouTube Data API v3 Key from the"
|
||||||
|
" [link=https://console.developers.google.com/apis/credentials]Google Cloud"
|
||||||
|
" Console[/link]. This key is only required if you're whitelisting"
|
||||||
|
" channels."
|
||||||
|
)
|
||||||
with Grid(id="api-key-grid"):
|
with Grid(id="api-key-grid"):
|
||||||
yield Input(placeholder="YouTube Api Key", id="api-key-input", password=True, value=self.config.apikey)
|
yield Input(
|
||||||
|
placeholder="YouTube Api Key",
|
||||||
|
id="api-key-input",
|
||||||
|
password=True,
|
||||||
|
value=self.config.apikey,
|
||||||
|
)
|
||||||
yield Button("Show key", id="api-key-view")
|
yield Button("Show key", id="api-key-view")
|
||||||
|
|
||||||
@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):
|
||||||
self.config.apikey = event.input.value
|
self.config.apikey = event.input.value
|
||||||
# try: # ChannelWhitelist might not be mounted
|
|
||||||
# self.app.query_one("#warning-no-key").display = not self.config.apikey
|
|
||||||
# except:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
@on(Button.Pressed, "#api-key-view")
|
@on(Button.Pressed, "#api-key-view")
|
||||||
def pressed_api_key_view(self, event: Button.Pressed):
|
def pressed_api_key_view(self, event: Button.Pressed):
|
||||||
@@ -503,7 +692,8 @@ class ApiKeyManager(Vertical):
|
|||||||
|
|
||||||
|
|
||||||
class SkipCategoriesManager(Vertical):
|
class SkipCategoriesManager(Vertical):
|
||||||
"""Manager for skip categories, allows to select which categories to skip."""
|
"""Manager for skip categories, allows selecting which categories to skip."""
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -528,6 +718,7 @@ class SkipCategoriesManager(Vertical):
|
|||||||
|
|
||||||
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."""
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -535,10 +726,22 @@ class SkipCountTrackingManager(Vertical):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Label("Skip count tracking", classes="title")
|
yield Label("Skip count tracking", classes="title")
|
||||||
yield Label(
|
yield Label(
|
||||||
"This feature tracks which segments you have skipped to let users know how much their submission has helped others and used as a metric along with upvotes to ensure that spam doesn't get into the database. The program sends a message to the sponsor block server each time you skip a segment. Hopefully most people don't change this setting so that the view numbers are accurate. :)",
|
(
|
||||||
classes="subtitle", id="skip-count-tracking-subtitle")
|
"This feature tracks which segments you have skipped to let users know"
|
||||||
yield Checkbox(value=self.config.skip_count_tracking, id="skip-count-tracking-switch",
|
" how much their submission has helped others and used as a metric"
|
||||||
label="Enable skip count tracking")
|
" along with upvotes to ensure that spam doesn't get into the database."
|
||||||
|
" The program sends a message to the sponsor block server each time you"
|
||||||
|
" skip a segment. Hopefully most people don't change this setting so"
|
||||||
|
" that the view numbers are accurate. :)"
|
||||||
|
),
|
||||||
|
classes="subtitle",
|
||||||
|
id="skip-count-tracking-subtitle",
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
value=self.config.skip_count_tracking,
|
||||||
|
id="skip-count-tracking-switch",
|
||||||
|
label="Enable skip count tracking",
|
||||||
|
)
|
||||||
|
|
||||||
@on(Checkbox.Changed, "#skip-count-tracking-switch")
|
@on(Checkbox.Changed, "#skip-count-tracking-switch")
|
||||||
def changed_skip_tracking(self, event: Checkbox.Changed):
|
def changed_skip_tracking(self, event: Checkbox.Changed):
|
||||||
@@ -547,6 +750,7 @@ class SkipCountTrackingManager(Vertical):
|
|||||||
|
|
||||||
class AdSkipMuteManager(Vertical):
|
class AdSkipMuteManager(Vertical):
|
||||||
"""Manager for ad skip/mute, allows to enable/disable ad skip/mute."""
|
"""Manager for ad skip/mute, allows to enable/disable ad skip/mute."""
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -554,13 +758,25 @@ class AdSkipMuteManager(Vertical):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Label("Skip/Mute ads", classes="title")
|
yield Label("Skip/Mute ads", classes="title")
|
||||||
yield Label(
|
yield Label(
|
||||||
"This feature allows you to automatically mute and/or skip native YouTube ads. Skipping ads only works if that ad shows the 'Skip Ad' button, if it doesn't then it will only be able to be muted.",
|
(
|
||||||
classes="subtitle", id="skip-count-tracking-subtitle")
|
"This feature allows you to automatically mute and/or skip native"
|
||||||
|
" YouTube ads. Skipping ads only works if that ad shows the 'Skip Ad'"
|
||||||
|
" button, if it doesn't then it will only be able to be muted."
|
||||||
|
),
|
||||||
|
classes="subtitle",
|
||||||
|
id="skip-count-tracking-subtitle",
|
||||||
|
)
|
||||||
with Horizontal(id="ad-skip-mute-container"):
|
with Horizontal(id="ad-skip-mute-container"):
|
||||||
yield Checkbox(value=self.config.mute_ads, id="mute-ads-switch",
|
yield Checkbox(
|
||||||
label="Enable skipping ads")
|
value=self.config.skip_ads,
|
||||||
yield Checkbox(value=self.config.skip_ads, id="skip-ads-switch",
|
id="skip-ads-switch",
|
||||||
label="Enable muting ads")
|
label="Enable skipping ads",
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
value=self.config.mute_ads,
|
||||||
|
id="mute-ads-switch",
|
||||||
|
label="Enable muting ads",
|
||||||
|
)
|
||||||
|
|
||||||
@on(Checkbox.Changed, "#mute-ads-switch")
|
@on(Checkbox.Changed, "#mute-ads-switch")
|
||||||
def changed_mute(self, event: Checkbox.Changed):
|
def changed_mute(self, event: Checkbox.Changed):
|
||||||
@@ -572,7 +788,8 @@ class AdSkipMuteManager(Vertical):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelWhitelistManager(Vertical):
|
class ChannelWhitelistManager(Vertical):
|
||||||
"""Manager for channel whitelist, allows to add/remove channels from the whitelist."""
|
"""Manager for channel whitelist, allows adding/removing channels from the whitelist."""
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -580,17 +797,31 @@ class ChannelWhitelistManager(Vertical):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Label("Channel Whitelist", classes="title")
|
yield Label("Channel Whitelist", classes="title")
|
||||||
yield Label(
|
yield Label(
|
||||||
"This feature allows to whitelist channels from being skipped. This feature is automatically disabled when no channels have been specified.",
|
(
|
||||||
classes="subtitle", id="channel-whitelist-subtitle")
|
"This feature allows to whitelist channels from being skipped. This"
|
||||||
yield Label(":warning: [#FF0000]You need to set your YouTube Api Key in order to use this feature",
|
" feature is automatically disabled when no channels have been"
|
||||||
id="warning-no-key")
|
" specified."
|
||||||
|
),
|
||||||
|
classes="subtitle",
|
||||||
|
id="channel-whitelist-subtitle",
|
||||||
|
)
|
||||||
|
yield Label(
|
||||||
|
(
|
||||||
|
":warning: [#FF0000]You need to set your YouTube Api Key in order to"
|
||||||
|
" use this feature"
|
||||||
|
),
|
||||||
|
id="warning-no-key",
|
||||||
|
)
|
||||||
with Horizontal(id="add-channel-button-container"):
|
with Horizontal(id="add-channel-button-container"):
|
||||||
yield Button("Add Channel", id="add-channel", classes="button-100")
|
yield Button("Add Channel", id="add-channel", classes="button-100")
|
||||||
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.config.channel_whitelist)
|
self.app.query_one("#warning-no-key").display = (
|
||||||
|
not self.config.apikey
|
||||||
|
) and bool(self.config.channel_whitelist)
|
||||||
|
|
||||||
def new_channel(self, channel: tuple) -> None:
|
def new_channel(self, channel: tuple) -> None:
|
||||||
if channel:
|
if channel:
|
||||||
channel_dict = {
|
channel_dict = {
|
||||||
@@ -601,30 +832,56 @@ 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):
|
||||||
self.app.push_screen(AddChannel(self.config), callback=self.new_channel)
|
self.app.push_screen(AddChannel(self.config), callback=self.new_channel)
|
||||||
|
|
||||||
|
|
||||||
class iSponsorBlockTVSetupMainScreen(Screen):
|
class AutoPlayManager(Vertical):
|
||||||
|
"""Manager for autoplay, allows enabling/disabling autoplay."""
|
||||||
|
|
||||||
|
def __init__(self, config, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("Autoplay", classes="title")
|
||||||
|
yield Label(
|
||||||
|
"This feature allows you to enable/disable autoplay",
|
||||||
|
classes="subtitle",
|
||||||
|
id="autoplay-subtitle",
|
||||||
|
)
|
||||||
|
with Horizontal(id="autoplay-container"):
|
||||||
|
yield Checkbox(
|
||||||
|
value=self.config.auto_play,
|
||||||
|
id="autoplay-switch",
|
||||||
|
label="Enable autoplay",
|
||||||
|
)
|
||||||
|
|
||||||
|
@on(Checkbox.Changed, "#autoplay-switch")
|
||||||
|
def changed_skip(self, event: Checkbox.Changed):
|
||||||
|
self.config.auto_play = event.checkbox.value
|
||||||
|
|
||||||
|
|
||||||
|
class ISponsorBlockTVSetupMainScreen(Screen):
|
||||||
"""Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
|
"""Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
|
||||||
|
|
||||||
TITLE = "iSponsorBlockTV"
|
TITLE = "iSponsorBlockTV"
|
||||||
SUB_TITLE = "Setup Wizard"
|
SUB_TITLE = "Setup Wizard"
|
||||||
BINDINGS = [
|
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
|
||||||
("q,ctrl+c", "exit_modal", "Exit"),
|
|
||||||
("s", "save", "Save")
|
|
||||||
]
|
|
||||||
AUTO_FOCUS = None
|
AUTO_FOCUS = None
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
@@ -637,12 +894,27 @@ class iSponsorBlockTVSetupMainScreen(Screen):
|
|||||||
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(
|
||||||
yield SkipCategoriesManager(config=self.config, id="skip-categories-manager", classes="container")
|
config=self.config, id="devices-manager", classes="container"
|
||||||
yield SkipCountTrackingManager(config=self.config, id="count-segments-manager", classes="container")
|
)
|
||||||
yield AdSkipMuteManager(config=self.config, id="ad-skip-mute-manager", classes="container")
|
yield SkipCategoriesManager(
|
||||||
yield ChannelWhitelistManager(config=self.config, id="channel-whitelist-manager", classes="container")
|
config=self.config, id="skip-categories-manager", classes="container"
|
||||||
yield ApiKeyManager(config=self.config, id="api-key-manager", classes="container")
|
)
|
||||||
|
yield SkipCountTrackingManager(
|
||||||
|
config=self.config, id="count-segments-manager", classes="container"
|
||||||
|
)
|
||||||
|
yield AdSkipMuteManager(
|
||||||
|
config=self.config, id="ad-skip-mute-manager", classes="container"
|
||||||
|
)
|
||||||
|
yield ChannelWhitelistManager(
|
||||||
|
config=self.config, id="channel-whitelist-manager", classes="container"
|
||||||
|
)
|
||||||
|
yield ApiKeyManager(
|
||||||
|
config=self.config, id="api-key-manager", classes="container"
|
||||||
|
)
|
||||||
|
yield AutoPlayManager(
|
||||||
|
config=self.config, id="autoplay-manager", classes="container"
|
||||||
|
)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
if self.check_for_old_config_entries():
|
if self.check_for_old_config_entries():
|
||||||
@@ -666,25 +938,26 @@ class iSponsorBlockTVSetupMainScreen(Screen):
|
|||||||
|
|
||||||
@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):
|
||||||
print("HIIII")
|
|
||||||
try: # ChannelWhitelist might not be mounted
|
try: # ChannelWhitelist might not be mounted
|
||||||
# Show if no api key is set and at least one channel is in the whitelist
|
# Show if no api key is set and at least one channel is in the whitelist
|
||||||
self.app.query_one("#warning-no-key").display = (not event.input.value) and self.config.channel_whitelist
|
self.app.query_one("#warning-no-key").display = (
|
||||||
|
not event.input.value
|
||||||
|
) and self.config.channel_whitelist
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class iSponsorBlockTVSetup(App):
|
|
||||||
CSS_PATH = "setup-wizard-style.tcss" # tcss is the recommended extension for textual css files
|
class ISponsorBlockTVSetup(App):
|
||||||
|
CSS_PATH = ( # tcss is the recommended extension for textual css files
|
||||||
|
"setup-wizard-style.tcss"
|
||||||
|
)
|
||||||
# Bindings for the whole app here, so they are available in all screens
|
# Bindings for the whole app here, so they are available in all screens
|
||||||
BINDINGS = [
|
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
|
||||||
("q,ctrl+c", "exit_modal", "Exit"),
|
|
||||||
("s", "save", "Save")
|
|
||||||
]
|
|
||||||
|
|
||||||
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.main_screen = iSponsorBlockTVSetupMainScreen(config=self.config)
|
self.main_screen = ISponsorBlockTVSetupMainScreen(config=self.config)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.push_screen(self.main_screen)
|
self.push_screen(self.main_screen)
|
||||||
@@ -697,5 +970,5 @@ class iSponsorBlockTVSetup(App):
|
|||||||
|
|
||||||
|
|
||||||
def main(config):
|
def main(config):
|
||||||
app = iSponsorBlockTVSetup(config)
|
app = ISponsorBlockTVSetup(config)
|
||||||
app.run()
|
app.run()
|
||||||
183
src/iSponsorBlockTV/ytlounge.py
Normal file
183
src/iSponsorBlockTV/ytlounge.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pyytlounge
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
from .constants import youtube_client_blacklist
|
||||||
|
|
||||||
|
create_task = asyncio.create_task
|
||||||
|
|
||||||
|
|
||||||
|
class YtLoungeApi(pyytlounge.YtLoungeApi):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
screen_id,
|
||||||
|
config=None,
|
||||||
|
api_helper=None,
|
||||||
|
logger=None,
|
||||||
|
web_session: ClientSession = None,
|
||||||
|
):
|
||||||
|
super().__init__("iSponsorBlockTV", logger=logger)
|
||||||
|
if web_session is not None:
|
||||||
|
self.session = web_session # And use the one we passed
|
||||||
|
self.auth.screen_id = screen_id
|
||||||
|
self.auth.lounge_id_token = None
|
||||||
|
self.api_helper = api_helper
|
||||||
|
self.volume_state = {}
|
||||||
|
self.subscribe_task = None
|
||||||
|
self.subscribe_task_watchdog = None
|
||||||
|
self.callback = None
|
||||||
|
self.logger = logger
|
||||||
|
self.shorts_disconnected = False
|
||||||
|
self.auto_play = True
|
||||||
|
if config:
|
||||||
|
self.mute_ads = config.mute_ads
|
||||||
|
self.skip_ads = config.skip_ads
|
||||||
|
self.auto_play = config.auto_play
|
||||||
|
|
||||||
|
# Ensures that we still are subscribed to the lounge
|
||||||
|
async def _watchdog(self):
|
||||||
|
await asyncio.sleep(
|
||||||
|
35
|
||||||
|
) # YouTube sends at least a message every 30 seconds (no-op or any other)
|
||||||
|
try:
|
||||||
|
self.subscribe_task.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Subscribe to the lounge and start the watchdog
|
||||||
|
async def subscribe_monitored(self, callback):
|
||||||
|
self.callback = callback
|
||||||
|
try:
|
||||||
|
self.subscribe_task_watchdog.cancel()
|
||||||
|
except:
|
||||||
|
pass # No watchdog task
|
||||||
|
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
|
||||||
|
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
|
||||||
|
return self.subscribe_task
|
||||||
|
|
||||||
|
# Process a lounge subscription event
|
||||||
|
def _process_event(self, event_id: int, event_type: str, args):
|
||||||
|
self.logger.debug(f"process_event({event_id}, {event_type}, {args})")
|
||||||
|
# (Re)start the watchdog
|
||||||
|
try:
|
||||||
|
self.subscribe_task_watchdog.cancel()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
|
||||||
|
# A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we
|
||||||
|
# can get the segments)
|
||||||
|
if event_type == "onStateChange":
|
||||||
|
data = args[0]
|
||||||
|
# print(data)
|
||||||
|
# Unmute when the video starts playing
|
||||||
|
if self.mute_ads and data["state"] == "1":
|
||||||
|
create_task(self.mute(False, override=True))
|
||||||
|
elif event_type == "nowPlaying":
|
||||||
|
data = args[0]
|
||||||
|
# Unmute when the video starts playing
|
||||||
|
if self.mute_ads and data.get("state", "0") == "1":
|
||||||
|
self.logger.info("Ad has ended, unmuting")
|
||||||
|
create_task(self.mute(False, override=True))
|
||||||
|
elif event_type == "onAdStateChange":
|
||||||
|
data = args[0]
|
||||||
|
if data["adState"] == "0": # Ad is not playing
|
||||||
|
self.logger.info("Ad has ended, unmuting")
|
||||||
|
create_task(self.mute(False, override=True))
|
||||||
|
elif (
|
||||||
|
self.skip_ads and data["isSkipEnabled"] == "true"
|
||||||
|
): # YouTube uses strings for booleans
|
||||||
|
self.logger.info("Ad can be skipped, skipping")
|
||||||
|
create_task(self.skip_ad())
|
||||||
|
create_task(self.mute(False, override=True))
|
||||||
|
elif (
|
||||||
|
self.mute_ads
|
||||||
|
): # Seen multiple other adStates, assuming they are all ads
|
||||||
|
self.logger.info("Ad has started, muting")
|
||||||
|
create_task(self.mute(True, override=True))
|
||||||
|
# Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it)
|
||||||
|
elif event_type == "onVolumeChanged":
|
||||||
|
self.volume_state = args[0]
|
||||||
|
pass
|
||||||
|
# Gets segments for the next video before it starts playing
|
||||||
|
elif event_type == "autoplayUpNext":
|
||||||
|
if len(args) > 0 and (
|
||||||
|
vid_id := args[0]["videoId"]
|
||||||
|
): # if video id is not empty
|
||||||
|
self.logger.info(f"Getting segments for next video: {vid_id}")
|
||||||
|
create_task(self.api_helper.get_segments(vid_id))
|
||||||
|
|
||||||
|
# #Used to know if an ad is skippable or not
|
||||||
|
elif event_type == "adPlaying":
|
||||||
|
data = args[0]
|
||||||
|
# Gets segments for the next video (after the ad) before it starts playing
|
||||||
|
if vid_id := data["contentVideoId"]:
|
||||||
|
self.logger.info(f"Getting segments for next video: {vid_id}")
|
||||||
|
create_task(self.api_helper.get_segments(vid_id))
|
||||||
|
elif (
|
||||||
|
self.skip_ads and data["isSkipEnabled"] == "true"
|
||||||
|
): # YouTube uses strings for booleans
|
||||||
|
self.logger.info("Ad can be skipped, skipping")
|
||||||
|
create_task(self.skip_ad())
|
||||||
|
create_task(self.mute(False, override=True))
|
||||||
|
elif (
|
||||||
|
self.mute_ads
|
||||||
|
): # Seen multiple other adStates, assuming they are all ads
|
||||||
|
self.logger.info("Ad has started, muting")
|
||||||
|
create_task(self.mute(True, override=True))
|
||||||
|
|
||||||
|
elif event_type == "loungeStatus":
|
||||||
|
data = args[0]
|
||||||
|
devices = json.loads(data["devices"])
|
||||||
|
for device in devices:
|
||||||
|
if device["type"] == "LOUNGE_SCREEN":
|
||||||
|
device_info = json.loads(device.get("deviceInfo", "{}"))
|
||||||
|
if device_info.get("clientName", "") in youtube_client_blacklist:
|
||||||
|
self._sid = None
|
||||||
|
self._gsession = None # Force disconnect
|
||||||
|
|
||||||
|
elif event_type == "onSubtitlesTrackChanged":
|
||||||
|
if self.shorts_disconnected:
|
||||||
|
data = args[0]
|
||||||
|
video_id_saved = data.get("videoId", None)
|
||||||
|
self.shorts_disconnected = False
|
||||||
|
create_task(self.play_video(video_id_saved))
|
||||||
|
elif event_type == "loungeScreenDisconnected":
|
||||||
|
data = args[0]
|
||||||
|
if data["reason"] == "disconnectedByUserScreenInitiated": # Short playing?
|
||||||
|
self.shorts_disconnected = True
|
||||||
|
elif event_type == "onAutoplayModeChanged":
|
||||||
|
create_task(self.set_auto_play_mode(self.auto_play))
|
||||||
|
|
||||||
|
super()._process_event(event_id, event_type, args)
|
||||||
|
|
||||||
|
# Set the volume to a specific value (0-100)
|
||||||
|
async def set_volume(self, volume: int) -> None:
|
||||||
|
await super()._command("setVolume", {"volume": volume})
|
||||||
|
|
||||||
|
# Mute or unmute the device (if the device already is in the desired state, nothing happens)
|
||||||
|
# mute: True to mute, False to unmute
|
||||||
|
# override: If True, the command is sent even if the device already is in the desired state
|
||||||
|
# TODO: Only works if the device is subscribed to the lounge
|
||||||
|
async def mute(self, mute: bool, override: bool = False) -> None:
|
||||||
|
if mute:
|
||||||
|
mute_str = "true"
|
||||||
|
else:
|
||||||
|
mute_str = "false"
|
||||||
|
if override or not (self.volume_state.get("muted", "false") == mute_str):
|
||||||
|
self.volume_state["muted"] = mute_str
|
||||||
|
# YouTube wants the volume when unmuting, so we send it
|
||||||
|
await super()._command(
|
||||||
|
"setVolume",
|
||||||
|
{"volume": self.volume_state.get("volume", 100), "muted": mute_str},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_auto_play_mode(self, enabled: bool):
|
||||||
|
await super()._command(
|
||||||
|
"setAutoplayMode", {"autoplayMode": "ENABLED" if enabled else "DISABLED"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def play_video(self, video_id: str) -> bool:
|
||||||
|
return await self._command("setPlaylist", {"videoId": video_id})
|
||||||
Reference in New Issue
Block a user