Compare commits

...

85 Commits

Author SHA1 Message Date
dmunozv04
390eb68310 Bump version 2025-08-10 19:05:59 +02:00
pre-commit-ci[bot]
7652c9f260 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.9 → v0.12.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.9...v0.12.7)
- [github.com/igorshubovych/markdownlint-cli: v0.44.0 → v0.45.0](https://github.com/igorshubovych/markdownlint-cli/compare/v0.44.0...v0.45.0)
2025-08-10 18:51:03 +02:00
dmunozv04
acf074e860 Various fixes to work with newer textual versions 2025-08-10 18:50:32 +02:00
dependabot[bot]
123f3d4000 Bump textual from 2.1.2 to 5.0.1
Bumps [textual](https://github.com/Textualize/textual) from 2.1.2 to 5.0.1.
- [Release notes](https://github.com/Textualize/textual/releases)
- [Changelog](https://github.com/Textualize/textual/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Textualize/textual/compare/v2.1.2...v5.0.1)

---
updated-dependencies:
- dependency-name: textual
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-10 18:50:32 +02:00
dependabot[bot]
303d805e5d Bump rich from 14.0.0 to 14.1.0
Bumps [rich](https://github.com/Textualize/rich) from 14.0.0 to 14.1.0.
- [Release notes](https://github.com/Textualize/rich/releases)
- [Changelog](https://github.com/Textualize/rich/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Textualize/rich/compare/v14.0.0...v14.1.0)

---
updated-dependencies:
- dependency-name: rich
  dependency-version: 14.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-10 17:54:50 +02:00
dependabot[bot]
c779e83d96 Bump rich-click from 1.8.8 to 1.8.9
Bumps [rich-click](https://github.com/ewels/rich-click) from 1.8.8 to 1.8.9.
- [Release notes](https://github.com/ewels/rich-click/releases)
- [Changelog](https://github.com/ewels/rich-click/blob/v1.8.9/CHANGELOG.md)
- [Commits](https://github.com/ewels/rich-click/compare/v1.8.8...v1.8.9)

---
updated-dependencies:
- dependency-name: rich-click
  dependency-version: 1.8.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-10 17:52:30 +02:00
dependabot[bot]
aac4b333c2 Bump aiohttp from 3.11.18 to 3.12.14
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.11.18 to 3.12.14.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.11.18...v3.12.14)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.12.14
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-10 17:51:24 +02:00
David
fa83124002 Merge pull request #334 from desofity/trust-system-proxy-by-default
Added proxy support
2025-07-28 22:19:04 +02:00
pre-commit-ci[bot]
76b82e8848 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-07-28 17:23:53 +00:00
desofity
26fec272a3 fixed prompts 2025-07-28 20:22:50 +03:00
desofity
3fdcee71fd Merge branch 'trust-system-proxy-by-default' of https://github.com/desofity/iSponsorBlockTV into trust-system-proxy-by-default 2025-07-18 14:25:58 +03:00
desofity
1a58ce6a57 split label text to pass deepSource python check 2025-07-18 14:23:08 +03:00
pre-commit-ci[bot]
90d313049b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-07-18 11:19:13 +00:00
desofity
cd7e5c83c7 added param to Config constructor 2025-07-18 14:12:15 +03:00
desofity
bf1fe68089 messing with gui 3 2025-07-18 00:06:21 +03:00
desofity
d179fe2b79 messing with gui 2 2025-07-17 23:52:25 +03:00
desofity
4724ee1a39 messing with gui 2025-07-17 23:40:25 +03:00
desofity
bb7fdbfb06 option added to GUI setup 2025-07-17 23:19:47 +03:00
desofity
930db16f53 added prompt for use_proxy in config_setup 2025-07-17 22:05:03 +03:00
desofity
8ed1cb4b00 added use_proxy setting in configuration file 2025-07-17 22:00:57 +03:00
desofity
65ecbb9193 trying to just fix aiohttp.ClientSession call 2025-07-17 21:17:46 +03:00
dmunozv04
f2155abad3 Bump version 2025-05-30 21:56:45 +02:00
David
edbea793ed Merge pull request #312 from dmunozv04/fix-311
Fixes constant "new decive connected"
2025-05-30 21:55:46 +02:00
dmunozv04
df629805c2 Fixes constant "new decive connected" 2025-05-30 21:55:00 +02:00
dmunozv04
ad9834b9f0 Bump version 2025-05-30 09:56:24 +02:00
David
97e7b31d9c Merge pull request #310 from dmunozv04/fix-error-401-connect
Fix error 401 connect
2025-05-28 23:52:45 +02:00
David
b5d275e01e Merge branch 'main' into fix-error-401-connect 2025-05-28 23:48:35 +02:00
pre-commit-ci[bot]
98c1211b09 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-05-28 21:48:24 +00:00
David
57f33ec354 Merge pull request #309 from dmunozv04/http-tracing
Add http tracing
2025-05-28 23:47:04 +02:00
pre-commit-ci[bot]
9f6a18a006 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-05-28 21:43:15 +00:00
dmunozv04
fd6f0d7283 Attempt to fix the issue 2025-05-28 00:18:32 +02:00
dmunozv04
166e238f41 Mimick YouTube iOS app 2025-05-25 14:02:59 +02:00
dmunozv04
8ecaa7e86f Add http tracing 2025-05-22 00:33:36 +02:00
dmunozv04
cafdf4f962 Bump version 2025-05-19 10:24:05 +02:00
David
3a80c76fb6 Merge pull request #292 from dmunozv04/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2025-05-19 10:16:30 +02:00
pre-commit-ci[bot]
a4f6462026 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.4 → v0.11.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.4...v0.11.9)
2025-05-19 10:16:12 +02:00
David
c4571ad90b Merge pull request #296 from dmunozv04/dependabot/pip/aiohttp-3.11.18
Bump aiohttp from 3.11.16 to 3.11.18
2025-05-19 10:15:36 +02:00
dependabot[bot]
c51c47b566 Bump aiohttp from 3.11.16 to 3.11.18
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.11.16 to 3.11.18.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.11.16...v3.11.18)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.11.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-19 08:15:16 +00:00
David
72ada4558e Merge pull request #300 from dmunozv04/improve-watchdog
Improve watchdog/ Fix ad muting/skipping
2025-05-14 23:56:03 +02:00
David
6219cfe0d0 Merge branch 'main' into improve-watchdog 2025-05-14 23:54:57 +02:00
pre-commit-ci[bot]
b2dfe35698 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-05-14 21:54:36 +00:00
David
af35982ac0 Merge pull request #278 from sternma/minimum-skip-length
Add support for minimum skip length
2025-05-14 23:52:17 +02:00
Matthew Stern
fb30d7e4cb Merge branch 'dmunozv04:main' into minimum-skip-length 2025-05-14 00:16:42 -04:00
pre-commit-ci[bot]
f04f7560b2 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-05-14 04:16:24 +00:00
Matthew Stern
d55fceda18 Merge pull request #1 from SimoMay/minimum-skip-length
Fix feedback for #278 (duplicate code)
2025-05-14 00:16:16 -04:00
dmunozv04
a6dacc1d84 Fix adPlaying logic 2025-05-11 01:37:28 +02:00
dmunozv04
a67e3eb860 Compare strings 2025-05-10 22:28:01 +02:00
dmunozv04
a9d64af2ac Fix muting not working 2025-05-10 18:27:18 +02:00
dmunozv04
fc8d1770cd Improve watchdog 2025-05-03 21:42:13 +02:00
Mohamed
82ce3e60e9 Remove redundant code for minimum skip length configuration 2025-04-29 17:52:11 +02:00
Mohamed
4417592b6b Fix: Correct line length error in setup wizard label
Split long string literal in the Minimum Skip Length label to adhere
to maximum line length constraints (FLK-E501)
2025-04-29 17:39:52 +02:00
David
7187ec5286 Merge pull request #293 from dmunozv04:use-mutex-all-commands
Use command mutex for all commands
2025-04-15 13:58:59 +02:00
dmunozv04
6ea500222e Use command mutex for all commands 2025-04-15 13:57:35 +02:00
David
905a74c103 Use arm64 runners for binary creation (#236)
* test the new arm64 runners
https://github.blog/changelog/2025-01-16-linux-arm64-hosted-runners-now-available-for-free-in-public-repositories-public-preview/

* Enable build provenance attestation

* Add permissions

* enable cross on arm64 linux builds

* update pyapp to v0.27
2025-04-12 20:14:04 +02:00
David
35bc1ea6dc Merge pull request #288 from dmunozv04/create_event_loop
Use asyncio.run to create new event loop
2025-04-12 20:02:47 +02:00
dmunozv04
c3f28f7cd1 Use asyncio.run to create new event loop 2025-04-12 19:59:54 +02:00
David
724b88a2ba Merge pull request #289 from dmunozv04/dependabot/pip/aiohttp-3.11.16
Bump aiohttp from 3.11.13 to 3.11.16
2025-04-12 19:57:15 +02:00
dependabot[bot]
18105e4aa8 Bump aiohttp from 3.11.13 to 3.11.16
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.11.13 to 3.11.16.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.11.13...v3.11.16)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.11.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-12 17:56:59 +00:00
David
8f60a2650a Merge pull request #286 from dmunozv04/dependabot/pip/rich-14.0.0
Bump rich from 13.9.4 to 14.0.0
2025-04-12 19:55:50 +02:00
dependabot[bot]
5888b8f9c6 Bump rich from 13.9.4 to 14.0.0
Bumps [rich](https://github.com/Textualize/rich) from 13.9.4 to 14.0.0.
- [Release notes](https://github.com/Textualize/rich/releases)
- [Changelog](https://github.com/Textualize/rich/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Textualize/rich/compare/v13.9.4...v14.0.0)

---
updated-dependencies:
- dependency-name: rich
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-12 17:55:06 +00:00
David
f890434dbf Merge pull request #279 from dmunozv04/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2025-04-12 19:54:37 +02:00
pre-commit-ci[bot]
6a32e42671 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.10 → v0.11.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.10...v0.11.4)
2025-04-12 19:54:03 +02:00
dependabot[bot]
ad35ecc778 Bump textual from 1.0.0 to 2.1.2 (#265)
* Bump textual from 1.0.0 to 2.1.2

Bumps [textual](https://github.com/Textualize/textual) from 1.0.0 to 2.1.2.
- [Release notes](https://github.com/Textualize/textual/releases)
- [Changelog](https://github.com/Textualize/textual/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Textualize/textual/compare/v1.0.0...v2.1.2)

---
updated-dependencies:
- dependency-name: textual
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Quote markdown link

Compatibility with textual >= 2.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dmunozv04 <39565245+dmunozv04@users.noreply.github.com>
2025-04-12 19:53:11 +02:00
David
e9925b02c3 Merge pull request #290 from dmunozv04/line-length-100
Change max line length to 100
2025-04-12 19:51:30 +02:00
dmunozv04
ffdeb4579e Ruff formatting 2025-04-12 19:49:25 +02:00
dmunozv04
70ecf78f01 Set line length to 100 2025-04-12 19:49:15 +02:00
David
1e10ab4e29 Merge pull request #277 from sternma/feat-use-upstream-autoplay
remove set_auto_play_mode
2025-04-12 15:09:50 +02:00
Matthew Stern
dee71939c5 Merge branch 'dmunozv04:main' into feat-use-upstream-autoplay 2025-03-27 09:55:15 -04:00
Matthew Stern
2dbeed99bc Merge branch 'dmunozv04:main' into minimum-skip-length 2025-03-27 09:53:06 -04:00
pre-commit-ci[bot]
2124fff81b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-03-14 23:30:01 +00:00
Matthew Stern
aabf5aa2bc Merge remote-tracking branch 'origin/minimum-skip-length' into minimum-skip-length 2025-03-14 19:29:49 -04:00
Matthew Stern
068623bb03 Add input validation for min skip length 2025-03-14 19:28:54 -04:00
pre-commit-ci[bot]
b93f480848 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-03-14 23:11:31 +00:00
Matthew Stern
e9fdc49480 Add minimum skip length input 2025-03-14 19:06:15 -04:00
Matthew Stern
4a55fe9539 set minimum skip length to 1 2025-03-14 19:05:55 -04:00
Matthew Stern
328e70a175 add minimum skip length manager 2025-03-14 19:00:05 -04:00
Matthew Stern
7a1d8967ae set default minimum skip length to 1 2025-03-14 18:42:47 -04:00
pre-commit-ci[bot]
33b0b6d224 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-03-14 18:39:52 -04:00
bourkemcrobbo
e0c4322524 Updated setup to match new format. Set default value of skip length to 0 so user has to explicitly enable functionality 2025-03-14 18:39:52 -04:00
pre-commit-ci[bot]
c360e2582e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-03-14 18:39:16 -04:00
bourkemcrobbo
7b3e618628 Fixed bad static method argument 2025-03-14 18:39:16 -04:00
pre-commit-ci[bot]
886997beab [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-03-14 18:39:16 -04:00
bourkemcrobbo
ee786a53b9 Fixed bad static method argument 2025-03-14 18:35:54 -04:00
bourkemcrobbo
0b785da448 Added support for specifying minimum skip length 2025-03-14 18:35:54 -04:00
Matthew Stern
ca9b7ee73a remove set_auto_play_mode 2025-03-13 23:19:44 -04:00
16 changed files with 433 additions and 243 deletions

View File

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

View File

@@ -57,6 +57,9 @@ 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
@@ -76,7 +79,7 @@ jobs:
cpu_variant: v1
release_suffix: x86_64-linux-v1
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
os: ubuntu-24.04-arm
cross: true
release_suffix: aarch64-linux
# Windows
@@ -100,7 +103,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.24.0
PYAPP_VERSION: v0.27.0
steps:
- name: Checkout
@@ -158,6 +161,11 @@ 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,13 +19,13 @@ 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.9.10
rev: v0.12.7
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- id: ruff-format
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.44.0
rev: v0.45.0
hooks:
- id: markdownlint
args: ["--fix"]

View File

@@ -12,6 +12,7 @@
"skip_count_tracking": true,
"mute_ads": true,
"skip_ads": true,
"minimum_skip_length": 1,
"auto_play": true,
"join_name": "iSponsorBlockTV",
"apikey": "",
@@ -19,5 +20,6 @@
{"id": "",
"name": ""
}
]
],
"use_proxy": false
}

View File

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

View File

@@ -1,10 +1,10 @@
aiohttp==3.11.13
aiohttp==3.12.14
appdirs==1.4.4
async-cache==1.1.1
pyytlounge==2.3.0
rich==13.9.4
rich==14.1.0
ssdp==1.3.0
textual==1.0.0
textual==5.3.0
textual-slider==0.2.0
xmltodict==0.14.2
rich_click==1.8.8
rich_click==1.8.9

View File

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

View File

@@ -33,9 +33,7 @@ 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

@@ -5,16 +5,14 @@ import aiohttp
from . import api_helpers, ytlounge
# Constants for user input prompts
USE_PROXY_PROMPT = "Do you want to use system-wide proxy? (y/N)"
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 = (
@@ -22,13 +20,15 @@ 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: "
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) "
@@ -46,8 +46,8 @@ def get_yn_input(prompt):
return None
async def create_web_session():
return aiohttp.ClientSession()
async def create_web_session(use_proxy):
return aiohttp.ClientSession(trust_env=use_proxy)
async def pair_device(web_session: aiohttp.ClientSession):
@@ -76,8 +76,12 @@ async def pair_device(web_session: aiohttp.ClientSession):
def main(config, debug: bool) -> None:
print("Welcome to the iSponsorBlockTV cli setup wizard")
choice = get_yn_input(USE_PROXY_PROMPT)
config.use_proxy = choice == "y"
loop = asyncio.get_event_loop_policy().get_event_loop()
web_session = loop.run_until_complete(create_web_session())
web_session = loop.run_until_complete(create_web_session(config.use_proxy))
if debug:
loop.set_debug(True)
asyncio.set_event_loop(loop)
@@ -121,15 +125,11 @@ 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
@@ -148,9 +148,7 @@ 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:
@@ -182,6 +180,21 @@ 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

@@ -0,0 +1,25 @@
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

@@ -83,9 +83,7 @@ class Handler(ssdp.aio.SSDP):
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"
)
raise NotImplementedError("Request received is not implemented, this is a client")
async def find_youtube_app(web_session, url_location):
@@ -121,9 +119,7 @@ async def discover(web_session):
family, _ = 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)
)
connect = loop.create_datagram_endpoint(handler, family=family, local_addr=(ip_address, None))
transport, _ = await connect
target = network.MULTICAST_ADDRESS_IPV4, network.PORT

View File

@@ -41,8 +41,10 @@ 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.use_proxy = False
self.__load()
def validate(self):
@@ -65,9 +67,7 @@ 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")
@@ -93,14 +93,8 @@ class Config:
f"{github_wiki_base_url}/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:"
),
("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",
)
print("Exiting in 10 seconds...")
@@ -134,15 +128,13 @@ class Config:
@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")
@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("--setup", is_flag=True, help="Setup the program graphically", hidden=True)
@click.option(
"--setup-cli",
is_flag=True,
@@ -150,18 +142,17 @@ class Config:
hidden=True,
)
@click.pass_context
def cli(ctx, data, debug, setup, setup_cli):
def cli(ctx, data, debug, http_tracing, 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
sh = logging.StreamHandler()
sh.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
sh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger.addHandler(sh)
if debug:
@@ -201,7 +192,7 @@ def start(ctx):
"""Start the main program"""
config = Config(ctx.obj["data_dir"])
config.validate()
main.main(config, ctx.obj["debug"])
main.main(config, ctx.obj["debug"], ctx.obj["http_tracing"])
# Create fake "self" group to show pyapp options in help menu
@@ -211,9 +202,7 @@ 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

@@ -7,6 +7,7 @@ from typing import Optional
import aiohttp
from . import api_helpers, ytlounge
from .debug_helpers import AiohttpTracer
class DeviceListener:
@@ -87,9 +88,7 @@ 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("Playing video %s with %d segments", state.videoId, len(segments))
if segments: # If there are segments
await self.time_to_segment(segments, state.currentTime, time_start)
@@ -107,9 +106,7 @@ class DeviceListener:
if is_within_start_range or is_beyond_current_position:
next_segment = segment
start_next_segment = (
position if is_within_start_range else segment_start
)
start_next_segment = position if is_within_start_range else segment_start
break
if start_next_segment:
time_to_next = (
@@ -148,9 +145,7 @@ 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()
@@ -159,14 +154,30 @@ def handle_signal(signum, frame):
raise KeyboardInterrupt()
async def main_async(config, debug):
async def main_async(config, debug, http_tracing):
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)
web_session = aiohttp.ClientSession(connector=tcp_connector)
# 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(
trust_env=config.use_proxy, connector=tcp_connector, trace_configs=[trace_config]
)
else:
web_session = aiohttp.ClientSession(trust_env=config.use_proxy, 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)
@@ -187,11 +198,8 @@ async def main_async(config, debug):
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()
def main(config, debug, http_tracing):
asyncio.run(main_async(config, debug, http_tracing))

View File

@@ -383,3 +383,9 @@ MigrationScreen {
padding: 1;
height: auto;
}
/* Use Proxy */
#useproxy-container{
padding: 1;
height: auto;
}

View File

@@ -13,6 +13,7 @@ from textual.containers import (
ScrollableContainer,
Vertical,
)
from textual.css.query import NoMatches
from textual.events import Click
from textual.screen import Screen
from textual.validation import Function
@@ -122,9 +123,7 @@ 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):
@@ -204,9 +203,7 @@ 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",
)
@@ -237,7 +234,7 @@ class AddDevice(ModalWithClickExit):
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
self.web_session = aiohttp.ClientSession()
self.web_session = aiohttp.ClientSession(trust_env=config.use_proxy)
self.api_helper = api_helpers.ApiHelper(config, self.web_session)
self.devices_discovered_dial = []
@@ -255,19 +252,13 @@ 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(
@@ -311,7 +302,11 @@ class AddDevice(ModalWithClickExit):
async def task_discover_devices(self):
devices_found = await self.api_helper.discover_youtube_devices_dial()
list_widget: SelectionList = self.query_one("#dial-devices-list")
try:
list_widget: SelectionList = self.query_one("#dial-devices-list")
except NoMatches:
# The widget was not found, probably the screen was dismissed
return
list_widget.clear_options()
if devices_found:
# print(devices_found)
@@ -332,9 +327,7 @@ 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")
@@ -348,7 +341,7 @@ class AddDevice(ModalWithClickExit):
pairing_code = int(
pairing_code.replace("-", "").replace(" ", "")
) # remove dashes and spaces
device_name = self.parent.query_one("#device-name-input").value
device_name = self.query_one("#device-name-input").value
paired = False
try:
paired = await lounge_controller.pair(pairing_code)
@@ -382,9 +375,7 @@ 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):
@@ -396,7 +387,7 @@ class AddChannel(ModalWithClickExit):
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
web_session = aiohttp.ClientSession()
web_session = aiohttp.ClientSession(trust_env=config.use_proxy)
self.api_helper = api_helpers.ApiHelper(config, web_session)
def compose(self) -> ComposeResult:
@@ -422,9 +413,7 @@ 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"):
@@ -432,9 +421,7 @@ 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",
@@ -457,15 +444,12 @@ 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",
)
@@ -491,9 +475,7 @@ 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...")
@@ -502,9 +484,7 @@ 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:
@@ -517,9 +497,7 @@ 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)
@@ -531,9 +509,7 @@ 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
@@ -624,9 +600,7 @@ class DevicesManager(Vertical):
def compose(self) -> ComposeResult:
yield Label("Devices", classes="title")
with Horizontal(id="add-device-button-container"):
yield Button(
"Add Device", id="add-device", classes="button-100 button-small"
)
yield Button("Add Device", id="add-device", classes="button-100 button-small")
for device in self.devices:
yield Device(device, tooltip="Click to edit")
@@ -671,7 +645,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."
)
@@ -690,7 +664,7 @@ class ApiKeyManager(Vertical):
@on(Button.Pressed, "#api-key-view")
def pressed_api_key_view(self, event: Button.Pressed):
if "Show" in event.button.label:
if "Show" in str(event.button.label):
event.button.label = "Hide key"
self.query_one("#api-key-input").password = False
else:
@@ -723,6 +697,43 @@ 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."""
@@ -814,23 +825,18 @@ class ChannelWhitelistManager(Vertical):
id="channel-whitelist-subtitle",
)
yield Label(
(
":warning: [#FF0000]You need to set your YouTube Api Key in order to"
" use this feature"
),
("⚠️ [#FF0000]You need to set your YouTube Api Key in order to use this feature"),
id="warning-no-key",
)
with Horizontal(id="add-channel-button-container"):
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:
@@ -842,18 +848,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):
@@ -886,11 +892,45 @@ class AutoPlayManager(Vertical):
self.config.auto_play = event.checkbox.value
class ISponsorBlockTVSetupMainScreen(Screen):
class UseProxyManager(Vertical):
"""Manager for proxy use, allows enabling/disabling use of proxy."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Use proxy", classes="title")
yield Label(
"This feature allows application to use system proxy,"
" if it is set in environment variables."
" This parameter will be passed in all [i]aiohttp.ClientSession[/i]"
' calls. For further information, see "[i]trust_env[/i]" section at'
" [link='https://docs.aiohttp.org/en/stable/client_reference.html']"
"aiohttp documentation[/link].",
classes="subtitle",
id="useproxy-subtitle",
)
with Horizontal(id="useproxy-container"):
yield Checkbox(
value=self.config.use_proxy,
id="useproxy-switch",
label="Use proxy",
)
@on(Checkbox.Changed, "#useproxy-switch")
def changed_skip(self, event: Checkbox.Changed):
self.config.use_proxy = event.checkbox.value
class ISponsorBlockTVSetup(App):
TITLE = "iSponsorBlockTV"
SUB_TITLE = "Setup Wizard"
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
AUTO_FOCUS = None
CSS_PATH = ( # tcss is the recommended extension for textual css files
"setup-wizard-style.tcss"
)
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
@@ -902,12 +942,15 @@ 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"
)
yield MinimumSkipLengthManager(
config=self.config,
id="minimum-skip-length-manager",
classes="container",
)
yield SkipCountTrackingManager(
config=self.config, id="count-segments-manager", classes="container"
)
@@ -917,12 +960,9 @@ 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")
yield UseProxyManager(config=self.config, id="useproxy-manager", classes="container")
def on_mount(self) -> None:
if self.check_for_old_config_entries():
@@ -946,36 +986,13 @@ class ISponsorBlockTVSetupMainScreen(Screen):
@on(Input.Changed, "#api-key-input")
def changed_api_key(self, event: Input.Changed):
try: # ChannelWhitelist might not be mounted
# Show if no api key is set and at least one channel is in the whitelist
self.app.query_one("#warning-no-key").display = (
not event.input.value
) and self.config.channel_whitelist
except BaseException:
self.app.query_one("#warning-no-key").display = bool(
(not event.input.value) and self.config.channel_whitelist
)
except NoMatches:
pass
class ISponsorBlockTVSetup(App):
CSS_PATH = ( # tcss is the recommended extension for textual css files
"setup-wizard-style.tcss"
)
# Bindings for the whole app here, so they are available in all screens
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
self.main_screen = ISponsorBlockTVSetupMainScreen(config=self.config)
def on_mount(self) -> None:
self.push_screen(self.main_screen)
def action_save(self) -> None:
self.main_screen.action_save()
def action_exit_modal(self) -> None:
self.main_screen.action_exit_modal()
def main(config):
app = ISponsorBlockTVSetup(config)
app.run()

