Compare commits

..

3 Commits

Author SHA1 Message Date
dmunozv04
7dd144357d update textual and aiohttp 2024-09-25 20:58:10 +02:00
dmunozv04
8c9ce641f0 remove argparse
fixes #195
2024-09-23 13:32:27 +02:00
dmunozv04
7b1a59e01f update requirements 2024-09-21 13:38:47 +02:00
24 changed files with 301 additions and 390 deletions

View File

@@ -7,14 +7,12 @@ assignees: ''
--- ---
Before opening an issue make sure that there are no duplicates and that you are Before opening an issue make sure that there are no duplicates and that you are on the latest version.
on the latest version.
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
@@ -27,14 +25,13 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**iSponsorBlockTV server (please complete the following information):** **iSponsorBlockTV server (please complete the following information):**
- OS: [e.g. Docker on linux Arm64, windows]
- OS: [e.g. Docker on linux Arm64, windows] - Python version [e.g. 3.7] (no need to fill if running on docker
- Python version [e.g. 3.7] (no need to fill if running on docker
**Apple TV (please complete the following information):** **Apple TV (please complete the following information):**
- Device: [e.g. Apple TV 4]
- OS: [e.g. tvOS 15.4]
- Device: [e.g. Apple TV 4]
- OS: [e.g. tvOS 15.4]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

View File

@@ -8,15 +8,13 @@ assignees: ''
--- ---
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
frustrated when [...]
**Describe the solution you'd like** **Describe the solution you'd like**
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
**Describe alternatives you've considered** **Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've A clear and concise description of any alternative solutions or features you've considered.
considered.
**Additional context** **Additional context**
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.

View File

@@ -1,19 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -21,8 +21,6 @@ permissions:
jobs: jobs:
build: build:
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Get the repository's code # Get the repository's code
@@ -34,9 +32,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: ghcr.io/dmunozv04/isponsorblocktv, dmunozv04/isponsorblocktv
ghcr.io/${{ github.repository }}
${{ env.DOCKERHUB_USERNAME && 'dmunozv04/isponsorblocktv' || '' }}
tags: | tags: |
type=raw,value=develop,priority=900,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} type=raw,value=develop,priority=900,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr
@@ -68,12 +64,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64, linux/arm64, linux/arm/v7 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=gha cache-from: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache
cache-to: type=gha,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' || '' }}

View File

@@ -57,7 +57,7 @@ jobs:
build-binaries: build-binaries:
name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }}) name: Build binaries for ${{ matrix.job.target }} (${{ matrix.job.os }})
needs: needs:
- build-sdist-and-wheel - build-sdist-and-wheel
runs-on: ${{ matrix.job.os }} runs-on: ${{ matrix.job.os }}
@@ -70,26 +70,20 @@ jobs:
os: ubuntu-latest os: ubuntu-latest
cross: true cross: true
release_suffix: x86_64-linux release_suffix: x86_64-linux
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
cross: true
cpu_variant: v1
release_suffix: x86_64-linux-v1
- target: aarch64-unknown-linux-gnu - target: aarch64-unknown-linux-gnu
os: ubuntu-latest os: ubuntu-latest
cross: true cross: true
release_suffix: aarch64-linux release_suffix: aarch64-linux
# Windows # Windows
- target: x86_64-pc-windows-msvc - target: x86_64-pc-windows-msvc
os: windows-latest os: windows-2022
release_suffix: x86_64-windows release_suffix: x86_64-windows
# macOS # macOS
- target: aarch64-apple-darwin - target: aarch64-apple-darwin
os: macos-latest os: macos-14
release_suffix: aarch64-osx release_suffix: aarch64-osx
- target: x86_64-apple-darwin - target: x86_64-apple-darwin
os: macos-latest os: macos-12
cross: true
release_suffix: x86_64-osx release_suffix: x86_64-osx
env: env:
@@ -98,9 +92,8 @@ jobs:
HATCH_BUILD_LOCATION: dist HATCH_BUILD_LOCATION: dist
CARGO: cargo CARGO: cargo
CARGO_BUILD_TARGET: ${{ matrix.job.target }} CARGO_BUILD_TARGET: ${{ matrix.job.target }}
PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }}
PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling) PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling)
PYAPP_VERSION: v0.24.0 PYAPP_VERSION: v0.23.0
steps: steps:
- name: Checkout - name: Checkout

View File

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

View File

@@ -3,7 +3,7 @@
# Inspired by textual pre-commit config # Inspired by textual pre-commit config
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v4.3.0
hooks: hooks:
- id: check-ast # simply checks whether the files parse as valid python - id: check-ast # simply checks whether the files parse as valid python
- id: check-builtin-literals # requires literal syntax when initializing empty or zero python builtin types - id: check-builtin-literals # requires literal syntax when initializing empty or zero python builtin types
@@ -18,14 +18,22 @@ repos:
- id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline - 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: mixed-line-ending # replaces or checks mixed line ending
- id: trailing-whitespace # checks for trailing whitespace - id: trailing-whitespace # checks for trailing whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/pycqa/isort
rev: v0.9.10 rev: 5.12.0
hooks: hooks:
- id: ruff - id: isort
args: [ --fix, --exit-non-zero-on-fix ] name: isort (python)
- id: ruff-format language_version: '3.11'
- repo: https://github.com/igorshubovych/markdownlint-cli args: ["--profile", "black", "--filter-files"]
rev: v0.44.0 - repo: https://github.com/psf/black
rev: 23.1.0
hooks: hooks:
- id: markdownlint - id: black
args: ["--fix"] language_version: '3.11'
args: ["--preview"]
- repo: https://github.com/hadialqattan/pycln # removes unused imports
rev: v2.3.0
hooks:
- id: pycln
language_version: "3.11"
args: [--all]

