Compare commits

...

179 Commits
v0.1 ... pr/161

Author SHA1 Message Date
dmunozv04
afc4ee7410 fixes autoplay padding 2024-06-21 16:25:26 +02:00
Ravioli8235
338e0479ba Merge remote-tracking branch 'origin/autoplay' into autoplay 2024-06-21 15:58:28 +02:00
Ravioli8235
bfefa94a7b fix panel fill 2024-06-21 15:58:15 +02:00
David
783e3d4240 Merge branch 'main' into autoplay 2024-06-17 14:55:07 +02:00
David
015f5a79c9 Merge pull request #168 from Shraymonks/fix-docker-root
Fix deps permissions in docker build
2024-06-16 14:22:25 +02:00
Raymond Ha
dc72db0609 Fix deps permissions in docker build 2024-06-05 16:18:10 -07:00
David
8de38cc92b Merge pull request #167 from dmunozv04/update-actions
Update actions versions
2024-06-05 19:29:42 +02:00
dmunozv04
94ba642af1 update actions versions to latest 2024-06-05 19:27:42 +02:00
dmunozv04
6e09db9994 bump version 2024-05-31 15:35:16 +02:00
David
b56d7443d1 Merge pull request #163 from dmunozv04/refresh-auth-after-disconnect
Refresh auth after every disconnect
2024-05-31 15:34:40 +02:00
dmunozv04
e92ba897c4 Refresh auth after every disconnect, ensuring there's a fresh token. Fixes Shorts aren't handled anymore #162
and maybe changes something related to Disable Autoplay #82
2024-05-31 15:26:58 +02:00
dmunozv04
5214190fd0 revert last commit 2024-05-30 12:35:59 +02:00
David
d3341009a6 Merge branch 'main' into autoplay 2024-05-30 12:27:47 +02:00
dmunozv04
adc0f5b95d enable building PR docker images 2024-05-30 12:26:55 +02:00
David
5dbd16ddd5 Merge branch 'main' into autoplay 2024-05-30 09:05:18 +02:00
pre-commit-ci[bot]
faa0379b89 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-05-30 07:03:27 +00:00
Ravioli8235
fb3ed6b39a Implement autoplay 2024-05-30 08:53:35 +02:00
dmunozv04
1ab7e73b52 bump version 2024-05-29 23:41:42 +02:00
David
d310e4c817 Merge pull request #160 from dmunozv04/update-dependencies
Update dependencies
2024-05-29 23:39:01 +02:00
pre-commit-ci[bot]
d21bb6320f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-05-29 21:31:56 +00:00
dmunozv04
dd42e20dc4 Remove session closer, it seems to break the config setup 2024-05-05 19:16:27 +02:00
dmunozv04
213ae97bf2 Fix web_session 2024-05-05 19:07:03 +02:00
dmunozv04
582b9bf725 Patch the main aiohttp.ClientSession() into YTlounge 2024-04-27 19:26:55 +02:00
dmunozv04
ce95b6dbf0 Update dependencies 2024-04-27 19:17:52 +02:00
dmunozv04
80196b19aa Rework dockerfile and build for armv7 2024-03-12 22:40:23 +01:00
David
4dd6aa1c4d Merge pull request #140 from SShah7433/utilize_logging_standards
Update logging for standards
2024-03-07 09:14:40 +01:00
David
1a5f29fe2a Merge branch 'main' into utilize_logging_standards 2024-03-07 09:14:33 +01:00
David
13fe1f69ae Bump version 2024-02-14 22:07:51 +01:00
David
3b1ce5297f Merge pull request #136 from PetkoVasilev/main
fix for overlapping segments
2024-02-12 09:07:40 +01:00
Sidd Shah
e689a713ef remove unused code from prev logging implementation 2024-02-02 22:34:01 -05:00
Sidd Shah
e6b1e14d80 remove unused argument 2024-02-02 22:30:58 -05:00
pre-commit-ci[bot]
4934eff8b7 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-02-03 03:27:03 +00:00
Sidd Shah
674a13e43a utilize standard logging libraries and formats 2024-02-02 22:22:08 -05:00
David
152ba104a6 Merge branch 'main' into main 2024-01-31 11:47:32 +01:00
dmunozv04
265c56f3d6 bump version 2024-01-29 22:28:07 +01:00
David
7e954478f2 Merge branch 'main' into main 2024-01-29 21:47:07 +01:00
David
4f39f64ed0 Merge pull request #134 from bertybuttface/patch-5
Fix list_to_tuple
2024-01-29 21:46:48 +01:00
Petko Vasilev
8208a51176 more understandable logic (same result) 2024-01-29 12:39:22 +02:00
pre-commit-ci[bot]
ab6b67f88b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-01-28 08:11:55 +00:00
Petko Vasilev
9068b58bf6 fix for overlapping segments 2024-01-28 09:59:47 +02:00
bertybuttface
a9f9a5b31c Update api_helpers.py
Fix list to tuple
2024-01-27 16:36:31 +00:00
David
a75dd83548 Merge pull request #132 from dmunozv04/open-file-utf-8
open and close config file with
2024-01-17 14:25:04 +01:00
dmunozv04
4e3c9d115c open and close config file with
utf-8 encoding
2024-01-15 21:53:08 +01:00
David
04533162cb Merge pull request #129 from dmunozv04/make-docker-image-smaller
Make docker image smaller by caching .pyc and removing .py files
2024-01-14 21:11:41 +01:00
pre-commit-ci[bot]
bee2a9c80f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-01-14 15:39:02 +00:00
dmunozv04
f620ed2fcc Make docker image smaller by caching .pyc and removing .py files 2024-01-14 16:38:39 +01:00
David
db26bff3d2 Merge pull request #128 from dmunozv04/deepsource-autofix-c896b47c
refactor: refactor unnecessary `else` / `elif` when `if` block has a `continue` statement
2024-01-14 15:19:53 +01:00
David
d205848132 Merge pull request #127 from dmunozv04/deepsource-autofix-75d7989d
refactor: replace range(len(...)) with enumerate(...)
2024-01-14 15:19:22 +01:00
David
29445e678f Merge pull request #126 from dmunozv04/deepsource-autofix-097a200c
refactor: refactor unnecessary `else` / `elif` when `if` block has a `return` statement
2024-01-14 15:19:04 +01:00
deepsource-autofix[bot]
35453bc49e refactor: refactor unnecessary else / elif when if block has a continue statement
The use of `else` or `elif` becomes redundant and can be dropped if the last statement under the leading `if` / `elif` block is a `continue` statement.
In the case of an `elif` after `continue`, it can be written as a separate `if` block.
For `else` blocks after `continue`, the statements can be shifted out of `else`. Please refer to the examples below for reference.

Refactoring the code this way can improve code-readability and make it easier to maintain.
2024-01-14 14:18:37 +00:00
deepsource-autofix[bot]
88875a82d3 refactor: replace range(len(...)) with enumerate(...)
Using `range(len(...))` is not pythonic. Python does not have not index-based loops. Instead, it uses collection iterators.  Python has a built-in method `enumerate` which adds a counter to an iterable.
2024-01-14 14:18:20 +00:00
deepsource-autofix[bot]
446393b078 refactor: refactor unnecessary else / elif when if block has a return statement
The use of `else` or `elif` becomes redundant and can be dropped if the last statement under the leading `if` / `elif` block is a `return` statement.
In the case of an `elif` after `return`, it can be written as a separate `if` block.
For `else` blocks after `return`, the statements can be shifted out of `else`. Please refer to the examples below for reference.

