Compare commits

..

1 Commits

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

View File

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

View File

@@ -57,9 +57,6 @@ jobs:
build-binaries:
permissions:
id-token: write
attestations: write
name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }})
needs:
- build-sdist-and-wheel
@@ -79,7 +76,7 @@ jobs:
cpu_variant: v1
release_suffix: x86_64-linux-v1
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
os: ubuntu-latest
cross: true
release_suffix: aarch64-linux
# Windows
@@ -103,7 +100,7 @@ jobs:
CARGO_BUILD_TARGET: ${{ matrix.job.target }}
PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }}
PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling)
PYAPP_VERSION: v0.27.0
PYAPP_VERSION: v0.24.0
steps:
- name: Checkout
@@ -161,11 +158,6 @@ jobs:
run: |-
mv dist/binary/iSponsorBlockTV* dist/binary/iSponsorBlockTV-${{ matrix.job.release_suffix }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: dist/binary/*
- name: Upload built binary package
uses: actions/upload-artifact@v4
with:

View File

@@ -19,7 +19,7 @@ repos:
- id: mixed-line-ending # replaces or checks mixed line ending
- id: trailing-whitespace # checks for trailing whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.4
rev: v0.9.6
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]

View File

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

View File

@@ -1,10 +1,10 @@
aiohttp==3.11.16
aiohttp==3.11.12
appdirs==1.4.4
async-cache==1.1.1
pyytlounge==2.3.0
rich==14.0.0
pyytlounge==2.1.2
rich==13.9.4
ssdp==1.3.0
textual==2.1.2
textual==1.0.0
textual-slider==0.2.0
xmltodict==0.14.2
rich_click==1.8.8
rich_click==1.8.5

View File

@@ -101,21 +101,26 @@ class ApiHelper:
if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]:
sub_count = "Hidden"
else:
sub_count = int(channel_data["items"][0]["statistics"]["subscriberCount"])
sub_count = int(
channel_data["items"][0]["statistics"]["subscriberCount"]
)
sub_count = format(sub_count, "_")
channels.append((i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count))
channels.append(
(i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count)
)
return channels
@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):
if await self.is_whitelisted(vid_id):
return (
[],
True,
) # Return empty list and True to indicate
# that the cache should last forever
) # Return empty list and True to indicate that the cache should last forever
vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[
:4
] # Hashes video id and gets the first 4 characters
@@ -126,7 +131,9 @@ class ApiHelper:
}
headers = {"Accept": "application/json"}
url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed
async with self.web_session.get(url, headers=headers, params=params) as response:
async with self.web_session.get(
url, headers=headers, params=params
) as response:
response_json = await response.json()
if response.status != 200:
response_text = await response.text()
@@ -176,7 +183,7 @@ class ApiHelper:
segment_before_start = segments[-1]["start"]
segment_before_UUID = segments[-1]["UUID"]
except IndexError:
except Exception:
segment_before_end = -10
if (
segment_dict["start"] - segment_before_end < 1
@@ -185,13 +192,12 @@ class ApiHelper:
segment_dict["UUID"].extend(segment_before_UUID)
segments.pop()
segments.append(segment_dict)
except BaseException:
except Exception:
pass
return segments, ignore_ttl
async def mark_viewed_segments(self, uuids):
"""Marks the segments as viewed in the SponsorBlock API
if skip_count_tracking is enabled.
"""Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled.
Lets the contributor know that someone skipped the segment (thanks)"""
if self.skip_count_tracking:
for i in uuids:

View File

@@ -3,29 +3,28 @@ 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
# 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
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:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Modified code from https://github.com/iamsinghrajat/async-cache
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE."""
"""Modified code from https://github.com/iamsinghrajat/async-cache"""
class AsyncConditionalTTL:
@@ -33,7 +32,9 @@ class AsyncConditionalTTL:
def __init__(self, time_to_live, maxsize):
super().__init__(maxsize=maxsize)
self.time_to_live = datetime.timedelta(seconds=time_to_live) if time_to_live else None
self.time_to_live = (
datetime.timedelta(seconds=time_to_live) if time_to_live else None
)
self.maxsize = maxsize

View File

@@ -6,12 +6,15 @@ 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) "
"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) "
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 = (
@@ -19,7 +22,9 @@ ENTER_SKIP_CATEGORIES_PROMPT = (
" 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) "
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: "
@@ -38,7 +43,6 @@ def get_yn_input(prompt):
if choice.lower() in ["y", "n"]:
return choice.lower()
print("Invalid input. Please enter 'y' or 'n'.")
return None
async def create_web_session():
@@ -116,11 +120,15 @@ def main(config, debug: bool) -> None:
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
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
skip_categories = [
x for x in skip_categories if x != ""
] # Remove empty strings
config.skip_categories = skip_categories
channel_whitelist = config.channel_whitelist
@@ -139,7 +147,9 @@ def main(config, debug: bool) -> None:
if channel == "/exit":
break
task = loop.create_task(api_helper.search_channels(channel, apikey, web_session))
task = loop.create_task(
api_helper.search_channels(channel, apikey, web_session)
)
loop.run_until_complete(task)
results = task.result()
if len(results) == 0:

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import rich_click as click
from appdirs import user_data_dir
from . import config_setup, main, setup_wizard
from .constants import config_file_blacklist_keys, github_wiki_base_url
from .constants import config_file_blacklist_keys
class Device:
@@ -51,8 +51,8 @@ class Config:
(
"The atvs config option is deprecated and has stopped working."
" Please read this for more information "
"on how to upgrade to V2:\n"
f"{github_wiki_base_url}/Migrate-from-V1-to-V2"
"on how to upgrade to V2:"
" \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
),
)
print("Exiting in 10 seconds...")
@@ -65,7 +65,9 @@ class Config:
sys.exit()
self.devices = [Device(i) for i in self.devices]
if not self.apikey and self.channel_whitelist:
raise ValueError("No youtube API key found and channel whitelist is not empty")
raise ValueError(
"No youtube API key found and channel whitelist is not empty"
)
if not self.skip_categories:
self.skip_categories = ["sponsor"]
print("No categories found, using default: sponsor")
@@ -88,12 +90,18 @@ class Config:
print(
"Running in docker without mounting the data dir, check the"
" wiki for more information: "
f"{github_wiki_base_url}/Installation#Docker"
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker"
)
print(
("This image has recently been updated to v2, and requires changes."),
("Please read this for more information on how to upgrade to V2:"),
f"{github_wiki_base_url}/Migrate-from-V1-to-V2",
(
"This image has recently been updated to v2, and requires"
" changes."
),
(
"Please read this for more information on how to upgrade"
" to V2:"
),
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2",
)
print("Exiting in 10 seconds...")
time.sleep(10)
@@ -118,20 +126,20 @@ class Config:
return self.__dict__ == other.__dict__
return False
def __hash__(self):
return hash(tuple(sorted(self.items())))
@click.group(invoke_without_command=True)
@click.option(
"--data",
"-d",
default=lambda: os.getenv("iSPBTV_data_dir") or user_data_dir("iSponsorBlockTV", "dmunozv04"),
default=lambda: os.getenv("iSPBTV_data_dir")
or user_data_dir("iSponsorBlockTV", "dmunozv04"),
help="data directory",
)
@click.option("--debug", is_flag=True, help="debug mode")
# legacy commands as arguments
@click.option("--setup", is_flag=True, help="Setup the program graphically", hidden=True)
@click.option(
"--setup", is_flag=True, help="Setup the program graphically", hidden=True
)
@click.option(
"--setup-cli",
is_flag=True,
@@ -144,18 +152,8 @@ def cli(ctx, data, debug, setup, setup_cli):
ctx.ensure_object(dict)
ctx.obj["data_dir"] = data
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:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
logging.basicConfig(level=logging.DEBUG)
if ctx.invoked_subcommand is None:
if setup:
ctx.invoke(setup_command)
@@ -165,23 +163,29 @@ def cli(ctx, data, debug, setup, setup_cli):
ctx.invoke(start)
@cli.command(name="setup")
@cli.command()
@click.pass_context
def setup_command(ctx):
def setup(ctx):
"""Setup the program graphically"""
config = Config(ctx.obj["data_dir"])
setup_wizard.main(config)
sys.exit()
@cli.command(name="setup-cli")
setup_command = setup
@cli.command()
@click.pass_context
def setup_cli_command(ctx):
def setup_cli(ctx):
"""Setup the program in the command line"""
config = Config(ctx.obj["data_dir"])
config_setup.main(config, ctx.obj["debug"])
setup_cli_command = setup_cli
@cli.command()
@click.pass_context
def start(ctx):
@@ -198,7 +202,9 @@ pyapp_group.add_command(
click.RichCommand("update", help="Update the package to the latest version")
)
pyapp_group.add_command(
click.Command("remove", help="Remove the package, wiping the installation but not the data")
click.Command(
"remove", help="Remove the package, wiping the installation but not the data"
)
)
pyapp_group.add_command(
click.RichCommand(

View File

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

View File

@@ -18,6 +18,16 @@ class DeviceListener:
self.cancelled = False
self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}")
self.web_session = web_session
if debug:
self.logger.setLevel(logging.DEBUG)
else:
self.logger.setLevel(logging.INFO)
sh = logging.StreamHandler()
sh.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
self.logger.addHandler(sh)
self.logger.info("Starting device")
self.lounge_controller = ytlounge.YtLoungeApi(
device.screen_id, config, api_helper, self.logger
)
@@ -29,12 +39,14 @@ class DeviceListener:
try:
await self.lounge_controller.refresh_auth()
except BaseException:
# traceback.print_exc()
pass
async def is_available(self):
try:
return await self.lounge_controller.is_available()
except BaseException:
# traceback.print_exc()
return False
# Main subscription loop
@@ -48,7 +60,6 @@ class DeviceListener:
except BaseException:
await asyncio.sleep(10)
while not (await self.is_available()) and not self.cancelled:
self.logger.debug("Waiting for device to be available")
await asyncio.sleep(10)
try:
await lounge_controller.connect()
@@ -56,7 +67,6 @@ class DeviceListener:
pass
while not lounge_controller.connected() and not self.cancelled:
# Doesn't connect to the device if it's a kids profile (it's broken)
self.logger.debug("Waiting for device to be connected")
await asyncio.sleep(10)
try:
await lounge_controller.connect()
@@ -66,7 +76,7 @@ class DeviceListener:
"Connected to device %s (%s)", lounge_controller.screen_name, self.name
)
try:
self.logger.debug("Subscribing to lounge")
self.logger.info("Subscribing to lounge")
sub = await lounge_controller.subscribe_monitored(self)
await sub
except BaseException:
@@ -74,11 +84,11 @@ class DeviceListener:
# Method called on playback state change
async def __call__(self, state):
time_start = time.monotonic()
try:
self.task.cancel()
except BaseException:
pass
time_start = time.time()
self.task = asyncio.create_task(self.process_playstatus(state, time_start))
# Processes the playback state change
@@ -87,7 +97,9 @@ class DeviceListener:
if state.videoId:
segments = await self.api_helper.get_segments(state.videoId)
if state.state.value == 1: # Playing
self.logger.info("Playing video %s with %d segments", state.videoId, len(segments))
self.logger.info(
f"Playing video {state.videoId} with {len(segments)} segments"
)
if segments: # If there are segments
await self.time_to_segment(segments, state.currentTime, time_start)
@@ -96,32 +108,28 @@ class DeviceListener:
start_next_segment = None
next_segment = None
for segment in segments:
segment_start = segment["start"]
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:
if position < 2 and (segment["start"] <= position < segment["end"]):
next_segment = segment
start_next_segment = position if is_within_start_range else segment_start
start_next_segment = (
position # different variable so segment doesn't change
)
break
if segment["start"] > position:
next_segment = segment
start_next_segment = next_segment["start"]
break
if start_next_segment:
time_to_next = (
(start_next_segment - position - (time.monotonic() - time_start))
/ self.lounge_controller.playback_speed
) - self.offset
start_next_segment - position - (time.time() - time_start) - self.offset
)
await self.skip(time_to_next, next_segment["end"], next_segment["UUID"])
# Skips to the next segment (waits for the time to pass)
async def skip(self, time_to, position, uuids):
await asyncio.sleep(time_to)
self.logger.info("Skipping segment: seeking to %s", position)
await asyncio.gather(
asyncio.create_task(self.lounge_controller.seek_to(position)),
asyncio.create_task(self.api_helper.mark_viewed_segments(uuids)),
)
await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids))
await asyncio.create_task(self.lounge_controller.seek_to(position))
async def cancel(self):
self.cancelled = True
@@ -144,7 +152,9 @@ class DeviceListener:
async def finish(devices, web_session, tcp_connector):
await asyncio.gather(*(device.cancel() for device in devices), return_exceptions=True)
await asyncio.gather(
*(device.cancel() for device in devices), return_exceptions=True
)
await web_session.close()
await tcp_connector.close()
@@ -181,8 +191,11 @@ async def main_async(config, debug):
finally:
await web_session.close()
await tcp_connector.close()
loop.close()
print("Exited")
def main(config, debug):
asyncio.run(main_async(config, debug))
loop = asyncio.get_event_loop()
loop.run_until_complete(main_async(config, debug))
loop.close()

View File

@@ -79,7 +79,7 @@ class Element(Static):
self.tooltip = tooltip
def process_values_from_data(self):
raise NotImplementedError("Subclasses must implement this method.")
pass
def compose(self) -> ComposeResult:
yield Button(
@@ -122,7 +122,9 @@ class Channel(Element):
if "name" in self.element_data:
self.element_name = self.element_data["name"]
else:
self.element_name = f"Unnamed channel with id {self.element_data['channel_id']}"
self.element_name = (
f"Unnamed channel with id {self.element_data['channel_id']}"
)
class ChannelRadio(RadioButton):
@@ -202,7 +204,9 @@ class ExitScreen(ModalWithClickExit):
classes="button-100",
),
Button("Save", variant="success", id="exit-save", classes="button-100"),
Button("Don't save", variant="error", id="exit-no-save", classes="button-100"),
Button(
"Don't save", variant="error", id="exit-no-save", classes="button-100"
),
Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"),
id="dialog-exit",
)
@@ -225,8 +229,7 @@ class ExitScreen(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")]
@@ -251,13 +254,19 @@ class AddDevice(ModalWithClickExit):
id="add-device-dial-button",
classes="button-switcher",
)
with ContentSwitcher(id="add-device-switcher", initial="add-device-pin-container"):
with ContentSwitcher(
id="add-device-switcher", initial="add-device-pin-container"
):
with Container(id="add-device-pin-container"):
yield Input(
placeholder=("Pairing Code (found in Settings - Link with TV code)"),
placeholder=(
"Pairing Code (found in Settings - Link with TV code)"
),
id="pairing-code-input",
validators=[
Function(_validate_pairing_code, "Invalid pairing code format")
Function(
_validate_pairing_code, "Invalid pairing code format"
)
],
)
yield Input(
@@ -322,7 +331,9 @@ class AddDevice(ModalWithClickExit):
@on(Input.Changed, "#pairing-code-input")
def changed_pairing_code(self, event: Input.Changed):
self.query_one("#add-device-pin-add-button").disabled = not event.validation_result.is_valid
self.query_one(
"#add-device-pin-add-button"
).disabled = not event.validation_result.is_valid
@on(Input.Submitted, "#pairing-code-input")
@on(Button.Pressed, "#add-device-pin-add-button")
@@ -370,12 +381,13 @@ class AddDevice(ModalWithClickExit):
@on(SelectionList.SelectedChanged, "#dial-devices-list")
def changed_device_list(self, event: SelectionList.SelectedChanged):
self.query_one("#add-device-dial-add-button").disabled = not event.selection_list.selected
self.query_one(
"#add-device-dial-add-button"
).disabled = not event.selection_list.selected
class AddChannel(ModalWithClickExit):
"""Screen with a dialog to add a channel,
either using search or with a channel id."""
"""Screen with a dialog to add a channel, either using search or with a channel id."""
BINDINGS = [("escape", "dismiss(())", "Return")]
@@ -408,7 +420,9 @@ class AddChannel(ModalWithClickExit):
classes="button-switcher",
)
yield Label(id="add-channel-info", classes="subtitle")
with ContentSwitcher(id="add-channel-switcher", initial="add-channel-search-container"):
with ContentSwitcher(
id="add-channel-switcher", initial="add-channel-search-container"
):
with Vertical(id="add-channel-search-container"):
if self.config.apikey:
with Grid(id="add-channel-search-inputs"):
@@ -416,7 +430,9 @@ class AddChannel(ModalWithClickExit):
placeholder="Enter channel name",
id="channel-name-input-search",
)
yield Button("Search", id="search-channel-button", variant="success")
yield Button(
"Search", id="search-channel-button", variant="success"
)
yield RadioSet(
RadioButton(label="Search to see results", disabled=True),
id="channel-search-results",
@@ -439,12 +455,15 @@ class AddChannel(ModalWithClickExit):
)
with Vertical(id="add-channel-id-container"):
yield Input(
placeholder=("Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"),
placeholder=(
"Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"
),
id="channel-id-input",
)
yield Input(
placeholder=(
"Enter channel name (only used to display in the config file)"
"Enter channel name (only used to display in the config"
" file)"
),
id="channel-name-input-id",
)
@@ -470,7 +489,9 @@ class AddChannel(ModalWithClickExit):
async def handle_search_channel(self) -> None:
channel_name = self.query_one("#channel-name-input-search").value
if not channel_name:
self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel name")
self.query_one("#add-channel-info").update(
"[#ff0000]Please enter a channel name"
)
return
self.query_one("#search-channel-button").disabled = True
self.query_one("#add-channel-info").update("Searching...")
@@ -479,7 +500,9 @@ class AddChannel(ModalWithClickExit):
try:
channels_list = await self.api_helper.search_channels(channel_name)
except BaseException:
self.query_one("#add-channel-info").update("[#ff0000]Failed to search for channel")
self.query_one("#add-channel-info").update(
"[#ff0000]Failed to search for channel"
)
self.query_one("#search-channel-button").disabled = False
return
for i in channels_list:
@@ -492,7 +515,9 @@ class AddChannel(ModalWithClickExit):
def handle_add_channel_search(self) -> None:
channel = self.query_one("#channel-search-results").pressed_button.channel_data
if not channel:
self.query_one("#add-channel-info").update("[#ff0000]Please select a channel")
self.query_one("#add-channel-info").update(
"[#ff0000]Please select a channel"
)
return
self.query_one("#add-channel-info").update("Adding...")
self.dismiss(channel)
@@ -504,7 +529,9 @@ class AddChannel(ModalWithClickExit):
channel_id = self.query_one("#channel-id-input").value
channel_name = self.query_one("#channel-name-input-id").value
if not channel_id:
self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel ID")
self.query_one("#add-channel-info").update(
"[#ff0000]Please enter a channel ID"
)
return
if not channel_name:
channel_name = channel_id
@@ -526,7 +553,7 @@ class EditDevice(ModalWithClickExit):
def action_close_screen_saving(self) -> None:
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["screen_id"] = self.query_one("#device-id-input").value
self.device_data["offset"] = int(self.query_one("#device-offset-input").value)
@@ -595,7 +622,9 @@ class DevicesManager(Vertical):
def compose(self) -> ComposeResult:
yield Label("Devices", classes="title")
with Horizontal(id="add-device-button-container"):
yield Button("Add Device", id="add-device", classes="button-100 button-small")
yield Button(
"Add Device", id="add-device", classes="button-100 button-small"
)
for device in self.devices:
yield Device(device, tooltip="Click to edit")
@@ -640,7 +669,7 @@ class ApiKeyManager(Vertical):
yield Label("YouTube Api Key", classes="title")
yield Label(
"You can get a YouTube Data API v3 Key from the"
" [link='https://console.developers.google.com/apis/credentials']Google Cloud"
" [link=https://console.developers.google.com/apis/credentials]Google Cloud"
" Console[/link]. This key is only required if you're whitelisting"
" channels."
)
@@ -764,8 +793,7 @@ class AdSkipMuteManager(Vertical):
class ChannelWhitelistManager(Vertical):
"""Manager for channel whitelist,
allows adding/removing channels from the whitelist."""
"""Manager for channel whitelist, allows adding/removing channels from the whitelist."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
@@ -790,14 +818,16 @@ class ChannelWhitelistManager(Vertical):
id="warning-no-key",
)
with Horizontal(id="add-channel-button-container"):
yield Button("Add Channel", id="add-channel", classes="button-100 button-small")
yield Button(
"Add Channel", id="add-channel", classes="button-100 button-small"
)
for channel in self.config.channel_whitelist:
yield Channel(channel)
def on_mount(self) -> None:
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
self.config.channel_whitelist
)
self.app.query_one("#warning-no-key").display = (
not self.config.apikey
) and bool(self.config.channel_whitelist)
def new_channel(self, channel: tuple) -> None:
if channel:
@@ -809,18 +839,18 @@ class ChannelWhitelistManager(Vertical):
channel_widget = Channel(channel_dict)
self.mount(channel_widget)
channel_widget.focus(scroll_visible=True)
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
self.config.channel_whitelist
)
self.app.query_one("#warning-no-key").display = (
not self.config.apikey
) and bool(self.config.channel_whitelist)
@on(Button.Pressed, "#element-remove")
def remove_channel(self, event: Button.Pressed):
channel_to_remove: Element = event.button.parent
self.config.channel_whitelist.remove(channel_to_remove.element_data)
channel_to_remove.remove()
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
self.config.channel_whitelist
)
self.app.query_one("#warning-no-key").display = (
not self.config.apikey
) and bool(self.config.channel_whitelist)
@on(Button.Pressed, "#add-channel")
def add_channel(self, event: Button.Pressed):
@@ -854,6 +884,8 @@ class AutoPlayManager(Vertical):
class ISponsorBlockTVSetupMainScreen(Screen):
"""Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
TITLE = "iSponsorBlockTV"
SUB_TITLE = "Setup Wizard"
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
@@ -869,7 +901,9 @@ class ISponsorBlockTVSetupMainScreen(Screen):
yield Header()
yield Footer()
with ScrollableContainer(id="setup-wizard"):
yield DevicesManager(config=self.config, id="devices-manager", classes="container")
yield DevicesManager(
config=self.config, id="devices-manager", classes="container"
)
yield SkipCategoriesManager(
config=self.config, id="skip-categories-manager", classes="container"
)
@@ -882,12 +916,17 @@ class ISponsorBlockTVSetupMainScreen(Screen):
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")
yield ApiKeyManager(
config=self.config, id="api-key-manager", classes="container"
)
yield AutoPlayManager(
config=self.config, id="autoplay-manager", classes="container"
)
def on_mount(self) -> None:
if self.check_for_old_config_entries():
self.app.push_screen(MigrationScreen())
pass
def action_save(self) -> None:
self.config.save()

View File

@@ -7,10 +7,6 @@ from aiohttp import ClientSession
from .constants import youtube_client_blacklist
from pyytlounge.api import api_base
from pyytlounge.exceptions import NotLinkedException
from pyytlounge.util import as_aiter
create_task = asyncio.create_task
@@ -22,12 +18,13 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
api_helper=None,
logger=None,
):
super().__init__(config.join_name if config else "iSponsorBlockTV", logger=logger)
super().__init__(
config.join_name if config else "iSponsorBlockTV", logger=logger
)
self.auth.screen_id = screen_id
self.auth.lounge_id_token = None
self.api_helper = api_helper
self.volume_state = {}
self.playback_speed = 1.0
self.subscribe_task = None
self.subscribe_task_watchdog = None
self.callback = None
@@ -59,10 +56,10 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
pass # No watchdog task
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
create_task(self.debug_command("bugchomp "))
return self.subscribe_task
# Process a lounge subscription event
# skipcq: PY-R1000
def _process_event(self, event_type: str, args: List[Any]):
self.logger.debug(f"process_event({event_type}, {args})")
# (Re)start the watchdog
@@ -72,10 +69,10 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
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)
# 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":
create_task(self.debug_command("exp 0 "))
data = args[0]
# print(data)
# Unmute when the video starts playing
@@ -98,16 +95,20 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
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
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)
# 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
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))
@@ -124,7 +125,9 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
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
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))
@@ -147,55 +150,51 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
elif event_type == "loungeScreenDisconnected":
if args: # Sometimes it's empty
data = args[0]
if data["reason"] == "disconnectedByUserScreenInitiated": # Short playing?
if (
data["reason"] == "disconnectedByUserScreenInitiated"
): # Short playing?
self.shorts_disconnected = True
elif event_type == "onAutoplayModeChanged":
create_task(self.set_auto_play_mode(self.auto_play))
elif event_type == "onPlaybackSpeedChanged":
data = args[0]
self.playback_speed = float(data.get("playbackSpeed", "1"))
create_task(self.get_now_playing())
super()._process_event(event_type, args)
# Set the volume to a specific value (0-100)
async def set_volume(self, volume: int) -> None:
await self._command("setVolume", {"volume": volume})
await super()._command("setVolume", {"volume": volume})
# Mute or unmute the device (if the device already is in the desired state, nothing happens)
# mute: True to mute, False to unmute
# override: If True, the command is sent even if the device already is in the desired state
# TODO: Only works if the device is subscribed to the lounge
async def mute(self, mute: bool, override: bool = False) -> None:
"""
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:
mute_str = "true"
else:
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
# YouTube wants the volume when unmuting, so we send it
await self._command(
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})
async def get_now_playing(self):
return await self._command("getNowPlaying")
# Test to wrap the command function in a mutex to avoid race conditions with
# the _command_offset (TODO: move to upstream if it works)
async def _command(self, command: str, command_parameters: dict = None) -> bool:
async with self._command_mutex:
self.logger.debug(
f"Send command: {command}, Parameters: {command_parameters}"
)
return await super()._command(command, command_parameters)
async def change_web_session(self, web_session: ClientSession):
@@ -204,52 +203,9 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if self.conn is not None:
await self.conn.close()
self.session = web_session
# TODO: Open a PR upstream to specify the connect_body.method
# if this works
async def connect(self) -> bool:
"""Attempt to connect using the previously set tokens"""
if not self.linked():
raise NotLinkedException("Not linked")
connect_body = {
"app": "web",
"mdx-version": "3",
"name": self.device_name,
"id": self.auth.screen_id,
"device": "REMOTE_CONTROL",
"capabilities": "que,dsdtr,atp,vsp",
"method": "getNowPlaying",
"magnaKey": "cloudPairedDevice",
"ui": "false",
"deviceContext": "user_agent=dunno&window_width_points=&window_height_points=&os_name=android&ms=",
"theme": "cl",
"loungeIdToken": self.auth.lounge_id_token,
}
connect_url = (
f"{api_base}/bc/bind?RID=1&VER=8&CVER=1&auth_failure_option=send_error"
async def debug_command(self, debug_command: str):
await super()._command(
"sendDebugCommand",
{"debugCommand": debug_command},
)
async with self.session.post(url=connect_url, data=connect_body) as resp:
try:
text = await resp.text()
if resp.status == 401:
self._lounge_token_expired()
return False
if resp.status != 200:
self._logger.warning(
"Unknown reply to connect %i %s", resp.status, resp.reason
)
return False
lines = text.splitlines()
async for events in self._parse_event_chunks(as_aiter(lines)):
self._process_events(events)
self._command_offset = 1
return self.connected()
except:
self._logger.exception(
"Handle connect failed, status %s reason %s",
resp.status,
resp.reason,
)
raise