View File

@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM python:3.13-alpine3.21 AS base FROM python:3.11-alpine3.19 AS base
FROM base AS compiler FROM base AS compiler
@@ -19,9 +19,9 @@ RUN apk add --no-cache gcc musl-dev && \
pip install -r requirements.txt && \ pip install -r requirements.txt && \
pip uninstall -y pip wheel && \ pip uninstall -y pip wheel && \
apk del gcc musl-dev && \ apk del gcc musl-dev && \
python3 -m compileall -b -f /usr/local/lib/python3.13/site-packages && \ python3 -m compileall -b -f /usr/local/lib/python3.11/site-packages && \
find /usr/local/lib/python3.13/site-packages -name "*.py" -type f -delete && \ find /usr/local/lib/python3.11/site-packages -name "*.py" -type f -delete && \
find /usr/local/lib/python3.13/ -name "__pycache__" -type d -exec rm -rf {} + find /usr/local/lib/python3.11/ -name "__pycache__" -type d -exec rm -rf {} +
FROM base FROM base

View File

@@ -657,7 +657,7 @@ notice like this when it starts in an interactive mode:
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. under certain conditions; type `show c' for details.
The hypothetical commands `show w' and`show c' should show the appropriate The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box". might be different; for a GUI interface, you would use an "about box".

View File