Refactoring the code this way can improve code-readability and make it easier to maintain.
2024-01-14 14:18:05 +00:00
David
784d54c4e2 Merge pull request #125 from dmunozv04/add-pre-commit
add pre-commit
2024-01-14 15:12:02 +01:00
pre-commit-ci[bot]
66b39c4cac [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-01-14 14:10:15 +00:00
dmunozv04
321a9e6e9b reformat DIAL license 2024-01-14 15:09:30 +01:00
pre-commit-ci[bot]
ecb3583c35 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-01-14 14:03:37 +00:00
dmunozv04
3cee674e91 Merge remote-tracking branch 'origin/add-pre-commit' into add-pre-commit 2024-01-14 15:03:17 +01:00
dmunozv04
d575a296e7 setup black --preview 2024-01-14 15:03:01 +01:00
pre-commit-ci[bot]
7bf52b6df1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-01-14 13:53:04 +00:00
dmunozv04
846ea444d2 Merge remote-tracking branch 'origin/add-pre-commit' into add-pre-commit 2024-01-14 14:52:56 +01:00
dmunozv04
1671d7841b set black for max line length 2024-01-14 14:52:38 +01:00
pre-commit-ci[bot]
a4f0b5fffe [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-01-14 13:47:14 +00:00
dmunozv04
dae27e7aa3 add pre-commit 2024-01-14 14:46:46 +01:00
David
5df90a234d Merge pull request #122 from dmunozv04/improve-logging-and-shorts-hack
Improve logging and shorts hack
2024-01-14 14:41:12 +01:00
dmunozv04
c3fd67df27 Clean code and fix #121 2023-12-29 16:19:44 +01:00
dmunozv04
35652b6247 Improve exit behaviour 2023-12-29 15:39:41 +01:00
dmunozv04
8ab9cf9519 Improve logging and add hack to view
one short
2023-12-26 19:34:24 +01:00
dmunozv04
f4fbbcdff5 Remove connect fix now that it's been pushed upstream 2023-12-19 08:24:45 +01:00
David
385ed8268c Update package version 2023-12-09 19:22:45 +01:00
David
c196e76205 Merge pull request #113 from dmunozv04/Fix-#112
Fixes Will not start v2.0.3 #112
2023-12-09 19:16:55 +01:00
dmunozv04
863ec5e163 Fixes Will not start v2.0.3 #112 2023-12-09 19:08:20 +01:00
David
128e1f72cb Merge pull request #111 from dmunozv04:fix-config-file-issues
ensures data_dir isn't saved to disk
2023-12-07 14:27:18 +01:00
dmunozv04
fede94e973 ensures data_dir isn't saved to disk 2023-12-07 14:26:14 +01:00
David
0d62f69460 Merge pull request #110 from dmunozv04/fix-ad-skips
Attempt to fix ad skips
2023-12-07 14:20:51 +01:00
dmunozv04
8d76bdd1c1 attempt to fix ad skips 2023-12-04 11:18:12 +01:00
David
8c7c2cc206 Merge pull request #106 from dmunozv04/publish-pypi
Publish on pypi
2023-11-29 12:06:40 +01:00
dmunozv04
7a0a264caa Fix requirements 2023-11-29 12:02:26 +01:00
David
b1f1bd1851 Merge pull request #107 from dmunozv04:use-newer-docker-image
Use newer docker image 3.11-alpine
2023-11-29 12:00:58 +01:00
dmunozv04
ab3285048d Use newer docker image 3.11-alpine 2023-11-29 12:00:20 +01:00
dmunozv04
799e0a6f77 Mark chromecast as working #87 2023-11-29 11:49:13 +01:00
dmunozv04
c95ab4897d Merge branch 'main' of https://github.com/dmunozv04/iSponsorBlockTV into publish-pypi 2023-11-29 11:44:13 +01:00
dmunozv04
23e90caefc Sync with main (again) 2023-11-29 11:39:09 +01:00
dmunozv04
7b6f9bd8a0 Sync with main
Co-authored-by: tsia <github@tsia.de>
Co-authored-by: kot0dama <89980752+kot0dama@users.noreply.github.com>
Co-authored-by: boltgolt <boltgolt@gmail.com>
2023-11-29 11:33:29 +01:00
David
f3a2c82a56 Merge pull request #105 from dmunozv04/fix-mark-viewed-segments
Fix to ensure that skipped segments get reported to SponsorBlock
2023-11-29 10:22:21 +01:00
David
d0506304fd Merge branch 'main' into fix-mark-viewed-segments 2023-11-29 10:14:40 +01:00
David
a01994e2b0 Merge pull request #76 from tsia/patch-1
print device name from config
2023-11-29 10:05:34 +01:00
dmunozv04
bc2c4727dc Make deepsource happier 2023-11-29 10:04:40 +01:00
dmunozv04
9ebd39d491 Merge branch 'main' into pr/tsia/76 2023-11-29 09:54:45 +01:00
David
2e6a0af8ce Merge pull request #78 from boltgolt/misc-wizard-key-text
Clarification on API key requirement
2023-11-29 09:33:21 +01:00
David
4aa5b1e08c Merge pull request #102 from kot0dama/kot0dama-patch-wizard-labels
Fix muting/skipping ads labels mixup in setup_wizard.py
2023-11-29 09:30:25 +01:00
David
2cc8fa128f Merge pull request #104 from dmunozv04/dependabot/pip/aiohttp-3.9.0
Bump aiohttp from 3.8.6 to 3.9.0
2023-11-29 09:28:04 +01:00
dependabot[bot]
4ab49ea61e Bump aiohttp from 3.8.6 to 3.9.0
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.8.6 to 3.9.0.
- [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.8.6...v3.9.0)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-28 00:51:31 +00:00
kot0dama
3d8fa562b4 Fix muting/skipping ads labels mixup in setup_wizard.py 2023-11-22 12:08:14 +09:00
dmunozv04
20c6870e4c fix branch 2023-11-17 11:53:04 +01:00
dmunozv04
b90a5e317c remove . 2023-11-17 11:50:50 +01:00
dmunozv04
93e3fd5720 fix dockerfile copy 2023-11-17 11:43:33 +01:00
dmunozv04
b88d2b61be prepare for pypi packaging 2023-11-17 11:40:04 +01:00
dmunozv04
5a5ebcfeb7 move directories and data path 2023-11-17 11:38:22 +01:00
dmunozv04
7a66d1acd5 Merge remote-tracking branch 'origin/main' 2023-11-10 11:36:42 +01:00
dmunozv04
de8d03285f Upgrade pyytlounge and fix #99 2023-11-10 11:36:26 +01:00
David
d850ec8162 Merge pull request #96 from cryingcavecat/chore/confirm-xbox-compatability
Confirm Xbox Compatability. Clarify TV Code.
2023-11-09 17:20:08 +01:00
Liam
81f5b6568d Confirm Xbox Compatability. Clarify TV Code 2023-11-06 12:51:45 +02:00
dmunozv04
28b6ac3218 General fixes to make deepsource happier 2023-11-05 17:56:51 +01:00
dmunozv04
138d8bd51c Log errors when gettting segments properly. Also forgot to remove a print 2023-11-05 17:38:10 +01:00
dmunozv04
db647362c6 Handle YouTube Kids (TVHTML5_FOR_KIDS)
Should fix #84
2023-11-05 17:36:39 +01:00
dmunozv04
58ee703501 Fix getting segments
(managed to break it last time)
2023-11-05 17:35:22 +01:00
dmunozv04
4f4d990544 Getting response is a method and not an attribute 2023-11-03 15:57:39 +01:00
dmunozv04
6e1ee572d5 Print video id when logging the error 2023-11-03 15:51:48 +01:00
dmunozv04
df118c967d Improve logging when an error occurs while getting segments 2023-11-03 15:50:39 +01:00
dmunozv04
262e3c606d Rollback attempt at fixing autoplay 2023-11-03 15:49:16 +01:00
David
d72b24c95b Merge pull request #86 from adripo/patch-1
Create .dockerignore
2023-10-23 18:17:06 +02:00
David
88f5e5d4bf Merge pull request #85 from bertybuttface/patch-1
Confirm FireTV compatibility (thanks to #83)
2023-10-23 18:16:16 +02:00
adripo
aa26e8fb04 Create .dockerignore 2023-10-23 15:27:37 +02:00
bertybuttface
3bbabdb26e Confirm FireTV compatibility (thanks to #83) 2023-10-23 13:02:17 +01:00
dmunozv04
064bdcd93c removed unnecesary prints 2023-10-20 11:10:45 +02:00
dmunozv04
50b71d9f5c Attempts to respect the user's autoplay choice 2023-10-20 10:54:13 +02:00
boltgolt
9461d6516f Clarification on API key requirement 2023-10-17 18:06:23 +02:00
tsia
f69d6d04cf print device name from config 2023-10-17 12:31:58 +02:00
dmunozv04
ace8f3564f Confirm Roku compatibility (thanks to #68) 2023-10-15 18:59:16 +02:00
David
70cba12efa Merge pull request #70 from outadoc/patch-1
chore: confirm Android TV compatibility
2023-10-15 18:56:45 +02:00
Baptiste Candellier
b35d9fd60e chore: confirm Android TV compatibility 2023-10-15 18:36:21 +02:00
dmunozv04
518b1d9b2e Fix to ensure that skipped segments get reported to SponsorBlock (if the option is enabled) 2023-10-15 13:59:36 +02:00
David
4460aaf35c Merge pull request #63 from JoshCooley/add-google-tv-status
Add Google TV and CCwGTV status to README
2023-10-14 13:51:34 +02:00
dmunozv04
d3260d17f1 Update README.md to reflect compatibility with Google TV devices and CCwGTV (ChromeCast with Google TV)
Fix readme sync to dockerhub
2023-10-14 13:50:00 +02:00
David
ac6f15042c Merge branch 'main' into add-google-tv-status 2023-10-14 13:47:38 +02:00
David
7e45f623f7 Update update_docker_readme.yml
Fix dockerhub description updater
2023-10-14 10:36:52 +02:00
David
cece0242c4 Merge pull request #64 from JoshCooley/fix-segment-uuid-concatenation
Fix segment UUID concatenation
2023-10-14 10:24:27 +02:00
David
b069487bd6 Merge pull request #62 from jtokoph/patch-1
Update Playstation Status in README.md
2023-10-14 10:01:48 +02:00
David
d4f9380eff Merge pull request #59 from nickgal/patch-1
README.md typo
2023-10-14 10:00:53 +02:00
Josh Cooley
245300d064 Fix segment UUID concatenation 2023-10-14 00:03:35 -05:00
Josh Cooley
0bcb70979f Add Google TV status to README 2023-10-13 22:42:51 -05:00
Jason Tokoph
b9e010af9b Update Playstation Status in README.md
I have tested this with the Youtube app on my Playstation 5 and can confirm that it correctly skips sponsorblock segments.
2023-10-13 20:17:04 -07:00
Nick Gal
bd0deec85e README.md typo 2023-10-13 17:54:14 -07:00
dmunozv04
d0846b7d2c Fix #58 2023-10-13 21:28:23 +02:00
David
d7a83035a6 Update main.yml
Run on a release
2023-10-13 20:09:50 +02:00
David
9d7d9665dc Merge pull request #57 from dmunozv04/v2-test
V2
2023-10-13 20:07:28 +02:00
dmunozv04
55f2798adc Inform that v2 has been released and requires changes 2023-10-13 19:54:11 +02:00
dmunozv04
907e0ec549 Pin dependencies 2023-10-13 17:57:14 +02:00
dmunozv04
c83e406c8f Fix GitHub actions 2023-10-13 17:50:29 +02:00
dmunozv04
143e5e4eff Final v2 commit before launch?
Adds better logging and modifies README.md
2023-10-13 17:46:25 +02:00
dmunozv04
aad6eea686 ArmV7 needs more work, PR welcome 2023-09-04 14:31:13 +02:00
dmunozv04
fc309102df Forgot to remove miniaudio dep 2023-09-04 14:21:23 +02:00
dmunozv04
359a7f7be1 first v2 commit 2023-09-04 14:11:34 +02:00
David
1aa06e677f Merge pull request #50 from themagic314/temp-miniaudio-fix
temp fix for miniaudio
2023-05-31 17:28:15 +02:00
Eli
6c879b1649 temp fix for miniaudio 2023-05-30 19:29:42 +04:00
David
b98a00a43e Merge pull request #49 from Shraymonks/tvOS-detect
Check tvOS instead of specific atv models
2023-05-28 18:31:00 +02:00
Raymond Ha
4ec4f7c101 Use f-string 2023-05-26 01:24:08 -07:00
Raymond Ha
49cd69de8b Combine ifs 2023-05-26 01:14:53 -07:00
Raymond Ha
230024c179 Check tvOS instead of specific atv models 2023-05-25 22:03:23 -07:00
David
f06e2b1490 Update README.md 2023-05-09 10:47:58 +02:00
David
3b0cbff054 Update config_setup.py
fixes #45
2023-04-23 09:48:56 +02:00
David
067d429d33 Update config_setup.py
Move loop creation on config to main
2023-04-23 00:48:13 +02:00
David
ce82c7b719 Merge pull request #44 from oxixes/channel_whitelist
Added channel whitelists and general fixes
2023-04-22 19:56:44 +02:00
oxixes
42c09ef588 Last fixes 2023-04-22 19:50:37 +02:00
oxixes
890c3956de Remove .DS_Store 2023-04-22 19:15:36 +02:00
oxixes
224d00174b I'm dumb, whatever, fixed 2023-04-22 19:09:37 +02:00
oxixes
3f8e24c8a4 Improved README 2023-04-22 13:55:32 +02:00
oxixes
eb51963da9 Merge branch 'channel_whitelist' of https://github.com/oxixes/iSponsorBlockTV into channel_whitelist 2023-04-22 13:43:15 +02:00
oxixes
975b2690a4 Added channel whitelist 2023-04-22 13:40:11 +02:00
oxixes
77e710849f Merge branch 'dmunozv04:main' into channel_whitelist 2023-04-22 13:22:09 +02:00
oxixes
73d1a024f3 Added channels whitelist 2023-04-22 13:20:10 +02:00
David
ab10c6d321 Merge pull request #42 from oxixes/comma_fix
Allow comma separated categories
2023-04-17 10:24:29 +02:00
oxixes
e0c0548496 Allow comma separated categories 2023-04-17 10:15:56 +02:00
David
a665f1cd27 Merge pull request #41 from bertybuttface/patch-2
Add projects using this project
2023-04-16 18:26:48 +02:00
bertybuttface
58ac0c1338 Update README.md 2023-04-16 16:43:53 +01:00
David
e2adf09db8 Merge pull request #39 from bertybuttface/patch-1
Update main.py
2023-04-16 17:17:46 +02:00
bertybuttface
8edf4859ab Update main.py
remove unused code
2023-03-28 22:13:20 +01:00
David
e5a795edab Merge pull request #37 from dmunozv04/not-build-armv7
remove armv7 docker build
2023-01-16 22:04:20 +01:00
David
fae6134295 Update README.md 2023-01-16 22:01:22 +01:00
David
5658dd7537 Merge branch 'main' into not-build-armv7 2023-01-16 22:00:45 +01:00
David
0413d612bb Fix naming for category setup
Previous naming could imply that a comma was required between categories
2023-01-08 15:12:46 +01:00
David
fe606292f8 fix PR docker builds 2022-11-14 00:26:31 +01:00
David
b9c41aa022 push docker containers in PRs 2022-11-14 00:22:42 +01:00
David
60d9a86413 remove armv7 docker build 2022-11-13 23:42:47 +01:00
DeepSource Bot
933ee36a18 Add .deepsource.toml 2022-07-25 16:12:43 +00:00
David
7c207c5b0a Merge pull request #23 from dmunozv04:Sending-hashed-video-ids
General fixes
2022-04-22 14:46:14 +02:00
dmunozv04
816b4ec9ef Fix the docker bug #22
Make video ids hashed
Get 10 videos from youtube to make sure that the right id is found
Refactored the api calls
2022-04-22 14:45:32 +02:00
David
0be5e2d4cd Update README.md 2022-04-12 00:24:26 +02:00
David
781c0d4a73 Update Dockerfile 2022-04-12 00:07:42 +02:00
35 changed files with 2816 additions and 501 deletions

BIN
.DS_Store vendored

Binary file not shown.

8
.deepsource.toml Normal file
View File

@@ -0,0 +1,8 @@
version = 1
[[analyzers]]
name = "python"
enabled = true
[analyzers.meta]
runtime_version = "3.x.x"

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
# Ignore files
.dockerignore
.gitignore
.deepsource.toml
.github
Dockerfile
docker-compose.yml
README.md

View File

@@ -7,7 +7,7 @@ assignees: ''
---
Before opening an issue make sure that there are no duplicates and that you are on the latest version.
Before opening an issue make sure that there are no duplicates and that you are on the latest version.
**Describe the bug**
A clear and concise description of what the bug is.

View File

@@ -1,67 +1,70 @@
# This is a basic workflow to help you get started with Actions
name: build docker images
# Controls when the workflow will run
on:
push:
branches:
- 'main'
- '*'
tags:
- 'v*'
pull_request:
branches:
- 'main'
- '*'
release:
types: [published]
workflow_dispatch:
permissions:
permissions:
contents: read
packages: write
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
build:
runs-on: ubuntu-latest
steps:
# Get the repository's code
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
# Generate docker tags
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: ghcr.io/dmunozv04/isponsorblocktv, dmunozv04/isponsorblocktv
tags: |
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
type=raw,value=develop,priority=900,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr
type=ref,event=tag
type=ref,event=branch
type=schedule
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v3
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64, linux/arm64, linux/arm/v7
@@ -69,4 +72,5 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache
cache-to: type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache,mode=max
# Only cache if it's not a pull request
cache-to: ${{ github.event_name != 'pull_request' && 'type=registry,ref=ghcr.io/dmunozv04/isponsorblocktv:buildcache,mode=max' || '' }}

37
.github/workflows/release_pypi.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build wheel
- name: Build package
run: python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1

View File

@@ -18,12 +18,13 @@ jobs:
steps:
# Get the repository's code
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
# Update description
- name: Update repo description
uses: peter-evans/dockerhub-description@v2
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: dmunozv04/isponsorblocktv
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: dmunozv04/isponsorblocktv
short-description: ${{ github.event.repository.description }}

11
.gitignore vendored
View File

@@ -35,6 +35,9 @@ MANIFEST
pip-log.txt
pip-delete-this-directory.txt
# macOS
*.DS_Store
# Unit test / coverage reports
htmlcov/
.tox/
@@ -151,5 +154,9 @@ cython_debug/
#.idea/
#config folder
config/
config.json
data/
data/config.json
.DS_Store
.DS_Store

39
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,39 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
# Inspired by textual pre-commit config
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-ast # simply checks whether the files parse as valid python
- id: check-builtin-literals # requires literal syntax when initializing empty or zero python builtin types
- id: check-case-conflict # checks for files that would conflict in case-insensitive filesystems
- id: check-merge-conflict # checks for files that contain merge conflict strings
- id: check-json # checks json files for parseable syntax
- id: check-toml # checks toml files for parseable syntax
- id: check-yaml # checks yaml files for parseable syntax
args: [ '--unsafe' ] # Instead of loading the files, parse them for syntax.
- id: check-shebang-scripts-are-executable # ensures that (non-binary) files with a shebang are executable
- id: check-vcs-permalinks # ensures that links to vcs websites are permalinks
- id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline
- id: mixed-line-ending # replaces or checks mixed line ending
- id: trailing-whitespace # checks for trailing whitespace
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
language_version: '3.11'
args: ["--profile", "black", "--filter-files"]
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: black
language_version: '3.11'
args: ["--preview"]
- repo: https://github.com/hadialqattan/pycln # removes unused imports
rev: v2.3.0
hooks:
- id: pycln
language_version: "3.11"
args: [--all]

View File

@@ -1,24 +1,38 @@
# syntax=docker/dockerfile:1
FROM python:3.11-alpine3.19 as BASE
FROM python:alpine
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" PIP_NO_CACHE_DIR=off iSPBTV_docker=True
COPY requirements.txt .
RUN apk add gcc musl-dev build-base linux-headers libffi-dev rust cargo openssl-dev git avahi && \
pip install --upgrade pip setuptools-rust wheel && \
pip install -r requirements.txt && \
apk del gcc musl-dev build-base linux-headers libffi-dev rust cargo openssl-dev git && \
rm -rf /root/.cache /root/.cargo
COPY requirements.txt .
FROM base as compiler
WORKDIR /app
COPY *.py .
COPY src .
ENTRYPOINT ["/opt/venv/bin/python3", "-u", "main.py"]
RUN python3 -m compileall -b -f . && \
find . -name "*.py" -type f -delete
FROM base as DEP_INSTALLER
COPY requirements.txt .
RUN apk add --no-cache gcc musl-dev && \
pip install --upgrade pip wheel && \
pip install -r requirements.txt && \
pip uninstall -y pip wheel && \
apk del gcc musl-dev && \
python3 -m compileall -b -f /usr/local/lib/python3.11/site-packages && \
find /usr/local/lib/python3.11/site-packages -name "*.py" -type f -delete && \
find /usr/local/lib/python3.11/ -name "__pycache__" -type d -exec rm -rf {} +
FROM base
ENV PIP_NO_CACHE_DIR=off iSPBTV_docker=True iSPBTV_data_dir=data TERM=xterm-256color COLORTERM=truecolor
COPY requirements.txt .
COPY --from=dep_installer /usr/local /usr/local
WORKDIR /app
COPY --from=compiler /app .
ENTRYPOINT ["python3", "-u", "main.pyc"]

View File

@@ -1,69 +1,52 @@
# iSponsorBlockTV
Skip sponsor segments in YouTube videos playing on a YouTube TV device (see below for compatibility details).
Skip sponsor segments in YouTube videos playing on an Apple TV.
This project is written in asynchronous python and should be pretty quick.
This project is written in asycronous python and should be pretty quick.
## Installation
Check the [wiki](https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation)
# Installation
Warning: docker armv7 builds have been deprecated. Amd64 and arm64 builds are still available.
## Docker
### Setup
## Compatibility
Legend: ✅ = Working, ❌ = Not working, ❔ = Not tested
You need to set up several things before you can run the project.
Create blank config file: `touch config.json`
Run:
```sh
docker run --rm -it \
--network=host \
--entrypoint /opt/venv/bin/python3 \
-v /PATH_TO_YOUR_CONFIG.json:/app/config.json \
ghcr.io/dmunozv04/isponsorblocktv \
/app/create_config.py
```
## Run
```sh
docker pull ghcr.io/dmunozv04/isponsorblocktv
docker run -d \
--name iSponsorBlockTV \
--restart=unless-stopped \
--network=host \
-v /PATH_TO_YOUR_CONFIG.json:/app/config.json \
ghcr.io/dmunozv04/isponsorblocktv
```
## From source
Open an issue/pull request if you have tested a device that isn't listed here.
You need to install [python](https://www.python.org/downloads/) first, and to make it available in your PATH. After, clone the repo.
Then you need to download the dependencies with pip:
```python3 -m pip install -r requirements.txt```
Lastly, run ```main.py```
| Device | Status |
|:-------------------|:------:|
| Apple TV | ✅ |
| Samsung TV (Tizen) | ✅ |
| LG TV (WebOS) | ✅ |
| Android TV | ✅ |
| Chromecast | ✅ |
| Google TV | ✅ |
| Roku | ✅ |
| Fire TV | ✅ |
| CCwGTV | ✅ |
| Nintendo Switch | ✅ |
| Xbox One/Series | ✅ |
| Playstation 4/5 | ✅ |
### Setup
## Usage
Run iSponsorBlockTV on a computer that has network access.
Auto discovery will require the computer to be on the same network as the device during setup.
The device can also be manually added to iSponsorBlockTV with a YouTube TV code. This code can be found in the settings page of your YouTube application.
You need to retrieve airplay keys to be able to connect to the Apple TV. (It will be made simpler in the future)
For now, use `atvremote`, a script included in pyatv:
1. atvremote scan
2. atvremote pair --protocol airplay --id `identifier you got on the previous step`
It connects to the device, watches its activity and skips any sponsor segment using the [SponsorBlock](https://sponsor.ajay.app/) API.
It can also skip/mute YouTube ads.
Get [YouTube api key](https://developers.google.com/youtube/registering_an_application)
## Libraries used
- [pyytlounge](https://github.com/FabioGNR/pyytlounge) Used to interact with the device
- asyncio and [aiohttp](https://github.com/aio-libs/aiohttp)
- [async-cache](https://github.com/iamsinghrajat/async-cache)
- [Textual](https://github.com/textualize/textual/) Used for the amazing new graphical configurator
- [ssdp](https://github.com/codingjoe/ssdp) Used for auto discovery
Edit config.json.template and save it as config.json
# Usage
Run iSponsorBLockTV in the same network as the Apple TV.
It connect to the Apple TV, watch its activity and skip any sponsor segment using the [SponsorBlock](https://sponsor.ajay.app/) API.
The last 5 videos' segments are cached to limit the number on queries on SponsorBlock and YouTube.
# Libraries used
- [pyatv](https://github.com/postlund/pyatv) Used to connect to the Apple TV
- [asyncio] and [aiohttp]
- [async_lru]
- [json]
# Contributing
## Projects using this project
- [Home Assistant Addon](https://github.com/bertybuttface/addons/tree/main/isponsorblocktv)
## Contributing
1. Fork it (<https://github.com/dmunozv04/iSponsorBlockTV/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
@@ -71,8 +54,8 @@ The last 5 videos' segments are cached to limit the number on queries on Sponsor
5. Create a new Pull Request
## Contributors
- [dmunozv04](https://github.com/dmunozv04) - creator and maintainer
- [HaltCatchFire](https://github.com/HaltCatchFire) - updated dependencies and improved skip logic
# License
- [Oxixes](https://github.com/oxixes) - added support for channel whitelist and minor improvements
## License
[![GNU GPLv3](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -1,7 +1,22 @@
{
"atvs": [
{"identifier": "", "airplay_credentials": ""}
"devices": [
{
"screen_id": "",
"name": "YouTube on TV",
"offset": 0
}
],
"apikey":"",
"skip_categories": ["sponsor"]
}
"skip_categories": [
"sponsor"
],
"skip_count_tracking": true,
"mute_ads": true,
"skip_ads": true,
"autoplay": true,
"apikey": "",
"channel_whitelist": [
{"id": "",
"name": ""
}
]
}

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
version: '3.3'
services:
iSponsorBlockTV:
image: ghcr.io/dmunozv04/isponsorblocktv
container_name: iSponsorBlockTV
restart: unless-stopped
volumes:
- /PATH_TO_YOUR_DATA_DIR:/app/data

View File

@@ -1,100 +0,0 @@
import pyatv
import json
import asyncio
from pyatv.const import DeviceModel
import sys
def save_config(config, config_file):
with open(config_file, "w") as f:
json.dump(config, f)
#Taken from postlund/pyatv atvremote.py
async def _read_input(loop: asyncio.AbstractEventLoop, prompt: str):
sys.stdout.write(prompt)
sys.stdout.flush()
user_input = await loop.run_in_executor(None, sys.stdin.readline)
return user_input.strip()
async def find_atvs(loop):
devices = await pyatv.scan(loop)
if not devices:
print("No devices found")
return
atvs = []
for i in devices:
#Only get Apple TV's
if i.device_info.model in [DeviceModel.Gen4, DeviceModel.Gen4K, DeviceModel.AppleTV4KGen2]:
#if i.device_info.model in [DeviceModel.AppleTV4KGen2]: #FOR TESTING
if input("Found {}. Do you want to add it? (y/n) ".format(i.name)) == "y":
identifier = i.identifier
pairing = await pyatv.pair(i, loop=loop, protocol=pyatv.Protocol.AirPlay)
await pairing.begin()
if pairing.device_provides_pin:
pin = await _read_input(loop, "Enter PIN on screen: ")
pairing.pin(pin)
await pairing.finish()
if pairing.has_paired:
creds = pairing.service.credentials
atvs.append({"identifier": identifier, "airplay_credentials": creds})
print("Pairing successful")
await pairing.close()
return atvs
def main(config, config_file, debug):
try: num_atvs = len(config["atvs"])
except: num_atvs = 0
if input("Found {} Apple TV(s) in config.json. Add more? (y/n) ".format(num_atvs)) == "y":
loop = asyncio.get_event_loop_policy().get_event_loop()
if debug:
loop.set_debug(True)
asyncio.set_event_loop(loop)
task = loop.create_task(find_atvs(loop))
loop.run_until_complete(task)
atvs = task.result()
try:
for i in atvs:
config["atvs"].append(i)
print("done adding")
except:
print("rewriting atvs (don't worry if none were saved before)")
config["atvs"] = atvs
try : apikey = config["apikey"]
except:
apikey = ""
if apikey != "" :
if input("Apikey already specified. Change it? (y/n) ") == "y":
apikey = input("Enter your API key: ")
config["apikey"] = apikey
else:
print("get youtube apikey here: https://developers.google.com/youtube/registering_an_application")
apikey = input("Enter your API key: ")
config["apikey"] = apikey
try: skip_categories = config["skip_categories"]
except:
skip_categories = []
if skip_categories != []:
if input("Skip categories already specified. Change them? (y/n) ") == "y":
categories = input("Enter skip categories (space sepparated) Options: [sponsor, selfpromo, exclusive_access, interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n")
skip_categories = categories.split(" ")
else:
categories = input("Enter skip categories (space sepparated) Options: [sponsor, selfpromo, exclusive_access, interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n")
skip_categories = categories.split(" ")
config["skip_categories"] = skip_categories
print("config finished")
save_config(config, config_file)
if __name__ == "__main__":
print("starting")
main()

View File

@@ -1,46 +0,0 @@
import argparse
from . import config_setup
from . import main
from . import macos_install
import json
import os
import logging
def load_config(config_file):
try:
with open(config_file) as f:
config = json.load(f)
except:
if os.getenv('iSPBTV_docker'):
print("You are running in docker, you have to mount the config file.\nPlease check the README.md for more information.")
else:
config = {} #Create blank config to setup
return config
def app_start():
parser = argparse.ArgumentParser(description='iSponsorblockTV')
parser.add_argument('--file', '-f', default='config.json', help='config file')
parser.add_argument('--setup', '-s', action='store_true', help='setup the program')
parser.add_argument('--debug', '-d', action='store_true', help='debug mode')
parser.add_argument('--macos_install', action='store_true', help='install in macOS')
args = parser.parse_args()
config = load_config(args.file)
if args.debug:
logging.basicConfig(level=logging.DEBUG)
if args.setup: #Setup the config file
config_setup.main(config, args.file, args.debug)
if args.macos_install:
macos_install.main()
else:
try: #Check if config file has the correct structure
config["atvs"], config["apikey"], config["skip_categories"]
except: #If not, ask to setup the program
print("invalid config file, please run with --setup")
os.exit()
main.main(config["atvs"], config["apikey"], config["skip_categories"], args.debug)
if __name__ == "__main__":
app_start()

View File

@@ -1,172 +0,0 @@
import asyncio
import pyatv
import aiohttp
from cache import AsyncTTL
import time
import logging
def listToTuple(function):
def wrapper(*args):
args = [tuple(x) if type(x) == list else x for x in args]
result = function(*args)
result = tuple(result) if type(result) == list else result
return result
return wrapper
class MyPushListener(pyatv.interface.PushListener):
task = None
apikey = None
rc = None
web_session = None
categories = ["sponsor"]
def __init__(self, apikey, atv, categories, web_session):
self.apikey = apikey
self.rc = atv.remote_control
self.web_session = web_session
self.categories = categories
self.atv = atv
def playstatus_update(self, updater, playstatus):
logging.debug("Playstatus update" + str(playstatus))
try:
self.task.cancel()
except:
pass
time_start = time.time()
self.task = asyncio.create_task(process_playstatus(playstatus, self.apikey, self.rc, self.web_session, self.categories, self.atv, time_start))
def playstatus_error(self, updater, exception):
logging.error(exception)
print("stopped")
async def process_playstatus(playstatus, apikey, rc, web_session, categories, atv, time_start):
logging.debug("App playing is:" + str(atv.metadata.app.identifier))
if playstatus.device_state == playstatus.device_state.Playing and atv.metadata.app.identifier == "com.google.ios.youtube":
vid_id = await get_vid_id(playstatus.title, playstatus.artist, apikey, web_session)
print(vid_id)
segments, duration = await get_segments(vid_id, web_session, categories)
print(segments)
await time_to_segment(segments, playstatus.position, rc, time_start)
@AsyncTTL(time_to_live=300, maxsize=5)
async def get_vid_id(title, artist, api_key, web_session):
url = f"https://youtube.googleapis.com/youtube/v3/search?q={title} - {artist}&key={api_key}&maxResults=1"
async with web_session.get(url) as response:
response = await response.json()
vid_id = response["items"][0]["id"]["videoId"]
return vid_id
@listToTuple
@AsyncTTL(time_to_live=300, maxsize=5)
async def get_segments(vid_id, web_session, categories = ["sponsor"]):
params = {"videoID": vid_id,
"category": categories,
"actionType": "skip",
"service": "youtube"}
headers = {'Accept': 'application/json'}
url = "https://sponsor.ajay.app/api/skipSegments"
async with web_session.get(url, headers = headers, params = params) as response:
response = await response.json()
segments = []
try:
duration = response[0]["videoDuration"]
for i in response:
segment = i["segment"]
try:
#Get segment before to check if they are too close to each other
segment_before_end = segments[-1][1]
segment_before_start = segments[-1][0]
except:
segment_before_end = -10
if segment[0] - segment_before_end < 1: #Less than 1 second appart, combine them and skip them together
segment = [segment_before_start, segment[1]]
segments.pop()
segments.append(segment)
except:
duration = 0
return segments, duration
async def time_to_segment(segments, position, rc, time_start):
position = position + (time.time() - time_start)
for segment in segments:
if position < 2 and (position >= segment[0] and position < segment[1]):
next_segment = [position, segment[1]]
break
if segment[0] > position:
next_segment = segment
break
time_to_next = next_segment[0] - position
await skip(time_to_next, next_segment[1], rc)
async def skip(time_to, position, rc):
await asyncio.sleep(time_to)
await rc.set_position(position)
async def connect_atv(loop, identifier, airplay_credentials):
"""Find a device and print what is playing."""
print("Discovering devices on network...")
atvs = await pyatv.scan(loop, identifier = identifier)
if not atvs:
print("No device found, will retry")
return
config = atvs[0]
config.set_credentials(pyatv.Protocol.AirPlay, airplay_credentials)
print(f"Connecting to {config.address}")
return await pyatv.connect(config, loop)
async def loop_atv(event_loop, atv_config, apikey, categories, web_session):
identifier = atv_config["identifier"]
airplay_credentials = atv_config["airplay_credentials"]
atv = await connect_atv(event_loop, identifier, airplay_credentials)
if atv:
listener = MyPushListener(apikey, atv, categories, web_session)
atv.push_updater.listener = listener
atv.push_updater.start()
print("Push updater started")
while True:
await asyncio.sleep(20)
try:
atv.metadata.app
except:
print("Reconnecting to Apple TV")
#reconnect to apple tv
atv = await connect_atv(event_loop, identifier, airplay_credentials)
if atv:
listener = MyPushListener(apikey, atv, categories, web_session)
atv.push_updater.listener = listener
atv.push_updater.start()
print("Push updater started")
def main(atv_configs, apikey, categories, debug):
loop = asyncio.get_event_loop_policy().get_event_loop()
if debug:
loop.set_debug(True)
asyncio.set_event_loop(loop)
web_session = aiohttp.ClientSession()
for i in atv_configs:
loop.create_task(loop_atv(loop, i, apikey, categories, web_session))
loop.run_forever()
if __name__ == "__main__":
print("starting")
main()

View File

@@ -1,7 +0,0 @@
from iSponsorBlockTV import helpers
import sys
import os
if getattr(sys, 'frozen', False):
os.environ['SSL_CERT_FILE'] = os.path.join(sys._MEIPASS, 'lib', 'cert.pem')
helpers.app_start()

View File

@@ -1,47 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import exec_statement
cert_datas = exec_statement("""
import ssl
print(ssl.get_default_verify_paths().cafile)""").strip().split()
cert_datas = [(f, 'lib') for f in cert_datas]
block_cipher = None
options = [ ('u', None, 'OPTION') ]
a = Analysis(['main-macos.py'],
pathex=[],
binaries=[],
datas=cert_datas,
hiddenimports=['certifi'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
options,
name='iSponsorBlockTV-macos',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None)

33
pyproject.toml Normal file
View File

@@ -0,0 +1,33 @@
[project]
name = "iSponsorBlockTV"
version = "2.0.8"
authors = [
{"name" = "dmunozv04"}
]
description = "SponsorBlock client for all YouTube TV clients"
readme = "README.md"
requires-python = ">=3.7"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
"Topic :: Home Automation"
]
dynamic = ["dependencies"]
[tool.hatch.metadata.hooks.requirements_txt]
files = ["requirements.txt"]
[project.urls]
"Homepage" = "https://github.com/dmunozv04/iSponsorBlockTV"
"Bug Tracker" = "https://github.com/dmunozv04/iSponsorBlockTV/issues"
[project.scripts]
"iSponsorBlockTV" = "iSponsorBlockTV.__main__:main"
[build-system]
requires = ["hatchling", "hatch-requirements-txt"]
build-backend = "hatchling.build"
[tool.black]
line-length = 88

View File

@@ -1,5 +1,10 @@
pyatv
aiohttp
aiodns
async-cache
argparse
aiohttp==3.9.5
appdirs==1.4.4
argparse==1.4.0
async-cache==1.1.1
pyytlounge==2.0.0
rich==13.7.1
ssdp==1.3.0
textual==0.58.0
textual-slider==0.1.1
xmltodict==0.13.0

View File

@@ -0,0 +1,9 @@
from . import helpers
def main():
helpers.app_start()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,212 @@
import html
from hashlib import sha256
from aiohttp import ClientSession
from cache import AsyncLRU
from . import constants, dial_client
from .conditional_ttl_cache import AsyncConditionalTTL
def list_to_tuple(function):
def wrapper(*args):
args = [tuple(x) if isinstance(x, list) else x for x in args]
result = function(*args)
result = tuple(result) if isinstance(result, list) else result
return result
return wrapper
# Class that handles all the api calls and their cache
class ApiHelper:
def __init__(self, config, web_session: ClientSession) -> None:
self.apikey = config.apikey
self.skip_categories = config.skip_categories
self.channel_whitelist = config.channel_whitelist
self.skip_count_tracking = config.skip_count_tracking
self.web_session = web_session
self.num_devices = len(config.devices)
# Not used anymore, maybe it can stay here a little longer
@AsyncLRU(maxsize=10)
async def get_vid_id(self, title, artist, api_key, web_session):
params = {"q": title + " " + artist, "key": api_key, "part": "snippet"}
url = constants.Youtube_api + "search"
async with web_session.get(url, params=params) as resp:
data = await resp.json()
if "error" in data:
return
for i in data["items"]:
if i["id"]["kind"] != "youtube#video":
continue
title_api = html.unescape(i["snippet"]["title"])
artist_api = html.unescape(i["snippet"]["channelTitle"])
if title_api == title and artist_api == artist:
return i["id"]["videoId"], i["snippet"]["channelId"]
return
@AsyncLRU(maxsize=100)
async def is_whitelisted(self, vid_id):
if self.apikey and self.channel_whitelist:
channel_id = await self.__get_channel_id(vid_id)
# check if channel id is in whitelist
for i in self.channel_whitelist:
if i["id"] == channel_id:
return True
return False
async def __get_channel_id(self, vid_id):
params = {"id": vid_id, "key": self.apikey, "part": "snippet"}
url = constants.Youtube_api + "videos"
async with self.web_session.get(url, params=params) as resp:
data = await resp.json()
if "error" in data:
return
data = data["items"][0]
if data["kind"] != "youtube#video":
return
return data["snippet"]["channelId"]
@AsyncLRU(maxsize=10)
async def search_channels(self, channel):
channels = []
params = {
"q": channel,
"key": self.apikey,
"part": "snippet",
"type": "channel",
"maxResults": "5",
}
url = constants.Youtube_api + "search"
async with self.web_session.get(url, params=params) as resp:
data = await resp.json()
if "error" in data:
return channels
for i in data["items"]:
# Get channel subscription number
params = {
"id": i["snippet"]["channelId"],
"key": self.apikey,
"part": "statistics",
}
url = constants.Youtube_api + "channels"
async with self.web_session.get(url, params=params) as resp:
channel_data = await resp.json()
if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]:
sub_count = "Hidden"
else:
sub_count = int(
channel_data["items"][0]["statistics"]["subscriberCount"]
)
sub_count = format(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
async def get_segments(self, vid_id):
if await self.is_whitelisted(vid_id):
return (
[],
True,
) # Return empty list and True to indicate that the cache should last forever
vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[
:4
] # Hashes video id and gets the first 4 characters
params = {
"category": self.skip_categories,
"actionType": constants.SponsorBlock_actiontype,
"service": constants.SponsorBlock_service,
}
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:
response_json = await response.json()
if response.status != 200:
response_text = await response.text()
print(
f"Error getting segments for video {vid_id}, hashed as {vid_id_hashed}."
f" Code: {response.status} - {response_text}"
)
return [], True
for i in response_json:
if str(i["videoID"]) == str(vid_id):
response_json = i
break
return self.process_segments(response_json)
@staticmethod
def process_segments(response):
segments = []
ignore_ttl = True
try:
response_segments = response["segments"]
# sort by end
response_segments.sort(key=lambda x: x["segment"][1])
# extend ends of overlapping segments to make one big segment
for i in response_segments:
for j in response_segments:
if j["segment"][0] <= i["segment"][1] <= j["segment"][1]:
i["segment"][1] = j["segment"][1]
# sort by start
response_segments.sort(key=lambda x: x["segment"][0])
# extend starts of overlapping segments to make one big segment
for i in reversed(response_segments):
for j in reversed(response_segments):
if j["segment"][0] <= i["segment"][0] <= j["segment"][1]:
i["segment"][0] = j["segment"][0]
for i in response_segments:
ignore_ttl = (
ignore_ttl and i["locked"] == 1
) # If all segments are locked, ignore ttl
segment = i["segment"]
UUID = i["UUID"]
segment_dict = {"start": segment[0], "end": segment[1], "UUID": [UUID]}
try:
# Get segment before to check if they are too close to each other
segment_before_end = segments[-1]["end"]
segment_before_start = segments[-1]["start"]
segment_before_UUID = segments[-1]["UUID"]
except Exception:
segment_before_end = -10
if (
segment_dict["start"] - segment_before_end < 1
): # Less than 1 second apart, combine them and skip them together
segment_dict["start"] = segment_before_start
segment_dict["UUID"].extend(segment_before_UUID)
segments.pop()
segments.append(segment_dict)
except Exception:
pass
return segments, ignore_ttl
async def mark_viewed_segments(self, uuids):
"""Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled.
Lets the contributor know that someone skipped the segment (thanks)"""
if self.skip_count_tracking:
for i in uuids:
url = constants.SponsorBlock_api + "viewedVideoSponsorTime/"
params = {"UUID": i}
await self.web_session.post(url, params=params)
async def discover_youtube_devices_dial(self):
"""Discovers YouTube devices using DIAL"""
dial_screens = await dial_client.discover(self.web_session)
# print(dial_screens)
return dial_screens

View File

@@ -0,0 +1,86 @@
import datetime
from cache.key import KEY
from cache.lru import LRU
"""MIT License
Copyright (c) 2020 Rajat Singh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE."""
"""Modified code from https://github.com/iamsinghrajat/async-cache"""
class AsyncConditionalTTL:
class _TTL(LRU):
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.maxsize = maxsize
def __contains__(self, key):
if key not in self.keys():
return False
key_expiration = super().__getitem__(key)[1]
if key_expiration and key_expiration < datetime.datetime.now():
del self[key]
return False
return True
def __getitem__(self, key):
value = super().__getitem__(key)[0]
return value
def __setitem__(self, key, value):
value, ignore_ttl = value # unpack tuple
ttl_value = (
(datetime.datetime.now() + self.time_to_live)
if (self.time_to_live and not ignore_ttl)
else None
) # ignore ttl if ignore_ttl is True
super().__setitem__(key, (value, ttl_value))
def __init__(self, time_to_live=60, maxsize=1024, skip_args: int = 0):
"""
:param time_to_live: Use time_to_live as None for non expiring cache
:param maxsize: Use maxsize as None for unlimited size cache
:param skip_args: Use `1` to skip first arg of func in determining cache key
"""
self.ttl = self._TTL(time_to_live=time_to_live, maxsize=maxsize)
self.skip_args = skip_args
def __call__(self, func):
async def wrapper(*args, **kwargs):
key = KEY(args[self.skip_args :], kwargs)
if key in self.ttl:
val = self.ttl[key]
else:
self.ttl[key] = await func(*args, **kwargs)
val = self.ttl[key]
return val
wrapper.__name__ += func.__name__
return wrapper

View File

@@ -0,0 +1,171 @@
import asyncio
import aiohttp
from . import api_helpers, ytlounge
async def pair_device(web_session):
try:
lounge_controller = ytlounge.YtLoungeApi(
"iSponsorBlockTV", web_session=web_session
)
pairing_code = input(
"Enter pairing code (found in Settings - Link with TV code): "
)
pairing_code = int(
pairing_code.replace("-", "").replace(" ", "")
) # remove dashes and spaces
print("Pairing...")
paired = await lounge_controller.pair(pairing_code)
if not paired:
print("Failed to pair device")
return
device = {
"screen_id": lounge_controller.auth.screen_id,
"name": lounge_controller.screen_name,
}
print(f"Paired device: {device['name']}")
return device
except Exception as e:
print(f"Failed to pair device: {e}")
return
def main(config, debug: bool) -> None:
print("Welcome to the iSponsorBlockTV cli setup wizard")
loop = asyncio.get_event_loop_policy().get_event_loop()
web_session = aiohttp.ClientSession()
if debug:
loop.set_debug(True)
asyncio.set_event_loop(loop)
if hasattr(config, "atvs"):
print(
"The atvs config option is deprecated and has stopped working. Please read"
" this for more information on how to upgrade to V2:"
" \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
)
if (
input(
"Do you want to remove the legacy 'atvs' entry (the app won't start"
" with it present)? (y/n) "
)
== "y"
):
del config["atvs"]
devices = config.devices
while not input(f"Paired with {len(devices)} Device(s). Add more? (y/n) ") == "n":
task = loop.create_task(pair_device(web_session))
loop.run_until_complete(task)
device = task.result()
if device:
devices.append(device)
config.devices = devices
apikey = config.apikey
if apikey:
if input("API key already specified. Change it? (y/n) ") == "y":
apikey = input("Enter your API key: ")
else:
if (
input(
"API key only needed for the channel whitelist function. Add it? (y/n) "
)
== "y"
):
print(
"Get youtube apikey here:"
" https://developers.google.com/youtube/registering_an_application"
)
apikey = input("Enter your API key: ")
config.apikey = apikey
skip_categories = config.skip_categories
if skip_categories:
if input("Skip categories already specified. Change them? (y/n) ") == "y":
categories = input(
"Enter skip categories (space or comma sepparated) Options: [sponsor"
" selfpromo exclusive_access interaction poi_highlight intro outro"
" preview filler music_offtopic]:\n"
)
skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [
x for x in skip_categories if x != ""
] # Remove empty strings
else:
categories = input(
"Enter skip categories (space or comma sepparated) Options: [sponsor,"
" selfpromo, exclusive_access, interaction, poi_highlight, intro, outro,"
" preview, filler, music_offtopic:\n"
)
skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [
x for x in skip_categories if x != ""
] # Remove empty strings
config.skip_categories = skip_categories
channel_whitelist = config.channel_whitelist
if (
input("Do you want to whitelist any channels from being ad-blocked? (y/n) ")
== "y"
):
if not apikey:
print(
"WARNING: You need to specify an API key to use this function,"
" otherwise the program will fail to start.\nYou can add one by"
" re-running this setup wizard."
)
api_helper = api_helpers.ApiHelper(config, web_session)
while True:
channel_info = {}
channel = input('Enter a channel name or "/exit" to exit: ')
if channel == "/exit":
break
task = loop.create_task(
api_helper.search_channels(channel, apikey, web_session)
)
loop.run_until_complete(task)
results = task.result()
if len(results) == 0:
print("No channels found")
continue
for i, item in enumerate(results):
print(f"{i}: {item[1]} - Subs: {item[2]}")
print("5: Enter a custom channel ID")
print("6: Go back")
choice = -1
choice = input("Select one option of the above [0-6]: ")
while choice not in [str(x) for x in range(7)]:
print("Invalid choice")
choice = input("Select one option of the above [0-6]: ")
if choice == "5":
channel_info["id"] = input("Enter a channel ID: ")
channel_info["name"] = input("Enter the channel name: ")
channel_whitelist.append(channel_info)
continue
if choice == "6":
continue
channel_info["id"] = results[int(choice)][0]
channel_info["name"] = results[int(choice)][1]
channel_whitelist.append(channel_info)
# Close web session asynchronously
config.channel_whitelist = channel_whitelist
config.skip_count_tracking = (
not input(
"Do you want to report skipped segments to sponsorblock. Only the segment"
" UUID will be sent? (y/n) "
)
== "n"
)
config.auto_play = not input("Do you want to enable autoplay? (y/n) ") == "n"
print("Config finished")
config.save()
loop.run_until_complete(web_session.close())

View File

@@ -0,0 +1,24 @@
userAgent = "iSponsorBlockTV/0.1"
SponsorBlock_service = "youtube"
SponsorBlock_actiontype = "skip"
SponsorBlock_api = "https://sponsor.ajay.app/api/"
Youtube_api = "https://www.googleapis.com/youtube/v3/"
skip_categories = (
("Sponsor", "sponsor"),
("Self Promotion", "selfpromo"),
("Intro", "intro"),
("Outro", "outro"),
("Music Offtopic", "music_offtopic"),
("Interaction", "interaction"),
("Exclusive Access", "exclusive_access"),
("POI Highlight", "poi_highlight"),
("Preview", "preview"),
("Filler", "filler"),
)
youtube_client_blacklist = ["TVHTML5_FOR_KIDS"]
config_file_blacklist_keys = ["config_file", "data_dir"]

View File

@@ -0,0 +1,148 @@
"""Send out an M-SEARCH request and listening for responses."""
import asyncio
import socket
import ssdp
import xmltodict
from ssdp import network
"""Redistribution and use of the DIAL DIscovery And Launch protocol
specification (the “DIAL Specification”), with or without modification,
are permitted provided that the following conditions are met: ●
Redistributions of the DIAL Specification must retain the above copyright
notice, this list of conditions and the following disclaimer. ●
Redistributions of implementations of the DIAL Specification in source code
form must retain the above copyright notice, this list of conditions and the
following disclaimer. ● Redistributions of implementations of the DIAL
Specification in binary form must include the above copyright notice. ● The
DIAL mark, the NETFLIX mark and the names of contributors to the DIAL
Specification may not be used to endorse or promote specifications, software,
products, or any other materials derived from the DIAL Specification without
specific prior written permission. The DIAL mark is owned by Netflix and
information on licensing the DIAL mark is available at
www.dial-multiscreen.org."""
"""
MIT License
Copyright (c) 2018 Johannes Hoppe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE."""
"""Modified code from
https://github.com/codingjoe/ssdp/blob/main/ssdp/__main__.py"""
def get_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0)
try:
# doesn't even have to be reachable
s.connect(("10.254.254.254", 1))
ip = s.getsockname()[0]
except Exception:
ip = "127.0.0.1"
finally:
s.close()
return ip
class Handler(ssdp.aio.SSDP):
def __init__(self):
super().__init__()
self.devices = []
def clear(self):
self.devices = []
def __call__(self):
return self
def response_received(self, response: ssdp.messages.SSDPResponse, addr):
headers = response.headers
headers = {k.lower(): v for k, v in headers}
# print(headers)
if "location" in headers:
self.devices.append(headers["location"])
async def find_youtube_app(web_session, url_location):
async with web_session.get(url_location) as response:
headers = response.headers
response = await response.text()
# print(headers)
data = xmltodict.parse(response)
name = data["root"]["device"]["friendlyName"]
handler = Handler()
handler.clear()
app_url = headers["application-url"]
youtube_url = app_url + "YouTube"
# print(youtube_url)
async with web_session.get(youtube_url) as response:
status_code = response.status
response = await response.text()
# print(status_code)
if status_code == 200:
data = xmltodict.parse(response)
data = data["service"]
screen_id = data["additionalData"]["screenId"]
return {"screen_id": screen_id, "name": name, "offset": 0}
async def discover(web_session):
bind = None
search_target = "urn:dial-multiscreen-org:service:dial:1"
max_wait = 10
handler = Handler()
"""Send out an M-SEARCH request and listening for responses."""
family, addr = network.get_best_family(bind, network.PORT)
loop = asyncio.get_event_loop()
ip_address = get_ip()
connect = loop.create_datagram_endpoint(
handler, family=family, local_addr=(ip_address, None)
)
transport, protocol = await connect
target = network.MULTICAST_ADDRESS_IPV4, network.PORT
search_request = ssdp.messages.SSDPRequest(
"M-SEARCH",
headers={
"HOST": "%s:%d" % target,
"MAN": '"ssdp:discover"',
"MX": str(max_wait), # seconds to delay response [1..5]
"ST": search_target,
},
)
target = network.MULTICAST_ADDRESS_IPV4, network.PORT
search_request.sendto(transport, target)
# print(search_request, addr[:2])
try:
await asyncio.sleep(4)
finally:
transport.close()
devices = []
for i in handler.devices:
devices.append(await find_youtube_app(web_session, i))
return devices

View File

@@ -0,0 +1,160 @@
import argparse
import json
import logging
import os
import sys
import time
from appdirs import user_data_dir
from . import config_setup, main, setup_wizard
from .constants import config_file_blacklist_keys
class Device:
def __init__(self, args_dict):
self.screen_id = ""
self.offset = 0
self.__load(args_dict)
self.__validate()
def __load(self, args_dict):
for i in args_dict:
setattr(self, i, args_dict[i])
# Change offset to seconds (from milliseconds)
self.offset = self.offset / 1000
def __validate(self):
if not self.screen_id:
raise ValueError("No screen id found")
class Config:
def __init__(self, data_dir):
self.data_dir = data_dir
self.config_file = data_dir + "/config.json"
self.devices = []
self.apikey = ""
self.skip_categories = [] # These are the categories on the config file
self.channel_whitelist = []
self.skip_count_tracking = True
self.mute_ads = False
self.skip_ads = False
self.auto_play = True
self.__load()
def validate(self):
if hasattr(self, "atvs"):
print(
(
"The atvs config option is deprecated and has stopped working."
" Please read this for more information "
"on how to upgrade to V2:"
" \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
),
)
print("Exiting in 10 seconds...")
time.sleep(10)
sys.exit()
if not self.devices:
print("No devices found, please add at least one device")
print("Exiting in 10 seconds...")
time.sleep(10)
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"
)
if not self.skip_categories:
self.skip_categories = ["sponsor"]
print("No categories found, using default: sponsor")
def __load(self):
try:
with open(self.config_file, "r", encoding="utf-8") as f:
config = json.load(f)
for i in config:
if i not in config_file_blacklist_keys:
setattr(self, i, config[i])
except FileNotFoundError:
print("Could not load config file")
# Create data directory if it doesn't exist (if we're not running in docker)
if not os.path.exists(self.data_dir):
if not os.getenv("iSPBTV_docker"):
print("Creating data directory")
os.makedirs(self.data_dir)
else: # Running in docker without mounting the data dir
print(
"Running in docker without mounting the data dir, check the"
" wiki for more information: "
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker"
)
print(
(
"This image has recently been updated to v2, and requires"
" changes."
),
(
"Please read this for more information on how to upgrade"
" to V2:"
),
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2",
)
print("Exiting in 10 seconds...")
time.sleep(10)
sys.exit()
else:
print("Blank config file created")
def save(self):
with open(self.config_file, "w", encoding="utf-8") as f:
config_dict = self.__dict__
# Don't save the config file name
config_file = self.config_file
data_dir = self.data_dir
del config_dict["config_file"]
del config_dict["data_dir"]
json.dump(config_dict, f, indent=4)
self.config_file = config_file
self.data_dir = data_dir
def __eq__(self, other):
if isinstance(other, Config):
return self.__dict__ == other.__dict__
return False
def app_start():
# If env has a data dir use that, otherwise use the default
default_data_dir = os.getenv("iSPBTV_data_dir") or user_data_dir(
"iSponsorBlockTV", "dmunozv04"
)
parser = argparse.ArgumentParser(description="iSponsorblockTV")
parser.add_argument(
"--data-dir", "-d", default=default_data_dir, help="data directory"
)
parser.add_argument(
"--setup", "-s", action="store_true", help="setup the program graphically"
)
parser.add_argument(
"--setup-cli",
"-sc",
action="store_true",
help="setup the program in the command line",
)
parser.add_argument("--debug", action="store_true", help="debug mode")
args = parser.parse_args()
config = Config(args.data_dir)
if args.debug:
logging.basicConfig(level=logging.DEBUG)
if args.setup: # Set up the config file graphically
setup_wizard.main(config)
sys.exit()
if args.setup_cli: # Set up the config file
config_setup.main(config, args.debug)
else:
config.validate()
main.main(config, args.debug)

View File

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

170
src/iSponsorBlockTV/main.py Normal file
View File

@@ -0,0 +1,170 @@
import asyncio
import logging
import time
from signal import SIGINT, SIGTERM, signal
from typing import Optional
import aiohttp
from . import api_helpers, ytlounge
class DeviceListener:
def __init__(self, api_helper, config, device, debug: bool, web_session):
self.task: Optional[asyncio.Task] = None
self.api_helper = api_helper
self.offset = device.offset
self.name = device.name
self.cancelled = False
self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}")
self.web_session = web_session
if debug:
self.logger.setLevel(logging.DEBUG)
else:
self.logger.setLevel(logging.INFO)
sh = logging.StreamHandler()
sh.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
self.logger.addHandler(sh)
self.logger.info(f"Starting device")
self.lounge_controller = ytlounge.YtLoungeApi(
device.screen_id, config, api_helper, self.logger, self.web_session
)
# Ensures that we have a valid auth token
async def refresh_auth_loop(self):
while True:
await asyncio.sleep(60 * 60 * 24) # Refresh every 24 hours
try:
await self.lounge_controller.refresh_auth()
except:
# traceback.print_exc()
pass
async def is_available(self):
try:
return await self.lounge_controller.is_available()
except:
# traceback.print_exc()
return False
# Main subscription loop
async def loop(self):
lounge_controller = self.lounge_controller
while not self.cancelled:
while not lounge_controller.linked():
try:
self.logger.debug("Refreshing auth")
await lounge_controller.refresh_auth()
except:
await asyncio.sleep(10)
while not (await self.is_available()) and not self.cancelled:
await asyncio.sleep(10)
try:
await lounge_controller.connect()
except:
pass
while not lounge_controller.connected() and not self.cancelled:
# Doesn't connect to the device if it's a kids profile (it's broken)
await asyncio.sleep(10)
try:
await lounge_controller.connect()
except:
pass
self.logger.info(
"Connected to device %s (%s)", lounge_controller.screen_name, self.name
)
try:
self.logger.info("Subscribing to lounge")
sub = await lounge_controller.subscribe_monitored(self)
await sub
except:
pass
# Method called on playback state change
async def __call__(self, state):
try:
self.task.cancel()
except:
pass
time_start = time.time()
self.task = asyncio.create_task(self.process_playstatus(state, time_start))
# Processes the playback state change
async def process_playstatus(self, state, time_start):
segments = []
if state.videoId:
segments = await self.api_helper.get_segments(state.videoId)
if state.state.value == 1: # Playing
self.logger.info(
f"Playing video {state.videoId} with {len(segments)} segments"
)
if segments: # If there are segments
await self.time_to_segment(segments, state.currentTime, time_start)
# Finds the next segment to skip to and skips to it
async def time_to_segment(self, segments, position, time_start):
start_next_segment = None
next_segment = None
for segment in segments:
if position < 2 and (segment["start"] <= position < segment["end"]):
next_segment = segment
start_next_segment = (
position # different variable so segment doesn't change
)
break
if segment["start"] > position:
next_segment = segment
start_next_segment = next_segment["start"]
break
if start_next_segment:
time_to_next = (
start_next_segment - position - (time.time() - time_start) - self.offset
)
await self.skip(time_to_next, next_segment["end"], next_segment["UUID"])
# Skips to the next segment (waits for the time to pass)
async def skip(self, time_to, position, uuids):
await asyncio.sleep(time_to)
self.logger.info("Skipping segment: seeking to %s", position)
await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids))
await asyncio.create_task(self.lounge_controller.seek_to(position))
# Stops the connection to the device
async def cancel(self):
self.cancelled = True
try:
self.task.cancel()
except Exception:
pass
async def finish(devices):
for i in devices:
await i.cancel()
def main(config, debug):
loop = asyncio.get_event_loop_policy().get_event_loop()
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
devices = [] # Save the devices to close them later
if debug:
loop.set_debug(True)
asyncio.set_event_loop(loop)
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300)
web_session = aiohttp.ClientSession(loop=loop, 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)
devices.append(device)
tasks.append(loop.create_task(device.loop()))
tasks.append(loop.create_task(device.refresh_auth_loop()))
signal(SIGINT, lambda s, f: loop.stop())
signal(SIGTERM, lambda s, f: loop.stop())
loop.run_forever()
print("Cancelling tasks and exiting...")
loop.run_until_complete(finish(devices))
loop.run_until_complete(web_session.close())
loop.run_until_complete(tcp_connector.close())
loop.close()

View File

@@ -0,0 +1,371 @@
.container {
background: $boost;
margin: 1;
padding: 1 1 0 1;
height: auto;
width: 100%;
}
.title {
text-style: bold underline;
dock: top;
background: $primary-background;
width: 100%;
}
.subtitle{
width: 100%;
}
#setup-wizard{
scrollbar-gutter: stable;
}
.small-button{
height: 3;
}
.button-100 {
width: 100%;
}
/* Exit screen */
ExitScreen {
align: center middle;
}
#dialog-exit {
grid-size: 3;
grid-gutter: 1 2;
grid-rows: 1fr 3;
padding: 1 2;
width: 35%;
min-width: 50;
max-width: 70;
height: 11;
border: thick $background 80%;
background: $surface;
}
#question {
column-span: 3;
height: 1fr;
width: 1fr;
content-align: center middle;
}
/* Device editor */
EditDevice {
align: center middle;
}
#edit-device-container {
padding: 1 2 0 2;
background: $surface;
border: thick $background 80%;
height: 17;
width: 50%;
min-width: 40;
}
#device-id-container{
grid-size: 4;
grid-gutter: 1 2;
grid-rows: 1fr 3;
width: 100%;
margin-right: 1;
}
#device-id-input{
column-span: 3;
height: 1fr;
width: 1fr;
content-align: center middle;
}
#device-id-view{
width: 1fr;
}
#device-offset-container{
width: 100%;
}
#device-offset-input{
width: 12;
}
#device-offset-slider{
width: 100%;
margin-right: 12;
}
/* devices */
#devices-manager {
min-height: 4;
height: auto;
max-height: 70%;
width: 100%;
scrollbar-gutter: stable;
overflow-y: auto;
}
Element {
background: $panel;
border-top: solid $panel-lighten-2;
layout: horizontal;
height: 2;
width: 100%;
margin: 0 1 0 1;
padding: 0;
}
Element > .element-name {
offset: 0 -1;
padding: 0;
width: 100%;
align: left middle;
text-align: left;
}
Element > .element-remove {
dock: right;
padding: 0;
align: left middle;
text-align: center;
width: 8;
min-width: 8;
margin: 0 1 0 0;
}
#add-device {
text-style: bold;
width: 100%;
align: left middle;
text-align: center;
dock: left;
text-align: left;
}
#add-device-button-container{
height: 1;
width: 100%;
margin: 1 1 0 1;
}
/* Add devices */
#add-device-container{
border: thick $background 80%;
background: $surface;
padding: 1 2 0 2;
height: auto;
width: 50%;
min-width: 40;
}
#add-device-switch-buttons{
grid-size: 2 1;
height: 5;
width: 100%;
padding: 1 1;
}
#add-device-pin-container{
height: auto;
}
#add-device-dial-container{
height: auto;
}
#pairing-code-input.-valid {
border: tall $success 60%;
}
#pairing-code-input.-valid:focus {
border: tall $success;
}
#add-device-pin-add-button{
margin: 1 1 0 1;
width: 100%;
}
#add-device-dial-add-button{
margin: 1 1 0 1;
width: 100%;
}
#add-device-info{
height: auto;
min-height: 0;
width: 100%;
padding: 0 1;
}
/* ApiKey */
#api-key-grid{
grid-size: 4;
grid-gutter: 1 2;
grid-rows: 1fr 3;
padding: 1 1;
width: 100%;
height: 5;
}
#api-key-input{
column-span: 3;
height: 1fr;
width: 1fr;
content-align: center middle;
}
/* Skip Categories */
#skip-categories-manager{
min-height: 10;
height: auto;
max-height: 70%;
width: 100%;
scrollbar-gutter: stable;
}
#skip-categories-selection-list{
height: auto;
width: 100%;
background: $boost;
margin: 1;
}
/* Segment count tracking */
#skip-count-tracking-switch{
margin: 1;
}
/* Channel Whitelist */
#channel-whitelist-manager {
min-height: 5;
height: auto;
max-height: 70%;
width: 100%;
scrollbar-gutter: stable;
overflow-y: auto;
}
#add-channel {
text-style: bold;
width: 100%;
align: left middle;
text-align: center;
dock: left;
text-align: left;
}
#add-channel-button-container{
height: 1;
width: 100%;
margin: 1 1 0 1;
}
/* Add Channel */
AddChannel{
align: center middle;
overflow-y: auto;
background: $background 60%;
}
#add-channel-container{
padding: 1 2 0 2;
border: thick $background 80%;
background: $surface;
height: auto;
width: 50%;
min-width: 40;
}
#add-channel-switch-buttons{
grid-size: 2 1;
height: 5;
width: 100%;
padding: 1 1;
}
.button-switcher{
width: 100%;
text-align: center;
}
#add-channel-switcher{
height: auto;
width: auto;
}
#add-channel-switcher-container{
height: auto;
width: 100%;
}
#add-channel-search-container{
height: auto;
}
#add-channel-search-inputs{
height: 3;
width: 100%;
grid-size: 4 1;
margin: 0 1 0 0;
/* padding: 0 1; */
}
#channel-name-input-search{
width: 100%;
height: auto;
column-span: 3;
}
#search-channel-button{
width: 1fr;
height: auto;
column-span: 1;
margin: 0 0 0 0;
}
#channel-search-results{
height: auto;
width: 100%;
background: $boost;
margin: 1;
}
#add-channel-info{
height: auto;
width: 100%;
margin: 0 1;
}
#add-channel-id-container{
height: auto;
}
#channel-name-input-id{
margin: 1 0;
}
#add-channel-search-no-key{
padding: 0 0 1 1;
}
/* Mute/Skip ads */
#ad-skip-mute-container{
padding: 1;
height: auto;
}
/* Migrate screen */
MigrationScreen {
align: center middle;
}
#dialog-migration {
grid-size: 2;
grid-gutter: 1 2;
grid-rows: 1fr 3;
padding: 1 2;
width: 35%;
min-width: 50;
max-width: 70;
height: 11;
border: thick $background 80%;
background: $surface;
}
#question-migrate {
column-span: 2;
height: 1fr;
width: 1fr;
content-align: center middle;
}
/* Autoplay */
#autoplay-container{
padding: 1;
height: auto;
}

