Compare commits

..

1 Commits

Author SHA1 Message Date
dmunozv04
93f4f0f752 Connect without playlist 2025-04-15 14:03:02 +02:00
11 changed files with 54 additions and 257 deletions

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.9
rev: v0.11.4
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]

View File

@@ -12,7 +12,6 @@
"skip_count_tracking": true,
"mute_ads": true,
"skip_ads": true,
"minimum_skip_length": 1,
"auto_play": true,
"join_name": "iSponsorBlockTV",
"apikey": "",

View File

@@ -1,6 +1,6 @@
[project]
name = "iSponsorBlockTV"
version = "2.5.2"
version = "2.4.0"
authors = [
{"name" = "dmunozv04"}
]

View File

@@ -1,4 +1,4 @@
aiohttp==3.11.18
aiohttp==3.11.16
appdirs==1.4.4
async-cache==1.1.1
pyytlounge==2.3.0

View File

@@ -27,7 +27,6 @@ class ApiHelper:
self.skip_count_tracking = config.skip_count_tracking
self.web_session = web_session
self.num_devices = len(config.devices)
self.minimum_skip_length = config.minimum_skip_length
# Not used anymore, maybe it can stay here a little longer
@AsyncLRU(maxsize=10)
@@ -140,10 +139,10 @@ class ApiHelper:
if str(i["videoID"]) == str(vid_id):
response_json = i
break
return self.process_segments(response_json, self.minimum_skip_length)
return self.process_segments(response_json)
@staticmethod
def process_segments(response, minimum_skip_length):
def process_segments(response):
segments = []
ignore_ttl = True
try:
@@ -185,9 +184,7 @@ class ApiHelper:
segment_dict["start"] = segment_before_start
segment_dict["UUID"].extend(segment_before_UUID)
segments.pop()
# Only add segments greater than minimum skip length
if segment_dict["end"] - segment_dict["start"] > minimum_skip_length:
segments.append(segment_dict)
segments.append(segment_dict)
except BaseException:
pass
return segments, ignore_ttl

View File

@@ -24,10 +24,6 @@ SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: '
SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: "
ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: "
ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: "
MINIMUM_SKIP_PROMPT = "Do you want to specify a minimum length of segment to skip? (y/N)"
MINIMUM_SKIP_SPECIFICATION_PROMPT = (
"Enter minimum length of segment to skip in seconds (enter 0 to disable):"
)
REPORT_SKIPPED_SEGMENTS_PROMPT = (
"Do you want to report skipped segments to sponsorblock. Only the segment"
" UUID will be sent? (Y/n) "
@@ -175,21 +171,6 @@ def main(config, debug: bool) -> None:
config.channel_whitelist = channel_whitelist
# Ask for minimum skip length. Confirm input is an integer
minimum_skip_length = config.minimum_skip_length
choice = get_yn_input(MINIMUM_SKIP_PROMPT)
if choice == "y":
while True:
try:
minimum_skip_length = int(input(MINIMUM_SKIP_SPECIFICATION_PROMPT))
break
except ValueError:
print("You entered a non integer value, try again.")
continue
config.minimum_skip_length = minimum_skip_length
choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT)
config.skip_count_tracking = choice != "n"

View File

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

View File

@@ -41,7 +41,6 @@ class Config:
self.skip_count_tracking = True
self.mute_ads = False
self.skip_ads = False
self.minimum_skip_length = 1
self.auto_play = True
self.join_name = "iSponsorBlockTV"
self.__load()
@@ -131,7 +130,6 @@ class Config:
help="data directory",
)
@click.option("--debug", is_flag=True, help="debug mode")
@click.option("--http-tracing", is_flag=True, help="Enable HTTP request/response tracing")
# legacy commands as arguments
@click.option("--setup", is_flag=True, help="Setup the program graphically", hidden=True)
@click.option(
@@ -141,12 +139,11 @@ class Config:
hidden=True,
)
@click.pass_context
def cli(ctx, data, debug, http_tracing, setup, setup_cli):
def cli(ctx, data, debug, setup, setup_cli):
"""iSponsorblockTV"""
ctx.ensure_object(dict)
ctx.obj["data_dir"] = data
ctx.obj["debug"] = debug
ctx.obj["http_tracing"] = http_tracing
logger = logging.getLogger()
ctx.obj["logger"] = logger
@@ -191,7 +188,7 @@ def start(ctx):
"""Start the main program"""
config = Config(ctx.obj["data_dir"])
config.validate()
main.main(config, ctx.obj["debug"], ctx.obj["http_tracing"])
main.main(config, ctx.obj["debug"])
# Create fake "self" group to show pyapp options in help menu

View File