@@ -1,21 +1,14 @@
# iSponsorBlockTV # iSponsorBlockTV
Skip sponsor segments in YouTube videos playing on a YouTube TV device (see below for compatibility details).
[![ghcr.io Pulls](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fipitio.github.io%2Fbackage%2Fdmunozv04%2FiSponsorBlockTV%2Fisponsorblocktv.json&query=downloads&logo=github&label=ghcr.io%20pulls&style=flat)](https://ghcr.io/dmunozv04/isponsorblocktv)
[![Docker Pulls](https://img.shields.io/docker/pulls/dmunozv04/isponsorblocktv?logo=docker&style=flat)](https://hub.docker.com/r/dmunozv04/isponsorblocktv/)
[![GitHub Release](https://img.shields.io/github/v/release/dmunozv04/isponsorblocktv?logo=GitHub&style=flat)](https://github.com/dmunozv04/iSponsorBlockTV/releases/latest)
[![GitHub Repo stars](https://img.shields.io/github/stars/dmunozv04/isponsorblocktv?style=flat)](https://github.com/dmunozv04/isponsorblocktv)
Skip sponsor segments in YouTube videos playing on a YouTube TV device (see
below for compatibility details).
This project is written in asynchronous python and should be pretty quick. This project is written in asynchronous python and should be pretty quick.
## Installation ## Installation
Check the [wiki](https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation) Check the [wiki](https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation)
## Compatibility Warning: docker armv7 builds have been deprecated. Amd64 and arm64 builds are still available.
## Compatibility
Legend: ✅ = Working, ❌ = Not working, ❔ = Not tested Legend: ✅ = Working, ❌ = Not working, ❔ = Not tested
Open an issue/pull request if you have tested a device that isn't listed here. Open an issue/pull request if you have tested a device that isn't listed here.
@@ -36,33 +29,24 @@ Open an issue/pull request if you have tested a device that isn't listed here.
| Playstation 4/5 | ✅ | | 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 Auto discovery will require the computer to be on the same network as the device during setup.
during setup. The device can also be manually added to iSponsorBlockTV with a YouTube TV code. This code can be found in the settings page of your YouTube application.
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 It connects to the device, watches its activity and skips any sponsor segment using the [SponsorBlock](https://sponsor.ajay.app/) API.
using the [SponsorBlock](https://sponsor.ajay.app/) API.
It can also skip/mute YouTube ads. It can also skip/mute YouTube ads.
## Libraries used ## Libraries used
- [pyytlounge](https://github.com/FabioGNR/pyytlounge) Used to interact with the device
- [pyytlounge](https://github.com/FabioGNR/pyytlounge) Used to interact with the
device
- asyncio and [aiohttp](https://github.com/aio-libs/aiohttp) - asyncio and [aiohttp](https://github.com/aio-libs/aiohttp)
- [async-cache](https://github.com/iamsinghrajat/async-cache) - [async-cache](https://github.com/iamsinghrajat/async-cache)
- [Textual](https://github.com/textualize/textual/) Used for the amazing new - [Textual](https://github.com/textualize/textual/) Used for the amazing new graphical configurator
graphical configurator
- [ssdp](https://github.com/codingjoe/ssdp) Used for auto discovery - [ssdp](https://github.com/codingjoe/ssdp) Used for auto discovery
## Projects using this project ## Projects using this project
- [Home Assistant Addon](https://github.com/bertybuttface/addons/tree/main/isponsorblocktv) - [Home Assistant Addon](https://github.com/bertybuttface/addons/tree/main/isponsorblocktv)
## Contributing ## Contributing
1. Fork it (<https://github.com/dmunozv04/iSponsorBlockTV/fork>) 1. Fork it (<https://github.com/dmunozv04/iSponsorBlockTV/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`) 2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`) 3. Commit your changes (`git commit -am 'Add some feature'`)
@@ -70,13 +54,8 @@ It can also skip/mute YouTube ads.
5. Create a new Pull Request 5. Create a new Pull Request
## Contributors ## Contributors
- [dmunozv04](https://github.com/dmunozv04) - creator and maintainer - [dmunozv04](https://github.com/dmunozv04) - creator and maintainer
- [HaltCatchFire](https://github.com/HaltCatchFire) - updated dependencies and - [HaltCatchFire](https://github.com/HaltCatchFire) - updated dependencies and improved skip logic
improved skip logic - [Oxixes](https://github.com/oxixes) - added support for channel whitelist and minor improvements
- [Oxixes](https://github.com/oxixes) - added support for channel whitelist and
minor improvements
## License ## License
[![GNU GPLv3](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![GNU GPLv3](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -13,7 +13,6 @@
"mute_ads": true, "mute_ads": true,
"skip_ads": true, "skip_ads": true,
"auto_play": true, "auto_play": true,
"join_name": "iSponsorBlockTV",
"apikey": "", "apikey": "",
"channel_whitelist": [ "channel_whitelist": [
{"id": "", {"id": "",

View File

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

View File

@@ -1,10 +1,10 @@
aiohttp==3.11.13 aiohttp==3.10.6
appdirs==1.4.4 appdirs==1.4.4
async-cache==1.1.1 async-cache==1.1.1
pyytlounge==2.3.0 pyytlounge==2.1.1
rich==13.9.4 rich==13.8.1
ssdp==1.3.0 ssdp==1.3.0
textual==1.0.0 textual==0.81.0
textual-slider==0.2.0 textual-slider==0.1.2
xmltodict==0.14.2 xmltodict==0.13.0
rich_click==1.8.8 rich_click==1.8.3

View File

@@ -120,8 +120,7 @@ class ApiHelper:
return ( return (
[], [],
True, True,
) # Return empty list and True to indicate ) # Return empty list and True to indicate that the cache should last forever
# that the cache should last forever
vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[ vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[
:4 :4
] # Hashes video id and gets the first 4 characters ] # Hashes video id and gets the first 4 characters
@@ -184,7 +183,7 @@ class ApiHelper:
segment_before_start = segments[-1]["start"] segment_before_start = segments[-1]["start"]
segment_before_UUID = segments[-1]["UUID"] segment_before_UUID = segments[-1]["UUID"]
except IndexError: except Exception:
segment_before_end = -10 segment_before_end = -10
if ( if (
segment_dict["start"] - segment_before_end < 1 segment_dict["start"] - segment_before_end < 1
@@ -193,13 +192,12 @@ class ApiHelper:
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 BaseException: except Exception:
pass pass
return segments, ignore_ttl return segments, ignore_ttl
async def mark_viewed_segments(self, uuids): async def mark_viewed_segments(self, uuids):
"""Marks the segments as viewed in the SponsorBlock API """Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled.
if skip_count_tracking is enabled.
Lets the contributor know that someone skipped the segment (thanks)""" Lets the contributor know that someone skipped the segment (thanks)"""
if self.skip_count_tracking: if self.skip_count_tracking:
for i in uuids: for i in uuids:

View File

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

View File

@@ -43,17 +43,11 @@ def get_yn_input(prompt):
if choice.lower() in ["y", "n"]: if choice.lower() in ["y", "n"]:
return choice.lower() return choice.lower()
print("Invalid input. Please enter 'y' or 'n'.") print("Invalid input. Please enter 'y' or 'n'.")
return None
async def create_web_session(): async def pair_device():
return aiohttp.ClientSession()
async def pair_device(web_session: aiohttp.ClientSession):
try: try:
lounge_controller = ytlounge.YtLoungeApi() lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV")
await lounge_controller.change_web_session(web_session)
pairing_code = input(PAIRING_CODE_PROMPT) pairing_code = input(PAIRING_CODE_PROMPT)
pairing_code = int( pairing_code = int(
pairing_code.replace("-", "").replace(" ", "") pairing_code.replace("-", "").replace(" ", "")
@@ -77,7 +71,7 @@ async def pair_device(web_session: aiohttp.ClientSession):
def main(config, debug: bool) -> None: def main(config, debug: bool) -> None:
print("Welcome to the iSponsorBlockTV cli setup wizard") print("Welcome to the iSponsorBlockTV cli setup wizard")
loop = asyncio.get_event_loop_policy().get_event_loop() loop = asyncio.get_event_loop_policy().get_event_loop()
web_session = loop.run_until_complete(create_web_session()) web_session = aiohttp.ClientSession()
if debug: if debug:
loop.set_debug(True) loop.set_debug(True)
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
@@ -94,7 +88,9 @@ def main(config, debug: bool) -> None:
devices = config.devices devices = config.devices
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices))) choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))
while choice == "y": while choice == "y":
device = loop.run_until_complete(pair_device(web_session)) task = loop.create_task(pair_device())
loop.run_until_complete(task)
device = task.result()
if device: if device:
devices.append(device) devices.append(device)
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices))) choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import rich_click as click
from appdirs import user_data_dir from appdirs import user_data_dir
from . import config_setup, main, setup_wizard from . import config_setup, main, setup_wizard
from .constants import config_file_blacklist_keys, github_wiki_base_url from .constants import config_file_blacklist_keys
class Device: class Device:
@@ -42,7 +42,6 @@ class Config:
self.mute_ads = False self.mute_ads = False
self.skip_ads = False self.skip_ads = False
self.auto_play = True self.auto_play = True
self.join_name = "iSponsorBlockTV"
self.__load() self.__load()
def validate(self): def validate(self):
@@ -51,8 +50,8 @@ class Config:
( (
"The atvs config option is deprecated and has stopped working." "The atvs config option is deprecated and has stopped working."
" Please read this for more information " " Please read this for more information "
"on how to upgrade to V2:\n" "on how to upgrade to V2:"
f"{github_wiki_base_url}/Migrate-from-V1-to-V2" " \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
), ),
) )
print("Exiting in 10 seconds...") print("Exiting in 10 seconds...")
@@ -90,7 +89,7 @@ class Config:
print( print(
"Running in docker without mounting the data dir, check the" "Running in docker without mounting the data dir, check the"
" wiki for more information: " " wiki for more information: "
f"{github_wiki_base_url}/Installation#Docker" "https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker"
) )
print( print(
( (
@@ -101,7 +100,7 @@ class Config:
"Please read this for more information on how to upgrade" "Please read this for more information on how to upgrade"
" to V2:" " to V2:"
), ),
f"{github_wiki_base_url}/Migrate-from-V1-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)
@@ -126,9 +125,6 @@ class Config:
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
return False return False
def __hash__(self):
return hash(tuple(sorted(self.items())))
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@click.option( @click.option(
@@ -155,20 +151,8 @@ def cli(ctx, data, debug, setup, setup_cli):
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["data_dir"] = data ctx.obj["data_dir"] = data
ctx.obj["debug"] = debug ctx.obj["debug"] = debug
logger = logging.getLogger()
ctx.obj["logger"] = logger
sh = logging.StreamHandler()
sh.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
logger.addHandler(sh)
if debug: if debug:
logger.setLevel(logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
else:
logger.setLevel(logging.INFO)
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
if setup: if setup:
ctx.invoke(setup_command) ctx.invoke(setup_command)
@@ -178,23 +162,29 @@ def cli(ctx, data, debug, setup, setup_cli):
ctx.invoke(start) ctx.invoke(start)
@cli.command(name="setup") @cli.command()
@click.pass_context @click.pass_context
def setup_command(ctx): def setup(ctx):
"""Setup the program graphically""" """Setup the program graphically"""
config = Config(ctx.obj["data_dir"]) config = Config(ctx.obj["data_dir"])
setup_wizard.main(config) setup_wizard.main(config)
sys.exit() sys.exit()
@cli.command(name="setup-cli") setup_command = setup
@cli.command()
@click.pass_context @click.pass_context
def setup_cli_command(ctx): def setup_cli(ctx):
"""Setup the program in the command line""" """Setup the program in the command line"""
config = Config(ctx.obj["data_dir"]) config = Config(ctx.obj["data_dir"])
config_setup.main(config, ctx.obj["debug"]) config_setup.main(config, ctx.obj["debug"])
setup_cli_command = setup_cli
@cli.command() @cli.command()
@click.pass_context @click.pass_context
def start(ctx): def start(ctx):

View File

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

View File

@@ -18,8 +18,18 @@ class DeviceListener:
self.cancelled = False self.cancelled = False
self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}") self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}")
self.web_session = web_session self.web_session = web_session
if debug:
self.logger.setLevel(logging.DEBUG)
else:
self.logger.setLevel(logging.INFO)
sh = logging.StreamHandler()
sh.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
self.logger.addHandler(sh)
self.logger.info(f"Starting device")
self.lounge_controller = ytlounge.YtLoungeApi( self.lounge_controller = ytlounge.YtLoungeApi(
device.screen_id, config, api_helper, self.logger device.screen_id, config, api_helper, self.logger, self.web_session
) )
# Ensures that we have a valid auth token # Ensures that we have a valid auth token
@@ -28,13 +38,15 @@ class DeviceListener:
await asyncio.sleep(60 * 60 * 24) # Refresh every 24 hours await asyncio.sleep(60 * 60 * 24) # Refresh every 24 hours
try: try:
await self.lounge_controller.refresh_auth() await self.lounge_controller.refresh_auth()
except BaseException: except:
# traceback.print_exc()
pass pass
async def is_available(self): async def is_available(self):
try: try:
return await self.lounge_controller.is_available() return await self.lounge_controller.is_available()
except BaseException: except:
# traceback.print_exc()
return False return False
# Main subscription loop # Main subscription loop
@@ -45,40 +57,38 @@ class DeviceListener:
try: try:
self.logger.debug("Refreshing auth") self.logger.debug("Refreshing auth")
await lounge_controller.refresh_auth() await lounge_controller.refresh_auth()
except BaseException: except:
await asyncio.sleep(10) await asyncio.sleep(10)
while not (await self.is_available()) and not self.cancelled: while not (await self.is_available()) and not self.cancelled:
self.logger.debug("Waiting for device to be available")
await asyncio.sleep(10) await asyncio.sleep(10)
try: try:
await lounge_controller.connect() await lounge_controller.connect()
except BaseException: 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) # Doesn't connect to the device if it's a kids profile (it's broken)
self.logger.debug("Waiting for device to be connected")
await asyncio.sleep(10) await asyncio.sleep(10)
try: try:
await lounge_controller.connect() await lounge_controller.connect()
except BaseException: except:
pass pass
self.logger.info( self.logger.info(
"Connected to device %s (%s)", lounge_controller.screen_name, self.name "Connected to device %s (%s)", lounge_controller.screen_name, self.name
) )
try: try:
self.logger.debug("Subscribing to lounge") self.logger.info("Subscribing to lounge")
sub = await lounge_controller.subscribe_monitored(self) sub = await lounge_controller.subscribe_monitored(self)
await sub await sub
except BaseException: except:
pass pass
# Method called on playback state change # Method called on playback state change
async def __call__(self, state): async def __call__(self, state):
time_start = time.monotonic()
try: try:
self.task.cancel() self.task.cancel()
except BaseException: except:
pass pass
time_start = time.time()
self.task = asyncio.create_task(self.process_playstatus(state, time_start)) self.task = asyncio.create_task(self.process_playstatus(state, time_start))
# Processes the playback state change # Processes the playback state change
@@ -88,7 +98,7 @@ class DeviceListener:
segments = await self.api_helper.get_segments(state.videoId) segments = await self.api_helper.get_segments(state.videoId)
if state.state.value == 1: # Playing if state.state.value == 1: # Playing
self.logger.info( self.logger.info(
"Playing video %s with %d segments", state.videoId, len(segments) 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)
@@ -98,100 +108,63 @@ class DeviceListener:
start_next_segment = None start_next_segment = None
next_segment = None next_segment = None
for segment in segments: for segment in segments:
segment_start = segment["start"] if position < 2 and (segment["start"] <= position < segment["end"]):
segment_end = segment["end"]
is_within_start_range = (
position < 1 < segment_end and segment_start <= position < segment_end
)
is_beyond_current_position = segment_start > position
if is_within_start_range or is_beyond_current_position:
next_segment = segment next_segment = segment
start_next_segment = ( start_next_segment = (
position if is_within_start_range else segment_start position # different variable so segment doesn't change
) )
break break
if segment["start"] > position:
next_segment = segment
start_next_segment = next_segment["start"]
break
if start_next_segment: if start_next_segment:
time_to_next = ( time_to_next = (
(start_next_segment - position - (time.monotonic() - time_start)) start_next_segment - position - (time.time() - time_start) - self.offset
/ self.lounge_controller.playback_speed )
) - self.offset
await self.skip(time_to_next, next_segment["end"], next_segment["UUID"]) await self.skip(time_to_next, next_segment["end"], next_segment["UUID"])
# Skips to the next segment (waits for the time to pass) # Skips to the next segment (waits for the time to pass)
async def skip(self, time_to, position, uuids): async def skip(self, time_to, position, uuids):
await asyncio.sleep(time_to) await asyncio.sleep(time_to)
self.logger.info("Skipping segment: seeking to %s", position) self.logger.info("Skipping segment: seeking to %s", position)
await asyncio.gather( await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids))
asyncio.create_task(self.lounge_controller.seek_to(position)), await asyncio.create_task(self.lounge_controller.seek_to(position))
asyncio.create_task(self.api_helper.mark_viewed_segments(uuids)),
)
# Stops the connection to the device
async def cancel(self): async def cancel(self):
self.cancelled = True self.cancelled = True
await self.lounge_controller.disconnect() try:
if self.task:
self.task.cancel() self.task.cancel()
if self.lounge_controller.subscribe_task_watchdog: except Exception:
self.lounge_controller.subscribe_task_watchdog.cancel() pass
if self.lounge_controller.subscribe_task:
self.lounge_controller.subscribe_task.cancel()
await asyncio.gather(
self.task,
self.lounge_controller.subscribe_task_watchdog,
self.lounge_controller.subscribe_task,
return_exceptions=True,
)
async def initialize_web_session(self):
await self.lounge_controller.change_web_session(self.web_session)
async def finish(devices, web_session, tcp_connector): async def finish(devices):
await asyncio.gather( for i in devices:
*(device.cancel() for device in devices), return_exceptions=True await i.cancel()
)
await web_session.close()
await tcp_connector.close()
def handle_signal(signum, frame): def main(config, debug):
raise KeyboardInterrupt()
async def main_async(config, debug):
loop = asyncio.get_event_loop_policy().get_event_loop() loop = asyncio.get_event_loop_policy().get_event_loop()
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
devices = [] # Save the devices to close them later devices = [] # Save the devices to close them later
if debug: if debug:
loop.set_debug(True) loop.set_debug(True)
asyncio.set_event_loop(loop)
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300) tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300)
web_session = aiohttp.ClientSession(connector=tcp_connector) web_session = aiohttp.ClientSession(loop=loop, connector=tcp_connector)
api_helper = api_helpers.ApiHelper(config, web_session) api_helper = api_helpers.ApiHelper(config, web_session)
for i in config.devices: for i in config.devices:
device = DeviceListener(api_helper, config, i, debug, web_session) device = DeviceListener(api_helper, config, i, debug, web_session)
devices.append(device) devices.append(device)
await device.initialize_web_session()
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()))
signal(SIGTERM, handle_signal) signal(SIGINT, lambda s, f: loop.stop())
signal(SIGINT, handle_signal) signal(SIGTERM, lambda s, f: loop.stop())
try: loop.run_forever()
await asyncio.gather(*tasks) print("Cancelling tasks and exiting...")
except KeyboardInterrupt: loop.run_until_complete(finish(devices))
print("Cancelling tasks and exiting...") loop.run_until_complete(web_session.close())
await finish(devices, web_session, tcp_connector) loop.run_until_complete(tcp_connector.close())
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
finally:
await web_session.close()
await tcp_connector.close()
loop.close()
print("Exited")
def main(config, debug):
loop = asyncio.get_event_loop()
loop.run_until_complete(main_async(config, debug))
loop.close() loop.close()

View File

@@ -21,13 +21,9 @@
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.button-small { .small-button{
height: 3; height: 3;
border: none;
border-top: none;
border-bottom: none;
offset: 0 -1;
padding: 0;
} }
.button-100 { .button-100 {
@@ -110,14 +106,13 @@ EditDevice {
} }
Element { Element {
background: $panel-darken-1; background: $panel;
border-top: solid $panel-lighten-2; border-top: solid $panel-lighten-2;
layout: horizontal; layout: horizontal;
height: 2; height: 2;
width: 100%; width: 100%;
margin: 0 1 0 1; margin: 0 1 0 1;
padding: 0; padding: 0;
} }
Element > .element-name { Element > .element-name {
offset: 0 -1; offset: 0 -1;
@@ -125,11 +120,7 @@ Element > .element-name {
width: 100%; width: 100%;
align: left middle; align: left middle;
text-align: left; text-align: left;
background: $panel-darken-1;
&:hover {
background: $panel-lighten-1;
border-top: tall $panel-lighten-3;
}
} }
Element > .element-remove { Element > .element-remove {
dock: right; dock: right;
@@ -141,7 +132,7 @@ Element > .element-remove {
margin: 0 1 0 0; margin: 0 1 0 0;
} }
#add-device, #add-channel { #add-device {
text-style: bold; text-style: bold;
width: 100%; width: 100%;
align: left middle; align: left middle;
@@ -149,11 +140,6 @@ Element > .element-remove {
dock: left; dock: left;
text-align: left; text-align: left;
background: $panel-darken-1;
&:hover {
background: $panel-lighten-1;
border-top: tall $panel-lighten-3;
}
} }
#add-device-button-container{ #add-device-button-container{
height: 1; height: 1;

View File

@@ -79,20 +79,17 @@ class Element(Static):
self.tooltip = tooltip self.tooltip = tooltip
def process_values_from_data(self): def process_values_from_data(self):
raise NotImplementedError("Subclasses must implement this method.") pass
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Button( yield Button(
label=self.element_name, label=self.element_name,
classes="element-name button-small", classes="element-name",
disabled=True, disabled=True,
id="element-name", id="element-name",
) )
yield Button( yield Button(
"Remove", "Remove", classes="element-remove", variant="error", id="element-remove"
classes="element-remove button-small",
variant="error",
id="element-remove",
) )
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -105,6 +102,7 @@ class Device(Element):
"""A device element.""" """A device element."""
def process_values_from_data(self): def process_values_from_data(self):
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:
@@ -229,8 +227,7 @@ class ExitScreen(ModalWithClickExit):
class AddDevice(ModalWithClickExit): class AddDevice(ModalWithClickExit):
"""Screen with a dialog to add a device, either with a pairing code """Screen with a dialog to add a device, either with a pairing code or with lan discovery."""
or with lan discovery."""
BINDINGS = [("escape", "dismiss({})", "Return")] BINDINGS = [("escape", "dismiss({})", "Return")]
@@ -288,8 +285,7 @@ class AddDevice(ModalWithClickExit):
" computer\nIf it isn't showing up, try restarting the" " computer\nIf it isn't showing up, try restarting the"
" app.\nIf running in docker, make sure to use" " app.\nIf running in docker, make sure to use"
" `--network=host`\nTo refresh the list, close and open the" " `--network=host`\nTo refresh the list, close and open the"
" dialog again\n[b][u]If it still doesn't work, " " dialog again"
"pair using a pairing code (it's much more reliable)"
), ),
classes="subtitle", classes="subtitle",
) )
@@ -332,18 +328,17 @@ class AddDevice(ModalWithClickExit):
@on(Input.Changed, "#pairing-code-input") @on(Input.Changed, "#pairing-code-input")
def changed_pairing_code(self, event: Input.Changed): def changed_pairing_code(self, event: Input.Changed):
self.query_one( self.query_one("#add-device-pin-add-button").disabled = (
"#add-device-pin-add-button" not event.validation_result.is_valid
).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( lounge_controller = ytlounge.YtLoungeApi(
"iSponsorBlockTV", "iSponsorBlockTV", web_session=self.web_session
) )
await lounge_controller.change_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 = int(
pairing_code.replace("-", "").replace(" ", "") pairing_code.replace("-", "").replace(" ", "")
@@ -352,7 +347,7 @@ class AddDevice(ModalWithClickExit):
paired = False paired = False
try: try:
paired = await lounge_controller.pair(pairing_code) paired = await lounge_controller.pair(pairing_code)
except BaseException: except:
pass pass
if paired: if paired:
device = { device = {
@@ -382,14 +377,13 @@ 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( self.query_one("#add-device-dial-add-button").disabled = (
"#add-device-dial-add-button" not event.selection_list.selected
).disabled = not event.selection_list.selected )
class AddChannel(ModalWithClickExit): class AddChannel(ModalWithClickExit):
"""Screen with a dialog to add a channel, """Screen with a dialog to add a channel, either using search or with a channel id."""
either using search or with a channel id."""
BINDINGS = [("escape", "dismiss(())", "Return")] BINDINGS = [("escape", "dismiss(())", "Return")]
@@ -482,6 +476,7 @@ 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
self.query_one("#add-channel-switcher").current = event.button.id.replace( self.query_one("#add-channel-switcher").current = event.button.id.replace(
"-button", "-container" "-button", "-container"
) )
@@ -501,7 +496,7 @@ class AddChannel(ModalWithClickExit):
self.query_one("#channel-search-results").remove_children() self.query_one("#channel-search-results").remove_children()
try: try:
channels_list = await self.api_helper.search_channels(channel_name) channels_list = await self.api_helper.search_channels(channel_name)
except BaseException: except:
self.query_one("#add-channel-info").update( self.query_one("#add-channel-info").update(
"[#ff0000]Failed to search for channel" "[#ff0000]Failed to search for channel"
) )
@@ -555,7 +550,7 @@ class EditDevice(ModalWithClickExit):
def action_close_screen_saving(self) -> None: def action_close_screen_saving(self) -> None:
self.dismiss() self.dismiss()
def dismiss(self, _=None) -> None: def dismiss(self) -> None:
self.device_data["name"] = self.query_one("#device-name-input").value self.device_data["name"] = self.query_one("#device-name-input").value
self.device_data["screen_id"] = self.query_one("#device-id-input").value self.device_data["screen_id"] = self.query_one("#device-id-input").value
self.device_data["offset"] = int(self.query_one("#device-offset-input").value) self.device_data["offset"] = int(self.query_one("#device-offset-input").value)
@@ -590,7 +585,7 @@ class EditDevice(ModalWithClickExit):
) )
def on_slider_changed(self, event: Slider.Changed) -> None: def on_slider_changed(self, event: Slider.Changed) -> None:
offset_input = self.query_one("#device-offset-input") offset_input = self.query_one("#device-offset-offset_input")
with offset_input.prevent(Input.Changed): with offset_input.prevent(Input.Changed):
offset_input.value = str(event.slider.value) offset_input.value = str(event.slider.value)
@@ -624,9 +619,7 @@ class DevicesManager(Vertical):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("Devices", classes="title") yield Label("Devices", classes="title")
with Horizontal(id="add-device-button-container"): with Horizontal(id="add-device-button-container"):
yield Button( yield Button("Add Device", id="add-device", classes="button-100")
"Add Device", id="add-device", classes="button-100 button-small"
)
for device in self.devices: for device in self.devices:
yield Device(device, tooltip="Click to edit") yield Device(device, tooltip="Click to edit")
@@ -795,8 +788,7 @@ class AdSkipMuteManager(Vertical):
class ChannelWhitelistManager(Vertical): class ChannelWhitelistManager(Vertical):
"""Manager for channel whitelist, """Manager for channel whitelist, allows adding/removing channels from the whitelist."""
allows adding/removing channels from the whitelist."""
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -821,9 +813,7 @@ class ChannelWhitelistManager(Vertical):
id="warning-no-key", id="warning-no-key",
) )
with Horizontal(id="add-channel-button-container"): with Horizontal(id="add-channel-button-container"):
yield Button( yield Button("Add Channel", id="add-channel", classes="button-100")
"Add Channel", id="add-channel", classes="button-100 button-small"
)
for channel in self.config.channel_whitelist: for channel in self.config.channel_whitelist:
yield Channel(channel) yield Channel(channel)
@@ -887,6 +877,8 @@ class AutoPlayManager(Vertical):
class ISponsorBlockTVSetupMainScreen(Screen): class ISponsorBlockTVSetupMainScreen(Screen):
"""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 = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")] BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
@@ -927,6 +919,7 @@ class ISponsorBlockTVSetupMainScreen(Screen):
def on_mount(self) -> None: def on_mount(self) -> None:
if self.check_for_old_config_entries(): if self.check_for_old_config_entries():
self.app.push_screen(MigrationScreen()) self.app.push_screen(MigrationScreen())
pass
def action_save(self) -> None: def action_save(self) -> None:
self.config.save() self.config.save()
@@ -950,7 +943,7 @@ class ISponsorBlockTVSetupMainScreen(Screen):
self.app.query_one("#warning-no-key").display = ( self.app.query_one("#warning-no-key").display = (
not event.input.value not event.input.value
) and self.config.channel_whitelist ) and self.config.channel_whitelist
except BaseException: except:
pass pass

View File

@@ -1,6 +1,5 @@
import asyncio import asyncio
import json import json
from typing import Any, List
import pyytlounge import pyytlounge
from aiohttp import ClientSession from aiohttp import ClientSession
@@ -13,19 +12,19 @@ create_task = asyncio.create_task
class YtLoungeApi(pyytlounge.YtLoungeApi): class YtLoungeApi(pyytlounge.YtLoungeApi):
def __init__( def __init__(
self, self,
screen_id=None, screen_id,
config=None, config=None,
api_helper=None, api_helper=None,
logger=None, logger=None,
web_session: ClientSession = None,
): ):
super().__init__( super().__init__("iSponsorBlockTV", logger=logger)
config.join_name if config else "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.screen_id = screen_id
self.auth.lounge_id_token = None self.auth.lounge_id_token = None
self.api_helper = api_helper self.api_helper = api_helper
self.volume_state = {} self.volume_state = {}
self.playback_speed = 1.0
self.subscribe_task = None self.subscribe_task = None
self.subscribe_task_watchdog = None self.subscribe_task_watchdog = None
self.callback = None self.callback = None
@@ -45,7 +44,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
) # YouTube sends at least a message every 30 seconds (no-op or any other) ) # YouTube sends at least a message every 30 seconds (no-op or any other)
try: try:
self.subscribe_task.cancel() self.subscribe_task.cancel()
except BaseException: except Exception:
pass pass
# Subscribe to the lounge and start the watchdog # Subscribe to the lounge and start the watchdog
@@ -53,26 +52,24 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.callback = callback self.callback = callback
try: try:
self.subscribe_task_watchdog.cancel() self.subscribe_task_watchdog.cancel()
except BaseException: except:
pass # No watchdog task pass # No watchdog task
self.subscribe_task = asyncio.create_task(super().subscribe(callback)) self.subscribe_task = asyncio.create_task(super().subscribe(callback))
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog()) self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
return self.subscribe_task return self.subscribe_task
# Process a lounge subscription event # Process a lounge subscription event
# skipcq: PY-R1000 def _process_event(self, event_id: int, event_type: str, args):
def _process_event(self, event_type: str, args: List[Any]): self.logger.debug(f"process_event({event_id}, {event_type}, {args})")
self.logger.debug(f"process_event({event_type}, {args})")
# (Re)start the watchdog # (Re)start the watchdog
try: try:
self.subscribe_task_watchdog.cancel() self.subscribe_task_watchdog.cancel()
except BaseException: except:
pass pass
finally: finally:
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog()) self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
# A bunch of events useful to detect ads playing, # A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we
# and the next video before it starts playing # can get the segments)
# (that way we can get the segments)
if event_type == "onStateChange": if event_type == "onStateChange":
data = args[0] data = args[0]
# print(data) # print(data)
@@ -101,10 +98,10 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
): # Seen multiple other adStates, assuming they are all ads ): # Seen multiple other adStates, assuming they are all ads
self.logger.info("Ad has started, muting") self.logger.info("Ad has started, muting")
create_task(self.mute(True, override=True)) create_task(self.mute(True, override=True))
# Manages volume, useful since YouTube wants to know the volume # Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it)
# when unmuting (even if they already have it)
elif event_type == "onVolumeChanged": elif event_type == "onVolumeChanged":
self.volume_state = args[0] self.volume_state = args[0]
pass
# Gets segments for the next video before it starts playing # Gets segments for the next video before it starts playing
elif event_type == "autoplayUpNext": elif event_type == "autoplayUpNext":
if len(args) > 0 and ( if len(args) > 0 and (
@@ -158,33 +155,22 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
elif event_type == "onAutoplayModeChanged": elif event_type == "onAutoplayModeChanged":
create_task(self.set_auto_play_mode(self.auto_play)) create_task(self.set_auto_play_mode(self.auto_play))
elif event_type == "onPlaybackSpeedChanged": super()._process_event(event_id, event_type, args)
data = args[0]
self.playback_speed = float(data.get("playbackSpeed", "1"))
create_task(self.get_now_playing())
super()._process_event(event_type, args)
# Set the volume to a specific value (0-100) # Set the volume to a specific value (0-100)
async def set_volume(self, volume: int) -> None: async def set_volume(self, volume: int) -> None:
await super()._command("setVolume", {"volume": volume}) await super()._command("setVolume", {"volume": volume})
# Mute or unmute the device (if the device already is in the desired state, nothing happens)
# mute: True to mute, False to unmute
# override: If True, the command is sent even if the device already is in the desired state
# TODO: Only works if the device is subscribed to the lounge
async def mute(self, mute: bool, override: bool = False) -> None: async def mute(self, mute: bool, override: bool = False) -> None:
"""
Mute or unmute the device (if the device already
is in the desired state, nothing happens)
:param bool mute: True to mute, False to unmute
:param bool override: If True, the command is sent even if the
device already is in the desired state
TODO: Only works if the device is subscribed to the lounge
"""
if mute: if mute:
mute_str = "true" mute_str = "true"
else: else:
mute_str = "false" mute_str = "false"
if override or not self.volume_state.get("muted", "false") == mute_str: if override or not (self.volume_state.get("muted", "false") == mute_str):
self.volume_state["muted"] = mute_str self.volume_state["muted"] = mute_str
# YouTube wants the volume when unmuting, so we send it # YouTube wants the volume when unmuting, so we send it
await super()._command( await super()._command(
@@ -200,18 +186,8 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
async def play_video(self, video_id: str) -> bool: async def play_video(self, video_id: str) -> bool:
return await self._command("setPlaylist", {"videoId": video_id}) return await self._command("setPlaylist", {"videoId": video_id})
async def get_now_playing(self):
return await super()._command("getNowPlaying")
# Test to wrap the command function in a mutex to avoid race conditions with # Test to wrap the command function in a mutex to avoid race conditions with
# the _command_offset (TODO: move to upstream if it works) # the _command_offset (TODO: move to upstream if it works)
async def _command(self, command: str, command_parameters: dict = None) -> bool: async def _command(self, command: str, command_parameters: dict = None) -> bool:
async with self._command_mutex: async with self._command_mutex:
return await super()._command(command, command_parameters) return await super()._command(command, command_parameters)
async def change_web_session(self, web_session: ClientSession):
if self.session is not None:
await self.session.close()
if self.conn is not None:
await self.conn.close()
self.session = web_session