View File

@@ -0,0 +1,974 @@
import asyncio
import copy
import aiohttp
# Textual imports (Textual is awesome!)
from textual import on
from textual.app import App, ComposeResult
from textual.containers import (
Container,
Grid,
Horizontal,
ScrollableContainer,
Vertical,
)
from textual.events import Click
from textual.screen import Screen
from textual.validation import Function
from textual.widgets import (
Button,
Checkbox,
ContentSwitcher,
Footer,
Header,
Input,
Label,
RadioButton,
RadioSet,
SelectionList,
Static,
)
from textual.widgets.selection_list import Selection
from textual_slider import Slider
# Local imports
from . import api_helpers, ytlounge
from .constants import skip_categories
def _validate_pairing_code(pairing_code: str) -> bool:
try:
pairing_code = pairing_code.replace("-", "").replace(" ", "")
int(pairing_code)
return len(pairing_code) == 12
except ValueError:
return False # not a number
class ModalWithClickExit(Screen):
"""A modal screen that exits when clicked outside its bounds.
https://discord.com/channels/1026214085173461072/1033754296224841768/1136015817356611604
"""
DEFAULT_CSS = """
ModalWithClickExit {
align: center middle;
layout: vertical;
overflow-y: auto;
background: $background 60%;
}
"""
@on(Click)
def close_out_bounds(self, event: Click) -> None:
if self.get_widget_at(event.screen_x, event.screen_y)[0] is self:
self.dismiss()
class Element(Static):
"""Base class for elements (devices and channels).
It has a name and a remove button.
"""
def __init__(self, element: dict, tooltip: str = None, **kwargs) -> None:
super().__init__(**kwargs)
self.element_data = element
self.element_name = ""
self.process_values_from_data()
self.tooltip = tooltip
def process_values_from_data(self):
pass
def compose(self) -> ComposeResult:
yield Button(
label=self.element_name,
classes="element-name",
disabled=True,
id="element-name",
)
yield Button(
"Remove", classes="element-remove", variant="error", id="element-remove"
)
def on_mount(self) -> None:
if self.tooltip:
self.query_one(".element-name").tooltip = self.tooltip
self.query_one(".element-name").disabled = False
class Device(Element):
"""A device element."""
def process_values_from_data(self):
print(self.element_data)
if "name" in self.element_data and self.element_data["name"]:
self.element_name = self.element_data["name"]
else:
self.element_name = (
"Unnamed device with id "
f"{self.element_data['screen_id'][:5]}..."
f"{self.element_data['screen_id'][-5:]}"
)
class Channel(Element):
"""A channel element."""
def process_values_from_data(self):
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']}"
)
class ChannelRadio(RadioButton):
"""A radio button for a channel."""
def __init__(self, channel: tuple, **kwargs) -> None:
label = f"{channel[1]} - Subs: {channel[2]}"
super().__init__(label=label, **kwargs)
self.channel_data = channel
class MigrationScreen(ModalWithClickExit):
"""Screen with a dialog to remove old ATVS config."""
BINDINGS = [
("escape", "dismiss()", "Cancel"),
("s", "remove_and_save", "Remove and save"),
("q,ctrl+c", "exit", "Exit"),
]
AUTO_FOCUS = "#exit-save"
def compose(self) -> ComposeResult:
yield Grid(
Label(
(
"Welcome to the new configurator! You seem to have the legacy"
" 'atvs' entry on your config file, do you want to remove it?\n(The"
" app won't start with it present)"
),
id="question",
classes="button-100",
),
Button(
"Remove and save",
variant="primary",
id="migrate-remove-save",
classes="button-100",
),
Button(
"Don't remove",
variant="error",
id="migrate-no-change",
classes="button-100",
),
id="dialog-migration",
)
def action_exit(self) -> None:
self.app.exit()
@on(Button.Pressed, "#migrate-no-change")
def action_no_change(self) -> None:
self.app.pop_screen()
@on(Button.Pressed, "#migrate-remove-save")
def action_remove_and_save(self) -> None:
del self.app.config.atvs
self.app.config.save()
self.app.pop_screen()
class ExitScreen(ModalWithClickExit):
"""Screen with a dialog to exit."""
BINDINGS = [
("escape", "dismiss()", "Cancel"),
("s", "save", "Save"),
("q,ctrl+c", "exit", "Exit"),
]
AUTO_FOCUS = "#exit-save"
def compose(self) -> ComposeResult:
yield Grid(
Label(
"Are you sure you want to exit without saving?",
id="question",
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("Cancel", variant="primary", id="exit-cancel", classes="button-100"),
id="dialog-exit",
)
def action_exit(self) -> None:
self.app.exit()
def action_save(self) -> None:
self.app.config.save()
self.app.exit()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "exit-no-save":
self.app.exit()
elif event.button.id == "exit-save":
self.app.config.save()
self.app.exit()
else:
self.app.pop_screen()
class AddDevice(ModalWithClickExit):
"""Screen with a dialog to add a device, either with a pairing code or with lan discovery."""
BINDINGS = [("escape", "dismiss({})", "Return")]
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
self.web_session = aiohttp.ClientSession()
self.api_helper = api_helpers.ApiHelper(config, self.web_session)
self.devices_discovered_dial = []
def compose(self) -> ComposeResult:
with Container(id="add-device-container"):
yield Label("Add Device", classes="title")
with Grid(id="add-device-switch-buttons"):
yield Button(
"Add with pairing code",
id="add-device-pin-button",
classes="button-switcher",
)
yield Button(
"Add with lan discovery",
id="add-device-dial-button",
classes="button-switcher",
)
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)"
),
id="pairing-code-input",
validators=[
Function(
_validate_pairing_code, "Invalid pairing code format"
)
],
)
yield Input(
placeholder="Device Name (auto filled if empty/optional)",
id="device-name-input",
)
yield Button(
"Add",
id="add-device-pin-add-button",
variant="success",
disabled=True,
)
yield Label(id="add-device-info")
with Container(id="add-device-dial-container"):
yield Label(
(
"Make sure your device is on the same network as this"
" computer\nIf it isn't showing up, try restarting the"
" app.\nIf running in docker, make sure to use"
" `--network=host`\nTo refresh the list, close and open the"
" dialog again"
),
classes="subtitle",
)
yield SelectionList(
("Searching for devices...", "", False),
id="dial-devices-list",
disabled=True,
)
yield Button(
"Add selected devices",
id="add-device-dial-add-button",
variant="success",
disabled=True,
)
async def on_mount(self) -> None:
self.devices_discovered_dial = []
asyncio.create_task(self.task_discover_devices())
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")
list_widget.clear_options()
if devices_found:
# print(devices_found)
devices_found_parsed = []
for index, i in enumerate(devices_found):
devices_found_parsed.append(Selection(i["name"], index, False))
list_widget.add_options(devices_found_parsed)
self.query_one("#dial-devices-list").disabled = False
self.devices_discovered_dial = devices_found
else:
list_widget.add_option(("No devices found", "", False))
@on(Button.Pressed, "#add-device-switch-buttons > *")
def handle_switch_buttons(self, event: Button.Pressed) -> None:
self.query_one("#add-device-switcher").current = event.button.id.replace(
"-button", "-container"
)
@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
)
@on(Input.Submitted, "#pairing-code-input")
@on(Button.Pressed, "#add-device-pin-add-button")
async def handle_add_device_pin(self) -> None:
self.query_one("#add-device-pin-add-button").disabled = True
lounge_controller = ytlounge.YtLoungeApi(
"iSponsorBlockTV", web_session=self.web_session
)
pairing_code = self.query_one("#pairing-code-input").value
pairing_code = int(
pairing_code.replace("-", "").replace(" ", "")
) # remove dashes and spaces
device_name = self.parent.query_one("#device-name-input").value
paired = False
try:
paired = await lounge_controller.pair(pairing_code)
except:
pass
if paired:
device = {
"screen_id": lounge_controller.auth.screen_id,
"name": device_name if device_name else lounge_controller.screen_name,
"offset": 0,
}
self.query_one("#pairing-code-input").value = ""
self.query_one("#device-name-input").value = ""
self.query_one("#add-device-info").update(
f"[#00ff00][b]Successfully added {device['name']}"
)
self.dismiss([device])
else:
self.query_one("#pairing-code-input").value = ""
self.query_one("#add-device-pin-add-button").disabled = False
self.query_one("#add-device-info").update("[#ff0000]Failed to add device")
@on(Button.Pressed, "#add-device-dial-add-button")
def handle_add_device_dial(self) -> None:
list_widget: SelectionList = self.query_one("#dial-devices-list")
selected_devices = list_widget.selected
devices = []
for i in selected_devices:
devices.append(self.devices_discovered_dial[i])
self.dismiss(devices)
@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
)
class AddChannel(ModalWithClickExit):
"""Screen with a dialog to add a channel, either using search or with a channel id."""
BINDINGS = [("escape", "dismiss(())", "Return")]
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
web_session = aiohttp.ClientSession()
self.api_helper = api_helpers.ApiHelper(config, web_session)
def compose(self) -> ComposeResult:
with Container(id="add-channel-container"):
yield Label("Add Channel", classes="title")
yield Label(
(
"Select a method to add a channel. Adding via search only works if"
" a YouTube api key has been set"
),
id="add-channel-label",
classes="subtitle",
)
with Grid(id="add-channel-switch-buttons"):
yield Button(
"Add by channel name",
id="add-channel-search-button",
classes="button-switcher",
)
yield Button(
"Add by channel ID",
id="add-channel-id-button",
classes="button-switcher",
)
yield Label(id="add-channel-info", classes="subtitle")
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"):
yield Input(
placeholder="Enter channel name",
id="channel-name-input-search",
)
yield Button(
"Search", id="search-channel-button", variant="success"
)
yield RadioSet(
RadioButton(label="Search to see results", disabled=True),
id="channel-search-results",
)
yield Button(
"Add",
id="add-channel-button-search",
variant="success",
disabled=True,
classes="button-100",
)
else:
yield Label(
(
"[#ff0000]No api key set, cannot search for channels."
" You can add it the config section below"
),
id="add-channel-search-no-key",
classes="subtitle",
)
with Vertical(id="add-channel-id-container"):
yield Input(
placeholder=(
"Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"
),
id="channel-id-input",
)
yield Input(
placeholder=(
"Enter channel name (only used to display in the config"
" file)"
),
id="channel-name-input-id",
)
yield Button(
"Add",
id="add-channel-button-id",
variant="success",
classes="button-100",
)
@on(RadioSet.Changed, "#channel-search-results")
def handle_radio_set_changed(self, event: RadioSet.Changed) -> None:
self.query_one("#add-channel-button-search").disabled = False
@on(Button.Pressed, "#add-channel-switch-buttons > *")
def handle_switch_buttons(self, event: Button.Pressed) -> None:
button_ = event.button.id
self.query_one("#add-channel-switcher").current = event.button.id.replace(
"-button", "-container"
)
@on(Button.Pressed, "#search-channel-button")
@on(Input.Submitted, "#channel-name-input-search")
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"
)
return
self.query_one("#search-channel-button").disabled = True
self.query_one("#add-channel-info").update("Searching...")
self.query_one("#add-channel-button-search").disabled = True
self.query_one("#channel-search-results").remove_children()
try:
channels_list = await self.api_helper.search_channels(channel_name)
except:
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:
self.query_one("#channel-search-results").mount(ChannelRadio(i))
if channels_list:
self.query_one("#search-channel-button").disabled = False
self.query_one("#add-channel-info").update("")
@on(Button.Pressed, "#add-channel-button-search")
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"
)
return
self.query_one("#add-channel-info").update("Adding...")
self.dismiss(channel)
@on(Button.Pressed, "#add-channel-button-id")
@on(Input.Submitted, "#channel-id-input")
@on(Input.Submitted, "#channel-name-input-id")
def handle_add_channel_id(self) -> None:
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"
)
return
if not channel_name:
channel_name = channel_id
channel = (channel_id, channel_name, "hidden")
self.query_one("#add-channel-info").update("Adding...")
self.dismiss(channel)
class EditDevice(ModalWithClickExit):
"""Screen with a dialog to edit a device. Used by the DevicesManager."""
BINDINGS = [("escape", "close_screen_saving", "Return")]
def __init__(self, device: Element, **kwargs) -> None:
super().__init__(**kwargs)
self.device_data = device.element_data
self.device_widget = device
def action_close_screen_saving(self) -> None:
self.dismiss()
def dismiss(self) -> None:
self.device_data["name"] = self.query_one("#device-name-input").value
self.device_data["screen_id"] = self.query_one("#device-id-input").value
self.device_data["offset"] = int(self.query_one("#device-offset-input").value)
super().dismiss(self.device_widget)
def compose(self) -> ComposeResult:
name = self.device_data.get("name", "")
offset = self.device_data.get("offset", 0)
with Container(id="edit-device-container"):
yield Label("Edit device (ESCAPE to exit)", classes="title")
yield Label("Device name")
yield Input(placeholder="Device name", id="device-name-input", value=name)
yield Label("Device screen id")
with Grid(id="device-id-container"):
yield Input(
placeholder="Device id",
id="device-id-input",
value=self.device_data["screen_id"],
password=True,
)
yield Button("Show id", id="device-id-view")
yield Label("Device offset (in milliseconds)")
with Horizontal(id="device-offset-container"):
yield Input(id="device-offset-input", value=str(offset))
yield Slider(
name="Device offset",
id="device-offset-slider",
min=0,
max=2000,
step=100,
value=offset,
)
def on_slider_changed(self, event: Slider.Changed) -> None:
offset_input = self.query_one("#device-offset-offset_input")
with offset_input.prevent(Input.Changed):
offset_input.value = str(event.slider.value)
def on_input_changed(self, event: Input.Changed):
if event.input.id == "device-offset-input":
value = event.input.value
if value.isdigit():
value = int(value)
slider = self.query_one("#device-offset-slider")
with slider.prevent(Slider.Changed):
self.query_one("#device-offset-slider").value = value
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "device-id-view":
if "Show" in event.button.label:
event.button.label = "Hide id"
self.query_one("#device-id-input").password = False
else:
event.button.label = "Show id"
self.query_one("#device-id-input").password = True
class DevicesManager(Vertical):
"""Manager for devices, allows adding, edit and removing devices."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
self.devices = config.devices
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")
for device in self.devices:
yield Device(device, tooltip="Click to edit")
def new_devices(self, device_data) -> None:
if device_data:
device_widget = None
for i in device_data:
self.devices.append(i)
device_widget = Device(i, tooltip="Click to edit")
self.mount(device_widget)
device_widget.focus(scroll_visible=True)
@staticmethod
def edit_device(device_widget: Element) -> None:
device_widget.process_values_from_data()
device_widget.query_one("#element-name").label = device_widget.element_name
@on(Button.Pressed, "#element-remove")
def remove_channel(self, event: Button.Pressed):
channel_to_remove: Element = event.button.parent
self.config.devices.remove(channel_to_remove.element_data)
channel_to_remove.remove()
@on(Button.Pressed, "#add-device")
def add_device(self, event: Button.Pressed):
self.app.push_screen(AddDevice(self.config), callback=self.new_devices)
@on(Button.Pressed, "#element-name")
def edit_channel(self, event: Button.Pressed):
channel_to_edit: Element = event.button.parent
self.app.push_screen(EditDevice(channel_to_edit), callback=self.edit_device)
class ApiKeyManager(Vertical):
"""Manager for the YouTube Api Key."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
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"
" Console[/link]. This key is only required if you're whitelisting"
" channels."
)
with Grid(id="api-key-grid"):
yield Input(
placeholder="YouTube Api Key",
id="api-key-input",
password=True,
value=self.config.apikey,
)
yield Button("Show key", id="api-key-view")
@on(Input.Changed, "#api-key-input")
def changed_api_key(self, event: Input.Changed):
self.config.apikey = event.input.value
@on(Button.Pressed, "#api-key-view")
def pressed_api_key_view(self, event: Button.Pressed):
if "Show" in event.button.label:
event.button.label = "Hide key"
self.query_one("#api-key-input").password = False
else:
event.button.label = "Show key"
self.query_one("#api-key-input").password = True
class SkipCategoriesManager(Vertical):
"""Manager for skip categories, allows selecting which categories to skip."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Skip Categories", classes="title")
yield Label("Select the categories you want to skip", classes="subtitle")
skip_categories_parsed = []
for i in skip_categories:
name, value = i
if value in self.config.skip_categories:
skip_categories_parsed.append((name, value, True))
else:
skip_categories_parsed.append((name, value, False))
# print(skip_categories_parsed)
yield SelectionList(*skip_categories_parsed, id="skip-categories-compact-list")
@on(SelectionList.SelectedChanged, "#skip-categories-compact-list")
def changed_skip_categories(self, event: SelectionList.SelectedChanged):
self.config.skip_categories = event.selection_list.selected
class SkipCountTrackingManager(Vertical):
"""Manager for skip count tracking, allows to enable/disable skip count tracking."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Skip count tracking", classes="title")
yield Label(
(
"This feature tracks which segments you have skipped to let users know"
" how much their submission has helped others and used as a metric"
" along with upvotes to ensure that spam doesn't get into the database."
" The program sends a message to the sponsor block server each time you"
" skip a segment. Hopefully most people don't change this setting so"
" that the view numbers are accurate. :)"
),
classes="subtitle",
id="skip-count-tracking-subtitle",
)
yield Checkbox(
value=self.config.skip_count_tracking,
id="skip-count-tracking-switch",
label="Enable skip count tracking",
)
@on(Checkbox.Changed, "#skip-count-tracking-switch")
def changed_skip_tracking(self, event: Checkbox.Changed):
self.config.skip_count_tracking = event.checkbox.value
class AdSkipMuteManager(Vertical):
"""Manager for ad skip/mute, allows to enable/disable ad skip/mute."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Skip/Mute ads", classes="title")
yield Label(
(
"This feature allows you to automatically mute and/or skip native"
" YouTube ads. Skipping ads only works if that ad shows the 'Skip Ad'"
" button, if it doesn't then it will only be able to be muted."
),
classes="subtitle",
id="skip-count-tracking-subtitle",
)
with Horizontal(id="ad-skip-mute-container"):
yield Checkbox(
value=self.config.skip_ads,
id="skip-ads-switch",
label="Enable skipping ads",
)
yield Checkbox(
value=self.config.mute_ads,
id="mute-ads-switch",
label="Enable muting ads",
)
@on(Checkbox.Changed, "#mute-ads-switch")
def changed_mute(self, event: Checkbox.Changed):
self.config.mute_ads = event.checkbox.value
@on(Checkbox.Changed, "#skip-ads-switch")
def changed_skip(self, event: Checkbox.Changed):
self.config.skip_ads = event.checkbox.value
class ChannelWhitelistManager(Vertical):
"""Manager for channel whitelist, allows adding/removing channels from the whitelist."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Channel Whitelist", classes="title")
yield Label(
(
"This feature allows to whitelist channels from being skipped. This"
" feature is automatically disabled when no channels have been"
" specified."
),
classes="subtitle",
id="channel-whitelist-subtitle",
)
yield Label(
(
":warning: [#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")
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)
def new_channel(self, channel: tuple) -> None:
if channel:
channel_dict = {
"id": channel[0],
"name": channel[1],
}
self.config.channel_whitelist.append(channel_dict)
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)
@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)
@on(Button.Pressed, "#add-channel")
def add_channel(self, event: Button.Pressed):
self.app.push_screen(AddChannel(self.config), callback=self.new_channel)
class AutoPlayManager(Vertical):
"""Manager for autoplay, allows enabling/disabling autoplay."""
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.config = config
def compose(self) -> ComposeResult:
yield Label("Autoplay", classes="title")
yield Label(
"This feature allows you to enable/disable autoplay",
classes="subtitle",
id="autoplay-subtitle",
)
with Horizontal(id="autoplay-container"):
yield Checkbox(
value=self.config.auto_play,
id="autoplay-switch",
label="Enable autoplay",
)
@on(Checkbox.Changed, "#autoplay-switch")
def changed_skip(self, event: Checkbox.Changed):
self.config.auto_play = event.checkbox.value
class ISponsorBlockTVSetupMainScreen(Screen):
"""Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
TITLE = "iSponsorBlockTV"
SUB_TITLE = "Setup Wizard"
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
AUTO_FOCUS = None
def __init__(self, config, **kwargs) -> None:
super().__init__(**kwargs)
self.dark = True
self.config = config
self.initial_config = copy.deepcopy(config)
def compose(self) -> ComposeResult:
yield Header()
yield Footer()
with ScrollableContainer(id="setup-wizard"):
yield DevicesManager(
config=self.config, id="devices-manager", classes="container"
)
yield SkipCategoriesManager(
config=self.config, id="skip-categories-manager", classes="container"
)
yield SkipCountTrackingManager(
config=self.config, id="count-segments-manager", classes="container"
)
yield AdSkipMuteManager(
config=self.config, id="ad-skip-mute-manager", classes="container"
)
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"
)
def on_mount(self) -> None:
if self.check_for_old_config_entries():
self.app.push_screen(MigrationScreen())
pass
def action_save(self) -> None:
self.config.save()
self.initial_config = copy.deepcopy(self.config)
def action_exit_modal(self) -> None:
if self.config != self.initial_config:
self.app.push_screen(ExitScreen())
else: # No changes were made
self.app.exit()
def check_for_old_config_entries(self) -> bool:
if hasattr(self.config, "atvs"):
return True
return False
@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:
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

@@ -0,0 +1,183 @@
import asyncio
import json
import pyytlounge
from aiohttp import ClientSession
from .constants import youtube_client_blacklist
create_task = asyncio.create_task
class YtLoungeApi(pyytlounge.YtLoungeApi):
def __init__(
self,
screen_id,
config=None,
api_helper=None,
logger=None,
web_session: ClientSession = None,
):
super().__init__("iSponsorBlockTV", logger=logger)
if web_session is not None:
self.session = web_session # And use the one we passed
self.auth.screen_id = screen_id
self.auth.lounge_id_token = None
self.api_helper = api_helper
self.volume_state = {}
self.subscribe_task = None
self.subscribe_task_watchdog = None
self.callback = None
self.logger = logger
self.shorts_disconnected = False
self.auto_play = True
if config:
self.mute_ads = config.mute_ads
self.skip_ads = config.skip_ads
self.auto_play = config.auto_play
# 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)
try:
self.subscribe_task.cancel()
except Exception:
pass
# Subscribe to the lounge and start the watchdog
async def subscribe_monitored(self, callback):
self.callback = callback
try:
self.subscribe_task_watchdog.cancel()
except:
pass # No watchdog task
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
return self.subscribe_task
# Process a lounge subscription event
def _process_event(self, event_id: int, event_type: str, args):
self.logger.debug(f"process_event({event_id}, {event_type}, {args})")
# (Re)start the watchdog
try:
self.subscribe_task_watchdog.cancel()
except:
pass
finally:
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
# A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we
# can get the segments)
if event_type == "onStateChange":
data = args[0]
# print(data)
# Unmute when the video starts playing
if self.mute_ads and data["state"] == "1":
create_task(self.mute(False, override=True))
elif event_type == "nowPlaying":
data = args[0]
# Unmute when the video starts playing
if self.mute_ads and data.get("state", "0") == "1":
self.logger.info("Ad has ended, unmuting")
create_task(self.mute(False, override=True))
elif event_type == "onAdStateChange":
data = args[0]
if data["adState"] == "0": # Ad is not playing
self.logger.info("Ad has ended, unmuting")
create_task(self.mute(False, override=True))
elif (
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
self.logger.info("Ad has started, muting")
create_task(self.mute(True, override=True))
# Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it)
elif event_type == "onVolumeChanged":
self.volume_state = args[0]
pass
# Gets segments for the next video before it starts playing
elif event_type == "autoplayUpNext":
if len(args) > 0 and (
vid_id := args[0]["videoId"]
): # if video id is not empty
self.logger.info(f"Getting segments for next video: {vid_id}")
create_task(self.api_helper.get_segments(vid_id))
# #Used to know if an ad is skippable or not
elif event_type == "adPlaying":
data = args[0]
# Gets segments for the next video (after the ad) before it starts playing
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 (
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
self.logger.info("Ad has started, muting")
create_task(self.mute(True, override=True))
elif event_type == "loungeStatus":
data = args[0]
devices = json.loads(data["devices"])
for device in devices:
if device["type"] == "LOUNGE_SCREEN":
device_info = json.loads(device.get("deviceInfo", "{}"))
if device_info.get("clientName", "") in youtube_client_blacklist:
self._sid = None
self._gsession = None # Force disconnect
elif event_type == "onSubtitlesTrackChanged":
if self.shorts_disconnected:
data = args[0]
video_id_saved = data.get("videoId", None)
self.shorts_disconnected = False
create_task(self.play_video(video_id_saved))
elif event_type == "loungeScreenDisconnected":
data = args[0]
if data["reason"] == "disconnectedByUserScreenInitiated": # Short playing?
self.shorts_disconnected = True
elif event_type == "onAutoplayModeChanged":
create_task(self.set_auto_play_mode(self.auto_play))
super()._process_event(event_id, event_type, args)
# Set the volume to a specific value (0-100)
async def set_volume(self, volume: int) -> None:
await super()._command("setVolume", {"volume": volume})
# Mute or unmute the device (if the device already is in the desired state, nothing happens)
# mute: True to mute, False to unmute
# override: If True, the command is sent even if the device already is in the desired state
# TODO: Only works if the device is subscribed to the lounge
async def mute(self, mute: bool, override: bool = False) -> None:
if mute:
mute_str = "true"
else:
mute_str = "false"
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(
"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})

View File

@@ -1,3 +1,3 @@
from iSponsorBlockTV import helpers
helpers.app_start()
helpers.app_start()

5
src/main_tui.py Normal file
View File

@@ -0,0 +1,5 @@
from iSponsorBlockTV import setup_wizard
from iSponsorBlockTV.helpers import Config
config = Config("data/config.json")
setup_wizard.main(config)