Compare commits

...

85 Commits
v2.4.0 ... v2.6

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] [analyzers.meta]
runtime_version = "3.x.x" runtime_version = "3.x.x"
max_line_length = 100

View File

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

View File

@@ -19,13 +19,13 @@ repos:
- id: mixed-line-ending # replaces or checks mixed line ending - id: mixed-line-ending # replaces or checks mixed line ending
- id: trailing-whitespace # checks for trailing whitespace - id: trailing-whitespace # checks for trailing whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.10 rev: v0.12.7
hooks: hooks:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
- id: ruff-format - id: ruff-format
- repo: https://github.com/igorshubovych/markdownlint-cli - repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.44.0 rev: v0.45.0
hooks: hooks:
- id: markdownlint - id: markdownlint
args: ["--fix"] args: ["--fix"]

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
aiohttp==3.11.13 aiohttp==3.12.14
appdirs==1.4.4 appdirs==1.4.4
async-cache==1.1.1 async-cache==1.1.1
pyytlounge==2.3.0 pyytlounge==2.3.0
rich==13.9.4 rich==14.1.0
ssdp==1.3.0 ssdp==1.3.0
textual==1.0.0 textual==5.3.0
textual-slider==0.2.0 textual-slider==0.2.0
xmltodict==0.14.2 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.skip_count_tracking = config.skip_count_tracking
self.web_session = web_session self.web_session = web_session
self.num_devices = len(config.devices) 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 # Not used anymore, maybe it can stay here a little longer
@AsyncLRU(maxsize=10) @AsyncLRU(maxsize=10)
@@ -101,20 +102,14 @@ class ApiHelper:
if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]: if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]:
sub_count = "Hidden" sub_count = "Hidden"
else: else:
sub_count = int( sub_count = int(channel_data["items"][0]["statistics"]["subscriberCount"])
channel_data["items"][0]["statistics"]["subscriberCount"]
)
sub_count = format(sub_count, "_") sub_count = format(sub_count, "_")
channels.append( channels.append((i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count))
(i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count)
)
return channels return channels
@list_to_tuple # Convert list to tuple so it can be used as a key in the cache @list_to_tuple # Convert list to tuple so it can be used as a key in the cache
@AsyncConditionalTTL( @AsyncConditionalTTL(time_to_live=300, maxsize=10) # 5 minutes for non-locked segments
time_to_live=300, maxsize=10
) # 5 minutes for non-locked segments
async def get_segments(self, vid_id): async def get_segments(self, vid_id):
if await self.is_whitelisted(vid_id): if await self.is_whitelisted(vid_id):
return ( return (
@@ -132,9 +127,7 @@ class ApiHelper:
} }
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed
async with self.web_session.get( async with self.web_session.get(url, headers=headers, params=params) as response:
url, headers=headers, params=params
) as response:
response_json = await response.json() response_json = await response.json()
if response.status != 200: if response.status != 200:
response_text = await response.text() response_text = await response.text()
@@ -147,10 +140,10 @@ class ApiHelper:
if str(i["videoID"]) == str(vid_id): if str(i["videoID"]) == str(vid_id):
response_json = i response_json = i
break break
return self.process_segments(response_json) return self.process_segments(response_json, self.minimum_skip_length)
@staticmethod @staticmethod
def process_segments(response): def process_segments(response, minimum_skip_length):
segments = [] segments = []
ignore_ttl = True ignore_ttl = True
try: try:
@@ -192,7 +185,9 @@ class ApiHelper:
segment_dict["start"] = segment_before_start segment_dict["start"] = segment_before_start
segment_dict["UUID"].extend(segment_before_UUID) segment_dict["UUID"].extend(segment_before_UUID)
segments.pop() segments.pop()
segments.append(segment_dict) # Only add segments greater than minimum skip length
if segment_dict["end"] - segment_dict["start"] > minimum_skip_length:
segments.append(segment_dict)
except BaseException: except BaseException:
pass pass
return segments, ignore_ttl return segments, ignore_ttl

View File

@@ -33,9 +33,7 @@ class AsyncConditionalTTL:
def __init__(self, time_to_live, maxsize): def __init__(self, time_to_live, maxsize):
super().__init__(maxsize=maxsize) super().__init__(maxsize=maxsize)
self.time_to_live = ( self.time_to_live = datetime.timedelta(seconds=time_to_live) if time_to_live else None
datetime.timedelta(seconds=time_to_live) if time_to_live else None
)
self.maxsize = maxsize self.maxsize = maxsize

View File