@@ -7,7 +7,6 @@ from typing import Optional
import aiohttp
from . import api_helpers, ytlounge
from .debug_helpers import AiohttpTracer
class DeviceListener:
@@ -154,28 +153,14 @@ def handle_signal(signum, frame):
raise KeyboardInterrupt()
async def main_async(config, debug, http_tracing):
async def main_async(config, debug):
loop = asyncio.get_event_loop_policy().get_event_loop()
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
devices = [] # Save the devices to close them later
if debug:
loop.set_debug(True)
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300)
# Configure session with tracing if enabled
if http_tracing:
root_logger = logging.getLogger("aiohttp_trace")
tracer = AiohttpTracer(root_logger)
trace_config = aiohttp.TraceConfig()
trace_config.on_request_start.append(tracer.on_request_start)
trace_config.on_response_chunk_received.append(tracer.on_response_chunk_received)
trace_config.on_request_end.append(tracer.on_request_end)
trace_config.on_request_exception.append(tracer.on_request_exception)
web_session = aiohttp.ClientSession(connector=tcp_connector, trace_configs=[trace_config])
else:
web_session = aiohttp.ClientSession(connector=tcp_connector)
web_session = aiohttp.ClientSession(connector=tcp_connector)
api_helper = api_helpers.ApiHelper(config, web_session)
for i in config.devices:
device = DeviceListener(api_helper, config, i, debug, web_session)
@@ -199,5 +184,5 @@ async def main_async(config, debug, http_tracing):
print("Exited")
def main(config, debug, http_tracing):
asyncio.run(main_async(config, debug, http_tracing))
def main(config, debug):
asyncio.run(main_async(config, debug))

View File