View File

@@ -1,10 +1,14 @@
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
create_task = asyncio.create_task
@@ -18,9 +22,7 @@ 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
@@ -32,6 +34,8 @@ 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
@@ -40,21 +44,58 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# Ensures that we still are subscribed to the lounge
async def _watchdog(self):
await asyncio.sleep(
35
) # YouTube sends at least a message every 30 seconds (no-op or any other)
"""
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()
try:
self.subscribe_task.cancel()
except BaseException:
pass
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
# Subscribe to the lounge and start the watchdog
async def subscribe_monitored(self, callback):
self.callback = callback
try:
# Stop existing watchdog if running
if self.subscribe_task_watchdog and not self.subscribe_task_watchdog.done():
self.watchdog_running = False
self.subscribe_task_watchdog.cancel()
except BaseException:
pass # No watchdog task
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
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
return self.subscribe_task
@@ -63,13 +104,9 @@ 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})")
# (Re)start the watchdog
try:
self.subscribe_task_watchdog.cancel()
except BaseException:
pass
finally:
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
# Update last event time for the watchdog
self.last_event_time = asyncio.get_event_loop().time()
# A bunch of events useful to detect ads playing,
# and the next video before it starts playing
# (that way we can get the segments)
@@ -87,7 +124,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
create_task(self.mute(False, override=True))
elif event_type == "onAdStateChange":
data = args[0]
if data["adState"] == "0": # Ad is not playing
if data["adState"] == "0" and data["currentTime"] != "0": # Ad is not playing
self.logger.info("Ad has ended, unmuting")
create_task(self.mute(False, override=True))
elif (
@@ -96,9 +133,7 @@ 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
@@ -107,9 +142,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.volume_state = args[0]
# 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))
@@ -120,15 +153,14 @@ 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))
elif (
if (
self.skip_ads and data["isSkipEnabled"] == "true"
): # YouTube uses strings for booleans
self.logger.info("Ad can be skipped, skipping")
create_task(self.skip_ad())
create_task(self.mute(False, override=True))
elif (
self.mute_ads
): # Seen multiple other adStates, assuming they are all ads
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))
@@ -151,9 +183,7 @@ 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))
@@ -167,7 +197,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# Set the volume to a specific value (0-100)
async def set_volume(self, volume: int) -> None:
await super()._command("setVolume", {"volume": volume})
await self._command("setVolume", {"volume": volume})
async def mute(self, mute: bool, override: bool = False) -> None:
"""
@@ -187,21 +217,16 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if override or not self.volume_state.get("muted", "false") == mute_str:
self.volume_state["muted"] = mute_str
# YouTube wants the volume when unmuting, so we send it
await super()._command(
await self._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 super()._command("getNowPlaying")
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)
@@ -215,3 +240,110 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if self.conn is not None:
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",
}
async def connect(self) -> bool:
"""Attempt to connect using the previously set tokens"""
if not self.linked():
raise NotLinkedException("Not linked")
connect_body = {
"id": self.auth.screen_id,
"mdx-version": "3",
"TYPE": "xmlhttp",
"theme": "cl",
"sessionSource": "MDX_SESSION_SOURCE_UNKNOWN",
"connectParams": '{"setStatesParams": "{"playbackSpeed":0}"}',
"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,
}
connect_url = f"{api_base}/bc/bind"
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()
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
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