@@ -5,16 +5,14 @@ import aiohttp
from . import api_helpers, ytlounge from . import api_helpers, ytlounge
# Constants for user input prompts # Constants for user input prompts
USE_PROXY_PROMPT = "Do you want to use system-wide proxy? (y/N)"
ATVS_REMOVAL_PROMPT = ( ATVS_REMOVAL_PROMPT = (
"Do you want to remove the legacy 'atvs' entry (the app won't start" "Do you want to remove the legacy 'atvs' entry (the app won't start with it present)? (y/N) "
" with it present)? (y/N) "
) )
PAIRING_CODE_PROMPT = "Enter pairing code (found in Settings - Link with TV code): " 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) " 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) " CHANGE_API_KEY_PROMPT = "API key already specified. Change it? (y/N) "
ADD_API_KEY_PROMPT = ( ADD_API_KEY_PROMPT = "API key only needed for the channel whitelist function. Add it? (y/N) "
"API key only needed for the channel whitelist function. Add it? (y/N) "
)
ENTER_API_KEY_PROMPT = "Enter your API key: " ENTER_API_KEY_PROMPT = "Enter your API key: "
CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) " CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) "
ENTER_SKIP_CATEGORIES_PROMPT = ( ENTER_SKIP_CATEGORIES_PROMPT = (
@@ -22,13 +20,15 @@ ENTER_SKIP_CATEGORIES_PROMPT = (
" selfpromo, exclusive_access, interaction, poi_highlight, intro, outro," " selfpromo, exclusive_access, interaction, poi_highlight, intro, outro,"
" preview, filler, music_offtopic]:\n" " preview, filler, music_offtopic]:\n"
) )
WHITELIST_CHANNELS_PROMPT = ( WHITELIST_CHANNELS_PROMPT = "Do you want to whitelist any channels from being ad-blocked? (y/N) "
"Do you want to whitelist any channels from being ad-blocked? (y/N) "
)
SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: ' SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: '
SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: " SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: "
ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: " ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: "
ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: " 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 = ( REPORT_SKIPPED_SEGMENTS_PROMPT = (
"Do you want to report skipped segments to sponsorblock. Only the segment" "Do you want to report skipped segments to sponsorblock. Only the segment"
" UUID will be sent? (Y/n) " " UUID will be sent? (Y/n) "
@@ -46,8 +46,8 @@ def get_yn_input(prompt):
return None return None
async def create_web_session(): async def create_web_session(use_proxy):
return aiohttp.ClientSession() return aiohttp.ClientSession(trust_env=use_proxy)
async def pair_device(web_session: aiohttp.ClientSession): 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: def main(config, debug: bool) -> None:
print("Welcome to the iSponsorBlockTV cli setup wizard") 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() 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: if debug:
loop.set_debug(True) loop.set_debug(True)
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
@@ -121,15 +125,11 @@ def main(config, debug: bool) -> None:
if choice == "y": if choice == "y":
categories = input(ENTER_SKIP_CATEGORIES_PROMPT) categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
skip_categories = categories.replace(",", " ").split(" ") skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [ skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings
x for x in skip_categories if x != ""
] # Remove empty strings
else: else:
categories = input(ENTER_SKIP_CATEGORIES_PROMPT) categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
skip_categories = categories.replace(",", " ").split(" ") skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [ skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings
x for x in skip_categories if x != ""
] # Remove empty strings
config.skip_categories = skip_categories config.skip_categories = skip_categories
channel_whitelist = config.channel_whitelist channel_whitelist = config.channel_whitelist
@@ -148,9 +148,7 @@ def main(config, debug: bool) -> None:
if channel == "/exit": if channel == "/exit":
break break
task = loop.create_task( task = loop.create_task(api_helper.search_channels(channel, apikey, web_session))
api_helper.search_channels(channel, apikey, web_session)
)
loop.run_until_complete(task) loop.run_until_complete(task)
results = task.result() results = task.result()
if len(results) == 0: if len(results) == 0:
@@ -182,6 +180,21 @@ def main(config, debug: bool) -> None:
config.channel_whitelist = channel_whitelist 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) choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT)
config.skip_count_tracking = choice != "n" 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"]) self.devices.append(headers["location"])
def request_received(self, request: ssdp.messages.SSDPRequest, addr): def request_received(self, request: ssdp.messages.SSDPRequest, addr):
raise NotImplementedError( raise NotImplementedError("Request received is not implemented, this is a client")
"Request received is not implemented, this is a client"
)
async def find_youtube_app(web_session, url_location): async def find_youtube_app(web_session, url_location):
@@ -121,9 +119,7 @@ async def discover(web_session):
family, _ = network.get_best_family(bind, network.PORT) family, _ = network.get_best_family(bind, network.PORT)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
ip_address = get_ip() ip_address = get_ip()
connect = loop.create_datagram_endpoint( connect = loop.create_datagram_endpoint(handler, family=family, local_addr=(ip_address, None))
handler, family=family, local_addr=(ip_address, None)
)
transport, _ = await connect transport, _ = await connect
target = network.MULTICAST_ADDRESS_IPV4, network.PORT target = network.MULTICAST_ADDRESS_IPV4, network.PORT

View File

