mirror of
https://github.com/dmunozv04/iSponsorBlockTV.git
synced 2025-12-08 12:56:45 +03:00
Compare commits
451 Commits
v0.1
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b985309944 | ||
|
|
12bed77cca | ||
|
|
dab84dec96 | ||
|
|
aaf1f64ec7 | ||
|
|
49ea01dd9c | ||
|
|
2a2949f552 | ||
|
|
85b4124a52 | ||
|
|
da7dcf67fe | ||
|
|
b4d1feb3a9 | ||
|
|
6afd1bcbaa | ||
|
|
516326e0ff | ||
|
|
461b8bfde7 | ||
|
|
580ac5e3e1 | ||
|
|
52a221c4e0 | ||
|
|
e6dff63b19 | ||
|
|
8bab77237d | ||
|
|
31a6d260e5 | ||
|
|
34256b5c5e | ||
|
|
91842c18f2 | ||
|
|
accb685bf3 | ||
|
|
f68311cbf6 | ||
|
|
aa76d130d8 | ||
|
|
258239338e | ||
|
|
21f52537d8 | ||
|
|
f6bfd9af98 | ||
|
|
4a57cce9bb | ||
|
|
6524361d5d | ||
|
|
27ecc54d93 | ||
|
|
3b7617ef14 | ||
|
|
aa35610c67 | ||
|
|
d581f7ee07 | ||
|
|
8e01755550 | ||
|
|
56b42e26ff | ||
|
|
c88861822c | ||
|
|
315ac2c726 | ||
|
|
390eb68310 | ||
|
|
7652c9f260 | ||
|
|
acf074e860 | ||
|
|
123f3d4000 | ||
|
|
303d805e5d | ||
|
|
c779e83d96 | ||
|
|
aac4b333c2 | ||
|
|
fa83124002 | ||
|
|
76b82e8848 | ||
|
|
26fec272a3 | ||
|
|
3fdcee71fd | ||
|
|
1a58ce6a57 | ||
|
|
90d313049b | ||
|
|
cd7e5c83c7 | ||
|
|
bf1fe68089 | ||
|
|
d179fe2b79 | ||
|
|
4724ee1a39 | ||
|
|
bb7fdbfb06 | ||
|
|
930db16f53 | ||
|
|
8ed1cb4b00 | ||
|
|
65ecbb9193 | ||
|
|
f2155abad3 | ||
|
|
edbea793ed | ||
|
|
df629805c2 | ||
|
|
ad9834b9f0 | ||
|
|
97e7b31d9c | ||
|
|
b5d275e01e | ||
|
|
98c1211b09 | ||
|
|
57f33ec354 | ||
|
|
9f6a18a006 | ||
|
|
fd6f0d7283 | ||
|
|
166e238f41 | ||
|
|
8ecaa7e86f | ||
|
|
cafdf4f962 | ||
|
|
3a80c76fb6 | ||
|
|
a4f6462026 | ||
|
|
c4571ad90b | ||
|
|
c51c47b566 | ||
|
|
72ada4558e | ||
|
|
6219cfe0d0 | ||
|
|
b2dfe35698 | ||
|
|
af35982ac0 | ||
|
|
fb30d7e4cb | ||
|
|
f04f7560b2 | ||
|
|
d55fceda18 | ||
|
|
a6dacc1d84 | ||
|
|
a67e3eb860 | ||
|
|
a9d64af2ac | ||
|
|
fc8d1770cd | ||
|
|
82ce3e60e9 | ||
|
|
4417592b6b | ||
|
|
7187ec5286 | ||
|
|
6ea500222e | ||
|
|
905a74c103 | ||
|
|
35bc1ea6dc | ||
|
|
c3f28f7cd1 | ||
|
|
724b88a2ba | ||
|
|
18105e4aa8 | ||
|
|
8f60a2650a | ||
|
|
5888b8f9c6 | ||
|
|
f890434dbf | ||
|
|
6a32e42671 | ||
|
|
ad35ecc778 | ||
|
|
e9925b02c3 | ||
|
|
ffdeb4579e | ||
|
|
70ecf78f01 | ||
|
|
1e10ab4e29 | ||
|
|
dee71939c5 | ||
|
|
2dbeed99bc | ||
|
|
451ffce47b | ||
|
|
2124fff81b | ||
|
|
aabf5aa2bc | ||
|
|
068623bb03 | ||
|
|
b93f480848 | ||
|
|
e9fdc49480 | ||
|
|
4a55fe9539 | ||
|
|
328e70a175 | ||
|
|
7a1d8967ae | ||
|
|
33b0b6d224 | ||
|
|
e0c4322524 | ||
|
|
c360e2582e | ||
|
|
7b3e618628 | ||
|
|
886997beab | ||
|
|
ee786a53b9 | ||
|
|
0b785da448 | ||
|
|
ca9b7ee73a | ||
|
|
c21ebe396e | ||
|
|
a5af3dfb1c | ||
|
|
7e3318dceb | ||
|
|
db7f0511a4 | ||
|
|
6d7c7c00a4 | ||
|
|
b81a023b0d | ||
|
|
33d8fb419f | ||
|
|
2630228b7b | ||
|
|
712e8f37f2 | ||
|
|
b3f07b9a9d | ||
|
|
5d20ca642b | ||
|
|
02c78e8aeb | ||
|
|
f15ba5d5a6 | ||
|
|
e451769a29 | ||
|
|
1ae4c3019b | ||
|
|
7a45284a50 | ||
|
|
8bfd19696b | ||
|
|
53d7405a9c | ||
|
|
7d769a9f62 | ||
|
|
6250353cb2 | ||
|
|
e2e3e78218 | ||
|
|
7b0cfc5e68 | ||
|
|
e4125c48e6 | ||
|
|
dbe64edf88 | ||
|
|
b4ccfb7e96 | ||
|
|
0d3ff8a54c | ||
|
|
f58eaeec22 | ||
|
|
a37c272662 | ||
|
|
e5a1686afb | ||
|
|
fb927aaacf | ||
|
|
f486fec0bd | ||
|
|
54015cf455 | ||
|
|
114326e34c | ||
|
|
3797200825 | ||
|
|
eafedb7cf7 | ||
|
|
1cf539be9a | ||
|
|
9d74a9b3ce | ||
|
|
9b5ea2b243 | ||
|
|
112a4faa50 | ||
|
|
027a8d7ebc | ||
|
|
a495cdf62e | ||
|
|
24f1612f20 | ||
|
|
c979d280a1 | ||
|
|
45bc7ff6e7 | ||
|
|
251a94f147 | ||
|
|
0ba8f4c3c5 | ||
|
|
2ebc821ed9 | ||
|
|
060fe7af5d | ||
|
|
879116e873 | ||
|
|
1914afa432 | ||
|
|
82459c8986 | ||
|
|
c7cb4e8282 | ||
|
|
7ea0b8642c | ||
|
|
79fc9c066c | ||
|
|
2b9e1f8bf4 | ||
|
|
f5b183a679 | ||
|
|
bececa5096 | ||
|
|
4589c612d1 | ||
|
|
26d1d7e481 | ||
|
|
73b277706e | ||
|
|
5884844da1 | ||
|
|
38efe843de | ||
|
|
ccfcd00aa9 | ||
|
|
e34dbb0f80 | ||
|
|
ae6da834e4 | ||
|
|
7e80b41bbb | ||
|
|
3c66dc3607 | ||
|
|
0c8427edc0 | ||
|
|
e637e514b2 | ||
|
|
c8b9fe157b | ||
|
|
dca9186d8b | ||
|
|
adc7e1efe9 | ||
|
|
b96014840a | ||
|
|
147004e257 | ||
|
|
6f4c27c0a5 | ||
|
|
f75dff8faf | ||
|
|
0e77418c2b | ||
|
|
f1d1787511 | ||
|
|
49b1b902d3 | ||
|
|
5116e6c1e0 | ||
|
|
0b0a235046 | ||
|
|
4eaebd3006 | ||
|
|
58e10f0f82 | ||
|
|
a93eeaa1cf | ||
|
|
77527ce4d5 | ||
|
|
6825ac6629 | ||
|
|
b59135316e | ||
|
|
7b17d5a2da | ||
|
|
7e9f53e175 | ||
|
|
84c22c2dde | ||
|
|
a7fbcd3dd2 | ||
|
|
dbf5e3ac1c | ||
|
|
23f65125e2 | ||
|
|
4feecaa570 | ||
|
|
52a3f238d6 | ||
|
|
afff2a44b9 | ||
|
|
15c165d89a | ||
|
|
e71598599a | ||
|
|
760970a751 | ||
|
|
fb598809da | ||
|
|
35d13373f9 | ||
|
|
0145b3ba8d | ||
|
|
8fdd13da04 | ||
|
|
6dcc12baed | ||
|
|
afaced8f84 | ||
|
|
b3dd27748b | ||
|
|
73192a1171 | ||
|
|
c7dc54fd66 | ||
|
|
edca2bf11a | ||
|
|
f3d3bdd432 | ||
|
|
67eb40ca9d | ||
|
|
284b7a1d2f | ||
|
|
ec109e0f10 | ||
|
|
658fad64eb | ||
|
|
7880e222b0 | ||
|
|
dcf53dcca9 | ||
|
|
1a271c567a | ||
|
|
4473fc925e | ||
|
|
d9ab2cd070 | ||
|
|
ea2004ba94 | ||
|
|
63f5a3bc41 | ||
|
|
e999a93503 | ||
|
|
8cc3f8aa05 | ||
|
|
5fadc81a69 | ||
|
|
39aef5babf | ||
|
|
c56cbfe095 | ||
|
|
bde4ecb72f | ||
|
|
167383dea8 | ||
|
|
fb3c40d477 | ||
|
|
cb738965a7 | ||
|
|
9ad335793a | ||
|
|
fd400d077a | ||
|
|
f9c7b58ece | ||
|
|
464baa7c59 | ||
|
|
d9986e52b3 | ||
|
|
547a47b9ec | ||
|
|
87d0e0e32e | ||
|
|
854cb2462f | ||
|
|
662b71fc00 | ||
|
|
fd6b7cb43a | ||
|
|
d17e59bf0d | ||
|
|
5bc6382f89 | ||
|
|
205191f442 | ||
|
|
810cd5eec3 | ||
|
|
e2ace8629f | ||
|
|
e54ead26d2 | ||
|
|
49fba2f28f | ||
|
|
b1333a2f61 | ||
|
|
cfef219d32 | ||
|
|
338e0479ba | ||
|
|
bfefa94a7b | ||
|
|
783e3d4240 | ||
|
|
015f5a79c9 | ||
|
|
dc72db0609 | ||
|
|
8de38cc92b | ||
|
|
94ba642af1 | ||
|
|
6e09db9994 | ||
|
|
b56d7443d1 | ||
|
|
e92ba897c4 | ||
|
|
5214190fd0 | ||
|
|
d3341009a6 | ||
|
|
adc0f5b95d | ||
|
|
5dbd16ddd5 | ||
|
|
faa0379b89 | ||
|
|
fb3ed6b39a | ||
|
|
1ab7e73b52 | ||
|
|
d310e4c817 | ||
|
|
d21bb6320f | ||
|
|
dd42e20dc4 | ||
|
|
213ae97bf2 | ||
|
|
865f5469a2 | ||
|
|
daa7026221 | ||
|
|
582b9bf725 | ||
|
|
ce95b6dbf0 | ||
|
|
80196b19aa | ||
|
|
4dd6aa1c4d | ||
|
|
1a5f29fe2a | ||
|
|
13fe1f69ae | ||
|
|
3b1ce5297f | ||
|
|
e689a713ef | ||
|
|
e6b1e14d80 | ||
|
|
4934eff8b7 | ||
|
|
674a13e43a | ||
|
|
152ba104a6 | ||
|
|
265c56f3d6 | ||
|
|
7e954478f2 | ||
|
|
4f39f64ed0 | ||
|
|
8208a51176 | ||
|
|
ab6b67f88b | ||
|
|
9068b58bf6 | ||
|
|
a9f9a5b31c | ||
|
|
a75dd83548 | ||
|
|
4e3c9d115c | ||
|
|
04533162cb | ||
|
|
bee2a9c80f | ||
|
|
f620ed2fcc | ||
|
|
db26bff3d2 | ||
|
|
d205848132 | ||
|
|
29445e678f | ||
|
|
35453bc49e | ||
|
|
88875a82d3 | ||
|
|
446393b078 | ||
|
|
784d54c4e2 | ||
|
|
66b39c4cac | ||
|
|
321a9e6e9b | ||
|
|
ecb3583c35 | ||
|
|
3cee674e91 | ||
|
|
d575a296e7 | ||
|
|
7bf52b6df1 | ||
|
|
846ea444d2 | ||
|
|
1671d7841b | ||
|
|
a4f0b5fffe | ||
|
|
dae27e7aa3 | ||
|
|
5df90a234d | ||
|
|
c3fd67df27 | ||
|
|
35652b6247 | ||
|
|
8ab9cf9519 | ||
|
|
f4fbbcdff5 | ||
|
|
385ed8268c | ||
|
|
c196e76205 | ||
|
|
863ec5e163 | ||
|
|
128e1f72cb | ||
|
|
fede94e973 | ||
|
|
0d62f69460 | ||
|
|
8d76bdd1c1 | ||
|
|
8c7c2cc206 | ||
|
|
7a0a264caa | ||
|
|
b1f1bd1851 | ||
|
|
ab3285048d | ||
|
|
799e0a6f77 | ||
|
|
c95ab4897d | ||
|
|
23e90caefc | ||
|
|
7b6f9bd8a0 | ||
|
|
f3a2c82a56 | ||
|
|
d0506304fd | ||
|
|
a01994e2b0 | ||
|
|
bc2c4727dc | ||
|
|
9ebd39d491 | ||
|
|
2e6a0af8ce | ||
|
|
4aa5b1e08c | ||
|
|
2cc8fa128f | ||
|
|
4ab49ea61e | ||
|
|
3d8fa562b4 | ||
|
|
20c6870e4c | ||
|
|
b90a5e317c | ||
|
|
93e3fd5720 | ||
|
|
b88d2b61be | ||
|
|
5a5ebcfeb7 | ||
|
|
7a66d1acd5 | ||
|
|
de8d03285f | ||
|
|
d850ec8162 | ||
|
|
81f5b6568d | ||
|
|
28b6ac3218 | ||
|
|
138d8bd51c | ||
|
|
db647362c6 | ||
|
|
58ee703501 | ||
|
|
4f4d990544 | ||
|
|
6e1ee572d5 | ||
|
|
df118c967d | ||
|
|
262e3c606d | ||
|
|
d72b24c95b | ||
|
|
88f5e5d4bf | ||
|
|
aa26e8fb04 | ||
|
|
3bbabdb26e | ||
|
|
064bdcd93c | ||
|
|
50b71d9f5c | ||
|
|
9461d6516f | ||
|
|
4de056d2a8 | ||
|
|
f69d6d04cf | ||
|
|
ace8f3564f | ||
|
|
70cba12efa | ||
|
|
b35d9fd60e | ||
|
|
518b1d9b2e | ||
|
|
4460aaf35c | ||
|
|
d3260d17f1 | ||
|
|
ac6f15042c | ||
|
|
7e45f623f7 | ||
|
|
cece0242c4 | ||
|
|
b069487bd6 | ||
|
|
d4f9380eff | ||
|
|
245300d064 | ||
|
|
0bcb70979f | ||
|
|
b9e010af9b | ||
|
|
bd0deec85e | ||
|
|
d0846b7d2c | ||
|
|
d7a83035a6 | ||
|
|
9d7d9665dc | ||
|
|
55f2798adc | ||
|
|
907e0ec549 | ||
|
|
c83e406c8f | ||
|
|
143e5e4eff | ||
|
|
aad6eea686 | ||
|
|
fc309102df | ||
|
|
359a7f7be1 | ||
|
|
1aa06e677f | ||
|
|
6c879b1649 | ||
|
|
b98a00a43e | ||
|
|
4ec4f7c101 | ||
|
|
49cd69de8b | ||
|
|
230024c179 | ||
|
|
f06e2b1490 | ||
|
|
3b0cbff054 | ||
|
|
067d429d33 | ||
|
|
ce82c7b719 | ||
|
|
42c09ef588 | ||
|
|
890c3956de | ||
|
|
224d00174b | ||
|
|
3f8e24c8a4 | ||
|
|
eb51963da9 | ||
|
|
975b2690a4 | ||
|
|
77e710849f | ||
|
|
73d1a024f3 | ||
|
|
ab10c6d321 | ||
|
|
e0c0548496 | ||
|
|
a665f1cd27 | ||
|
|
58ac0c1338 | ||
|
|
e2adf09db8 | ||
|
|
8edf4859ab | ||
|
|
e5a795edab | ||
|
|
fae6134295 | ||
|
|
5658dd7537 | ||
|
|
0413d612bb | ||
|
|
fe606292f8 | ||
|
|
b9c41aa022 | ||
|
|
60d9a86413 | ||
|
|
933ee36a18 | ||
|
|
7c207c5b0a | ||
|
|
816b4ec9ef | ||
|
|
0be5e2d4cd | ||
|
|
781c0d4a73 |
9
.deepsource.toml
Normal file
9
.deepsource.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "python"
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[analyzers.meta]
|
||||||
|
runtime_version = "3.x.x"
|
||||||
|
max_line_length = 100
|
||||||
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Ignore files
|
||||||
|
.dockerignore
|
||||||
|
.gitignore
|
||||||
|
.deepsource.toml
|
||||||
|
.github
|
||||||
|
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
13
.github/ISSUE_TEMPLATE/bug_report.md
vendored
13
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -7,12 +7,14 @@ 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**
|
**Describe the bug**
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
@@ -25,13 +27,14 @@ A clear and concise description of what you expected to happen.
|
|||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**iSponsorBlockTV server (please complete the following information):**
|
**iSponsorBlockTV server (please complete the following information):**
|
||||||
- OS: [e.g. Docker on linux Arm64, windows]
|
|
||||||
- Python version [e.g. 3.7] (no need to fill if running on docker
|
- OS: [e.g. Docker on linux Arm64, windows]
|
||||||
|
- Python version [e.g. 3.7] (no need to fill if running on docker
|
||||||
|
|
||||||
**Apple TV (please complete the following information):**
|
**Apple TV (please complete the following information):**
|
||||||
- Device: [e.g. Apple TV 4]
|
|
||||||
- OS: [e.g. tvOS 15.4]
|
|
||||||
|
|
||||||
|
- Device: [e.g. Apple TV 4]
|
||||||
|
- OS: [e.g. tvOS 15.4]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -8,13 +8,15 @@ assignees: ''
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
A clear and concise description of what the problem is. Ex. I'm always
|
||||||
|
frustrated when [...]
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
**Describe alternatives you've considered**
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've
|
||||||
|
considered.
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|||||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
82
.github/workflows/build_docker_images.yml
vendored
Normal file
82
.github/workflows/build_docker_images.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: build docker images
|
||||||
|
|
||||||
|
# Controls when the workflow will run
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
- 'v*.*.*-*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Get the repository's code
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
# Generate docker tags
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
${{ env.DOCKERHUB_USERNAME && 'dmunozv04/isponsorblocktv' || '' }}
|
||||||
|
tags: |
|
||||||
|
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=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern=v{{major}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=schedule
|
||||||
|
|
||||||
|
# https://github.com/docker/setup-qemu-action
|
||||||
|
- name: Set up QEMU
|
||||||
|
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@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request' && env.DOCKERHUB_USERNAME != ''
|
||||||
|
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@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64, linux/arm64, linux/arm/v7, linux/386, linux/arm/v6
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
72
.github/workflows/main.yml
vendored
72
.github/workflows/main.yml
vendored
@@ -1,72 +0,0 @@
|
|||||||
# 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'
|
|
||||||
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Generate docker tags
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v3
|
|
||||||
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) }}
|
|
||||||
|
|
||||||
# https://github.com/docker/setup-qemu-action
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
# https://github.com/docker/setup-buildx-action
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
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
|
|
||||||
211
.github/workflows/release.yml
vendored
Normal file
211
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# 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: Release Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.11"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
build-sdist-and-wheel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Install Hatch
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install hatch
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: python -m hatch build
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: sdist-and-wheel
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
|
||||||
|
build-binaries:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }})
|
||||||
|
needs:
|
||||||
|
- build-sdist-and-wheel
|
||||||
|
runs-on: ${{ matrix.job.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
job:
|
||||||
|
# Linux
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
cross: true
|
||||||
|
release_suffix: x86_64-linux
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
cross: true
|
||||||
|
cpu_variant: v1
|
||||||
|
release_suffix: x86_64-linux-v1
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
cross: true
|
||||||
|
release_suffix: aarch64-linux
|
||||||
|
# Windows
|
||||||
|
- target: x86_64-pc-windows-msvc
|
||||||
|
os: windows-latest
|
||||||
|
release_suffix: x86_64-windows
|
||||||
|
# macOS
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
release_suffix: aarch64-osx
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
cross: true
|
||||||
|
release_suffix: x86_64-osx
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYAPP_PASS_LOCATION: "1"
|
||||||
|
PYAPP_UV_ENABLED: "1"
|
||||||
|
HATCH_BUILD_LOCATION: dist
|
||||||
|
CARGO: cargo
|
||||||
|
CARGO_BUILD_TARGET: ${{ matrix.job.target }}
|
||||||
|
PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }}
|
||||||
|
PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling)
|
||||||
|
PYAPP_VERSION: v0.28.0
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Clone PyApp
|
||||||
|
run: git clone --depth 1 --branch $PYAPP_VERSION https://github.com/ofek/pyapp $PYAPP_REPO
|
||||||
|
|
||||||
|
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Install Hatch
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install hatch
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
if: ${{ !matrix.job.cross }}
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.job.target }}
|
||||||
|
|
||||||
|
- name: Set up cross compiling tools
|
||||||
|
if: matrix.job.cross
|
||||||
|
uses: taiki-e/setup-cross-toolchain-action@v1
|
||||||
|
with:
|
||||||
|
target: ${{ matrix.job.target}}
|
||||||
|
|
||||||
|
- name: Show toolchain information
|
||||||
|
run: |-
|
||||||
|
rustup toolchain list
|
||||||
|
rustup default
|
||||||
|
rustup -V
|
||||||
|
rustc -V
|
||||||
|
cargo -V
|
||||||
|
hatch --version
|
||||||
|
|
||||||
|
- name: Get artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: sdist-and-wheel
|
||||||
|
path: ${{ github.workspace }}/dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Build Binary
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |-
|
||||||
|
current_version=$(hatch version)
|
||||||
|
PYAPP_PROJECT_PATH="${{ github.workspace }}/dist/isponsorblocktv-${current_version}-py3-none-any.whl" hatch -v build -t binary
|
||||||
|
|
||||||
|
- name: Rename binary
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |-
|
||||||
|
mv dist/binary/iSponsorBlockTV* dist/binary/iSponsorBlockTV-${{ matrix.job.release_suffix }}
|
||||||
|
|
||||||
|
- name: Attest build provenance
|
||||||
|
uses: actions/attest-build-provenance@v3
|
||||||
|
with:
|
||||||
|
subject-path: dist/binary/*
|
||||||
|
continue-on-error: true # Continue if attestation fails (it will fail on forks)
|
||||||
|
|
||||||
|
- name: Upload built binary package
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: binaries-${{ matrix.job.release_suffix }}
|
||||||
|
path: dist/binary/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
|
||||||
|
publish-to-pypi:
|
||||||
|
needs: build-sdist-and-wheel
|
||||||
|
permissions:
|
||||||
|
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||||
|
# only run step if the event is a published release
|
||||||
|
if: github.event_name == 'release' && github.event.action == 'published'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: sdist-and-wheel
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Publish package
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
|
||||||
|
|
||||||
|
publish-to-release:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
needs:
|
||||||
|
- build-sdist-and-wheel
|
||||||
|
- build-binaries
|
||||||
|
if: github.event_name == 'release' && github.event.action == 'published'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v5
|
||||||
|
name: Get artifact
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Add assets to release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/*
|
||||||
9
.github/workflows/update_docker_readme.yml
vendored
9
.github/workflows/update_docker_readme.yml
vendored
@@ -18,12 +18,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Get the repository's code
|
# Get the repository's code
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
# Update description
|
# Update description
|
||||||
- name: Update repo description
|
- name: Update repo description
|
||||||
uses: peter-evans/dockerhub-description@v2
|
uses: peter-evans/dockerhub-description@v5
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
repository: dmunozv04/isponsorblocktv
|
repository: dmunozv04/isponsorblocktv
|
||||||
|
short-description: ${{ github.event.repository.description }}
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -35,6 +35,9 @@ MANIFEST
|
|||||||
pip-log.txt
|
pip-log.txt
|
||||||
pip-delete-this-directory.txt
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
*.DS_Store
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
@@ -151,5 +154,9 @@ cython_debug/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
#config folder
|
#config folder
|
||||||
config/
|
data/
|
||||||
config.json
|
data/config.json
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|||||||
1
.markdownlintignore
Normal file
1
.markdownlintignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
LICENSE.md
|
||||||
31
.pre-commit-config.yaml
Normal file
31
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 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: v6.0.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/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.12.12
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [ --fix, --exit-non-zero-on-fix ]
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||||
|
rev: v0.45.0
|
||||||
|
hooks:
|
||||||
|
- id: markdownlint
|
||||||
|
args: ["--fix"]
|
||||||
50
Dockerfile
50
Dockerfile
@@ -1,24 +1,38 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM python:3.13-alpine3.21 AS base
|
||||||
|
|
||||||
FROM python:alpine
|
FROM base AS compiler
|
||||||
|
|
||||||
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 .
|
|
||||||
|
|
||||||
WORKDIR /app
|
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.13/site-packages && \
|
||||||
|
find /usr/local/lib/python3.13/site-packages -name "*.py" -type f -delete && \
|
||||||
|
find /usr/local/lib/python3.13/ -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"]
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ notice like this when it starts in an interactive mode:
|
|||||||
This is free software, and you are welcome to redistribute it
|
This is free software, and you are welcome to redistribute it
|
||||||
under certain conditions; type `show c' for details.
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
The hypothetical commands `show w' and`show c' should show the appropriate
|
||||||
parts of the General Public License. Of course, your program's commands
|
parts of the General Public License. Of course, your program's commands
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
|||||||
105
README.md
105
README.md
@@ -1,68 +1,68 @@
|
|||||||
# iSponsorBlockTV
|
# iSponsorBlockTV
|
||||||
|
|
||||||
Skip sponsor segments in YouTube videos playing on an Apple TV.
|
[](https://ghcr.io/dmunozv04/isponsorblocktv)
|
||||||
|
[](https://hub.docker.com/r/dmunozv04/isponsorblocktv/)
|
||||||
|
[](https://github.com/dmunozv04/iSponsorBlockTV/releases/latest)
|
||||||
|
[](https://github.com/dmunozv04/isponsorblocktv)
|
||||||
|
|
||||||
This project is written in asycronous python and should be pretty quick.
|
iSponsorBlockTV is a self-hosted application that connects to your YouTube TV
|
||||||
|
app (see compatibility below) and automatically skips segments (like Sponsors
|
||||||
|
or intros) in YouTube videos using the [SponsorBlock](https://sponsor.ajay.app/)
|
||||||
|
API. It can also auto mute and press the "Skip Ad" button the moment it becomes
|
||||||
|
available on YouTube ads.
|
||||||
|
|
||||||
# Installation
|
## Installation
|
||||||
|
|
||||||
## Docker
|
Check the [wiki](https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation)
|
||||||
### Setup
|
|
||||||
|
|
||||||
You need to set up several things before you can run the project.
|
## Compatibility
|
||||||
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
|
|
||||||
|
|
||||||
You need to install [python](https://www.python.org/downloads/) first, and to make it available in your PATH. After, clone the repo.
|
Legend: ✅ = Working, ❌ = Not working, ❔ = Not tested
|
||||||
Then you need to download the dependencies with pip:
|
|
||||||
```python3 -m pip install -r requirements.txt```
|
|
||||||
Lastly, run ```main.py```
|
|
||||||
|
|
||||||
### Setup
|
Open an issue/pull request if you have tested a device that isn't listed here.
|
||||||
|
|
||||||
You need to retrieve airplay keys to be able to connect to the Apple TV. (It will be made simpler in the future)
|
| Device | Status |
|
||||||
For now, use `atvremote`, a script included in pyatv:
|
|:-------------------|:------:|
|
||||||
1. atvremote scan
|
| Apple TV | ✅* |
|
||||||
2. atvremote pair --protocol airplay --id `identifier you got on the previous step`
|
| Samsung TV (Tizen) | ✅ |
|
||||||
|
| LG TV (WebOS) | ✅ |
|
||||||
|
| Android TV | ✅ |
|
||||||
|
| Chromecast | ✅ |
|
||||||
|
| Google TV | ✅ |
|
||||||
|
| Roku | ✅ |
|
||||||
|
| Fire TV | ✅ |
|
||||||
|
| CCwGTV | ✅ |
|
||||||
|
| Nintendo Switch | ✅ |
|
||||||
|
| Xbox One/Series | ✅ |
|
||||||
|
| Playstation 4/5 | ✅ |
|
||||||
|
|
||||||
Get [YouTube api key](https://developers.google.com/youtube/registering_an_application)
|
*Ad muting won't work when using AirPlay to send the audio to another speaker.
|
||||||
|
|
||||||
Edit config.json.template and save it as config.json
|
## Usage
|
||||||
# Usage
|
|
||||||
|
|
||||||
Run iSponsorBLockTV in the same network as the Apple TV.
|
Run iSponsorBlockTV on a computer that has network access. It doesn't need to
|
||||||
|
be on the same network as the device, only access to youtube.com is required.
|
||||||
|
|
||||||
It connect to the Apple TV, watch its activity and skip any sponsor segment using the [SponsorBlock](https://sponsor.ajay.app/) API.
|
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 TV application.
|
||||||
|
|
||||||
The last 5 videos' segments are cached to limit the number on queries on SponsorBlock and YouTube.
|
## 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
|
||||||
|
|
||||||
# Libraries used
|
## Projects using this project
|
||||||
- [pyatv](https://github.com/postlund/pyatv) Used to connect to the Apple TV
|
|
||||||
- [asyncio] and [aiohttp]
|
|
||||||
- [async_lru]
|
|
||||||
- [json]
|
|
||||||
|
|
||||||
# Contributing
|
- [Home Assistant Addon](https://github.com/bertybuttface/addons/tree/main/isponsorblocktv)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
1. Fork it (<https://github.com/dmunozv04/iSponsorBlockTV/fork>)
|
1. Fork it (<https://github.com/dmunozv04/iSponsorBlockTV/fork>)
|
||||||
2. Create your feature branch (`git checkout -b my-new-feature`)
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||||
@@ -72,7 +72,10 @@ The last 5 videos' segments are cached to limit the number on queries on Sponsor
|
|||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
- [dmunozv04](https://github.com/dmunozv04) - creator and maintainer
|
[](https://github.com/dmunozv04/iSponsorBlockTV/graphs/contributors)
|
||||||
- [HaltCatchFire](https://github.com/HaltCatchFire) - updated dependencies and improved skip logic
|
|
||||||
# License
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
{
|
{
|
||||||
"atvs": [
|
"devices": [
|
||||||
{"identifier": "", "airplay_credentials": ""}
|
{
|
||||||
|
"screen_id": "",
|
||||||
|
"name": "YouTube on TV",
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"apikey":"",
|
"skip_categories": [
|
||||||
"skip_categories": ["sponsor"]
|
"sponsor"
|
||||||
}
|
],
|
||||||
|
"skip_count_tracking": true,
|
||||||
|
"mute_ads": true,
|
||||||
|
"skip_ads": true,
|
||||||
|
"minimum_skip_length": 1,
|
||||||
|
"auto_play": true,
|
||||||
|
"join_name": "iSponsorBlockTV",
|
||||||
|
"apikey": "",
|
||||||
|
"channel_whitelist": [
|
||||||
|
{"id": "",
|
||||||
|
"name": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"use_proxy": false
|
||||||
|
}
|
||||||
|
|||||||
7
docker-compose.yml
Normal file
7
docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
iSponsorBlockTV:
|
||||||
|
image: ghcr.io/dmunozv04/isponsorblocktv
|
||||||
|
container_name: iSponsorBlockTV
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /PATH_TO_YOUR_DATA_DIR:/app/data
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import plistlib
|
|
||||||
import os
|
|
||||||
from . import config_setup
|
|
||||||
|
|
||||||
default_plist = {"Label": "com.dmunozv04iSponsorBlockTV",
|
|
||||||
"RunAtLoad": True,
|
|
||||||
"StartInterval": 20,
|
|
||||||
"EnvironmentVariables": {"PYTHONUNBUFFERED": "YES"},
|
|
||||||
"StandardErrorPath": "", #Fill later
|
|
||||||
"StandardOutPath": "",
|
|
||||||
"ProgramArguments" : "",
|
|
||||||
"WorkingDirectory": ""
|
|
||||||
}
|
|
||||||
def create_plist(path):
|
|
||||||
plist = default_plist
|
|
||||||
plist["ProgramArguments"] = [path + "/iSponsorBlockTV-macos"]
|
|
||||||
plist["StandardErrorPath"] = path + "/iSponsorBlockTV.error.log"
|
|
||||||
plist["StandardOutPath"] = path + "/iSponsorBlockTV.out.log"
|
|
||||||
plist["WorkingDirectory"] = path
|
|
||||||
launchd_path = os.path.expanduser("~/Library/LaunchAgents/")
|
|
||||||
path_to_save = launchd_path + "com.dmunozv04.iSponsorBlockTV.plist"
|
|
||||||
|
|
||||||
with open(path_to_save, 'wb') as fp:
|
|
||||||
plistlib.dump(plist, fp)
|
|
||||||
|
|
||||||
def run_setup(file):
|
|
||||||
config = {}
|
|
||||||
config_setup.main(config, file, debug=False)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
correct_path = os.path.expanduser("~/iSponsorBlockTV")
|
|
||||||
if os.path.isfile(correct_path + "/iSponsorBlockTV-macos"):
|
|
||||||
print("Program is on the right path")
|
|
||||||
print("The launch daemon will now be installed")
|
|
||||||
create_plist(correct_path)
|
|
||||||
run_setup(correct_path + "/config.json")
|
|
||||||
print("Launch daemon installed. Please restart the computer to enable it or use:\n launchctl load ~/Library/LaunchAgents/com.dmunozv04.iSponsorBlockTV.plist")
|
|
||||||
else:
|
|
||||||
if not os.path.exists(correct_path):
|
|
||||||
os.makedirs(correct_path)
|
|
||||||
print("Please move the program to the correct path: " + correct_path + "opeing now on finder...")
|
|
||||||
os.system("open -R " + correct_path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
33
pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[project]
|
||||||
|
name = "iSponsorBlockTV"
|
||||||
|
version = "2.6.1"
|
||||||
|
authors = [
|
||||||
|
{"name" = "dmunozv04"}
|
||||||
|
]
|
||||||
|
description = "SponsorBlock client for all YouTube TV clients"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
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.ruff]
|
||||||
|
line-length = 100
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
pyatv
|
aiohttp==3.13.1
|
||||||
aiohttp
|
appdirs==1.4.4
|
||||||
aiodns
|
async-cache==1.1.1
|
||||||
async-cache
|
pyytlounge==2.3.0
|
||||||
argparse
|
rich==14.1.0
|
||||||
|
ssdp==1.3.1
|
||||||
|
textual==5.3.0
|
||||||
|
textual-slider==0.2.0
|
||||||
|
xmltodict==0.15.1
|
||||||
|
rich_click==1.8.9
|
||||||
|
|||||||
9
src/iSponsorBlockTV/__main__.py
Normal file
9
src/iSponsorBlockTV/__main__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from . import helpers
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
helpers.app_start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
209
src/iSponsorBlockTV/api_helpers.py
Normal file
209
src/iSponsorBlockTV/api_helpers.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
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)
|
||||||
|
self.minimum_skip_length = config.minimum_skip_length
|
||||||
|
|
||||||
|
# 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, self.minimum_skip_length)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_segments(response, minimum_skip_length):
|
||||||
|
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 IndexError:
|
||||||
|
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()
|
||||||
|
# Only add segments greater than minimum skip length
|
||||||
|
if segment_dict["end"] - segment_dict["start"] > minimum_skip_length:
|
||||||
|
segments.append(segment_dict)
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
return segments, ignore_ttl
|
||||||
|
|
||||||
|
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
|
||||||
85
src/iSponsorBlockTV/conditional_ttl_cache.py
Normal file
85
src/iSponsorBlockTV/conditional_ttl_cache.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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
|
||||||
212
src/iSponsorBlockTV/config_setup.py
Normal file
212
src/iSponsorBlockTV/config_setup.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from . import api_helpers, ytlounge
|
||||||
|
|
||||||
|
# Constants for user input prompts
|
||||||
|
USE_PROXY_PROMPT = "Do you want to use system-wide proxy? (y/N)"
|
||||||
|
ATVS_REMOVAL_PROMPT = (
|
||||||
|
"Do you want to remove the legacy 'atvs' entry (the app won't start with it present)? (y/N) "
|
||||||
|
)
|
||||||
|
PAIRING_CODE_PROMPT = "Enter pairing code (found in Settings - Link with TV code): "
|
||||||
|
ADD_MORE_DEVICES_PROMPT = "Paired with {num_devices} Device(s). Add more? (y/N) "
|
||||||
|
CHANGE_API_KEY_PROMPT = "API key already specified. Change it? (y/N) "
|
||||||
|
ADD_API_KEY_PROMPT = "API key only needed for the channel whitelist function. Add it? (y/N) "
|
||||||
|
ENTER_API_KEY_PROMPT = "Enter your API key: "
|
||||||
|
CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) "
|
||||||
|
ENTER_SKIP_CATEGORIES_PROMPT = (
|
||||||
|
"Enter skip categories (space or comma sepparated) Options: [sponsor,"
|
||||||
|
" selfpromo, exclusive_access, interaction, poi_highlight, intro, outro,"
|
||||||
|
" preview, filler, music_offtopic]:\n"
|
||||||
|
)
|
||||||
|
WHITELIST_CHANNELS_PROMPT = "Do you want to whitelist any channels from being ad-blocked? (y/N) "
|
||||||
|
SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: '
|
||||||
|
SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: "
|
||||||
|
ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: "
|
||||||
|
ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: "
|
||||||
|
MINIMUM_SKIP_PROMPT = "Do you want to specify a minimum length of segment to skip? (y/N)"
|
||||||
|
MINIMUM_SKIP_SPECIFICATION_PROMPT = (
|
||||||
|
"Enter minimum length of segment to skip in seconds (enter 0 to disable):"
|
||||||
|
)
|
||||||
|
REPORT_SKIPPED_SEGMENTS_PROMPT = (
|
||||||
|
"Do you want to report skipped segments to sponsorblock. Only the segment"
|
||||||
|
" UUID will be sent? (Y/n) "
|
||||||
|
)
|
||||||
|
MUTE_ADS_PROMPT = "Do you want to mute native YouTube ads automatically? (y/N) "
|
||||||
|
SKIP_ADS_PROMPT = "Do you want to skip native YouTube ads automatically? (y/N) "
|
||||||
|
AUTOPLAY_PROMPT = "Do you want to enable autoplay? (Y/n) "
|
||||||
|
|
||||||
|
|
||||||
|
def get_yn_input(prompt):
|
||||||
|
while choice := input(prompt):
|
||||||
|
if choice.lower() in ["y", "n"]:
|
||||||
|
return choice.lower()
|
||||||
|
print("Invalid input. Please enter 'y' or 'n'.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_web_session(use_proxy):
|
||||||
|
return aiohttp.ClientSession(trust_env=use_proxy)
|
||||||
|
|
||||||
|
|
||||||
|
async def pair_device(web_session: aiohttp.ClientSession):
|
||||||
|
try:
|
||||||
|
lounge_controller = ytlounge.YtLoungeApi()
|
||||||
|
await lounge_controller.change_web_session(web_session)
|
||||||
|
pairing_code = input(PAIRING_CODE_PROMPT)
|
||||||
|
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")
|
||||||
|
|
||||||
|
choice = get_yn_input(USE_PROXY_PROMPT)
|
||||||
|
config.use_proxy = choice == "y"
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
|
web_session = loop.run_until_complete(create_web_session(config.use_proxy))
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
choice = get_yn_input(ATVS_REMOVAL_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
del config["atvs"]
|
||||||
|
|
||||||
|
devices = config.devices
|
||||||
|
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))
|
||||||
|
while choice == "y":
|
||||||
|
device = loop.run_until_complete(pair_device(web_session))
|
||||||
|
if device:
|
||||||
|
devices.append(device)
|
||||||
|
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))
|
||||||
|
config.devices = devices
|
||||||
|
|
||||||
|
apikey = config.apikey
|
||||||
|
if apikey:
|
||||||
|
choice = get_yn_input(CHANGE_API_KEY_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
apikey = input(ENTER_API_KEY_PROMPT)
|
||||||
|
else:
|
||||||
|
choice = get_yn_input(ADD_API_KEY_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
print(
|
||||||
|
"Get youtube apikey here:"
|
||||||
|
" https://developers.google.com/youtube/registering_an_application"
|
||||||
|
)
|
||||||
|
apikey = input(ENTER_API_KEY_PROMPT)
|
||||||
|
config.apikey = apikey
|
||||||
|
|
||||||
|
skip_categories = config.skip_categories
|
||||||
|
if skip_categories:
|
||||||
|
choice = get_yn_input(CHANGE_SKIP_CATEGORIES_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
||||||
|
skip_categories = categories.replace(",", " ").split(" ")
|
||||||
|
skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings
|
||||||
|
else:
|
||||||
|
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
||||||
|
skip_categories = categories.replace(",", " ").split(" ")
|
||||||
|
skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings
|
||||||
|
config.skip_categories = skip_categories
|
||||||
|
|
||||||
|
channel_whitelist = config.channel_whitelist
|
||||||
|
choice = get_yn_input(WHITELIST_CHANNELS_PROMPT)
|
||||||
|
if choice == "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(SEARCH_CHANNEL_PROMPT)
|
||||||
|
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")
|
||||||
|
|
||||||
|
while choice := input(SELECT_CHANNEL_PROMPT):
|
||||||
|
if choice in [str(x) for x in range(7)]:
|
||||||
|
break
|
||||||
|
print("Invalid choice")
|
||||||
|
|
||||||
|
if choice == "5":
|
||||||
|
channel_info["id"] = input(ENTER_CHANNEL_ID_PROMPT)
|
||||||
|
channel_info["name"] = input(ENTER_CUSTOM_CHANNEL_NAME_PROMPT)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Ask for minimum skip length. Confirm input is an integer
|
||||||
|
minimum_skip_length = config.minimum_skip_length
|
||||||
|
|
||||||
|
choice = get_yn_input(MINIMUM_SKIP_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
minimum_skip_length = int(input(MINIMUM_SKIP_SPECIFICATION_PROMPT))
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
print("You entered a non integer value, try again.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
config.minimum_skip_length = minimum_skip_length
|
||||||
|
|
||||||
|
choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT)
|
||||||
|
config.skip_count_tracking = choice != "n"
|
||||||
|
|
||||||
|
choice = get_yn_input(MUTE_ADS_PROMPT)
|
||||||
|
config.mute_ads = choice == "y"
|
||||||
|
|
||||||
|
choice = get_yn_input(SKIP_ADS_PROMPT)
|
||||||
|
config.skip_ads = choice == "y"
|
||||||
|
|
||||||
|
choice = get_yn_input(AUTOPLAY_PROMPT)
|
||||||
|
config.auto_play = choice != "n"
|
||||||
|
|
||||||
|
print("Config finished")
|
||||||
|
config.save()
|
||||||
|
loop.run_until_complete(web_session.close())
|
||||||
26
src/iSponsorBlockTV/constants.py
Normal file
26
src/iSponsorBlockTV/constants.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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"]
|
||||||
|
|
||||||
|
github_wiki_base_url = "https://github.com/dmunozv04/iSponsorBlockTV/wiki"
|
||||||
25
src/iSponsorBlockTV/debug_helpers.py
Normal file
25
src/iSponsorBlockTV/debug_helpers.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class AiohttpTracer:
|
||||||
|
def __init__(self, logger):
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
async def on_request_start(self, session, context, params):
|
||||||
|
self.logger.debug(f"Request started ({id(context):#x}): {params.method} {params.url}")
|
||||||
|
|
||||||
|
async def on_request_end(self, session, context, params):
|
||||||
|
self.logger.debug(f"Request ended ({id(context):#x}): {params.response.status}")
|
||||||
|
|
||||||
|
async def on_request_exception(self, session, context, params):
|
||||||
|
self.logger.debug(f"Request exception ({id(context):#x}): {params.exception}")
|
||||||
|
|
||||||
|
async def on_response_chunk_received(self, session, context, params):
|
||||||
|
chunk_size = len(params.chunk)
|
||||||
|
try:
|
||||||
|
# Try to decode as text
|
||||||
|
text = params.chunk.decode("utf-8")
|
||||||
|
self.logger.debug(f"Response chunk ({id(context):#x}) {chunk_size} bytes: {text}")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# If not valid UTF-8, show as hex
|
||||||
|
hex_data = params.chunk.hex()
|
||||||
|
self.logger.debug(
|
||||||
|
f"Response chunk ({id(context):#x}) ({chunk_size} bytes) [HEX]: {hex_data}"
|
||||||
|
)
|
||||||
150
src/iSponsorBlockTV/dial_client.py
Normal file
150
src/iSponsorBlockTV/dial_client.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""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"])
|
||||||
|
|
||||||
|
def request_received(self, request: ssdp.messages.SSDPRequest, addr):
|
||||||
|
raise NotImplementedError("Request received is not implemented, this is a client")
|
||||||
|
|
||||||
|
|
||||||
|
async def find_youtube_app(web_session, url_location):
|
||||||
|
async with web_session.get(url_location) as response:
|
||||||
|
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, _ = network.get_best_family(bind, network.PORT)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
ip_address = get_ip()
|
||||||
|
connect = loop.create_datagram_endpoint(handler, family=family, local_addr=(ip_address, None))
|
||||||
|
transport, _ = await connect
|
||||||
|
|
||||||
|
target = network.MULTICAST_ADDRESS_IPV4, network.PORT
|
||||||
|
|
||||||
|
search_request = ssdp.messages.SSDPRequest(
|
||||||
|
"M-SEARCH",
|
||||||
|
headers={
|
||||||
|
"HOST": f"{target[0]}:{target[1]}",
|
||||||
|
"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)
|
||||||
|
|
||||||
|
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
|
||||||
217
src/iSponsorBlockTV/helpers.py
Normal file
217
src/iSponsorBlockTV/helpers.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import rich_click as click
|
||||||
|
from appdirs import user_data_dir
|
||||||
|
|
||||||
|
from . import config_setup, main, setup_wizard
|
||||||
|
from .constants import config_file_blacklist_keys, github_wiki_base_url
|
||||||
|
|
||||||
|
|
||||||
|
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.minimum_skip_length = 1
|
||||||
|
self.auto_play = True
|
||||||
|
self.join_name = "iSponsorBlockTV"
|
||||||
|
self.use_proxy = False
|
||||||
|
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:\n"
|
||||||
|
f"{github_wiki_base_url}/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: "
|
||||||
|
f"{github_wiki_base_url}/Installation#Docker"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
("This image has recently been updated to v2, and requires changes."),
|
||||||
|
("Please read this for more information on how to upgrade to V2:"),
|
||||||
|
f"{github_wiki_base_url}/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 __hash__(self):
|
||||||
|
return hash(tuple(sorted(self.items())))
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.option(
|
||||||
|
"--data",
|
||||||
|
"-d",
|
||||||
|
default=lambda: os.getenv("iSPBTV_data_dir") or user_data_dir("iSponsorBlockTV", "dmunozv04"),
|
||||||
|
help="data directory",
|
||||||
|
)
|
||||||
|
@click.option("--debug", is_flag=True, help="debug mode")
|
||||||
|
@click.option("--http-tracing", is_flag=True, help="Enable HTTP request/response tracing")
|
||||||
|
# legacy commands as arguments
|
||||||
|
@click.option("--setup", is_flag=True, help="Setup the program graphically", hidden=True)
|
||||||
|
@click.option(
|
||||||
|
"--setup-cli",
|
||||||
|
is_flag=True,
|
||||||
|
help="Setup the program in the command line",
|
||||||
|
hidden=True,
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, data, debug, http_tracing, setup, setup_cli):
|
||||||
|
"""iSponsorblockTV"""
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["data_dir"] = data
|
||||||
|
ctx.obj["debug"] = debug
|
||||||
|
ctx.obj["http_tracing"] = http_tracing
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
ctx.obj["logger"] = logger
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
||||||
|
logger.addHandler(sh)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
if setup:
|
||||||
|
ctx.invoke(setup_command)
|
||||||
|
elif setup_cli:
|
||||||
|
ctx.invoke(setup_cli_command)
|
||||||
|
else:
|
||||||
|
ctx.invoke(start)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(name="setup")
|
||||||
|
@click.pass_context
|
||||||
|
def setup_command(ctx):
|
||||||
|
"""Setup the program graphically"""
|
||||||
|
config = Config(ctx.obj["data_dir"])
|
||||||
|
setup_wizard.main(config)
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(name="setup-cli")
|
||||||
|
@click.pass_context
|
||||||
|
def setup_cli_command(ctx):
|
||||||
|
"""Setup the program in the command line"""
|
||||||
|
config = Config(ctx.obj["data_dir"])
|
||||||
|
config_setup.main(config, ctx.obj["debug"])
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.pass_context
|
||||||
|
def start(ctx):
|
||||||
|
"""Start the main program"""
|
||||||
|
config = Config(ctx.obj["data_dir"])
|
||||||
|
config.validate()
|
||||||
|
main.main(config, ctx.obj["debug"], ctx.obj["http_tracing"])
|
||||||
|
|
||||||
|
|
||||||
|
# Create fake "self" group to show pyapp options in help menu
|
||||||
|
# Subcommands remove, restore, update
|
||||||
|
pyapp_group = click.RichGroup("self", help="pyapp options (update, remove, restore)")
|
||||||
|
pyapp_group.add_command(
|
||||||
|
click.RichCommand("update", help="Update the package to the latest version")
|
||||||
|
)
|
||||||
|
pyapp_group.add_command(
|
||||||
|
click.Command("remove", help="Remove the package, wiping the installation but not the data")
|
||||||
|
)
|
||||||
|
pyapp_group.add_command(
|
||||||
|
click.RichCommand(
|
||||||
|
"restore", help="Restore the package to its original state by reinstalling it"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if os.getenv("PYAPP"):
|
||||||
|
cli.add_command(pyapp_group)
|
||||||
|
|
||||||
|
|
||||||
|
def app_start():
|
||||||
|
cli(obj={})
|
||||||
205
src/iSponsorBlockTV/main.py
Normal file
205
src/iSponsorBlockTV/main.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from signal import SIGINT, SIGTERM, signal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from . import api_helpers, ytlounge
|
||||||
|
from .debug_helpers import AiohttpTracer
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
self.lounge_controller = ytlounge.YtLoungeApi(
|
||||||
|
device.screen_id, config, api_helper, self.logger
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 BaseException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def is_available(self):
|
||||||
|
try:
|
||||||
|
return await self.lounge_controller.is_available()
|
||||||
|
except BaseException:
|
||||||
|
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 BaseException:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
while not (await self.is_available()) and not self.cancelled:
|
||||||
|
self.logger.debug("Waiting for device to be available")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
try:
|
||||||
|
await lounge_controller.connect()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
while not lounge_controller.connected() and not self.cancelled:
|
||||||
|
# Doesn't connect to the device if it's a kids profile (it's broken)
|
||||||
|
self.logger.debug("Waiting for device to be connected")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
try:
|
||||||
|
await lounge_controller.connect()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
self.logger.info(
|
||||||
|
"Connected to device %s (%s)", lounge_controller.screen_name, self.name
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.logger.debug("Subscribing to lounge")
|
||||||
|
sub = await lounge_controller.subscribe_monitored(self)
|
||||||
|
await sub
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method called on playback state change
|
||||||
|
async def __call__(self, state):
|
||||||
|
time_start = time.monotonic()
|
||||||
|
try:
|
||||||
|
self.task.cancel()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
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("Playing video %s with %d segments", state.videoId, len(segments))
|
||||||
|
if segments: # If there are segments
|
||||||
|
await self.time_to_segment(segments, state.currentTime, time_start)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
segment_start = segment["start"]
|
||||||
|
segment_end = segment["end"]
|
||||||
|
is_within_start_range = (
|
||||||
|
position < 1 < segment_end and segment_start <= position < segment_end
|
||||||
|
)
|
||||||
|
is_beyond_current_position = segment_start > position
|
||||||
|
|
||||||
|
if is_within_start_range or is_beyond_current_position:
|
||||||
|
next_segment = segment
|
||||||
|
start_next_segment = position if is_within_start_range else segment_start
|
||||||
|
break
|
||||||
|
if start_next_segment:
|
||||||
|
time_to_next = (
|
||||||
|
(start_next_segment - position - (time.monotonic() - time_start))
|
||||||
|
/ self.lounge_controller.playback_speed
|
||||||
|
) - self.offset
|
||||||
|
await self.skip(time_to_next, next_segment["end"], next_segment["UUID"])
|
||||||
|
|
||||||
|
# Skips to the next segment (waits for the time to pass)
|
||||||
|
async def skip(self, time_to, position, uuids):
|
||||||
|
await asyncio.sleep(time_to)
|
||||||
|
self.logger.info("Skipping segment: seeking to %s", position)
|
||||||
|
await asyncio.gather(
|
||||||
|
asyncio.create_task(self.lounge_controller.seek_to(position)),
|
||||||
|
asyncio.create_task(self.api_helper.mark_viewed_segments(uuids)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cancel(self):
|
||||||
|
self.cancelled = True
|
||||||
|
await self.lounge_controller.disconnect()
|
||||||
|
if self.task:
|
||||||
|
self.task.cancel()
|
||||||
|
if self.lounge_controller.subscribe_task_watchdog:
|
||||||
|
self.lounge_controller.subscribe_task_watchdog.cancel()
|
||||||
|
if self.lounge_controller.subscribe_task:
|
||||||
|
self.lounge_controller.subscribe_task.cancel()
|
||||||
|
await asyncio.gather(
|
||||||
|
self.task,
|
||||||
|
self.lounge_controller.subscribe_task_watchdog,
|
||||||
|
self.lounge_controller.subscribe_task,
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def initialize_web_session(self):
|
||||||
|
await self.lounge_controller.change_web_session(self.web_session)
|
||||||
|
|
||||||
|
|
||||||
|
async def finish(devices, web_session, tcp_connector):
|
||||||
|
await asyncio.gather(*(device.cancel() for device in devices), return_exceptions=True)
|
||||||
|
await web_session.close()
|
||||||
|
await tcp_connector.close()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_signal(signum, frame):
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
|
||||||
|
async def main_async(config, debug, http_tracing):
|
||||||
|
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
|
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
|
||||||
|
devices = [] # Save the devices to close them later
|
||||||
|
if debug:
|
||||||
|
loop.set_debug(True)
|
||||||
|
|
||||||
|
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300)
|
||||||
|
|
||||||
|
# Configure session with tracing if enabled
|
||||||
|
if http_tracing:
|
||||||
|
root_logger = logging.getLogger("aiohttp_trace")
|
||||||
|
tracer = AiohttpTracer(root_logger)
|
||||||
|
trace_config = aiohttp.TraceConfig()
|
||||||
|
trace_config.on_request_start.append(tracer.on_request_start)
|
||||||
|
trace_config.on_response_chunk_received.append(tracer.on_response_chunk_received)
|
||||||
|
trace_config.on_request_end.append(tracer.on_request_end)
|
||||||
|
trace_config.on_request_exception.append(tracer.on_request_exception)
|
||||||
|
web_session = aiohttp.ClientSession(
|
||||||
|
trust_env=config.use_proxy, connector=tcp_connector, trace_configs=[trace_config]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
web_session = aiohttp.ClientSession(trust_env=config.use_proxy, connector=tcp_connector)
|
||||||
|
|
||||||
|
api_helper = api_helpers.ApiHelper(config, web_session)
|
||||||
|
for i in config.devices:
|
||||||
|
device = DeviceListener(api_helper, config, i, debug, web_session)
|
||||||
|
devices.append(device)
|
||||||
|
await device.initialize_web_session()
|
||||||
|
tasks.append(loop.create_task(device.loop()))
|
||||||
|
tasks.append(loop.create_task(device.refresh_auth_loop()))
|
||||||
|
signal(SIGTERM, handle_signal)
|
||||||
|
signal(SIGINT, handle_signal)
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Cancelling tasks and exiting...")
|
||||||
|
await finish(devices, web_session, tcp_connector)
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
finally:
|
||||||
|
await web_session.close()
|
||||||
|
await tcp_connector.close()
|
||||||
|
print("Exited")
|
||||||
|
|
||||||
|
|
||||||
|
def main(config, debug, http_tracing):
|
||||||
|
asyncio.run(main_async(config, debug, http_tracing))
|
||||||
391
src/iSponsorBlockTV/setup-wizard-style.tcss
Normal file
391
src/iSponsorBlockTV/setup-wizard-style.tcss
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
height: 3;
|
||||||
|
border: none;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
offset: 0 -1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-darken-1;
|
||||||
|
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;
|
||||||
|
background: $panel-darken-1;
|
||||||
|
&:hover {
|
||||||
|
background: $panel-lighten-1;
|
||||||
|
border-top: tall $panel-lighten-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, #add-channel {
|
||||||
|
text-style: bold;
|
||||||
|
width: 100%;
|
||||||
|
align: left middle;
|
||||||
|
text-align: center;
|
||||||
|
dock: left;
|
||||||
|
|
||||||
|
text-align: left;
|
||||||
|
background: $panel-darken-1;
|
||||||
|
&:hover {
|
||||||
|
background: $panel-lighten-1;
|
||||||
|
border-top: tall $panel-lighten-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use Proxy */
|
||||||
|
#useproxy-container{
|
||||||
|
padding: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
998
src/iSponsorBlockTV/setup_wizard.py
Normal file
998
src/iSponsorBlockTV/setup_wizard.py
Normal file
@@ -0,0 +1,998 @@
|
|||||||
|
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.css.query import NoMatches
|
||||||
|
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):
|
||||||
|
raise NotImplementedError("Subclasses must implement this method.")
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Button(
|
||||||
|
label=self.element_name,
|
||||||
|
classes="element-name button-small",
|
||||||
|
disabled=True,
|
||||||
|
id="element-name",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Remove",
|
||||||
|
classes="element-remove button-small",
|
||||||
|
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):
|
||||||
|
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(trust_env=config.use_proxy)
|
||||||
|
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\n[b][u]If it still doesn't work, "
|
||||||
|
"pair using a pairing code (it's much more reliable)"
|
||||||
|
),
|
||||||
|
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()
|
||||||
|
try:
|
||||||
|
list_widget: SelectionList = self.query_one("#dial-devices-list")
|
||||||
|
except NoMatches:
|
||||||
|
# The widget was not found, probably the screen was dismissed
|
||||||
|
return
|
||||||
|
list_widget.clear_options()
|
||||||
|
if devices_found:
|
||||||
|
# print(devices_found)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
await lounge_controller.change_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.query_one("#device-name-input").value
|
||||||
|
paired = False
|
||||||
|
try:
|
||||||
|
paired = await lounge_controller.pair(pairing_code)
|
||||||
|
except BaseException:
|
||||||
|
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(trust_env=config.use_proxy)
|
||||||
|
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:
|
||||||
|
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 BaseException:
|
||||||
|
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) -> 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-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 button-small")
|
||||||
|
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 str(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 MinimumSkipLengthManager(Vertical):
|
||||||
|
"""Manager for minimum skip length setting."""
|
||||||
|
|
||||||
|
def __init__(self, config, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("Minimum Skip Length", classes="title")
|
||||||
|
yield Label(
|
||||||
|
(
|
||||||
|
"Specify the minimum length a segment must meet in order to skip "
|
||||||
|
"it (in seconds). Default is 1 second; entering 0 will skip all "
|
||||||
|
"segments."
|
||||||
|
),
|
||||||
|
classes="subtitle",
|
||||||
|
)
|
||||||
|
yield Input(
|
||||||
|
placeholder="Minimum skip length (0 to skip all)",
|
||||||
|
id="minimum-skip-length-input",
|
||||||
|
value=str(getattr(self.config, "minimum_skip_length", 1)),
|
||||||
|
validators=[
|
||||||
|
Function(
|
||||||
|
lambda user_input: user_input.isdigit(),
|
||||||
|
"Please enter a valid non-negative number",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@on(Input.Changed, "#minimum-skip-length-input")
|
||||||
|
def changed_minimum_skip_length(self, event: Input.Changed):
|
||||||
|
try:
|
||||||
|
self.config.minimum_skip_length = int(event.input.value)
|
||||||
|
except ValueError:
|
||||||
|
self.config.minimum_skip_length = 1
|
||||||
|
|
||||||
|
|
||||||
|
class SkipCountTrackingManager(Vertical):
|
||||||
|
"""Manager for skip count tracking, allows to enable/disable skip count tracking."""
|
||||||
|
|
||||||
|
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(
|
||||||
|
("⚠️ [#FF0000]You need to set your YouTube Api Key in order to use this feature"),
|
||||||
|
id="warning-no-key",
|
||||||
|
)
|
||||||
|
with Horizontal(id="add-channel-button-container"):
|
||||||
|
yield Button("Add Channel", id="add-channel", classes="button-100 button-small")
|
||||||
|
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 UseProxyManager(Vertical):
|
||||||
|
"""Manager for proxy use, allows enabling/disabling use of proxy."""
|
||||||
|
|
||||||
|
def __init__(self, config, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("Use proxy", classes="title")
|
||||||
|
yield Label(
|
||||||
|
"This feature allows application to use system proxy,"
|
||||||
|
" if it is set in environment variables."
|
||||||
|
" This parameter will be passed in all [i]aiohttp.ClientSession[/i]"
|
||||||
|
' calls. For further information, see "[i]trust_env[/i]" section at'
|
||||||
|
" [link='https://docs.aiohttp.org/en/stable/client_reference.html']"
|
||||||
|
"aiohttp documentation[/link].",
|
||||||
|
classes="subtitle",
|
||||||
|
id="useproxy-subtitle",
|
||||||
|
)
|
||||||
|
with Horizontal(id="useproxy-container"):
|
||||||
|
yield Checkbox(
|
||||||
|
value=self.config.use_proxy,
|
||||||
|
id="useproxy-switch",
|
||||||
|
label="Use proxy",
|
||||||
|
)
|
||||||
|
|
||||||
|
@on(Checkbox.Changed, "#useproxy-switch")
|
||||||
|
def changed_skip(self, event: Checkbox.Changed):
|
||||||
|
self.config.use_proxy = event.checkbox.value
|
||||||
|
|
||||||
|
|
||||||
|
class ISponsorBlockTVSetup(App):
|
||||||
|
TITLE = "iSponsorBlockTV"
|
||||||
|
SUB_TITLE = "Setup Wizard"
|
||||||
|
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
|
||||||
|
AUTO_FOCUS = None
|
||||||
|
CSS_PATH = ( # tcss is the recommended extension for textual css files
|
||||||
|
"setup-wizard-style.tcss"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, config, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
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 MinimumSkipLengthManager(
|
||||||
|
config=self.config,
|
||||||
|
id="minimum-skip-length-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")
|
||||||
|
yield UseProxyManager(config=self.config, id="useproxy-manager", classes="container")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
if self.check_for_old_config_entries():
|
||||||
|
self.app.push_screen(MigrationScreen())
|
||||||
|
|
||||||
|
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
|
||||||
|
self.app.query_one("#warning-no-key").display = bool(
|
||||||
|
(not event.input.value) and self.config.channel_whitelist
|
||||||
|
)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main(config):
|
||||||
|
app = ISponsorBlockTVSetup(config)
|
||||||
|
app.run()
|
||||||
349
src/iSponsorBlockTV/ytlounge.py
Normal file
349
src/iSponsorBlockTV/ytlounge.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
import pyytlounge
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
from pyytlounge.wrapper import NotLinkedException, api_base, as_aiter, Dict
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .constants import youtube_client_blacklist
|
||||||
|
|
||||||
|
create_task = asyncio.create_task
|
||||||
|
|
||||||
|
|
||||||
|
class YtLoungeApi(pyytlounge.YtLoungeApi):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
screen_id=None,
|
||||||
|
config=None,
|
||||||
|
api_helper=None,
|
||||||
|
logger=None,
|
||||||
|
):
|
||||||
|
super().__init__(config.join_name if config else "iSponsorBlockTV", logger=logger)
|
||||||
|
self.auth.screen_id = screen_id
|
||||||
|
self.auth.lounge_id_token = None
|
||||||
|
self.api_helper = api_helper
|
||||||
|
self.volume_state = {}
|
||||||
|
self.playback_speed = 1.0
|
||||||
|
self.subscribe_task = None
|
||||||
|
self.subscribe_task_watchdog = None
|
||||||
|
self.callback = None
|
||||||
|
self.logger = logger
|
||||||
|
self.shorts_disconnected = False
|
||||||
|
self.auto_play = True
|
||||||
|
self.watchdog_running = False
|
||||||
|
self.last_event_time = 0
|
||||||
|
if config:
|
||||||
|
self.mute_ads = config.mute_ads
|
||||||
|
self.skip_ads = config.skip_ads
|
||||||
|
self.auto_play = config.auto_play
|
||||||
|
self._command_mutex = asyncio.Lock()
|
||||||
|
|
||||||
|
# Ensures that we still are subscribed to the lounge
|
||||||
|
async def _watchdog(self):
|
||||||
|
"""
|
||||||
|
Continuous watchdog that monitors for connection health.
|
||||||
|
If no events are received within the expected timeframe,
|
||||||
|
it cancels the current subscription.
|
||||||
|
"""
|
||||||
|
self.watchdog_running = True
|
||||||
|
self.last_event_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self.watchdog_running:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
current_time = asyncio.get_event_loop().time()
|
||||||
|
time_since_last_event = current_time - self.last_event_time
|
||||||
|
|
||||||
|
# YouTube sends a message at least every 30 seconds
|
||||||
|
if time_since_last_event > 60:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Watchdog triggered: No events for {time_since_last_event:.1f} seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel current subscription
|
||||||
|
if self.subscribe_task and not self.subscribe_task.done():
|
||||||
|
self.subscribe_task.cancel()
|
||||||
|
await asyncio.sleep(1) # Give it time to cancel
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.logger.debug("Watchdog task cancelled")
|
||||||
|
self.watchdog_running = False
|
||||||
|
except BaseException as e:
|
||||||
|
self.logger.error(f"Watchdog error: {e}")
|
||||||
|
self.watchdog_running = False
|
||||||
|
|
||||||
|
# Subscribe to the lounge and start the watchdog
|
||||||
|
async def subscribe_monitored(self, callback):
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
# Stop existing watchdog if running
|
||||||
|
if self.subscribe_task_watchdog and not self.subscribe_task_watchdog.done():
|
||||||
|
self.watchdog_running = False
|
||||||
|
self.subscribe_task_watchdog.cancel()
|
||||||
|
try:
|
||||||
|
await self.subscribe_task_watchdog
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Start new subscription
|
||||||
|
if self.subscribe_task and not self.subscribe_task.done():
|
||||||
|
self.subscribe_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.subscribe_task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
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
|
||||||
|
# skipcq: PY-R1000
|
||||||
|
def _process_event(self, event_type: str, args: List[Any]):
|
||||||
|
self.logger.debug(f"process_event({event_type}, {args})")
|
||||||
|
# Update last event time for the watchdog
|
||||||
|
self.last_event_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
# 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" and data["currentTime"] != "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]
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.skip_ads and data["isSkipEnabled"] == "true"
|
||||||
|
): # YouTube uses strings for booleans
|
||||||
|
self.logger.info("Ad can be skipped, skipping")
|
||||||
|
create_task(self.skip_ad())
|
||||||
|
create_task(self.mute(False, override=True))
|
||||||
|
elif self.mute_ads: # Seen multiple other adStates, assuming they are all ads
|
||||||
|
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":
|
||||||
|
if args: # Sometimes it's empty
|
||||||
|
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))
|
||||||
|
|
||||||
|
elif event_type == "onPlaybackSpeedChanged":
|
||||||
|
data = args[0]
|
||||||
|
self.playback_speed = float(data.get("playbackSpeed", "1"))
|
||||||
|
create_task(self.get_now_playing())
|
||||||
|
|
||||||
|
super()._process_event(event_type, args)
|
||||||
|
|
||||||
|
# Set the volume to a specific value (0-100)
|
||||||
|
async def set_volume(self, volume: int) -> None:
|
||||||
|
await self._command("setVolume", {"volume": volume})
|
||||||
|
|
||||||
|
async def mute(self, mute: bool, override: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Mute or unmute the device (if the device already
|
||||||
|
is in the desired state, nothing happens)
|
||||||
|
|
||||||
|
:param bool mute: True to mute, False to unmute
|
||||||
|
:param bool override: If True, the command is sent even if the
|
||||||
|
device already is in the desired state
|
||||||
|
|
||||||
|
TODO: Only works if the device is subscribed to the lounge
|
||||||
|
"""
|
||||||
|
if mute:
|
||||||
|
mute_str = "true"
|
||||||
|
else:
|
||||||
|
mute_str = "false"
|
||||||
|
if override or not self.volume_state.get("muted", "false") == mute_str:
|
||||||
|
self.volume_state["muted"] = mute_str
|
||||||
|
# YouTube wants the volume when unmuting, so we send it
|
||||||
|
await self._command(
|
||||||
|
"setVolume",
|
||||||
|
{"volume": self.volume_state.get("volume", 100), "muted": mute_str},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def play_video(self, video_id: str) -> bool:
|
||||||
|
return await self._command("setPlaylist", {"videoId": video_id})
|
||||||
|
|
||||||
|
async def get_now_playing(self):
|
||||||
|
return await self._command("getNowPlaying")
|
||||||
|
|
||||||
|
# Test to wrap the command function in a mutex to avoid race conditions with
|
||||||
|
# the _command_offset (TODO: move to upstream if it works)
|
||||||
|
async def _command(self, command: str, command_parameters: dict = None) -> bool:
|
||||||
|
async with self._command_mutex:
|
||||||
|
return await super()._command(command, command_parameters)
|
||||||
|
|
||||||
|
async def change_web_session(self, web_session: ClientSession):
|
||||||
|
if self.session is not None:
|
||||||
|
await self.session.close()
|
||||||
|
if self.conn is not None:
|
||||||
|
await self.conn.close()
|
||||||
|
self.session = web_session
|
||||||
|
|
||||||
|
def _common_connection_parameters(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.device_name,
|
||||||
|
"loungeIdToken": self.auth.lounge_id_token,
|
||||||
|
"SID": self._sid,
|
||||||
|
"AID": self._last_event_id,
|
||||||
|
"gsessionid": self._gsession,
|
||||||
|
"device": "REMOTE_CONTROL",
|
||||||
|
"app": "ytios-phone-20.15.1",
|
||||||
|
"VER": "8",
|
||||||
|
"v": "2",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Attempt to connect using the previously set tokens"""
|
||||||
|
if not self.linked():
|
||||||
|
raise NotLinkedException("Not linked")
|
||||||
|
|
||||||
|
connect_body = {
|
||||||
|
"id": self.auth.screen_id,
|
||||||
|
"mdx-version": "3",
|
||||||
|
"TYPE": "xmlhttp",
|
||||||
|
"theme": "cl",
|
||||||
|
"sessionSource": "MDX_SESSION_SOURCE_UNKNOWN",
|
||||||
|
"connectParams": '{"setStatesParams": "{"playbackSpeed":0}"}',
|
||||||
|
"RID": "1",
|
||||||
|
"CVER": "1",
|
||||||
|
"capabilities": "que,dsdtr,atp,vsp",
|
||||||
|
"ui": "false",
|
||||||
|
"app": "ytios-phone-20.15.1",
|
||||||
|
"pairing_type": "manual",
|
||||||
|
"VER": "8",
|
||||||
|
"loungeIdToken": self.auth.lounge_id_token,
|
||||||
|
"device": "REMOTE_CONTROL",
|
||||||
|
"name": self.device_name,
|
||||||
|
}
|
||||||
|
connect_url = f"{api_base}/bc/bind"
|
||||||
|
async with self.session.post(url=connect_url, data=connect_body) as resp:
|
||||||
|
try:
|
||||||
|
text = await resp.text()
|
||||||
|
if resp.status == 401:
|
||||||
|
if "Connection denied" in text:
|
||||||
|
self._logger.warning(
|
||||||
|
"Connection denied, attempting to circumvent the issue"
|
||||||
|
)
|
||||||
|
await self.connect_as_screen()
|
||||||
|
# self._lounge_token_expired()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if resp.status != 200:
|
||||||
|
self._logger.warning("Unknown reply to connect %i %s", resp.status, resp.reason)
|
||||||
|
return False
|
||||||
|
lines = text.splitlines()
|
||||||
|
async for events in self._parse_event_chunks(as_aiter(lines)):
|
||||||
|
self._process_events(events)
|
||||||
|
self._command_offset = 1
|
||||||
|
return self.connected()
|
||||||
|
except:
|
||||||
|
self._logger.exception(
|
||||||
|
"Handle connect failed, status %s reason %s",
|
||||||
|
resp.status,
|
||||||
|
resp.reason,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def connect_as_screen(self) -> bool:
|
||||||
|
"""Attempt to connect using the previously set tokens"""
|
||||||
|
if not self.linked():
|
||||||
|
raise NotLinkedException("Not linked")
|
||||||
|
|
||||||
|
connect_body = {
|
||||||
|
"id": str(uuid4()),
|
||||||
|
"mdx-version": "3",
|
||||||
|
"TYPE": "xmlhttp",
|
||||||
|
"theme": "cl",
|
||||||
|
"sessionSource": "MDX_SESSION_SOURCE_UNKNOWN",
|
||||||
|
"connectParams": '{"setStatesParams": "{"playbackSpeed":0}"}',
|
||||||
|
"sessionNonce": str(uuid4()),
|
||||||
|
"RID": "1",
|
||||||
|
"CVER": "1",
|
||||||
|
"capabilities": "que,dsdtr,atp,vsp",
|
||||||
|
"ui": "false",
|
||||||
|
"app": "ytios-phone-20.15.1",
|
||||||
|
"pairing_type": "manual",
|
||||||
|
"VER": "8",
|
||||||
|
"loungeIdToken": self.auth.lounge_id_token,
|
||||||
|
"device": "LOUNGE_SCREEN",
|
||||||
|
"name": self.device_name,
|
||||||
|
}
|
||||||
|
connect_url = f"{api_base}/bc/bind"
|
||||||
|
async with self.session.post(url=connect_url, data=connect_body) as resp:
|
||||||
|
try:
|
||||||
|
await resp.text()
|
||||||
|
self.logger.error(
|
||||||
|
"Connected as screen: please force close the app on the device for iSponsorBlockTV to work properly"
|
||||||
|
)
|
||||||
|
self.logger.warn("Exiting in 5 seconds")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
sys.exit(0)
|
||||||
|
except:
|
||||||
|
self._logger.exception(
|
||||||
|
"Handle connect failed, status %s reason %s",
|
||||||
|
resp.status,
|
||||||
|
resp.reason,
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
from iSponsorBlockTV import helpers
|
from iSponsorBlockTV import helpers
|
||||||
|
|
||||||
helpers.app_start()
|
helpers.app_start()
|
||||||
5
src/main_tui.py
Normal file
5
src/main_tui.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from iSponsorBlockTV import setup_wizard
|
||||||
|
from iSponsorBlockTV.helpers import Config
|
||||||
|
|
||||||
|
config = Config("data/config.json")
|
||||||
|
setup_wizard.main(config)
|
||||||
Reference in New Issue
Block a user