@@ -692,43 +692,6 @@ class SkipCategoriesManager(Vertical):
self.config.skip_categories = event.selection_list.selected
class MinimumSkipLengthManager(Vertical):
"""Manager for minimum skip length setting."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Minimum Skip Length", classes="title")
yield Label(
(
"Specify the minimum length a segment must meet in order to skip "
"it (in seconds). Default is 1 second; entering 0 will skip all "
"segments."
),
classes="subtitle",
)
yield Input(
placeholder="Minimum skip length (0 to skip all)",
id="minimum-skip-length-input",
value=str(getattr(self.config, "minimum_skip_length", 1)),
validators=[
Function(
lambda user_input: user_input.isdigit(),
"Please enter a valid non-negative number",
)
],
)
@on(Input.Changed, "#minimum-skip-length-input")
def changed_minimum_skip_length(self, event: Input.Changed):
try:
self.config.minimum_skip_length = int(event.input.value)
except ValueError:
self.config.minimum_skip_length = 1
class SkipCountTrackingManager(Vertical):
"""Manager for skip count tracking, allows to enable/disable skip count tracking."""
@@ -910,11 +873,6 @@ class ISponsorBlockTVSetupMainScreen(Screen):
yield SkipCategoriesManager(
config=self.config, id="skip-categories-manager", classes="container"
)
yield MinimumSkipLengthManager(
config=self.config,
id="minimum-skip-length-manager",
classes="container",
)
yield SkipCountTrackingManager(
config=self.config, id="count-segments-manager", classes="container"
)

View File

@@ -1,16 +1,16 @@
import asyncio
import json
import sys
from typing import Any, List
import pyytlounge
from aiohttp import ClientSession
from pyytlounge.wrapper import NotLinkedException, api_base, as_aiter, Dict
from uuid import uuid4
from .constants import youtube_client_blacklist
from pyytlounge.api import api_base
from pyytlounge.exceptions import NotLinkedException
from pyytlounge.util import as_aiter
create_task = asyncio.create_task
@@ -34,8 +34,6 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.logger = logger
self.shorts_disconnected = False
self.auto_play = True
self.watchdog_running = False
self.last_event_time = 0
if config:
self.mute_ads = config.mute_ads
self.skip_ads = config.skip_ads
@@ -44,58 +42,21 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# Ensures that we still are subscribed to the lounge
async def _watchdog(self):
"""
Continuous watchdog that monitors for connection health.
If no events are received within the expected timeframe,
it cancels the current subscription.
"""
self.watchdog_running = True
self.last_event_time = asyncio.get_event_loop().time()
await asyncio.sleep(
35
) # YouTube sends at least a message every 30 seconds (no-op or any other)
try:
while self.watchdog_running:
await asyncio.sleep(10)
current_time = asyncio.get_event_loop().time()
time_since_last_event = current_time - self.last_event_time
# YouTube sends a message at least every 30 seconds
if time_since_last_event > 60:
self.logger.debug(
f"Watchdog triggered: No events for {time_since_last_event:.1f} seconds"
)
# Cancel current subscription
if self.subscribe_task and not self.subscribe_task.done():
self.subscribe_task.cancel()
await asyncio.sleep(1) # Give it time to cancel
except asyncio.CancelledError:
self.logger.debug("Watchdog task cancelled")
self.watchdog_running = False
except BaseException as e:
self.logger.error(f"Watchdog error: {e}")
self.watchdog_running = False
self.subscribe_task.cancel()
except BaseException:
pass
# Subscribe to the lounge and start the watchdog
async def subscribe_monitored(self, callback):
self.callback = callback
# Stop existing watchdog if running
if self.subscribe_task_watchdog and not self.subscribe_task_watchdog.done():
self.watchdog_running = False
try:
self.subscribe_task_watchdog.cancel()
try:
await self.subscribe_task_watchdog
except (asyncio.CancelledError, Exception):
pass
# Start new subscription
if self.subscribe_task and not self.subscribe_task.done():
self.subscribe_task.cancel()
try:
await self.subscribe_task
except (asyncio.CancelledError, Exception):
pass
except BaseException:
pass # No watchdog task
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
return self.subscribe_task
@@ -104,9 +65,13 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# skipcq: PY-R1000
def _process_event(self, event_type: str, args: List[Any]):
self.logger.debug(f"process_event({event_type}, {args})")
# Update last event time for the watchdog
self.last_event_time = asyncio.get_event_loop().time()
# (Re)start the watchdog
try:
self.subscribe_task_watchdog.cancel()
except BaseException:
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)
@@ -124,7 +89,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
create_task(self.mute(False, override=True))
elif event_type == "onAdStateChange":
data = args[0]
if data["adState"] == "0" and data["currentTime"] != "0": # Ad is not playing
if data["adState"] == "0": # Ad is not playing
self.logger.info("Ad has ended, unmuting")
create_task(self.mute(False, override=True))
elif (
@@ -153,8 +118,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if vid_id := data["contentVideoId"]:
self.logger.info(f"Getting segments for next video: {vid_id}")
create_task(self.api_helper.get_segments(vid_id))
if (
elif (
self.skip_ads and data["isSkipEnabled"] == "true"
): # YouTube uses strings for booleans
self.logger.info("Ad can be skipped, skipping")
@@ -241,58 +205,41 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
await self.conn.close()
self.session = web_session
def _common_connection_parameters(self) -> Dict[str, Any]:
return {
"name": self.device_name,
"loungeIdToken": self.auth.lounge_id_token,
"SID": self._sid,
"AID": self._last_event_id,
"gsessionid": self._gsession,
"device": "REMOTE_CONTROL",
"app": "ytios-phone-20.15.1",
"VER": "8",
"v": "2",
}
# 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 = {
"id": str(uuid4()),
"app": "web",
"mdx-version": "3",
"TYPE": "xmlhttp",
"theme": "cl",
"sessionSource": "MDX_SESSION_SOURCE_UNKNOWN",
"connectParams": '{"setStatesParams": "{"playbackSpeed":0}"}',
"sessionNonce": str(uuid4()),
"RID": "1",
"CVER": "1",
"capabilities": "que,dsdtr,atp,vsp",
"ui": "false",
"app": "ytios-phone-20.15.1",
"pairing_type": "manual",
"VER": "8",
"loungeIdToken": self.auth.lounge_id_token,
"device": "REMOTE_CONTROL",
"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"
connect_url = (
f"{api_base}/bc/bind?RID=1&VER=8&CVER=1&auth_failure_option=send_error"
)
async with self.session.post(url=connect_url, data=connect_body) as resp:
try:
text = await resp.text()
if resp.status == 401:
if "Connection denied" in text:
self._logger.warning(
"Connection denied, attempting to circumvent the issue"
)
await self.connect_as_screen()
# self._lounge_token_expired()
self._lounge_token_expired()
return False
if resp.status != 200:
self._logger.warning("Unknown reply to connect %i %s", resp.status, resp.reason)
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)):
@@ -305,46 +252,4 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
resp.status,
resp.reason,
)
raise
async def connect_as_screen(self) -> bool:
"""Attempt to connect using the previously set tokens"""
if not self.linked():
raise NotLinkedException("Not linked")
connect_body = {
"id": str(uuid4()),
"mdx-version": "3",
"TYPE": "xmlhttp",
"theme": "cl",
"sessionSource": "MDX_SESSION_SOURCE_UNKNOWN",
"connectParams": '{"setStatesParams": "{"playbackSpeed":0}"}',
"sessionNonce": str(uuid4()),
"RID": "1",
"CVER": "1",
"capabilities": "que,dsdtr,atp,vsp",
"ui": "false",
"app": "ytios-phone-20.15.1",
"pairing_type": "manual",
"VER": "8",
"loungeIdToken": self.auth.lounge_id_token,
"device": "LOUNGE_SCREEN",
"name": self.device_name,
}
connect_url = f"{api_base}/bc/bind"
async with self.session.post(url=connect_url, data=connect_body) as resp:
try:
await resp.text()
self.logger.error(
"Connected as screen: please force close the app on the device for iSponsorBlockTV to work properly"
)
self.logger.warn("Exiting in 5 seconds")
await asyncio.sleep(5)
sys.exit(0)
except:
self._logger.exception(
"Handle connect failed, status %s reason %s",
resp.status,
resp.reason,
)
raise
raise