@@ -41,8 +41,10 @@ class Config:
self.skip_count_tracking = True self.skip_count_tracking = True
self.mute_ads = False self.mute_ads = False
self.skip_ads = False self.skip_ads = False
self.minimum_skip_length = 1
self.auto_play = True self.auto_play = True
self.join_name = "iSponsorBlockTV" self.join_name = "iSponsorBlockTV"
self.use_proxy = False
self.__load() self.__load()
def validate(self): def validate(self):
@@ -65,9 +67,7 @@ class Config:
sys.exit() sys.exit()
self.devices = [Device(i) for i in self.devices] self.devices = [Device(i) for i in self.devices]
if not self.apikey and self.channel_whitelist: if not self.apikey and self.channel_whitelist:
raise ValueError( raise ValueError("No youtube API key found and channel whitelist is not empty")
"No youtube API key found and channel whitelist is not empty"
)
if not self.skip_categories: if not self.skip_categories:
self.skip_categories = ["sponsor"] self.skip_categories = ["sponsor"]
print("No categories found, using default: sponsor") print("No categories found, using default: sponsor")
@@ -93,14 +93,8 @@ class Config:
f"{github_wiki_base_url}/Installation#Docker" f"{github_wiki_base_url}/Installation#Docker"
) )
print( print(
( ("This image has recently been updated to v2, and requires changes."),
"This image has recently been updated to v2, and requires" ("Please read this for more information on how to upgrade to V2:"),
" changes."
),
(
"Please read this for more information on how to upgrade"
" to V2:"
),
f"{github_wiki_base_url}/Migrate-from-V1-to-V2", f"{github_wiki_base_url}/Migrate-from-V1-to-V2",
) )
print("Exiting in 10 seconds...") print("Exiting in 10 seconds...")
@@ -134,15 +128,13 @@ class Config:
@click.option( @click.option(
"--data", "--data",
"-d", "-d",
default=lambda: os.getenv("iSPBTV_data_dir") default=lambda: os.getenv("iSPBTV_data_dir") or user_data_dir("iSponsorBlockTV", "dmunozv04"),
or user_data_dir("iSponsorBlockTV", "dmunozv04"),
help="data directory", help="data directory",
) )
@click.option("--debug", is_flag=True, help="debug mode") @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 # legacy commands as arguments
@click.option( @click.option("--setup", is_flag=True, help="Setup the program graphically", hidden=True)
"--setup", is_flag=True, help="Setup the program graphically", hidden=True
)
@click.option( @click.option(
"--setup-cli", "--setup-cli",
is_flag=True, is_flag=True,
@@ -150,18 +142,17 @@ class Config:
hidden=True, hidden=True,
) )
@click.pass_context @click.pass_context
def cli(ctx, data, debug, setup, setup_cli): def cli(ctx, data, debug, http_tracing, setup, setup_cli):
"""iSponsorblockTV""" """iSponsorblockTV"""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["data_dir"] = data ctx.obj["data_dir"] = data
ctx.obj["debug"] = debug ctx.obj["debug"] = debug
ctx.obj["http_tracing"] = http_tracing
logger = logging.getLogger() logger = logging.getLogger()
ctx.obj["logger"] = logger ctx.obj["logger"] = logger
sh = logging.StreamHandler() sh = logging.StreamHandler()
sh.setFormatter( sh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
logger.addHandler(sh) logger.addHandler(sh)
if debug: if debug:
@@ -201,7 +192,7 @@ def start(ctx):
"""Start the main program""" """Start the main program"""
config = Config(ctx.obj["data_dir"]) config = Config(ctx.obj["data_dir"])
config.validate() 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 # 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") click.RichCommand("update", help="Update the package to the latest version")
) )
pyapp_group.add_command( pyapp_group.add_command(
click.Command( click.Command("remove", help="Remove the package, wiping the installation but not the data")
"remove", help="Remove the package, wiping the installation but not the data"
)
) )
pyapp_group.add_command( pyapp_group.add_command(
click.RichCommand( click.RichCommand(

View File

@@ -7,6 +7,7 @@ from typing import Optional
import aiohttp import aiohttp
from . import api_helpers, ytlounge from . import api_helpers, ytlounge
from .debug_helpers import AiohttpTracer
class DeviceListener: class DeviceListener:
@@ -87,9 +88,7 @@ class DeviceListener:
if state.videoId: if state.videoId:
segments = await self.api_helper.get_segments(state.videoId) segments = await self.api_helper.get_segments(state.videoId)
if state.state.value == 1: # Playing if state.state.value == 1: # Playing
self.logger.info( self.logger.info("Playing video %s with %d segments", state.videoId, len(segments))
"Playing video %s with %d segments", state.videoId, len(segments)
)
if segments: # If there are segments if segments: # If there are segments
await self.time_to_segment(segments, state.currentTime, time_start) await self.time_to_segment(segments, state.currentTime, time_start)
@@ -107,9 +106,7 @@ class DeviceListener:
if is_within_start_range or is_beyond_current_position: if is_within_start_range or is_beyond_current_position:
next_segment = segment next_segment = segment
start_next_segment = ( start_next_segment = position if is_within_start_range else segment_start
position if is_within_start_range else segment_start
)
break break
if start_next_segment: if start_next_segment:
time_to_next = ( time_to_next = (
@@ -148,9 +145,7 @@ class DeviceListener:
async def finish(devices, web_session, tcp_connector): async def finish(devices, web_session, tcp_connector):
await asyncio.gather( await asyncio.gather(*(device.cancel() for device in devices), return_exceptions=True)
*(device.cancel() for device in devices), return_exceptions=True
)
await web_session.close() await web_session.close()
await tcp_connector.close() await tcp_connector.close()
@@ -159,14 +154,30 @@ def handle_signal(signum, frame):
raise KeyboardInterrupt() 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() loop = asyncio.get_event_loop_policy().get_event_loop()
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
devices = [] # Save the devices to close them later devices = [] # Save the devices to close them later
if debug: if debug:
loop.set_debug(True) loop.set_debug(True)
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300) 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) api_helper = api_helpers.ApiHelper(config, web_session)
for i in config.devices: for i in config.devices:
device = DeviceListener(api_helper, config, i, debug, web_session) device = DeviceListener(api_helper, config, i, debug, web_session)
@@ -187,11 +198,8 @@ async def main_async(config, debug):
finally: finally:
await web_session.close() await web_session.close()
await tcp_connector.close() await tcp_connector.close()
loop.close()
print("Exited") print("Exited")
def main(config, debug): def main(config, debug, http_tracing):
loop = asyncio.get_event_loop() asyncio.run(main_async(config, debug, http_tracing))
loop.run_until_complete(main_async(config, debug))
loop.close()

View File

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

View File

@@ -13,6 +13,7 @@ from textual.containers import (
ScrollableContainer, ScrollableContainer,
Vertical, Vertical,
) )
from textual.css.query import NoMatches
from textual.events import Click from textual.events import Click
from textual.screen import Screen from textual.screen import Screen
from textual.validation import Function from textual.validation import Function
@@ -122,9 +123,7 @@ class Channel(Element):
if "name" in self.element_data: if "name" in self.element_data:
self.element_name = self.element_data["name"] self.element_name = self.element_data["name"]
else: else:
self.element_name = ( self.element_name = f"Unnamed channel with id {self.element_data['channel_id']}"
f"Unnamed channel with id {self.element_data['channel_id']}"
)
class ChannelRadio(RadioButton): class ChannelRadio(RadioButton):
@@ -204,9 +203,7 @@ class ExitScreen(ModalWithClickExit):
classes="button-100", classes="button-100",
), ),
Button("Save", variant="success", id="exit-save", classes="button-100"), Button("Save", variant="success", id="exit-save", classes="button-100"),
Button( Button("Don't save", variant="error", id="exit-no-save", classes="button-100"),
"Don't save", variant="error", id="exit-no-save", classes="button-100"
),
Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"), Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"),
id="dialog-exit", id="dialog-exit",
) )
@@ -237,7 +234,7 @@ class AddDevice(ModalWithClickExit):
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.config = config self.config = config
self.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.api_helper = api_helpers.ApiHelper(config, self.web_session)
self.devices_discovered_dial = [] self.devices_discovered_dial = []
@@ -255,19 +252,13 @@ class AddDevice(ModalWithClickExit):
id="add-device-dial-button", id="add-device-dial-button",
classes="button-switcher", classes="button-switcher",
) )
with ContentSwitcher( with ContentSwitcher(id="add-device-switcher", initial="add-device-pin-container"):
id="add-device-switcher", initial="add-device-pin-container"
):
with Container(id="add-device-pin-container"): with Container(id="add-device-pin-container"):
yield Input( yield Input(
placeholder=( placeholder=("Pairing Code (found in Settings - Link with TV code)"),
"Pairing Code (found in Settings - Link with TV code)"
),
id="pairing-code-input", id="pairing-code-input",
validators=[ validators=[
Function( Function(_validate_pairing_code, "Invalid pairing code format")
_validate_pairing_code, "Invalid pairing code format"
)
], ],
) )
yield Input( yield Input(
@@ -311,7 +302,11 @@ class AddDevice(ModalWithClickExit):
async def task_discover_devices(self): async def task_discover_devices(self):
devices_found = await self.api_helper.discover_youtube_devices_dial() 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() list_widget.clear_options()
if devices_found: if devices_found:
# print(devices_found) # print(devices_found)
@@ -332,9 +327,7 @@ class AddDevice(ModalWithClickExit):
@on(Input.Changed, "#pairing-code-input") @on(Input.Changed, "#pairing-code-input")
def changed_pairing_code(self, event: Input.Changed): def changed_pairing_code(self, event: Input.Changed):
self.query_one( self.query_one("#add-device-pin-add-button").disabled = not event.validation_result.is_valid
"#add-device-pin-add-button"
).disabled = not event.validation_result.is_valid
@on(Input.Submitted, "#pairing-code-input") @on(Input.Submitted, "#pairing-code-input")
@on(Button.Pressed, "#add-device-pin-add-button") @on(Button.Pressed, "#add-device-pin-add-button")
@@ -348,7 +341,7 @@ class AddDevice(ModalWithClickExit):
pairing_code = int( pairing_code = int(
pairing_code.replace("-", "").replace(" ", "") pairing_code.replace("-", "").replace(" ", "")
) # remove dashes and spaces ) # 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 paired = False
try: try:
paired = await lounge_controller.pair(pairing_code) paired = await lounge_controller.pair(pairing_code)
@@ -382,9 +375,7 @@ class AddDevice(ModalWithClickExit):
@on(SelectionList.SelectedChanged, "#dial-devices-list") @on(SelectionList.SelectedChanged, "#dial-devices-list")
def changed_device_list(self, event: SelectionList.SelectedChanged): def changed_device_list(self, event: SelectionList.SelectedChanged):
self.query_one( self.query_one("#add-device-dial-add-button").disabled = not event.selection_list.selected
"#add-device-dial-add-button"
).disabled = not event.selection_list.selected
class AddChannel(ModalWithClickExit): class AddChannel(ModalWithClickExit):
@@ -396,7 +387,7 @@ class AddChannel(ModalWithClickExit):
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.config = config self.config = config
web_session = aiohttp.ClientSession() web_session = aiohttp.ClientSession(trust_env=config.use_proxy)
self.api_helper = api_helpers.ApiHelper(config, web_session) self.api_helper = api_helpers.ApiHelper(config, web_session)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@@ -422,9 +413,7 @@ class AddChannel(ModalWithClickExit):
classes="button-switcher", classes="button-switcher",
) )
yield Label(id="add-channel-info", classes="subtitle") yield Label(id="add-channel-info", classes="subtitle")
with ContentSwitcher( with ContentSwitcher(id="add-channel-switcher", initial="add-channel-search-container"):
id="add-channel-switcher", initial="add-channel-search-container"
):
with Vertical(id="add-channel-search-container"): with Vertical(id="add-channel-search-container"):
if self.config.apikey: if self.config.apikey:
with Grid(id="add-channel-search-inputs"): with Grid(id="add-channel-search-inputs"):
@@ -432,9 +421,7 @@ class AddChannel(ModalWithClickExit):
placeholder="Enter channel name", placeholder="Enter channel name",
id="channel-name-input-search", id="channel-name-input-search",
) )
yield Button( yield Button("Search", id="search-channel-button", variant="success")
"Search", id="search-channel-button", variant="success"
)
yield RadioSet( yield RadioSet(
RadioButton(label="Search to see results", disabled=True), RadioButton(label="Search to see results", disabled=True),
id="channel-search-results", id="channel-search-results",
@@ -457,15 +444,12 @@ class AddChannel(ModalWithClickExit):
) )
with Vertical(id="add-channel-id-container"): with Vertical(id="add-channel-id-container"):
yield Input( yield Input(
placeholder=( placeholder=("Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"),
"Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"
),
id="channel-id-input", id="channel-id-input",
) )
yield Input( yield Input(
placeholder=( placeholder=(
"Enter channel name (only used to display in the config" "Enter channel name (only used to display in the config file)"
" file)"
), ),
id="channel-name-input-id", id="channel-name-input-id",
) )
@@ -491,9 +475,7 @@ class AddChannel(ModalWithClickExit):
async def handle_search_channel(self) -> None: async def handle_search_channel(self) -> None:
channel_name = self.query_one("#channel-name-input-search").value channel_name = self.query_one("#channel-name-input-search").value
if not channel_name: if not channel_name:
self.query_one("#add-channel-info").update( self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel name")
"[#ff0000]Please enter a channel name"
)
return return
self.query_one("#search-channel-button").disabled = True self.query_one("#search-channel-button").disabled = True
self.query_one("#add-channel-info").update("Searching...") self.query_one("#add-channel-info").update("Searching...")
@@ -502,9 +484,7 @@ class AddChannel(ModalWithClickExit):
try: try:
channels_list = await self.api_helper.search_channels(channel_name) channels_list = await self.api_helper.search_channels(channel_name)
except BaseException: except BaseException:
self.query_one("#add-channel-info").update( self.query_one("#add-channel-info").update("[#ff0000]Failed to search for channel")
"[#ff0000]Failed to search for channel"
)
self.query_one("#search-channel-button").disabled = False self.query_one("#search-channel-button").disabled = False
return return
for i in channels_list: for i in channels_list:
@@ -517,9 +497,7 @@ class AddChannel(ModalWithClickExit):
def handle_add_channel_search(self) -> None: def handle_add_channel_search(self) -> None:
channel = self.query_one("#channel-search-results").pressed_button.channel_data channel = self.query_one("#channel-search-results").pressed_button.channel_data
if not channel: if not channel:
self.query_one("#add-channel-info").update( self.query_one("#add-channel-info").update("[#ff0000]Please select a channel")
"[#ff0000]Please select a channel"
)
return return
self.query_one("#add-channel-info").update("Adding...") self.query_one("#add-channel-info").update("Adding...")
self.dismiss(channel) self.dismiss(channel)
@@ -531,9 +509,7 @@ class AddChannel(ModalWithClickExit):
channel_id = self.query_one("#channel-id-input").value channel_id = self.query_one("#channel-id-input").value
channel_name = self.query_one("#channel-name-input-id").value channel_name = self.query_one("#channel-name-input-id").value
if not channel_id: if not channel_id:
self.query_one("#add-channel-info").update( self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel ID")
"[#ff0000]Please enter a channel ID"
)
return return
if not channel_name: if not channel_name:
channel_name = channel_id channel_name = channel_id
@@ -624,9 +600,7 @@ class DevicesManager(Vertical):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("Devices", classes="title") yield Label("Devices", classes="title")
with Horizontal(id="add-device-button-container"): with Horizontal(id="add-device-button-container"):
yield Button( yield Button("Add Device", id="add-device", classes="button-100 button-small")
"Add Device", id="add-device", classes="button-100 button-small"
)
for device in self.devices: for device in self.devices:
yield Device(device, tooltip="Click to edit") yield Device(device, tooltip="Click to edit")
@@ -671,7 +645,7 @@ class ApiKeyManager(Vertical):
yield Label("YouTube Api Key", classes="title") yield Label("YouTube Api Key", classes="title")
yield Label( yield Label(
"You can get a YouTube Data API v3 Key from the" "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" " Console[/link]. This key is only required if you're whitelisting"
" channels." " channels."
) )
@@ -690,7 +664,7 @@ class ApiKeyManager(Vertical):
@on(Button.Pressed, "#api-key-view") @on(Button.Pressed, "#api-key-view")
def pressed_api_key_view(self, event: Button.Pressed): def pressed_api_key_view(self, event: Button.Pressed):
if "Show" in event.button.label: if "Show" in str(event.button.label):
event.button.label = "Hide key" event.button.label = "Hide key"
self.query_one("#api-key-input").password = False self.query_one("#api-key-input").password = False
else: else:
@@ -723,6 +697,43 @@ class SkipCategoriesManager(Vertical):
self.config.skip_categories = event.selection_list.selected 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): class SkipCountTrackingManager(Vertical):
"""Manager for skip count tracking, allows to enable/disable skip count tracking.""" """Manager for skip count tracking, allows to enable/disable skip count tracking."""
@@ -814,23 +825,18 @@ class ChannelWhitelistManager(Vertical):
id="channel-whitelist-subtitle", id="channel-whitelist-subtitle",
) )
yield Label( yield Label(
( ("⚠️ [#FF0000]You need to set your YouTube Api Key in order to use this feature"),
":warning: [#FF0000]You need to set your YouTube Api Key in order to"
" use this feature"
),
id="warning-no-key", id="warning-no-key",
) )
with Horizontal(id="add-channel-button-container"): with Horizontal(id="add-channel-button-container"):
yield Button( yield Button("Add Channel", id="add-channel", classes="button-100 button-small")
"Add Channel", id="add-channel", classes="button-100 button-small"
)
for channel in self.config.channel_whitelist: for channel in self.config.channel_whitelist:
yield Channel(channel) yield Channel(channel)
def on_mount(self) -> None: def on_mount(self) -> None:
self.app.query_one("#warning-no-key").display = ( self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
not self.config.apikey self.config.channel_whitelist
) and bool(self.config.channel_whitelist) )
def new_channel(self, channel: tuple) -> None: def new_channel(self, channel: tuple) -> None:
if channel: if channel:
@@ -842,18 +848,18 @@ class ChannelWhitelistManager(Vertical):
channel_widget = Channel(channel_dict) channel_widget = Channel(channel_dict)
self.mount(channel_widget) self.mount(channel_widget)
channel_widget.focus(scroll_visible=True) channel_widget.focus(scroll_visible=True)
self.app.query_one("#warning-no-key").display = ( self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
not self.config.apikey self.config.channel_whitelist
) and bool(self.config.channel_whitelist) )
@on(Button.Pressed, "#element-remove") @on(Button.Pressed, "#element-remove")
def remove_channel(self, event: Button.Pressed): def remove_channel(self, event: Button.Pressed):
channel_to_remove: Element = event.button.parent channel_to_remove: Element = event.button.parent
self.config.channel_whitelist.remove(channel_to_remove.element_data) self.config.channel_whitelist.remove(channel_to_remove.element_data)
channel_to_remove.remove() channel_to_remove.remove()
self.app.query_one("#warning-no-key").display = ( self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
not self.config.apikey self.config.channel_whitelist
) and bool(self.config.channel_whitelist) )
@on(Button.Pressed, "#add-channel") @on(Button.Pressed, "#add-channel")
def add_channel(self, event: Button.Pressed): def add_channel(self, event: Button.Pressed):
@@ -886,11 +892,45 @@ class AutoPlayManager(Vertical):
self.config.auto_play = event.checkbox.value 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" TITLE = "iSponsorBlockTV"
SUB_TITLE = "Setup Wizard" SUB_TITLE = "Setup Wizard"
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")] BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
AUTO_FOCUS = None AUTO_FOCUS = None
CSS_PATH = ( # tcss is the recommended extension for textual css files
"setup-wizard-style.tcss"
)
def __init__(self, config, **kwargs) -> None: def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -902,12 +942,15 @@ class ISponsorBlockTVSetupMainScreen(Screen):
yield Header() yield Header()
yield Footer() yield Footer()
with ScrollableContainer(id="setup-wizard"): with ScrollableContainer(id="setup-wizard"):
yield DevicesManager( yield DevicesManager(config=self.config, id="devices-manager", classes="container")
config=self.config, id="devices-manager", classes="container"
)
yield SkipCategoriesManager( yield SkipCategoriesManager(
config=self.config, id="skip-categories-manager", classes="container" config=self.config, id="skip-categories-manager", classes="container"
) )
yield MinimumSkipLengthManager(
config=self.config,
id="minimum-skip-length-manager",
classes="container",
)
yield SkipCountTrackingManager( yield SkipCountTrackingManager(
config=self.config, id="count-segments-manager", classes="container" config=self.config, id="count-segments-manager", classes="container"
) )
@@ -917,12 +960,9 @@ class ISponsorBlockTVSetupMainScreen(Screen):
yield ChannelWhitelistManager( yield ChannelWhitelistManager(
config=self.config, id="channel-whitelist-manager", classes="container" config=self.config, id="channel-whitelist-manager", classes="container"
) )
yield ApiKeyManager( yield ApiKeyManager(config=self.config, id="api-key-manager", classes="container")
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")
yield AutoPlayManager(
config=self.config, id="autoplay-manager", classes="container"
)
def on_mount(self) -> None: def on_mount(self) -> None:
if self.check_for_old_config_entries(): if self.check_for_old_config_entries():
@@ -946,36 +986,13 @@ class ISponsorBlockTVSetupMainScreen(Screen):
@on(Input.Changed, "#api-key-input") @on(Input.Changed, "#api-key-input")
def changed_api_key(self, event: Input.Changed): def changed_api_key(self, event: Input.Changed):
try: # ChannelWhitelist might not be mounted try: # ChannelWhitelist might not be mounted
# Show if no api key is set and at least one channel is in the whitelist self.app.query_one("#warning-no-key").display = bool(
self.app.query_one("#warning-no-key").display = ( (not event.input.value) and self.config.channel_whitelist
not event.input.value )
) and self.config.channel_whitelist except NoMatches:
except BaseException:
pass 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): def main(config):
app = ISponsorBlockTVSetup(config) app = ISponsorBlockTVSetup(config)
app.run() app.run()

