mirror of
https://github.com/dmunozv04/iSponsorBlockTV.git
synced 2025-12-06 20:06:44 +03:00
Compare commits
151 Commits
debug-comm
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f7acd275 | ||
|
|
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 |
@@ -6,3 +6,4 @@ enabled = true
|
|||||||
|
|
||||||
[analyzers.meta]
|
[analyzers.meta]
|
||||||
runtime_version = "3.x.x"
|
runtime_version = "3.x.x"
|
||||||
|
max_line_length = 100
|
||||||
|
|||||||
13
.github/workflows/build_docker_images.yml
vendored
13
.github/workflows/build_docker_images.yml
vendored
@@ -6,7 +6,8 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- '*'
|
- '*'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*.*.*'
|
||||||
|
- 'v*.*.*-*'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- '*'
|
||||||
@@ -27,7 +28,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Get the repository's code
|
# Get the repository's code
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
# Generate docker tags
|
# Generate docker tags
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
@@ -40,7 +41,9 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
type=raw,value=develop,priority=900,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
type=raw,value=develop,priority=900,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||||
type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr
|
type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr
|
||||||
type=ref,event=tag
|
type=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern=v{{major}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=schedule
|
type=schedule
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && env.DOCKERHUB_USERNAME != ''
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@@ -71,7 +74,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
platforms: linux/amd64, linux/arm64, linux/arm/v7, linux/386, linux/arm/v6
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
@@ -11,12 +11,9 @@ name: Release Package
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- 'main'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- '*'
|
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
@@ -33,10 +30,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
@@ -57,6 +54,9 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
build-binaries:
|
build-binaries:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }})
|
name: Build binaries for ${{ matrix.job.release_suffix }} (${{ matrix.job.os }})
|
||||||
needs:
|
needs:
|
||||||
- build-sdist-and-wheel
|
- build-sdist-and-wheel
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
cpu_variant: v1
|
cpu_variant: v1
|
||||||
release_suffix: x86_64-linux-v1
|
release_suffix: x86_64-linux-v1
|
||||||
- target: aarch64-unknown-linux-gnu
|
- target: aarch64-unknown-linux-gnu
|
||||||
os: ubuntu-latest
|
os: ubuntu-24.04-arm
|
||||||
cross: true
|
cross: true
|
||||||
release_suffix: aarch64-linux
|
release_suffix: aarch64-linux
|
||||||
# Windows
|
# Windows
|
||||||
@@ -100,17 +100,17 @@ jobs:
|
|||||||
CARGO_BUILD_TARGET: ${{ matrix.job.target }}
|
CARGO_BUILD_TARGET: ${{ matrix.job.target }}
|
||||||
PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }}
|
PYAPP_DISTRIBUTION_VARIANT_CPU: ${{ matrix.job.cpu_variant }}
|
||||||
PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling)
|
PYAPP_REPO: pyapp # Use local copy of pyapp (needed for cross-compiling)
|
||||||
PYAPP_VERSION: v0.24.0
|
PYAPP_VERSION: v0.28.0
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Clone PyApp
|
- name: Clone PyApp
|
||||||
run: git clone --depth 1 --branch $PYAPP_VERSION https://github.com/ofek/pyapp $PYAPP_REPO
|
run: git clone --depth 1 --branch $PYAPP_VERSION https://github.com/ofek/pyapp $PYAPP_REPO
|
||||||
|
|
||||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
hatch --version
|
hatch --version
|
||||||
|
|
||||||
- name: Get artifact
|
- name: Get artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: sdist-and-wheel
|
name: sdist-and-wheel
|
||||||
path: ${{ github.workspace }}/dist
|
path: ${{ github.workspace }}/dist
|
||||||
@@ -158,6 +158,12 @@ jobs:
|
|||||||
run: |-
|
run: |-
|
||||||
mv dist/binary/iSponsorBlockTV* dist/binary/iSponsorBlockTV-${{ matrix.job.release_suffix }}
|
mv dist/binary/iSponsorBlockTV* dist/binary/iSponsorBlockTV-${{ matrix.job.release_suffix }}
|
||||||
|
|
||||||
|
- name: Attest build provenance
|
||||||
|
uses: actions/attest-build-provenance@v3
|
||||||
|
with:
|
||||||
|
subject-path: dist/binary/*
|
||||||
|
continue-on-error: true # Continue if attestation fails (it will fail on forks)
|
||||||
|
|
||||||
- name: Upload built binary package
|
- name: Upload built binary package
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -175,7 +181,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Get artifact
|
- name: Get artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: sdist-and-wheel
|
name: sdist-and-wheel
|
||||||
path: dist
|
path: dist
|
||||||
@@ -194,7 +200,7 @@ jobs:
|
|||||||
if: github.event_name == 'release' && github.event.action == 'published'
|
if: github.event_name == 'release' && github.event.action == 'published'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v5
|
||||||
name: Get artifact
|
name: Get artifact
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
|
|||||||
4
.github/workflows/update_docker_readme.yml
vendored
4
.github/workflows/update_docker_readme.yml
vendored
@@ -18,11 +18,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Get the repository's code
|
# Get the repository's code
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
# Update description
|
# Update description
|
||||||
- name: Update repo description
|
- name: Update repo description
|
||||||
uses: peter-evans/dockerhub-description@v4
|
uses: peter-evans/dockerhub-description@v5
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Inspired by textual pre-commit config
|
# Inspired by textual pre-commit config
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-ast # simply checks whether the files parse as valid python
|
- 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-builtin-literals # requires literal syntax when initializing empty or zero python builtin types
|
||||||
@@ -19,13 +19,13 @@ repos:
|
|||||||
- id: mixed-line-ending # replaces or checks mixed line ending
|
- id: mixed-line-ending # replaces or checks mixed line ending
|
||||||
- id: trailing-whitespace # checks for trailing whitespace
|
- id: trailing-whitespace # checks for trailing whitespace
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.9.6
|
rev: v0.12.12
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [ --fix, --exit-non-zero-on-fix ]
|
args: [ --fix, --exit-non-zero-on-fix ]
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||||
rev: v0.44.0
|
rev: v0.45.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: markdownlint
|
- id: markdownlint
|
||||||
args: ["--fix"]
|
args: ["--fix"]
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -5,10 +5,11 @@
|
|||||||
[](https://github.com/dmunozv04/iSponsorBlockTV/releases/latest)
|
[](https://github.com/dmunozv04/iSponsorBlockTV/releases/latest)
|
||||||
[](https://github.com/dmunozv04/isponsorblocktv)
|
[](https://github.com/dmunozv04/isponsorblocktv)
|
||||||
|
|
||||||
Skip sponsor segments in YouTube videos playing on a YouTube TV device (see
|
iSponsorBlockTV is a self-hosted application that connects to your YouTube TV
|
||||||
below for compatibility details).
|
app (see compatibility below) and automatically skips segments (like Sponsors
|
||||||
|
or intros) in YouTube videos using the [SponsorBlock](https://sponsor.ajay.app/)
|
||||||
This project is written in asynchronous python and should be pretty quick.
|
API. It can also auto mute and press the "Skip Ad" button the moment it becomes
|
||||||
|
available on YouTube ads.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ Open an issue/pull request if you have tested a device that isn't listed here.
|
|||||||
|
|
||||||
| Device | Status |
|
| Device | Status |
|
||||||
|:-------------------|:------:|
|
|:-------------------|:------:|
|
||||||
| Apple TV | ✅ |
|
| Apple TV | ✅* |
|
||||||
| Samsung TV (Tizen) | ✅ |
|
| Samsung TV (Tizen) | ✅ |
|
||||||
| LG TV (WebOS) | ✅ |
|
| LG TV (WebOS) | ✅ |
|
||||||
| Android TV | ✅ |
|
| Android TV | ✅ |
|
||||||
@@ -35,17 +36,17 @@ Open an issue/pull request if you have tested a device that isn't listed here.
|
|||||||
| Xbox One/Series | ✅ |
|
| Xbox One/Series | ✅ |
|
||||||
| Playstation 4/5 | ✅ |
|
| Playstation 4/5 | ✅ |
|
||||||
|
|
||||||
|
*Ad muting won't work when using AirPlay to send the audio to another speaker.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Run iSponsorBlockTV on a computer that has network access.
|
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.
|
||||||
|
|
||||||
Auto discovery will require the computer to be on the same network as the device
|
Auto discovery will require the computer to be on the same network as the device
|
||||||
during setup.
|
during setup.
|
||||||
The device can also be manually added to iSponsorBlockTV with a YouTube TV code.
|
The device can also be manually added to iSponsorBlockTV with a YouTube TV code.
|
||||||
This code can be found in the settings page of your YouTube application.
|
This code can be found in the settings page of your YouTube TV application.
|
||||||
|
|
||||||
It connects to the device, watches its activity and skips any sponsor segment
|
|
||||||
using the [SponsorBlock](https://sponsor.ajay.app/) API.
|
|
||||||
It can also skip/mute YouTube ads.
|
|
||||||
|
|
||||||
## Libraries used
|
## Libraries used
|
||||||
|
|
||||||
@@ -71,11 +72,9 @@ It can also skip/mute YouTube ads.
|
|||||||
|
|
||||||
## 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
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
- [Oxixes](https://github.com/oxixes) - added support for channel whitelist and
|
|
||||||
minor improvements
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"skip_count_tracking": true,
|
"skip_count_tracking": true,
|
||||||
"mute_ads": true,
|
"mute_ads": true,
|
||||||
"skip_ads": true,
|
"skip_ads": true,
|
||||||
|
"minimum_skip_length": 1,
|
||||||
"auto_play": true,
|
"auto_play": true,
|
||||||
"join_name": "iSponsorBlockTV",
|
"join_name": "iSponsorBlockTV",
|
||||||
"apikey": "",
|
"apikey": "",
|
||||||
@@ -19,5 +20,6 @@
|
|||||||
{"id": "",
|
{"id": "",
|
||||||
"name": ""
|
"name": ""
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"use_proxy": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
version: '3.3'
|
|
||||||
services:
|
services:
|
||||||
iSponsorBlockTV:
|
iSponsorBlockTV:
|
||||||
image: ghcr.io/dmunozv04/isponsorblocktv
|
image: ghcr.io/dmunozv04/isponsorblocktv
|
||||||
container_name: iSponsorBlockTV
|
container_name: iSponsorBlockTV
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /PATH_TO_YOUR_DATA_DIR:/app/data
|
- /PATH_TO_YOUR_DATA_DIR:/app/data
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "iSponsorBlockTV"
|
name = "iSponsorBlockTV"
|
||||||
version = "2.3.1"
|
version = "2.6.1"
|
||||||
authors = [
|
authors = [
|
||||||
{"name" = "dmunozv04"}
|
{"name" = "dmunozv04"}
|
||||||
]
|
]
|
||||||
@@ -29,5 +29,5 @@ files = ["requirements.txt"]
|
|||||||
requires = ["hatchling", "hatch-requirements-txt"]
|
requires = ["hatchling", "hatch-requirements-txt"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.black]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 100
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
aiohttp==3.11.12
|
aiohttp==3.12.15
|
||||||
appdirs==1.4.4
|
appdirs==1.4.4
|
||||||
async-cache==1.1.1
|
async-cache==1.1.1
|
||||||
pyytlounge==2.1.2
|
pyytlounge==2.3.0
|
||||||
rich==13.9.4
|
rich==14.1.0
|
||||||
ssdp==1.3.0
|
ssdp==1.3.1
|
||||||
textual==1.0.0
|
textual==5.3.0
|
||||||
textual-slider==0.2.0
|
textual-slider==0.2.0
|
||||||
xmltodict==0.14.2
|
xmltodict==0.15.1
|
||||||
rich_click==1.8.5
|
rich_click==1.9.4
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ApiHelper:
|
|||||||
self.skip_count_tracking = config.skip_count_tracking
|
self.skip_count_tracking = config.skip_count_tracking
|
||||||
self.web_session = web_session
|
self.web_session = web_session
|
||||||
self.num_devices = len(config.devices)
|
self.num_devices = len(config.devices)
|
||||||
|
self.minimum_skip_length = config.minimum_skip_length
|
||||||
|
|
||||||
# Not used anymore, maybe it can stay here a little longer
|
# Not used anymore, maybe it can stay here a little longer
|
||||||
@AsyncLRU(maxsize=10)
|
@AsyncLRU(maxsize=10)
|
||||||
@@ -101,26 +102,21 @@ class ApiHelper:
|
|||||||
if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]:
|
if channel_data["items"][0]["statistics"]["hiddenSubscriberCount"]:
|
||||||
sub_count = "Hidden"
|
sub_count = "Hidden"
|
||||||
else:
|
else:
|
||||||
sub_count = int(
|
sub_count = int(channel_data["items"][0]["statistics"]["subscriberCount"])
|
||||||
channel_data["items"][0]["statistics"]["subscriberCount"]
|
|
||||||
)
|
|
||||||
sub_count = format(sub_count, "_")
|
sub_count = format(sub_count, "_")
|
||||||
|
|
||||||
channels.append(
|
channels.append((i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count))
|
||||||
(i["snippet"]["channelId"], i["snippet"]["channelTitle"], sub_count)
|
|
||||||
)
|
|
||||||
return channels
|
return channels
|
||||||
|
|
||||||
@list_to_tuple # Convert list to tuple so it can be used as a key in the cache
|
@list_to_tuple # Convert list to tuple so it can be used as a key in the cache
|
||||||
@AsyncConditionalTTL(
|
@AsyncConditionalTTL(time_to_live=300, maxsize=10) # 5 minutes for non-locked segments
|
||||||
time_to_live=300, maxsize=10
|
|
||||||
) # 5 minutes for non-locked segments
|
|
||||||
async def get_segments(self, vid_id):
|
async def get_segments(self, vid_id):
|
||||||
if await self.is_whitelisted(vid_id):
|
if await self.is_whitelisted(vid_id):
|
||||||
return (
|
return (
|
||||||
[],
|
[],
|
||||||
True,
|
True,
|
||||||
) # Return empty list and True to indicate that the cache should last forever
|
) # Return empty list and True to indicate
|
||||||
|
# that the cache should last forever
|
||||||
vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[
|
vid_id_hashed = sha256(vid_id.encode("utf-8")).hexdigest()[
|
||||||
:4
|
:4
|
||||||
] # Hashes video id and gets the first 4 characters
|
] # Hashes video id and gets the first 4 characters
|
||||||
@@ -131,9 +127,7 @@ class ApiHelper:
|
|||||||
}
|
}
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json"}
|
||||||
url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed
|
url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed
|
||||||
async with self.web_session.get(
|
async with self.web_session.get(url, headers=headers, params=params) as response:
|
||||||
url, headers=headers, params=params
|
|
||||||
) as response:
|
|
||||||
response_json = await response.json()
|
response_json = await response.json()
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
response_text = await response.text()
|
response_text = await response.text()
|
||||||
@@ -146,10 +140,10 @@ class ApiHelper:
|
|||||||
if str(i["videoID"]) == str(vid_id):
|
if str(i["videoID"]) == str(vid_id):
|
||||||
response_json = i
|
response_json = i
|
||||||
break
|
break
|
||||||
return self.process_segments(response_json)
|
return self.process_segments(response_json, self.minimum_skip_length)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def process_segments(response):
|
def process_segments(response, minimum_skip_length):
|
||||||
segments = []
|
segments = []
|
||||||
ignore_ttl = True
|
ignore_ttl = True
|
||||||
try:
|
try:
|
||||||
@@ -183,7 +177,7 @@ class ApiHelper:
|
|||||||
segment_before_start = segments[-1]["start"]
|
segment_before_start = segments[-1]["start"]
|
||||||
segment_before_UUID = segments[-1]["UUID"]
|
segment_before_UUID = segments[-1]["UUID"]
|
||||||
|
|
||||||
except Exception:
|
except IndexError:
|
||||||
segment_before_end = -10
|
segment_before_end = -10
|
||||||
if (
|
if (
|
||||||
segment_dict["start"] - segment_before_end < 1
|
segment_dict["start"] - segment_before_end < 1
|
||||||
@@ -191,13 +185,16 @@ class ApiHelper:
|
|||||||
segment_dict["start"] = segment_before_start
|
segment_dict["start"] = segment_before_start
|
||||||
segment_dict["UUID"].extend(segment_before_UUID)
|
segment_dict["UUID"].extend(segment_before_UUID)
|
||||||
segments.pop()
|
segments.pop()
|
||||||
segments.append(segment_dict)
|
# Only add segments greater than minimum skip length
|
||||||
except Exception:
|
if segment_dict["end"] - segment_dict["start"] > minimum_skip_length:
|
||||||
|
segments.append(segment_dict)
|
||||||
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
return segments, ignore_ttl
|
return segments, ignore_ttl
|
||||||
|
|
||||||
async def mark_viewed_segments(self, uuids):
|
async def mark_viewed_segments(self, uuids):
|
||||||
"""Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled.
|
"""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)"""
|
Lets the contributor know that someone skipped the segment (thanks)"""
|
||||||
if self.skip_count_tracking:
|
if self.skip_count_tracking:
|
||||||
for i in uuids:
|
for i in uuids:
|
||||||
|
|||||||
@@ -3,28 +3,29 @@ import datetime
|
|||||||
from cache.key import KEY
|
from cache.key import KEY
|
||||||
from cache.lru import LRU
|
from cache.lru import LRU
|
||||||
|
|
||||||
"""MIT License
|
# MIT License
|
||||||
|
|
||||||
Copyright (c) 2020 Rajat Singh
|
# Copyright (c) 2020 Rajat Singh
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
# in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
# furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
# copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# 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
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE."""
|
# SOFTWARE.
|
||||||
"""Modified code from https://github.com/iamsinghrajat/async-cache"""
|
|
||||||
|
# Modified code from https://github.com/iamsinghrajat/async-cache
|
||||||
|
|
||||||
|
|
||||||
class AsyncConditionalTTL:
|
class AsyncConditionalTTL:
|
||||||
@@ -32,9 +33,7 @@ class AsyncConditionalTTL:
|
|||||||
def __init__(self, time_to_live, maxsize):
|
def __init__(self, time_to_live, maxsize):
|
||||||
super().__init__(maxsize=maxsize)
|
super().__init__(maxsize=maxsize)
|
||||||
|
|
||||||
self.time_to_live = (
|
self.time_to_live = datetime.timedelta(seconds=time_to_live) if time_to_live else None
|
||||||
datetime.timedelta(seconds=time_to_live) if time_to_live else None
|
|
||||||
)
|
|
||||||
|
|
||||||
self.maxsize = maxsize
|
self.maxsize = maxsize
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import aiohttp
|
|||||||
from . import api_helpers, ytlounge
|
from . import api_helpers, ytlounge
|
||||||
|
|
||||||
# Constants for user input prompts
|
# Constants for user input prompts
|
||||||
|
USE_PROXY_PROMPT = "Do you want to use system-wide proxy? (y/N)"
|
||||||
ATVS_REMOVAL_PROMPT = (
|
ATVS_REMOVAL_PROMPT = (
|
||||||
"Do you want to remove the legacy 'atvs' entry (the app won't start"
|
"Do you want to remove the legacy 'atvs' entry (the app won't start with it present)? (y/N) "
|
||||||
" with it present)? (y/N) "
|
|
||||||
)
|
)
|
||||||
PAIRING_CODE_PROMPT = "Enter pairing code (found in Settings - Link with TV code): "
|
PAIRING_CODE_PROMPT = "Enter pairing code (found in Settings - Link with TV code): "
|
||||||
ADD_MORE_DEVICES_PROMPT = "Paired with {num_devices} Device(s). Add more? (y/N) "
|
ADD_MORE_DEVICES_PROMPT = "Paired with {num_devices} Device(s). Add more? (y/N) "
|
||||||
CHANGE_API_KEY_PROMPT = "API key already specified. Change it? (y/N) "
|
CHANGE_API_KEY_PROMPT = "API key already specified. Change it? (y/N) "
|
||||||
ADD_API_KEY_PROMPT = (
|
ADD_API_KEY_PROMPT = "API key only needed for the channel whitelist function. Add it? (y/N) "
|
||||||
"API key only needed for the channel whitelist function. Add it? (y/N) "
|
|
||||||
)
|
|
||||||
ENTER_API_KEY_PROMPT = "Enter your API key: "
|
ENTER_API_KEY_PROMPT = "Enter your API key: "
|
||||||
CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) "
|
CHANGE_SKIP_CATEGORIES_PROMPT = "Skip categories already specified. Change them? (y/N) "
|
||||||
ENTER_SKIP_CATEGORIES_PROMPT = (
|
ENTER_SKIP_CATEGORIES_PROMPT = (
|
||||||
@@ -22,13 +20,15 @@ ENTER_SKIP_CATEGORIES_PROMPT = (
|
|||||||
" selfpromo, exclusive_access, interaction, poi_highlight, intro, outro,"
|
" selfpromo, exclusive_access, interaction, poi_highlight, intro, outro,"
|
||||||
" preview, filler, music_offtopic]:\n"
|
" preview, filler, music_offtopic]:\n"
|
||||||
)
|
)
|
||||||
WHITELIST_CHANNELS_PROMPT = (
|
WHITELIST_CHANNELS_PROMPT = "Do you want to whitelist any channels from being ad-blocked? (y/N) "
|
||||||
"Do you want to whitelist any channels from being ad-blocked? (y/N) "
|
|
||||||
)
|
|
||||||
SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: '
|
SEARCH_CHANNEL_PROMPT = 'Enter a channel name or "/exit" to exit: '
|
||||||
SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: "
|
SELECT_CHANNEL_PROMPT = "Select one option of the above [0-6]: "
|
||||||
ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: "
|
ENTER_CHANNEL_ID_PROMPT = "Enter a channel ID: "
|
||||||
ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: "
|
ENTER_CUSTOM_CHANNEL_NAME_PROMPT = "Enter the channel name: "
|
||||||
|
MINIMUM_SKIP_PROMPT = "Do you want to specify a minimum length of segment to skip? (y/N)"
|
||||||
|
MINIMUM_SKIP_SPECIFICATION_PROMPT = (
|
||||||
|
"Enter minimum length of segment to skip in seconds (enter 0 to disable):"
|
||||||
|
)
|
||||||
REPORT_SKIPPED_SEGMENTS_PROMPT = (
|
REPORT_SKIPPED_SEGMENTS_PROMPT = (
|
||||||
"Do you want to report skipped segments to sponsorblock. Only the segment"
|
"Do you want to report skipped segments to sponsorblock. Only the segment"
|
||||||
" UUID will be sent? (Y/n) "
|
" UUID will be sent? (Y/n) "
|
||||||
@@ -43,10 +43,11 @@ def get_yn_input(prompt):
|
|||||||
if choice.lower() in ["y", "n"]:
|
if choice.lower() in ["y", "n"]:
|
||||||
return choice.lower()
|
return choice.lower()
|
||||||
print("Invalid input. Please enter 'y' or 'n'.")
|
print("Invalid input. Please enter 'y' or 'n'.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def create_web_session():
|
async def create_web_session(use_proxy):
|
||||||
return aiohttp.ClientSession()
|
return aiohttp.ClientSession(trust_env=use_proxy)
|
||||||
|
|
||||||
|
|
||||||
async def pair_device(web_session: aiohttp.ClientSession):
|
async def pair_device(web_session: aiohttp.ClientSession):
|
||||||
@@ -75,8 +76,12 @@ async def pair_device(web_session: aiohttp.ClientSession):
|
|||||||
|
|
||||||
def main(config, debug: bool) -> None:
|
def main(config, debug: bool) -> None:
|
||||||
print("Welcome to the iSponsorBlockTV cli setup wizard")
|
print("Welcome to the iSponsorBlockTV cli setup wizard")
|
||||||
|
|
||||||
|
choice = get_yn_input(USE_PROXY_PROMPT)
|
||||||
|
config.use_proxy = choice == "y"
|
||||||
|
|
||||||
loop = asyncio.get_event_loop_policy().get_event_loop()
|
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
web_session = loop.run_until_complete(create_web_session())
|
web_session = loop.run_until_complete(create_web_session(config.use_proxy))
|
||||||
if debug:
|
if debug:
|
||||||
loop.set_debug(True)
|
loop.set_debug(True)
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
@@ -120,15 +125,11 @@ def main(config, debug: bool) -> None:
|
|||||||
if choice == "y":
|
if choice == "y":
|
||||||
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
||||||
skip_categories = categories.replace(",", " ").split(" ")
|
skip_categories = categories.replace(",", " ").split(" ")
|
||||||
skip_categories = [
|
skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings
|
||||||
x for x in skip_categories if x != ""
|
|
||||||
] # Remove empty strings
|
|
||||||
else:
|
else:
|
||||||
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
categories = input(ENTER_SKIP_CATEGORIES_PROMPT)
|
||||||
skip_categories = categories.replace(",", " ").split(" ")
|
skip_categories = categories.replace(",", " ").split(" ")
|
||||||
skip_categories = [
|
skip_categories = [x for x in skip_categories if x != ""] # Remove empty strings
|
||||||
x for x in skip_categories if x != ""
|
|
||||||
] # Remove empty strings
|
|
||||||
config.skip_categories = skip_categories
|
config.skip_categories = skip_categories
|
||||||
|
|
||||||
channel_whitelist = config.channel_whitelist
|
channel_whitelist = config.channel_whitelist
|
||||||
@@ -147,9 +148,7 @@ def main(config, debug: bool) -> None:
|
|||||||
if channel == "/exit":
|
if channel == "/exit":
|
||||||
break
|
break
|
||||||
|
|
||||||
task = loop.create_task(
|
task = loop.create_task(api_helper.search_channels(channel, apikey, web_session))
|
||||||
api_helper.search_channels(channel, apikey, web_session)
|
|
||||||
)
|
|
||||||
loop.run_until_complete(task)
|
loop.run_until_complete(task)
|
||||||
results = task.result()
|
results = task.result()
|
||||||
if len(results) == 0:
|
if len(results) == 0:
|
||||||
@@ -181,6 +180,21 @@ def main(config, debug: bool) -> None:
|
|||||||
|
|
||||||
config.channel_whitelist = channel_whitelist
|
config.channel_whitelist = channel_whitelist
|
||||||
|
|
||||||
|
# Ask for minimum skip length. Confirm input is an integer
|
||||||
|
minimum_skip_length = config.minimum_skip_length
|
||||||
|
|
||||||
|
choice = get_yn_input(MINIMUM_SKIP_PROMPT)
|
||||||
|
if choice == "y":
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
minimum_skip_length = int(input(MINIMUM_SKIP_SPECIFICATION_PROMPT))
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
print("You entered a non integer value, try again.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
config.minimum_skip_length = minimum_skip_length
|
||||||
|
|
||||||
choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT)
|
choice = get_yn_input(REPORT_SKIPPED_SEGMENTS_PROMPT)
|
||||||
config.skip_count_tracking = choice != "n"
|
config.skip_count_tracking = choice != "n"
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,5 @@ youtube_client_blacklist = ["TVHTML5_FOR_KIDS"]
|
|||||||
|
|
||||||
|
|
||||||
config_file_blacklist_keys = ["config_file", "data_dir"]
|
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}"
|
||||||
|
)
|
||||||
@@ -7,46 +7,47 @@ import ssdp
|
|||||||
import xmltodict
|
import xmltodict
|
||||||
from ssdp import network
|
from ssdp import network
|
||||||
|
|
||||||
"""Redistribution and use of the DIAL DIscovery And Launch protocol
|
# Redistribution and use of the DIAL DIscovery And Launch protocol
|
||||||
specification (the “DIAL Specification”), with or without modification,
|
# specification (the “DIAL Specification”), with or without modification,
|
||||||
are permitted provided that the following conditions are met: ●
|
# are permitted provided that the following conditions are met: ●
|
||||||
Redistributions of the DIAL Specification must retain the above copyright
|
# Redistributions of the DIAL Specification must retain the above copyright
|
||||||
notice, this list of conditions and the following disclaimer. ●
|
# notice, this list of conditions and the following disclaimer. ●
|
||||||
Redistributions of implementations of the DIAL Specification in source code
|
# Redistributions of implementations of the DIAL Specification in source code
|
||||||
form must retain the above copyright notice, this list of conditions and the
|
# form must retain the above copyright notice, this list of conditions and the
|
||||||
following disclaimer. ● Redistributions of implementations of the DIAL
|
# following disclaimer. ● Redistributions of implementations of the DIAL
|
||||||
Specification in binary form must include the above copyright notice. ● The
|
# Specification in binary form must include the above copyright notice. ● The
|
||||||
DIAL mark, the NETFLIX mark and the names of contributors to the DIAL
|
# DIAL mark, the NETFLIX mark and the names of contributors to the DIAL
|
||||||
Specification may not be used to endorse or promote specifications, software,
|
# Specification may not be used to endorse or promote specifications, software,
|
||||||
products, or any other materials derived from the DIAL Specification without
|
# products, or any other materials derived from the DIAL Specification without
|
||||||
specific prior written permission. The DIAL mark is owned by Netflix and
|
# specific prior written permission. The DIAL mark is owned by Netflix and
|
||||||
information on licensing the DIAL mark is available at
|
# information on licensing the DIAL mark is available at
|
||||||
www.dial-multiscreen.org."""
|
# www.dial-multiscreen.org.
|
||||||
|
|
||||||
"""
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2018 Johannes Hoppe
|
# MIT License
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
# Copyright (c) 2018 Johannes Hoppe
|
||||||
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
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
copies or substantial portions of the Software.
|
# 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
# copies or substantial portions of the Software.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
SOFTWARE."""
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
"""Modified code from
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
https://github.com/codingjoe/ssdp/blob/main/ssdp/__main__.py"""
|
# 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():
|
def get_ip():
|
||||||
@@ -81,6 +82,9 @@ class Handler(ssdp.aio.SSDP):
|
|||||||
if "location" in headers:
|
if "location" in headers:
|
||||||
self.devices.append(headers["location"])
|
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 def find_youtube_app(web_session, url_location):
|
||||||
async with web_session.get(url_location) as response:
|
async with web_session.get(url_location) as response:
|
||||||
@@ -111,21 +115,19 @@ async def discover(web_session):
|
|||||||
search_target = "urn:dial-multiscreen-org:service:dial:1"
|
search_target = "urn:dial-multiscreen-org:service:dial:1"
|
||||||
max_wait = 10
|
max_wait = 10
|
||||||
handler = Handler()
|
handler = Handler()
|
||||||
"""Send out an M-SEARCH request and listening for responses."""
|
# Send out an M-SEARCH request and listening for responses
|
||||||
family, addr = network.get_best_family(bind, network.PORT)
|
family, _ = network.get_best_family(bind, network.PORT)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
ip_address = get_ip()
|
ip_address = get_ip()
|
||||||
connect = loop.create_datagram_endpoint(
|
connect = loop.create_datagram_endpoint(handler, family=family, local_addr=(ip_address, None))
|
||||||
handler, family=family, local_addr=(ip_address, None)
|
transport, _ = await connect
|
||||||
)
|
|
||||||
transport, protocol = await connect
|
|
||||||
|
|
||||||
target = network.MULTICAST_ADDRESS_IPV4, network.PORT
|
target = network.MULTICAST_ADDRESS_IPV4, network.PORT
|
||||||
|
|
||||||
search_request = ssdp.messages.SSDPRequest(
|
search_request = ssdp.messages.SSDPRequest(
|
||||||
"M-SEARCH",
|
"M-SEARCH",
|
||||||
headers={
|
headers={
|
||||||
"HOST": "%s:%d" % target,
|
"HOST": f"{target[0]}:{target[1]}",
|
||||||
"MAN": '"ssdp:discover"',
|
"MAN": '"ssdp:discover"',
|
||||||
"MX": str(max_wait), # seconds to delay response [1..5]
|
"MX": str(max_wait), # seconds to delay response [1..5]
|
||||||
"ST": search_target,
|
"ST": search_target,
|
||||||
@@ -136,7 +138,6 @@ async def discover(web_session):
|
|||||||
|
|
||||||
search_request.sendto(transport, target)
|
search_request.sendto(transport, target)
|
||||||
|
|
||||||
# print(search_request, addr[:2])
|
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(4)
|
await asyncio.sleep(4)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import rich_click as click
|
|||||||
from appdirs import user_data_dir
|
from appdirs import user_data_dir
|
||||||
|
|
||||||
from . import config_setup, main, setup_wizard
|
from . import config_setup, main, setup_wizard
|
||||||
from .constants import config_file_blacklist_keys
|
from .constants import config_file_blacklist_keys, github_wiki_base_url
|
||||||
|
|
||||||
|
|
||||||
class Device:
|
class Device:
|
||||||
@@ -41,8 +41,10 @@ class Config:
|
|||||||
self.skip_count_tracking = True
|
self.skip_count_tracking = True
|
||||||
self.mute_ads = False
|
self.mute_ads = False
|
||||||
self.skip_ads = False
|
self.skip_ads = False
|
||||||
|
self.minimum_skip_length = 1
|
||||||
self.auto_play = True
|
self.auto_play = True
|
||||||
self.join_name = "iSponsorBlockTV"
|
self.join_name = "iSponsorBlockTV"
|
||||||
|
self.use_proxy = False
|
||||||
self.__load()
|
self.__load()
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
@@ -51,8 +53,8 @@ class Config:
|
|||||||
(
|
(
|
||||||
"The atvs config option is deprecated and has stopped working."
|
"The atvs config option is deprecated and has stopped working."
|
||||||
" Please read this for more information "
|
" Please read this for more information "
|
||||||
"on how to upgrade to V2:"
|
"on how to upgrade to V2:\n"
|
||||||
" \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2"
|
f"{github_wiki_base_url}/Migrate-from-V1-to-V2"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
print("Exiting in 10 seconds...")
|
print("Exiting in 10 seconds...")
|
||||||
@@ -65,9 +67,7 @@ class Config:
|
|||||||
sys.exit()
|
sys.exit()
|
||||||
self.devices = [Device(i) for i in self.devices]
|
self.devices = [Device(i) for i in self.devices]
|
||||||
if not self.apikey and self.channel_whitelist:
|
if not self.apikey and self.channel_whitelist:
|
||||||
raise ValueError(
|
raise ValueError("No youtube API key found and channel whitelist is not empty")
|
||||||
"No youtube API key found and channel whitelist is not empty"
|
|
||||||
)
|
|
||||||
if not self.skip_categories:
|
if not self.skip_categories:
|
||||||
self.skip_categories = ["sponsor"]
|
self.skip_categories = ["sponsor"]
|
||||||
print("No categories found, using default: sponsor")
|
print("No categories found, using default: sponsor")
|
||||||
@@ -90,18 +90,12 @@ class Config:
|
|||||||
print(
|
print(
|
||||||
"Running in docker without mounting the data dir, check the"
|
"Running in docker without mounting the data dir, check the"
|
||||||
" wiki for more information: "
|
" wiki for more information: "
|
||||||
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Installation#Docker"
|
f"{github_wiki_base_url}/Installation#Docker"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
(
|
("This image has recently been updated to v2, and requires changes."),
|
||||||
"This image has recently been updated to v2, and requires"
|
("Please read this for more information on how to upgrade to V2:"),
|
||||||
" changes."
|
f"{github_wiki_base_url}/Migrate-from-V1-to-V2",
|
||||||
),
|
|
||||||
(
|
|
||||||
"Please read this for more information on how to upgrade"
|
|
||||||
" to V2:"
|
|
||||||
),
|
|
||||||
"https://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2",
|
|
||||||
)
|
)
|
||||||
print("Exiting in 10 seconds...")
|
print("Exiting in 10 seconds...")
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
@@ -126,20 +120,21 @@ class Config:
|
|||||||
return self.__dict__ == other.__dict__
|
return self.__dict__ == other.__dict__
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(tuple(sorted(self.items())))
|
||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--data",
|
"--data",
|
||||||
"-d",
|
"-d",
|
||||||
default=lambda: os.getenv("iSPBTV_data_dir")
|
default=lambda: os.getenv("iSPBTV_data_dir") or user_data_dir("iSponsorBlockTV", "dmunozv04"),
|
||||||
or user_data_dir("iSponsorBlockTV", "dmunozv04"),
|
|
||||||
help="data directory",
|
help="data directory",
|
||||||
)
|
)
|
||||||
@click.option("--debug", is_flag=True, help="debug mode")
|
@click.option("--debug", is_flag=True, help="debug mode")
|
||||||
|
@click.option("--http-tracing", is_flag=True, help="Enable HTTP request/response tracing")
|
||||||
# legacy commands as arguments
|
# legacy commands as arguments
|
||||||
@click.option(
|
@click.option("--setup", is_flag=True, help="Setup the program graphically", hidden=True)
|
||||||
"--setup", is_flag=True, help="Setup the program graphically", hidden=True
|
|
||||||
)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--setup-cli",
|
"--setup-cli",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
@@ -147,13 +142,24 @@ class Config:
|
|||||||
hidden=True,
|
hidden=True,
|
||||||
)
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, data, debug, setup, setup_cli):
|
def cli(ctx, data, debug, http_tracing, setup, setup_cli):
|
||||||
"""iSponsorblockTV"""
|
"""iSponsorblockTV"""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["data_dir"] = data
|
ctx.obj["data_dir"] = data
|
||||||
ctx.obj["debug"] = debug
|
ctx.obj["debug"] = debug
|
||||||
|
ctx.obj["http_tracing"] = http_tracing
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
ctx.obj["logger"] = logger
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
||||||
|
logger.addHandler(sh)
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
if setup:
|
if setup:
|
||||||
ctx.invoke(setup_command)
|
ctx.invoke(setup_command)
|
||||||
@@ -163,36 +169,30 @@ def cli(ctx, data, debug, setup, setup_cli):
|
|||||||
ctx.invoke(start)
|
ctx.invoke(start)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command(name="setup")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def setup(ctx):
|
def setup_command(ctx):
|
||||||
"""Setup the program graphically"""
|
"""Setup the program graphically"""
|
||||||
config = Config(ctx.obj["data_dir"])
|
config = Config(ctx.obj["data_dir"])
|
||||||
setup_wizard.main(config)
|
setup_wizard.main(config)
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
setup_command = setup
|
@cli.command(name="setup-cli")
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def setup_cli(ctx):
|
def setup_cli_command(ctx):
|
||||||
"""Setup the program in the command line"""
|
"""Setup the program in the command line"""
|
||||||
config = Config(ctx.obj["data_dir"])
|
config = Config(ctx.obj["data_dir"])
|
||||||
config_setup.main(config, ctx.obj["debug"])
|
config_setup.main(config, ctx.obj["debug"])
|
||||||
|
|
||||||
|
|
||||||
setup_cli_command = setup_cli
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def start(ctx):
|
def start(ctx):
|
||||||
"""Start the main program"""
|
"""Start the main program"""
|
||||||
config = Config(ctx.obj["data_dir"])
|
config = Config(ctx.obj["data_dir"])
|
||||||
config.validate()
|
config.validate()
|
||||||
main.main(config, ctx.obj["debug"])
|
main.main(config, ctx.obj["debug"], ctx.obj["http_tracing"])
|
||||||
|
|
||||||
|
|
||||||
# Create fake "self" group to show pyapp options in help menu
|
# Create fake "self" group to show pyapp options in help menu
|
||||||
@@ -202,9 +202,7 @@ pyapp_group.add_command(
|
|||||||
click.RichCommand("update", help="Update the package to the latest version")
|
click.RichCommand("update", help="Update the package to the latest version")
|
||||||
)
|
)
|
||||||
pyapp_group.add_command(
|
pyapp_group.add_command(
|
||||||
click.Command(
|
click.Command("remove", help="Remove the package, wiping the installation but not the data")
|
||||||
"remove", help="Remove the package, wiping the installation but not the data"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
pyapp_group.add_command(
|
pyapp_group.add_command(
|
||||||
click.RichCommand(
|
click.RichCommand(
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import os
|
|
||||||
import plistlib
|
|
||||||
|
|
||||||
from . import config_setup
|
|
||||||
|
|
||||||
"""Not updated to V2 yet, should still work. Here be dragons"""
|
|
||||||
default_plist = {
|
|
||||||
"Label": "com.dmunozv04iSponsorBlockTV",
|
|
||||||
"RunAtLoad": True,
|
|
||||||
"StartInterval": 20,
|
|
||||||
"EnvironmentVariables": {"PYTHONUNBUFFERED": "YES"},
|
|
||||||
"StandardErrorPath": "", # Fill later
|
|
||||||
"StandardOutPath": "",
|
|
||||||
"ProgramArguments": "",
|
|
||||||
"WorkingDirectory": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_plist(path):
|
|
||||||
plist = default_plist
|
|
||||||
plist["ProgramArguments"] = [path + "/iSponsorBlockTV-macos"]
|
|
||||||
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
|
|
||||||
+ "opening now on finder..."
|
|
||||||
)
|
|
||||||
os.system("open -R " + correct_path)
|
|
||||||
@@ -7,6 +7,7 @@ from typing import Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from . import api_helpers, ytlounge
|
from . import api_helpers, ytlounge
|
||||||
|
from .debug_helpers import AiohttpTracer
|
||||||
|
|
||||||
|
|
||||||
class DeviceListener:
|
class DeviceListener:
|
||||||
@@ -18,16 +19,6 @@ class DeviceListener:
|
|||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}")
|
self.logger = logging.getLogger(f"iSponsorBlockTV-{device.screen_id}")
|
||||||
self.web_session = web_session
|
self.web_session = web_session
|
||||||
if debug:
|
|
||||||
self.logger.setLevel(logging.DEBUG)
|
|
||||||
else:
|
|
||||||
self.logger.setLevel(logging.INFO)
|
|
||||||
sh = logging.StreamHandler()
|
|
||||||
sh.setFormatter(
|
|
||||||
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
||||||
)
|
|
||||||
self.logger.addHandler(sh)
|
|
||||||
self.logger.info("Starting device")
|
|
||||||
self.lounge_controller = ytlounge.YtLoungeApi(
|
self.lounge_controller = ytlounge.YtLoungeApi(
|
||||||
device.screen_id, config, api_helper, self.logger
|
device.screen_id, config, api_helper, self.logger
|
||||||
)
|
)
|
||||||
@@ -39,14 +30,12 @@ class DeviceListener:
|
|||||||
try:
|
try:
|
||||||
await self.lounge_controller.refresh_auth()
|
await self.lounge_controller.refresh_auth()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# traceback.print_exc()
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def is_available(self):
|
async def is_available(self):
|
||||||
try:
|
try:
|
||||||
return await self.lounge_controller.is_available()
|
return await self.lounge_controller.is_available()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# traceback.print_exc()
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Main subscription loop
|
# Main subscription loop
|
||||||
@@ -60,6 +49,7 @@ class DeviceListener:
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
while not (await self.is_available()) and not self.cancelled:
|
while not (await self.is_available()) and not self.cancelled:
|
||||||
|
self.logger.debug("Waiting for device to be available")
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
try:
|
try:
|
||||||
await lounge_controller.connect()
|
await lounge_controller.connect()
|
||||||
@@ -67,6 +57,7 @@ class DeviceListener:
|
|||||||
pass
|
pass
|
||||||
while not lounge_controller.connected() and not self.cancelled:
|
while not lounge_controller.connected() and not self.cancelled:
|
||||||
# Doesn't connect to the device if it's a kids profile (it's broken)
|
# 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)
|
await asyncio.sleep(10)
|
||||||
try:
|
try:
|
||||||
await lounge_controller.connect()
|
await lounge_controller.connect()
|
||||||
@@ -76,7 +67,7 @@ class DeviceListener:
|
|||||||
"Connected to device %s (%s)", lounge_controller.screen_name, self.name
|
"Connected to device %s (%s)", lounge_controller.screen_name, self.name
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.logger.info("Subscribing to lounge")
|
self.logger.debug("Subscribing to lounge")
|
||||||
sub = await lounge_controller.subscribe_monitored(self)
|
sub = await lounge_controller.subscribe_monitored(self)
|
||||||
await sub
|
await sub
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -84,11 +75,11 @@ class DeviceListener:
|
|||||||
|
|
||||||
# Method called on playback state change
|
# Method called on playback state change
|
||||||
async def __call__(self, state):
|
async def __call__(self, state):
|
||||||
|
time_start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
self.task.cancel()
|
self.task.cancel()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
time_start = time.time()
|
|
||||||
self.task = asyncio.create_task(self.process_playstatus(state, time_start))
|
self.task = asyncio.create_task(self.process_playstatus(state, time_start))
|
||||||
|
|
||||||
# Processes the playback state change
|
# Processes the playback state change
|
||||||
@@ -97,9 +88,7 @@ class DeviceListener:
|
|||||||
if state.videoId:
|
if state.videoId:
|
||||||
segments = await self.api_helper.get_segments(state.videoId)
|
segments = await self.api_helper.get_segments(state.videoId)
|
||||||
if state.state.value == 1: # Playing
|
if state.state.value == 1: # Playing
|
||||||
self.logger.info(
|
self.logger.info("Playing video %s with %d segments", state.videoId, len(segments))
|
||||||
f"Playing video {state.videoId} with {len(segments)} segments"
|
|
||||||
)
|
|
||||||
if segments: # If there are segments
|
if segments: # If there are segments
|
||||||
await self.time_to_segment(segments, state.currentTime, time_start)
|
await self.time_to_segment(segments, state.currentTime, time_start)
|
||||||
|
|
||||||
@@ -108,28 +97,32 @@ class DeviceListener:
|
|||||||
start_next_segment = None
|
start_next_segment = None
|
||||||
next_segment = None
|
next_segment = None
|
||||||
for segment in segments:
|
for segment in segments:
|
||||||
if position < 2 and (segment["start"] <= position < segment["end"]):
|
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
|
next_segment = segment
|
||||||
start_next_segment = (
|
start_next_segment = position if is_within_start_range else segment_start
|
||||||
position # different variable so segment doesn't change
|
|
||||||
)
|
|
||||||
break
|
|
||||||
if segment["start"] > position:
|
|
||||||
next_segment = segment
|
|
||||||
start_next_segment = next_segment["start"]
|
|
||||||
break
|
break
|
||||||
if start_next_segment:
|
if start_next_segment:
|
||||||
time_to_next = (
|
time_to_next = (
|
||||||
start_next_segment - position - (time.time() - time_start) - self.offset
|
(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"])
|
await self.skip(time_to_next, next_segment["end"], next_segment["UUID"])
|
||||||
|
|
||||||
# Skips to the next segment (waits for the time to pass)
|
# Skips to the next segment (waits for the time to pass)
|
||||||
async def skip(self, time_to, position, uuids):
|
async def skip(self, time_to, position, uuids):
|
||||||
await asyncio.sleep(time_to)
|
await asyncio.sleep(time_to)
|
||||||
self.logger.info("Skipping segment: seeking to %s", position)
|
self.logger.info("Skipping segment: seeking to %s", position)
|
||||||
await asyncio.create_task(self.api_helper.mark_viewed_segments(uuids))
|
await asyncio.gather(
|
||||||
await asyncio.create_task(self.lounge_controller.seek_to(position))
|
asyncio.create_task(self.lounge_controller.seek_to(position)),
|
||||||
|
asyncio.create_task(self.api_helper.mark_viewed_segments(uuids)),
|
||||||
|
)
|
||||||
|
|
||||||
async def cancel(self):
|
async def cancel(self):
|
||||||
self.cancelled = True
|
self.cancelled = True
|
||||||
@@ -152,9 +145,7 @@ class DeviceListener:
|
|||||||
|
|
||||||
|
|
||||||
async def finish(devices, web_session, tcp_connector):
|
async def finish(devices, web_session, tcp_connector):
|
||||||
await asyncio.gather(
|
await asyncio.gather(*(device.cancel() for device in devices), return_exceptions=True)
|
||||||
*(device.cancel() for device in devices), return_exceptions=True
|
|
||||||
)
|
|
||||||
await web_session.close()
|
await web_session.close()
|
||||||
await tcp_connector.close()
|
await tcp_connector.close()
|
||||||
|
|
||||||
@@ -163,14 +154,30 @@ def handle_signal(signum, frame):
|
|||||||
raise KeyboardInterrupt()
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
|
||||||
async def main_async(config, debug):
|
async def main_async(config, debug, http_tracing):
|
||||||
loop = asyncio.get_event_loop_policy().get_event_loop()
|
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
|
tasks = [] # Save the tasks so the interpreter doesn't garbage collect them
|
||||||
devices = [] # Save the devices to close them later
|
devices = [] # Save the devices to close them later
|
||||||
if debug:
|
if debug:
|
||||||
loop.set_debug(True)
|
loop.set_debug(True)
|
||||||
|
|
||||||
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300)
|
tcp_connector = aiohttp.TCPConnector(ttl_dns_cache=300)
|
||||||
web_session = aiohttp.ClientSession(connector=tcp_connector)
|
|
||||||
|
# Configure session with tracing if enabled
|
||||||
|
if http_tracing:
|
||||||
|
root_logger = logging.getLogger("aiohttp_trace")
|
||||||
|
tracer = AiohttpTracer(root_logger)
|
||||||
|
trace_config = aiohttp.TraceConfig()
|
||||||
|
trace_config.on_request_start.append(tracer.on_request_start)
|
||||||
|
trace_config.on_response_chunk_received.append(tracer.on_response_chunk_received)
|
||||||
|
trace_config.on_request_end.append(tracer.on_request_end)
|
||||||
|
trace_config.on_request_exception.append(tracer.on_request_exception)
|
||||||
|
web_session = aiohttp.ClientSession(
|
||||||
|
trust_env=config.use_proxy, connector=tcp_connector, trace_configs=[trace_config]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
web_session = aiohttp.ClientSession(trust_env=config.use_proxy, connector=tcp_connector)
|
||||||
|
|
||||||
api_helper = api_helpers.ApiHelper(config, web_session)
|
api_helper = api_helpers.ApiHelper(config, web_session)
|
||||||
for i in config.devices:
|
for i in config.devices:
|
||||||
device = DeviceListener(api_helper, config, i, debug, web_session)
|
device = DeviceListener(api_helper, config, i, debug, web_session)
|
||||||
@@ -191,11 +198,8 @@ async def main_async(config, debug):
|
|||||||
finally:
|
finally:
|
||||||
await web_session.close()
|
await web_session.close()
|
||||||
await tcp_connector.close()
|
await tcp_connector.close()
|
||||||
loop.close()
|
|
||||||
print("Exited")
|
print("Exited")
|
||||||
|
|
||||||
|
|
||||||
def main(config, debug):
|
def main(config, debug, http_tracing):
|
||||||
loop = asyncio.get_event_loop()
|
asyncio.run(main_async(config, debug, http_tracing))
|
||||||
loop.run_until_complete(main_async(config, debug))
|
|
||||||
loop.close()
|
|
||||||
|
|||||||
@@ -383,3 +383,9 @@ MigrationScreen {
|
|||||||
padding: 1;
|
padding: 1;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Use Proxy */
|
||||||
|
#useproxy-container{
|
||||||
|
padding: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from textual.containers import (
|
|||||||
ScrollableContainer,
|
ScrollableContainer,
|
||||||
Vertical,
|
Vertical,
|
||||||
)
|
)
|
||||||
|
from textual.css.query import NoMatches
|
||||||
from textual.events import Click
|
from textual.events import Click
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.validation import Function
|
from textual.validation import Function
|
||||||
@@ -79,7 +80,7 @@ class Element(Static):
|
|||||||
self.tooltip = tooltip
|
self.tooltip = tooltip
|
||||||
|
|
||||||
def process_values_from_data(self):
|
def process_values_from_data(self):
|
||||||
pass
|
raise NotImplementedError("Subclasses must implement this method.")
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Button(
|
yield Button(
|
||||||
@@ -122,9 +123,7 @@ class Channel(Element):
|
|||||||
if "name" in self.element_data:
|
if "name" in self.element_data:
|
||||||
self.element_name = self.element_data["name"]
|
self.element_name = self.element_data["name"]
|
||||||
else:
|
else:
|
||||||
self.element_name = (
|
self.element_name = f"Unnamed channel with id {self.element_data['channel_id']}"
|
||||||
f"Unnamed channel with id {self.element_data['channel_id']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelRadio(RadioButton):
|
class ChannelRadio(RadioButton):
|
||||||
@@ -204,9 +203,7 @@ class ExitScreen(ModalWithClickExit):
|
|||||||
classes="button-100",
|
classes="button-100",
|
||||||
),
|
),
|
||||||
Button("Save", variant="success", id="exit-save", classes="button-100"),
|
Button("Save", variant="success", id="exit-save", classes="button-100"),
|
||||||
Button(
|
Button("Don't save", variant="error", id="exit-no-save", classes="button-100"),
|
||||||
"Don't save", variant="error", id="exit-no-save", classes="button-100"
|
|
||||||
),
|
|
||||||
Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"),
|
Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"),
|
||||||
id="dialog-exit",
|
id="dialog-exit",
|
||||||
)
|
)
|
||||||
@@ -229,14 +226,15 @@ class ExitScreen(ModalWithClickExit):
|
|||||||
|
|
||||||
|
|
||||||
class AddDevice(ModalWithClickExit):
|
class AddDevice(ModalWithClickExit):
|
||||||
"""Screen with a dialog to add a device, either with a pairing code or with lan discovery."""
|
"""Screen with a dialog to add a device, either with a pairing code
|
||||||
|
or with lan discovery."""
|
||||||
|
|
||||||
BINDINGS = [("escape", "dismiss({})", "Return")]
|
BINDINGS = [("escape", "dismiss({})", "Return")]
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
self.web_session = aiohttp.ClientSession()
|
self.web_session = aiohttp.ClientSession(trust_env=config.use_proxy)
|
||||||
self.api_helper = api_helpers.ApiHelper(config, self.web_session)
|
self.api_helper = api_helpers.ApiHelper(config, self.web_session)
|
||||||
self.devices_discovered_dial = []
|
self.devices_discovered_dial = []
|
||||||
|
|
||||||
@@ -254,19 +252,13 @@ class AddDevice(ModalWithClickExit):
|
|||||||
id="add-device-dial-button",
|
id="add-device-dial-button",
|
||||||
classes="button-switcher",
|
classes="button-switcher",
|
||||||
)
|
)
|
||||||
with ContentSwitcher(
|
with ContentSwitcher(id="add-device-switcher", initial="add-device-pin-container"):
|
||||||
id="add-device-switcher", initial="add-device-pin-container"
|
|
||||||
):
|
|
||||||
with Container(id="add-device-pin-container"):
|
with Container(id="add-device-pin-container"):
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder=(
|
placeholder=("Pairing Code (found in Settings - Link with TV code)"),
|
||||||
"Pairing Code (found in Settings - Link with TV code)"
|
|
||||||
),
|
|
||||||
id="pairing-code-input",
|
id="pairing-code-input",
|
||||||
validators=[
|
validators=[
|
||||||
Function(
|
Function(_validate_pairing_code, "Invalid pairing code format")
|
||||||
_validate_pairing_code, "Invalid pairing code format"
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
yield Input(
|
yield Input(
|
||||||
@@ -310,7 +302,11 @@ class AddDevice(ModalWithClickExit):
|
|||||||
|
|
||||||
async def task_discover_devices(self):
|
async def task_discover_devices(self):
|
||||||
devices_found = await self.api_helper.discover_youtube_devices_dial()
|
devices_found = await self.api_helper.discover_youtube_devices_dial()
|
||||||
list_widget: SelectionList = self.query_one("#dial-devices-list")
|
try:
|
||||||
|
list_widget: SelectionList = self.query_one("#dial-devices-list")
|
||||||
|
except NoMatches:
|
||||||
|
# The widget was not found, probably the screen was dismissed
|
||||||
|
return
|
||||||
list_widget.clear_options()
|
list_widget.clear_options()
|
||||||
if devices_found:
|
if devices_found:
|
||||||
# print(devices_found)
|
# print(devices_found)
|
||||||
@@ -331,9 +327,7 @@ class AddDevice(ModalWithClickExit):
|
|||||||
|
|
||||||
@on(Input.Changed, "#pairing-code-input")
|
@on(Input.Changed, "#pairing-code-input")
|
||||||
def changed_pairing_code(self, event: Input.Changed):
|
def changed_pairing_code(self, event: Input.Changed):
|
||||||
self.query_one(
|
self.query_one("#add-device-pin-add-button").disabled = not event.validation_result.is_valid
|
||||||
"#add-device-pin-add-button"
|
|
||||||
).disabled = not event.validation_result.is_valid
|
|
||||||
|
|
||||||
@on(Input.Submitted, "#pairing-code-input")
|
@on(Input.Submitted, "#pairing-code-input")
|
||||||
@on(Button.Pressed, "#add-device-pin-add-button")
|
@on(Button.Pressed, "#add-device-pin-add-button")
|
||||||
@@ -347,7 +341,7 @@ class AddDevice(ModalWithClickExit):
|
|||||||
pairing_code = int(
|
pairing_code = int(
|
||||||
pairing_code.replace("-", "").replace(" ", "")
|
pairing_code.replace("-", "").replace(" ", "")
|
||||||
) # remove dashes and spaces
|
) # remove dashes and spaces
|
||||||
device_name = self.parent.query_one("#device-name-input").value
|
device_name = self.query_one("#device-name-input").value
|
||||||
paired = False
|
paired = False
|
||||||
try:
|
try:
|
||||||
paired = await lounge_controller.pair(pairing_code)
|
paired = await lounge_controller.pair(pairing_code)
|
||||||
@@ -381,20 +375,19 @@ class AddDevice(ModalWithClickExit):
|
|||||||
|
|
||||||
@on(SelectionList.SelectedChanged, "#dial-devices-list")
|
@on(SelectionList.SelectedChanged, "#dial-devices-list")
|
||||||
def changed_device_list(self, event: SelectionList.SelectedChanged):
|
def changed_device_list(self, event: SelectionList.SelectedChanged):
|
||||||
self.query_one(
|
self.query_one("#add-device-dial-add-button").disabled = not event.selection_list.selected
|
||||||
"#add-device-dial-add-button"
|
|
||||||
).disabled = not event.selection_list.selected
|
|
||||||
|
|
||||||
|
|
||||||
class AddChannel(ModalWithClickExit):
|
class AddChannel(ModalWithClickExit):
|
||||||
"""Screen with a dialog to add a channel, either using search or with a channel id."""
|
"""Screen with a dialog to add a channel,
|
||||||
|
either using search or with a channel id."""
|
||||||
|
|
||||||
BINDINGS = [("escape", "dismiss(())", "Return")]
|
BINDINGS = [("escape", "dismiss(())", "Return")]
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.config = config
|
self.config = config
|
||||||
web_session = aiohttp.ClientSession()
|
web_session = aiohttp.ClientSession(trust_env=config.use_proxy)
|
||||||
self.api_helper = api_helpers.ApiHelper(config, web_session)
|
self.api_helper = api_helpers.ApiHelper(config, web_session)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
@@ -420,9 +413,7 @@ class AddChannel(ModalWithClickExit):
|
|||||||
classes="button-switcher",
|
classes="button-switcher",
|
||||||
)
|
)
|
||||||
yield Label(id="add-channel-info", classes="subtitle")
|
yield Label(id="add-channel-info", classes="subtitle")
|
||||||
with ContentSwitcher(
|
with ContentSwitcher(id="add-channel-switcher", initial="add-channel-search-container"):
|
||||||
id="add-channel-switcher", initial="add-channel-search-container"
|
|
||||||
):
|
|
||||||
with Vertical(id="add-channel-search-container"):
|
with Vertical(id="add-channel-search-container"):
|
||||||
if self.config.apikey:
|
if self.config.apikey:
|
||||||
with Grid(id="add-channel-search-inputs"):
|
with Grid(id="add-channel-search-inputs"):
|
||||||
@@ -430,9 +421,7 @@ class AddChannel(ModalWithClickExit):
|
|||||||
placeholder="Enter channel name",
|
placeholder="Enter channel name",
|
||||||
id="channel-name-input-search",
|
id="channel-name-input-search",
|
||||||
)
|
)
|
||||||
yield Button(
|
yield Button("Search", id="search-channel-button", variant="success")
|
||||||
"Search", id="search-channel-button", variant="success"
|
|
||||||
)
|
|
||||||
yield RadioSet(
|
yield RadioSet(
|
||||||
RadioButton(label="Search to see results", disabled=True),
|
RadioButton(label="Search to see results", disabled=True),
|
||||||
id="channel-search-results",
|
id="channel-search-results",
|
||||||
@@ -455,15 +444,12 @@ class AddChannel(ModalWithClickExit):
|
|||||||
)
|
)
|
||||||
with Vertical(id="add-channel-id-container"):
|
with Vertical(id="add-channel-id-container"):
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder=(
|
placeholder=("Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"),
|
||||||
"Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)"
|
|
||||||
),
|
|
||||||
id="channel-id-input",
|
id="channel-id-input",
|
||||||
)
|
)
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder=(
|
placeholder=(
|
||||||
"Enter channel name (only used to display in the config"
|
"Enter channel name (only used to display in the config file)"
|
||||||
" file)"
|
|
||||||
),
|
),
|
||||||
id="channel-name-input-id",
|
id="channel-name-input-id",
|
||||||
)
|
)
|
||||||
@@ -489,9 +475,7 @@ class AddChannel(ModalWithClickExit):
|
|||||||
async def handle_search_channel(self) -> None:
|
async def handle_search_channel(self) -> None:
|
||||||
channel_name = self.query_one("#channel-name-input-search").value
|
channel_name = self.query_one("#channel-name-input-search").value
|
||||||
if not channel_name:
|
if not channel_name:
|
||||||
self.query_one("#add-channel-info").update(
|
self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel name")
|
||||||
"[#ff0000]Please enter a channel name"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
self.query_one("#search-channel-button").disabled = True
|
self.query_one("#search-channel-button").disabled = True
|
||||||
self.query_one("#add-channel-info").update("Searching...")
|
self.query_one("#add-channel-info").update("Searching...")
|
||||||
@@ -500,9 +484,7 @@ class AddChannel(ModalWithClickExit):
|
|||||||
try:
|
try:
|
||||||
channels_list = await self.api_helper.search_channels(channel_name)
|
channels_list = await self.api_helper.search_channels(channel_name)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self.query_one("#add-channel-info").update(
|
self.query_one("#add-channel-info").update("[#ff0000]Failed to search for channel")
|
||||||
"[#ff0000]Failed to search for channel"
|
|
||||||
)
|
|
||||||
self.query_one("#search-channel-button").disabled = False
|
self.query_one("#search-channel-button").disabled = False
|
||||||
return
|
return
|
||||||
for i in channels_list:
|
for i in channels_list:
|
||||||
@@ -515,9 +497,7 @@ class AddChannel(ModalWithClickExit):
|
|||||||
def handle_add_channel_search(self) -> None:
|
def handle_add_channel_search(self) -> None:
|
||||||
channel = self.query_one("#channel-search-results").pressed_button.channel_data
|
channel = self.query_one("#channel-search-results").pressed_button.channel_data
|
||||||
if not channel:
|
if not channel:
|
||||||
self.query_one("#add-channel-info").update(
|
self.query_one("#add-channel-info").update("[#ff0000]Please select a channel")
|
||||||
"[#ff0000]Please select a channel"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
self.query_one("#add-channel-info").update("Adding...")
|
self.query_one("#add-channel-info").update("Adding...")
|
||||||
self.dismiss(channel)
|
self.dismiss(channel)
|
||||||
@@ -529,9 +509,7 @@ class AddChannel(ModalWithClickExit):
|
|||||||
channel_id = self.query_one("#channel-id-input").value
|
channel_id = self.query_one("#channel-id-input").value
|
||||||
channel_name = self.query_one("#channel-name-input-id").value
|
channel_name = self.query_one("#channel-name-input-id").value
|
||||||
if not channel_id:
|
if not channel_id:
|
||||||
self.query_one("#add-channel-info").update(
|
self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel ID")
|
||||||
"[#ff0000]Please enter a channel ID"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
if not channel_name:
|
if not channel_name:
|
||||||
channel_name = channel_id
|
channel_name = channel_id
|
||||||
@@ -553,7 +531,7 @@ class EditDevice(ModalWithClickExit):
|
|||||||
def action_close_screen_saving(self) -> None:
|
def action_close_screen_saving(self) -> None:
|
||||||
self.dismiss()
|
self.dismiss()
|
||||||
|
|
||||||
def dismiss(self) -> None:
|
def dismiss(self, _=None) -> None:
|
||||||
self.device_data["name"] = self.query_one("#device-name-input").value
|
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["screen_id"] = self.query_one("#device-id-input").value
|
||||||
self.device_data["offset"] = int(self.query_one("#device-offset-input").value)
|
self.device_data["offset"] = int(self.query_one("#device-offset-input").value)
|
||||||
@@ -622,9 +600,7 @@ class DevicesManager(Vertical):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Label("Devices", classes="title")
|
yield Label("Devices", classes="title")
|
||||||
with Horizontal(id="add-device-button-container"):
|
with Horizontal(id="add-device-button-container"):
|
||||||
yield Button(
|
yield Button("Add Device", id="add-device", classes="button-100 button-small")
|
||||||
"Add Device", id="add-device", classes="button-100 button-small"
|
|
||||||
)
|
|
||||||
for device in self.devices:
|
for device in self.devices:
|
||||||
yield Device(device, tooltip="Click to edit")
|
yield Device(device, tooltip="Click to edit")
|
||||||
|
|
||||||
@@ -669,7 +645,7 @@ class ApiKeyManager(Vertical):
|
|||||||
yield Label("YouTube Api Key", classes="title")
|
yield Label("YouTube Api Key", classes="title")
|
||||||
yield Label(
|
yield Label(
|
||||||
"You can get a YouTube Data API v3 Key from the"
|
"You can get a YouTube Data API v3 Key from the"
|
||||||
" [link=https://console.developers.google.com/apis/credentials]Google Cloud"
|
" [link='https://console.developers.google.com/apis/credentials']Google Cloud"
|
||||||
" Console[/link]. This key is only required if you're whitelisting"
|
" Console[/link]. This key is only required if you're whitelisting"
|
||||||
" channels."
|
" channels."
|
||||||
)
|
)
|
||||||
@@ -688,7 +664,7 @@ class ApiKeyManager(Vertical):
|
|||||||
|
|
||||||
@on(Button.Pressed, "#api-key-view")
|
@on(Button.Pressed, "#api-key-view")
|
||||||
def pressed_api_key_view(self, event: Button.Pressed):
|
def pressed_api_key_view(self, event: Button.Pressed):
|
||||||
if "Show" in event.button.label:
|
if "Show" in str(event.button.label):
|
||||||
event.button.label = "Hide key"
|
event.button.label = "Hide key"
|
||||||
self.query_one("#api-key-input").password = False
|
self.query_one("#api-key-input").password = False
|
||||||
else:
|
else:
|
||||||
@@ -721,6 +697,43 @@ class SkipCategoriesManager(Vertical):
|
|||||||
self.config.skip_categories = event.selection_list.selected
|
self.config.skip_categories = event.selection_list.selected
|
||||||
|
|
||||||
|
|
||||||
|
class MinimumSkipLengthManager(Vertical):
|
||||||
|
"""Manager for minimum skip length setting."""
|
||||||
|
|
||||||
|
def __init__(self, config, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("Minimum Skip Length", classes="title")
|
||||||
|
yield Label(
|
||||||
|
(
|
||||||
|
"Specify the minimum length a segment must meet in order to skip "
|
||||||
|
"it (in seconds). Default is 1 second; entering 0 will skip all "
|
||||||
|
"segments."
|
||||||
|
),
|
||||||
|
classes="subtitle",
|
||||||
|
)
|
||||||
|
yield Input(
|
||||||
|
placeholder="Minimum skip length (0 to skip all)",
|
||||||
|
id="minimum-skip-length-input",
|
||||||
|
value=str(getattr(self.config, "minimum_skip_length", 1)),
|
||||||
|
validators=[
|
||||||
|
Function(
|
||||||
|
lambda user_input: user_input.isdigit(),
|
||||||
|
"Please enter a valid non-negative number",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@on(Input.Changed, "#minimum-skip-length-input")
|
||||||
|
def changed_minimum_skip_length(self, event: Input.Changed):
|
||||||
|
try:
|
||||||
|
self.config.minimum_skip_length = int(event.input.value)
|
||||||
|
except ValueError:
|
||||||
|
self.config.minimum_skip_length = 1
|
||||||
|
|
||||||
|
|
||||||
class SkipCountTrackingManager(Vertical):
|
class SkipCountTrackingManager(Vertical):
|
||||||
"""Manager for skip count tracking, allows to enable/disable skip count tracking."""
|
"""Manager for skip count tracking, allows to enable/disable skip count tracking."""
|
||||||
|
|
||||||
@@ -793,7 +806,8 @@ class AdSkipMuteManager(Vertical):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelWhitelistManager(Vertical):
|
class ChannelWhitelistManager(Vertical):
|
||||||
"""Manager for channel whitelist, allows adding/removing channels from the whitelist."""
|
"""Manager for channel whitelist,
|
||||||
|
allows adding/removing channels from the whitelist."""
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@@ -811,23 +825,18 @@ class ChannelWhitelistManager(Vertical):
|
|||||||
id="channel-whitelist-subtitle",
|
id="channel-whitelist-subtitle",
|
||||||
)
|
)
|
||||||
yield Label(
|
yield Label(
|
||||||
(
|
("⚠️ [#FF0000]You need to set your YouTube Api Key in order to use this feature"),
|
||||||
":warning: [#FF0000]You need to set your YouTube Api Key in order to"
|
|
||||||
" use this feature"
|
|
||||||
),
|
|
||||||
id="warning-no-key",
|
id="warning-no-key",
|
||||||
)
|
)
|
||||||
with Horizontal(id="add-channel-button-container"):
|
with Horizontal(id="add-channel-button-container"):
|
||||||
yield Button(
|
yield Button("Add Channel", id="add-channel", classes="button-100 button-small")
|
||||||
"Add Channel", id="add-channel", classes="button-100 button-small"
|
|
||||||
)
|
|
||||||
for channel in self.config.channel_whitelist:
|
for channel in self.config.channel_whitelist:
|
||||||
yield Channel(channel)
|
yield Channel(channel)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.app.query_one("#warning-no-key").display = (
|
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
|
||||||
not self.config.apikey
|
self.config.channel_whitelist
|
||||||
) and bool(self.config.channel_whitelist)
|
)
|
||||||
|
|
||||||
def new_channel(self, channel: tuple) -> None:
|
def new_channel(self, channel: tuple) -> None:
|
||||||
if channel:
|
if channel:
|
||||||
@@ -839,18 +848,18 @@ class ChannelWhitelistManager(Vertical):
|
|||||||
channel_widget = Channel(channel_dict)
|
channel_widget = Channel(channel_dict)
|
||||||
self.mount(channel_widget)
|
self.mount(channel_widget)
|
||||||
channel_widget.focus(scroll_visible=True)
|
channel_widget.focus(scroll_visible=True)
|
||||||
self.app.query_one("#warning-no-key").display = (
|
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
|
||||||
not self.config.apikey
|
self.config.channel_whitelist
|
||||||
) and bool(self.config.channel_whitelist)
|
)
|
||||||
|
|
||||||
@on(Button.Pressed, "#element-remove")
|
@on(Button.Pressed, "#element-remove")
|
||||||
def remove_channel(self, event: Button.Pressed):
|
def remove_channel(self, event: Button.Pressed):
|
||||||
channel_to_remove: Element = event.button.parent
|
channel_to_remove: Element = event.button.parent
|
||||||
self.config.channel_whitelist.remove(channel_to_remove.element_data)
|
self.config.channel_whitelist.remove(channel_to_remove.element_data)
|
||||||
channel_to_remove.remove()
|
channel_to_remove.remove()
|
||||||
self.app.query_one("#warning-no-key").display = (
|
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
|
||||||
not self.config.apikey
|
self.config.channel_whitelist
|
||||||
) and bool(self.config.channel_whitelist)
|
)
|
||||||
|
|
||||||
@on(Button.Pressed, "#add-channel")
|
@on(Button.Pressed, "#add-channel")
|
||||||
def add_channel(self, event: Button.Pressed):
|
def add_channel(self, event: Button.Pressed):
|
||||||
@@ -883,13 +892,45 @@ class AutoPlayManager(Vertical):
|
|||||||
self.config.auto_play = event.checkbox.value
|
self.config.auto_play = event.checkbox.value
|
||||||
|
|
||||||
|
|
||||||
class ISponsorBlockTVSetupMainScreen(Screen):
|
class UseProxyManager(Vertical):
|
||||||
"""Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
|
"""Manager for proxy use, allows enabling/disabling use of proxy."""
|
||||||
|
|
||||||
|
def __init__(self, config, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("Use proxy", classes="title")
|
||||||
|
yield Label(
|
||||||
|
"This feature allows application to use system proxy,"
|
||||||
|
" if it is set in environment variables."
|
||||||
|
" This parameter will be passed in all [i]aiohttp.ClientSession[/i]"
|
||||||
|
' calls. For further information, see "[i]trust_env[/i]" section at'
|
||||||
|
" [link='https://docs.aiohttp.org/en/stable/client_reference.html']"
|
||||||
|
"aiohttp documentation[/link].",
|
||||||
|
classes="subtitle",
|
||||||
|
id="useproxy-subtitle",
|
||||||
|
)
|
||||||
|
with Horizontal(id="useproxy-container"):
|
||||||
|
yield Checkbox(
|
||||||
|
value=self.config.use_proxy,
|
||||||
|
id="useproxy-switch",
|
||||||
|
label="Use proxy",
|
||||||
|
)
|
||||||
|
|
||||||
|
@on(Checkbox.Changed, "#useproxy-switch")
|
||||||
|
def changed_skip(self, event: Checkbox.Changed):
|
||||||
|
self.config.use_proxy = event.checkbox.value
|
||||||
|
|
||||||
|
|
||||||
|
class ISponsorBlockTVSetup(App):
|
||||||
TITLE = "iSponsorBlockTV"
|
TITLE = "iSponsorBlockTV"
|
||||||
SUB_TITLE = "Setup Wizard"
|
SUB_TITLE = "Setup Wizard"
|
||||||
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
|
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
|
||||||
AUTO_FOCUS = None
|
AUTO_FOCUS = None
|
||||||
|
CSS_PATH = ( # tcss is the recommended extension for textual css files
|
||||||
|
"setup-wizard-style.tcss"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
def __init__(self, config, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@@ -901,12 +942,15 @@ class ISponsorBlockTVSetupMainScreen(Screen):
|
|||||||
yield Header()
|
yield Header()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
with ScrollableContainer(id="setup-wizard"):
|
with ScrollableContainer(id="setup-wizard"):
|
||||||
yield DevicesManager(
|
yield DevicesManager(config=self.config, id="devices-manager", classes="container")
|
||||||
config=self.config, id="devices-manager", classes="container"
|
|
||||||
)
|
|
||||||
yield SkipCategoriesManager(
|
yield SkipCategoriesManager(
|
||||||
config=self.config, id="skip-categories-manager", classes="container"
|
config=self.config, id="skip-categories-manager", classes="container"
|
||||||
)
|
)
|
||||||
|
yield MinimumSkipLengthManager(
|
||||||
|
config=self.config,
|
||||||
|
id="minimum-skip-length-manager",
|
||||||
|
classes="container",
|
||||||
|
)
|
||||||
yield SkipCountTrackingManager(
|
yield SkipCountTrackingManager(
|
||||||
config=self.config, id="count-segments-manager", classes="container"
|
config=self.config, id="count-segments-manager", classes="container"
|
||||||
)
|
)
|
||||||
@@ -916,17 +960,13 @@ class ISponsorBlockTVSetupMainScreen(Screen):
|
|||||||
yield ChannelWhitelistManager(
|
yield ChannelWhitelistManager(
|
||||||
config=self.config, id="channel-whitelist-manager", classes="container"
|
config=self.config, id="channel-whitelist-manager", classes="container"
|
||||||
)
|
)
|
||||||
yield ApiKeyManager(
|
yield ApiKeyManager(config=self.config, id="api-key-manager", classes="container")
|
||||||
config=self.config, id="api-key-manager", classes="container"
|
yield AutoPlayManager(config=self.config, id="autoplay-manager", classes="container")
|
||||||
)
|
yield UseProxyManager(config=self.config, id="useproxy-manager", classes="container")
|
||||||
yield AutoPlayManager(
|
|
||||||
config=self.config, id="autoplay-manager", classes="container"
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
if self.check_for_old_config_entries():
|
if self.check_for_old_config_entries():
|
||||||
self.app.push_screen(MigrationScreen())
|
self.app.push_screen(MigrationScreen())
|
||||||
pass
|
|
||||||
|
|
||||||
def action_save(self) -> None:
|
def action_save(self) -> None:
|
||||||
self.config.save()
|
self.config.save()
|
||||||
@@ -946,36 +986,13 @@ class ISponsorBlockTVSetupMainScreen(Screen):
|
|||||||
@on(Input.Changed, "#api-key-input")
|
@on(Input.Changed, "#api-key-input")
|
||||||
def changed_api_key(self, event: Input.Changed):
|
def changed_api_key(self, event: Input.Changed):
|
||||||
try: # ChannelWhitelist might not be mounted
|
try: # ChannelWhitelist might not be mounted
|
||||||
# Show if no api key is set and at least one channel is in the whitelist
|
self.app.query_one("#warning-no-key").display = bool(
|
||||||
self.app.query_one("#warning-no-key").display = (
|
(not event.input.value) and self.config.channel_whitelist
|
||||||
not event.input.value
|
)
|
||||||
) and self.config.channel_whitelist
|
except NoMatches:
|
||||||
except BaseException:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ISponsorBlockTVSetup(App):
|
|
||||||
CSS_PATH = ( # tcss is the recommended extension for textual css files
|
|
||||||
"setup-wizard-style.tcss"
|
|
||||||
)
|
|
||||||
# Bindings for the whole app here, so they are available in all screens
|
|
||||||
BINDINGS = [("q,ctrl+c", "exit_modal", "Exit"), ("s", "save", "Save")]
|
|
||||||
|
|
||||||
def __init__(self, config, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.config = config
|
|
||||||
self.main_screen = ISponsorBlockTVSetupMainScreen(config=self.config)
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.push_screen(self.main_screen)
|
|
||||||
|
|
||||||
def action_save(self) -> None:
|
|
||||||
self.main_screen.action_save()
|
|
||||||
|
|
||||||
def action_exit_modal(self) -> None:
|
|
||||||
self.main_screen.action_exit_modal()
|
|
||||||
|
|
||||||
|
|
||||||
def main(config):
|
def main(config):
|
||||||
app = ISponsorBlockTVSetup(config)
|
app = ISponsorBlockTVSetup(config)
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
import pyytlounge
|
import pyytlounge
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
from pyytlounge.wrapper import NotLinkedException, api_base, as_aiter, Dict
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from .constants import youtube_client_blacklist
|
from .constants import youtube_client_blacklist
|
||||||
|
|
||||||
create_task = asyncio.create_task
|
create_task = asyncio.create_task
|
||||||
@@ -18,19 +22,20 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|||||||
api_helper=None,
|
api_helper=None,
|
||||||
logger=None,
|
logger=None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(config.join_name if config else "iSponsorBlockTV", logger=logger)
|
||||||
config.join_name if config else "iSponsorBlockTV", logger=logger
|
|
||||||
)
|
|
||||||
self.auth.screen_id = screen_id
|
self.auth.screen_id = screen_id
|
||||||
self.auth.lounge_id_token = None
|
self.auth.lounge_id_token = None
|
||||||
self.api_helper = api_helper
|
self.api_helper = api_helper
|
||||||
self.volume_state = {}
|
self.volume_state = {}
|
||||||
|
self.playback_speed = 1.0
|
||||||
self.subscribe_task = None
|
self.subscribe_task = None
|
||||||
self.subscribe_task_watchdog = None
|
self.subscribe_task_watchdog = None
|
||||||
self.callback = None
|
self.callback = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.shorts_disconnected = False
|
self.shorts_disconnected = False
|
||||||
self.auto_play = True
|
self.auto_play = True
|
||||||
|
self.watchdog_running = False
|
||||||
|
self.last_event_time = 0
|
||||||
if config:
|
if config:
|
||||||
self.mute_ads = config.mute_ads
|
self.mute_ads = config.mute_ads
|
||||||
self.skip_ads = config.skip_ads
|
self.skip_ads = config.skip_ads
|
||||||
@@ -39,40 +44,73 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|||||||
|
|
||||||
# Ensures that we still are subscribed to the lounge
|
# Ensures that we still are subscribed to the lounge
|
||||||
async def _watchdog(self):
|
async def _watchdog(self):
|
||||||
await asyncio.sleep(
|
"""
|
||||||
35
|
Continuous watchdog that monitors for connection health.
|
||||||
) # YouTube sends at least a message every 30 seconds (no-op or any other)
|
If no events are received within the expected timeframe,
|
||||||
|
it cancels the current subscription.
|
||||||
|
"""
|
||||||
|
self.watchdog_running = True
|
||||||
|
self.last_event_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.subscribe_task.cancel()
|
while self.watchdog_running:
|
||||||
except BaseException:
|
await asyncio.sleep(10)
|
||||||
pass
|
current_time = asyncio.get_event_loop().time()
|
||||||
|
time_since_last_event = current_time - self.last_event_time
|
||||||
|
|
||||||
|
# YouTube sends a message at least every 30 seconds
|
||||||
|
if time_since_last_event > 60:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Watchdog triggered: No events for {time_since_last_event:.1f} seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel current subscription
|
||||||
|
if self.subscribe_task and not self.subscribe_task.done():
|
||||||
|
self.subscribe_task.cancel()
|
||||||
|
await asyncio.sleep(1) # Give it time to cancel
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.logger.debug("Watchdog task cancelled")
|
||||||
|
self.watchdog_running = False
|
||||||
|
except BaseException as e:
|
||||||
|
self.logger.error(f"Watchdog error: {e}")
|
||||||
|
self.watchdog_running = False
|
||||||
|
|
||||||
# Subscribe to the lounge and start the watchdog
|
# Subscribe to the lounge and start the watchdog
|
||||||
async def subscribe_monitored(self, callback):
|
async def subscribe_monitored(self, callback):
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
try:
|
|
||||||
|
# Stop existing watchdog if running
|
||||||
|
if self.subscribe_task_watchdog and not self.subscribe_task_watchdog.done():
|
||||||
|
self.watchdog_running = False
|
||||||
self.subscribe_task_watchdog.cancel()
|
self.subscribe_task_watchdog.cancel()
|
||||||
except BaseException:
|
try:
|
||||||
pass # No watchdog task
|
await self.subscribe_task_watchdog
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Start new subscription
|
||||||
|
if self.subscribe_task and not self.subscribe_task.done():
|
||||||
|
self.subscribe_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.subscribe_task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
|
self.subscribe_task = asyncio.create_task(super().subscribe(callback))
|
||||||
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
|
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
|
||||||
create_task(self.debug_command("bugchomp "))
|
|
||||||
return self.subscribe_task
|
return self.subscribe_task
|
||||||
|
|
||||||
# Process a lounge subscription event
|
# Process a lounge subscription event
|
||||||
|
# skipcq: PY-R1000
|
||||||
def _process_event(self, event_type: str, args: List[Any]):
|
def _process_event(self, event_type: str, args: List[Any]):
|
||||||
self.logger.debug(f"process_event({event_type}, {args})")
|
self.logger.debug(f"process_event({event_type}, {args})")
|
||||||
# (Re)start the watchdog
|
# Update last event time for the watchdog
|
||||||
try:
|
self.last_event_time = asyncio.get_event_loop().time()
|
||||||
self.subscribe_task_watchdog.cancel()
|
|
||||||
except BaseException:
|
# A bunch of events useful to detect ads playing,
|
||||||
pass
|
# and the next video before it starts playing
|
||||||
finally:
|
# (that way we can get the segments)
|
||||||
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
|
|
||||||
# A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we
|
|
||||||
# can get the segments)
|
|
||||||
if event_type == "onStateChange":
|
if event_type == "onStateChange":
|
||||||
create_task(self.debug_command("exp 0 "))
|
|
||||||
data = args[0]
|
data = args[0]
|
||||||
# print(data)
|
# print(data)
|
||||||
# Unmute when the video starts playing
|
# Unmute when the video starts playing
|
||||||
@@ -86,7 +124,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|||||||
create_task(self.mute(False, override=True))
|
create_task(self.mute(False, override=True))
|
||||||
elif event_type == "onAdStateChange":
|
elif event_type == "onAdStateChange":
|
||||||
data = args[0]
|
data = args[0]
|
||||||
if data["adState"] == "0": # Ad is not playing
|
if data["adState"] == "0" and data["currentTime"] != "0": # Ad is not playing
|
||||||
self.logger.info("Ad has ended, unmuting")
|
self.logger.info("Ad has ended, unmuting")
|
||||||
create_task(self.mute(False, override=True))
|
create_task(self.mute(False, override=True))
|
||||||
elif (
|
elif (
|
||||||
@@ -95,20 +133,16 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|||||||
self.logger.info("Ad can be skipped, skipping")
|
self.logger.info("Ad can be skipped, skipping")
|
||||||
create_task(self.skip_ad())
|
create_task(self.skip_ad())
|
||||||
create_task(self.mute(False, override=True))
|
create_task(self.mute(False, override=True))
|
||||||
elif (
|
elif self.mute_ads: # Seen multiple other adStates, assuming they are all ads
|
||||||
self.mute_ads
|
|
||||||
): # Seen multiple other adStates, assuming they are all ads
|
|
||||||
self.logger.info("Ad has started, muting")
|
self.logger.info("Ad has started, muting")
|
||||||
create_task(self.mute(True, override=True))
|
create_task(self.mute(True, override=True))
|
||||||
# Manages volume, useful since YouTube wants to know the volume when unmuting (even if they already have it)
|
# Manages volume, useful since YouTube wants to know the volume
|
||||||
|
# when unmuting (even if they already have it)
|
||||||
elif event_type == "onVolumeChanged":
|
elif event_type == "onVolumeChanged":
|
||||||
self.volume_state = args[0]
|
self.volume_state = args[0]
|
||||||
pass
|
|
||||||
# Gets segments for the next video before it starts playing
|
# Gets segments for the next video before it starts playing
|
||||||
elif event_type == "autoplayUpNext":
|
elif event_type == "autoplayUpNext":
|
||||||
if len(args) > 0 and (
|
if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty
|
||||||
vid_id := args[0]["videoId"]
|
|
||||||
): # if video id is not empty
|
|
||||||
self.logger.info(f"Getting segments for next video: {vid_id}")
|
self.logger.info(f"Getting segments for next video: {vid_id}")
|
||||||
create_task(self.api_helper.get_segments(vid_id))
|
create_task(self.api_helper.get_segments(vid_id))
|
||||||
|
|
||||||
@@ -119,15 +153,14 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|||||||
if vid_id := data["contentVideoId"]:
|
if vid_id := data["contentVideoId"]:
|
||||||
self.logger.info(f"Getting segments for next video: {vid_id}")
|
self.logger.info(f"Getting segments for next video: {vid_id}")
|
||||||
create_task(self.api_helper.get_segments(vid_id))
|
create_task(self.api_helper.get_segments(vid_id))
|
||||||
elif (
|
|
||||||
|
if (
|
||||||
self.skip_ads and data["isSkipEnabled"] == "true"
|
self.skip_ads and data["isSkipEnabled"] == "true"
|
||||||
): # YouTube uses strings for booleans
|
): # YouTube uses strings for booleans
|
||||||
self.logger.info("Ad can be skipped, skipping")
|
self.logger.info("Ad can be skipped, skipping")
|
||||||
create_task(self.skip_ad())
|
create_task(self.skip_ad())
|
||||||
create_task(self.mute(False, override=True))
|
create_task(self.mute(False, override=True))
|
||||||
elif (
|
elif self.mute_ads: # Seen multiple other adStates, assuming they are all ads
|
||||||
self.mute_ads
|
|
||||||
): # Seen multiple other adStates, assuming they are all ads
|
|
||||||
self.logger.info("Ad has started, muting")
|
self.logger.info("Ad has started, muting")
|
||||||
create_task(self.mute(True, override=True))
|
create_task(self.mute(True, override=True))
|
||||||
|
|
||||||
@@ -150,51 +183,55 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|||||||
elif event_type == "loungeScreenDisconnected":
|
elif event_type == "loungeScreenDisconnected":
|
||||||
if args: # Sometimes it's empty
|
if args: # Sometimes it's empty
|
||||||
data = args[0]
|
data = args[0]
|
||||||
if (
|
if data["reason"] == "disconnectedByUserScreenInitiated": # Short playing?
|
||||||
data["reason"] == "disconnectedByUserScreenInitiated"
|
|
||||||
): # Short playing?
|
|
||||||
self.shorts_disconnected = True
|
self.shorts_disconnected = True
|
||||||
elif event_type == "onAutoplayModeChanged":
|
elif event_type == "onAutoplayModeChanged":
|
||||||
create_task(self.set_auto_play_mode(self.auto_play))
|
create_task(self.set_auto_play_mode(self.auto_play))
|
||||||
|
|
||||||
|
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)
|
super()._process_event(event_type, args)
|
||||||
|
|
||||||
# Set the volume to a specific value (0-100)
|
# Set the volume to a specific value (0-100)
|
||||||
async def set_volume(self, volume: int) -> None:
|
async def set_volume(self, volume: int) -> None:
|
||||||
await super()._command("setVolume", {"volume": volume})
|
await self._command("setVolume", {"volume": volume})
|
||||||
|
|
||||||
# Mute or unmute the device (if the device already is in the desired state, nothing happens)
|
|
||||||
# mute: True to mute, False to unmute
|
|
||||||
# override: If True, the command is sent even if the device already is in the desired state
|
|
||||||
# TODO: Only works if the device is subscribed to the lounge
|
|
||||||
async def mute(self, mute: bool, override: bool = False) -> None:
|
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:
|
if mute:
|
||||||
mute_str = "true"
|
mute_str = "true"
|
||||||
else:
|
else:
|
||||||
mute_str = "false"
|
mute_str = "false"
|
||||||
if override or not (self.volume_state.get("muted", "false") == mute_str):
|
if override or not self.volume_state.get("muted", "false") == mute_str:
|
||||||
self.volume_state["muted"] = mute_str
|
self.volume_state["muted"] = mute_str
|
||||||
# YouTube wants the volume when unmuting, so we send it
|
# YouTube wants the volume when unmuting, so we send it
|
||||||
await super()._command(
|
await self._command(
|
||||||
"setVolume",
|
"setVolume",
|
||||||
{"volume": self.volume_state.get("volume", 100), "muted": mute_str},
|
{"volume": self.volume_state.get("volume", 100), "muted": mute_str},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_auto_play_mode(self, enabled: bool):
|
|
||||||
await super()._command(
|
|
||||||
"setAutoplayMode", {"autoplayMode": "ENABLED" if enabled else "DISABLED"}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def play_video(self, video_id: str) -> bool:
|
async def play_video(self, video_id: str) -> bool:
|
||||||
return await self._command("setPlaylist", {"videoId": video_id})
|
return await self._command("setPlaylist", {"videoId": video_id})
|
||||||
|
|
||||||
|
async def get_now_playing(self):
|
||||||
|
return await self._command("getNowPlaying")
|
||||||
|
|
||||||
# Test to wrap the command function in a mutex to avoid race conditions with
|
# Test to wrap the command function in a mutex to avoid race conditions with
|
||||||
# the _command_offset (TODO: move to upstream if it works)
|
# the _command_offset (TODO: move to upstream if it works)
|
||||||
async def _command(self, command: str, command_parameters: dict = None) -> bool:
|
async def _command(self, command: str, command_parameters: dict = None) -> bool:
|
||||||
async with self._command_mutex:
|
async with self._command_mutex:
|
||||||
self.logger.debug(
|
|
||||||
f"Send command: {command}, Parameters: {command_parameters}"
|
|
||||||
)
|
|
||||||
return await super()._command(command, command_parameters)
|
return await super()._command(command, command_parameters)
|
||||||
|
|
||||||
async def change_web_session(self, web_session: ClientSession):
|
async def change_web_session(self, web_session: ClientSession):
|
||||||
@@ -203,9 +240,110 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
|
|||||||
if self.conn is not None:
|
if self.conn is not None:
|
||||||
await self.conn.close()
|
await self.conn.close()
|
||||||
self.session = web_session
|
self.session = web_session
|
||||||
|
|
||||||
async def debug_command(self, debug_command: str):
|
def _common_connection_parameters(self) -> Dict[str, Any]:
|
||||||
await super()._command(
|
return {
|
||||||
"sendDebugCommand",
|
"name": self.device_name,
|
||||||
{"debugCommand": debug_command},
|
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user