View File

@@ -1,10 +1,14 @@
import asyncio import asyncio
import json import json
import sys
from typing import Any, List from typing import Any, List
import pyytlounge import pyytlounge
from aiohttp import ClientSession 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 .constants import youtube_client_blacklist
create_task = asyncio.create_task create_task = asyncio.create_task
@@ -18,9 +22,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
api_helper=None, api_helper=None,
logger=None, logger=None,
): ):
super().__init__( super().__init__(config.join_name if config else "iSponsorBlockTV", logger=logger)
config.join_name if config else "iSponsorBlockTV", logger=logger
)
self.auth.screen_id = screen_id self.auth.screen_id = screen_id
self.auth.lounge_id_token = None self.auth.lounge_id_token = None
self.api_helper = api_helper self.api_helper = api_helper
@@ -32,6 +34,8 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.logger = logger self.logger = logger
self.shorts_disconnected = False self.shorts_disconnected = False
self.auto_play = True self.auto_play = True
self.watchdog_running = False
self.last_event_time = 0
if config: if config:
self.mute_ads = config.mute_ads self.mute_ads = config.mute_ads
self.skip_ads = config.skip_ads self.skip_ads = config.skip_ads
@@ -40,21 +44,58 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# Ensures that we still are subscribed to the lounge # Ensures that we still are subscribed to the lounge
async def _watchdog(self): async def _watchdog(self):
await asyncio.sleep( """
35 Continuous watchdog that monitors for connection health.
) # YouTube sends at least a message every 30 seconds (no-op or any other) 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: try:
self.subscribe_task.cancel() while self.watchdog_running:
except BaseException: await asyncio.sleep(10)
pass 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 # Subscribe to the lounge and start the watchdog
async def subscribe_monitored(self, callback): async def subscribe_monitored(self, callback):
self.callback = 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() self.subscribe_task_watchdog.cancel()
except BaseException: try:
pass # No watchdog task 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 = asyncio.create_task(super().subscribe(callback))
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog()) self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
return self.subscribe_task return self.subscribe_task
@@ -63,13 +104,9 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# skipcq: PY-R1000 # skipcq: PY-R1000
def _process_event(self, event_type: str, args: List[Any]): def _process_event(self, event_type: str, args: List[Any]):
self.logger.debug(f"process_event({event_type}, {args})") self.logger.debug(f"process_event({event_type}, {args})")
# (Re)start the watchdog # Update last event time for the watchdog
try: self.last_event_time = asyncio.get_event_loop().time()
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, # A bunch of events useful to detect ads playing,
# and the next video before it starts playing # and the next video before it starts playing
# (that way we can get the segments) # (that way we can get the segments)
@@ -87,7 +124,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif event_type == "onAdStateChange": elif event_type == "onAdStateChange":
data = args[0] 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") self.logger.info("Ad has ended, unmuting")
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif ( elif (
@@ -96,9 +133,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.logger.info("Ad can be skipped, skipping") self.logger.info("Ad can be skipped, skipping")
create_task(self.skip_ad()) create_task(self.skip_ad())
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif ( elif self.mute_ads: # Seen multiple other adStates, assuming they are all ads
self.mute_ads
): # Seen multiple other adStates, assuming they are all ads
self.logger.info("Ad has started, muting") self.logger.info("Ad has started, muting")
create_task(self.mute(True, override=True)) create_task(self.mute(True, override=True))
# Manages volume, useful since YouTube wants to know the volume # Manages volume, useful since YouTube wants to know the volume
@@ -107,9 +142,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.volume_state = args[0] self.volume_state = args[0]
# Gets segments for the next video before it starts playing # Gets segments for the next video before it starts playing
elif event_type == "autoplayUpNext": elif event_type == "autoplayUpNext":
if len(args) > 0 and ( if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty
vid_id := args[0]["videoId"]
): # if video id is not empty
self.logger.info(f"Getting segments for next video: {vid_id}") self.logger.info(f"Getting segments for next video: {vid_id}")
create_task(self.api_helper.get_segments(vid_id)) create_task(self.api_helper.get_segments(vid_id))
@@ -120,15 +153,14 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if vid_id := data["contentVideoId"]: if vid_id := data["contentVideoId"]:
self.logger.info(f"Getting segments for next video: {vid_id}") self.logger.info(f"Getting segments for next video: {vid_id}")
create_task(self.api_helper.get_segments(vid_id)) create_task(self.api_helper.get_segments(vid_id))
elif (
if (
self.skip_ads and data["isSkipEnabled"] == "true" self.skip_ads and data["isSkipEnabled"] == "true"
): # YouTube uses strings for booleans ): # YouTube uses strings for booleans
self.logger.info("Ad can be skipped, skipping") self.logger.info("Ad can be skipped, skipping")
create_task(self.skip_ad()) create_task(self.skip_ad())
create_task(self.mute(False, override=True)) create_task(self.mute(False, override=True))
elif ( elif self.mute_ads: # Seen multiple other adStates, assuming they are all ads
self.mute_ads
): # Seen multiple other adStates, assuming they are all ads
self.logger.info("Ad has started, muting") self.logger.info("Ad has started, muting")
create_task(self.mute(True, override=True)) create_task(self.mute(True, override=True))
@@ -151,9 +183,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
elif event_type == "loungeScreenDisconnected": elif event_type == "loungeScreenDisconnected":
if args: # Sometimes it's empty if args: # Sometimes it's empty
data = args[0] data = args[0]
if ( if data["reason"] == "disconnectedByUserScreenInitiated": # Short playing?
data["reason"] == "disconnectedByUserScreenInitiated"
): # Short playing?
self.shorts_disconnected = True self.shorts_disconnected = True
elif event_type == "onAutoplayModeChanged": elif event_type == "onAutoplayModeChanged":
create_task(self.set_auto_play_mode(self.auto_play)) create_task(self.set_auto_play_mode(self.auto_play))
@@ -167,7 +197,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
# Set the volume to a specific value (0-100) # Set the volume to a specific value (0-100)
async def set_volume(self, volume: int) -> None: async def set_volume(self, volume: int) -> None:
await super()._command("setVolume", {"volume": volume}) await self._command("setVolume", {"volume": volume})
async def mute(self, mute: bool, override: bool = False) -> None: 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: if override or not self.volume_state.get("muted", "false") == mute_str:
self.volume_state["muted"] = mute_str self.volume_state["muted"] = mute_str
# YouTube wants the volume when unmuting, so we send it # YouTube wants the volume when unmuting, so we send it
await super()._command( await self._command(
"setVolume", "setVolume",
{"volume": self.volume_state.get("volume", 100), "muted": mute_str}, {"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: async def play_video(self, video_id: str) -> bool:
return await self._command("setPlaylist", {"videoId": video_id}) return await self._command("setPlaylist", {"videoId": video_id})
async def get_now_playing(self): 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 # Test to wrap the command function in a mutex to avoid race conditions with
# the _command_offset (TODO: move to upstream if it works) # the _command_offset (TODO: move to upstream if it works)
@@ -215,3 +240,110 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
if self.conn is not None: if self.conn is not None:
await self.conn.close() await self.conn.close()
self.session = web_session 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