Merge branch 'main' into pr/tsia/76

This commit is contained in:
dmunozv04
2023-11-29 09:54:45 +01:00
12 changed files with 167 additions and 73 deletions

9
.dockerignore Normal file
View File

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

View File

@@ -22,15 +22,16 @@ Open an issue/pull request if you have tested a device that isn't listed here.
| Chromecast | ❔ |
| Google TV | ✅ |
| Roku | ✅ |
| Fire TV | |
| Fire TV | |
| CCwGTV | ✅ |
| Nintendo Switch | ✅ |
| Xbox One/Series | |
| Xbox One/Series | |
| Playstation 4/5 | ✅ |
## Usage
Run iSponsorBlockTV on a computer that has network access.
Auto discovery will require the computer to be on the same network as the device during setup.
The device can also be manually added to iSponsorBlockTV with a YouTube TV code. This code can be found in the settings page of your YouTube application.
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.

View File

@@ -1,8 +1,7 @@
from cache import AsyncTTL, AsyncLRU
from cache import AsyncLRU
from .conditional_ttl_cache import AsyncConditionalTTL
from . import constants, dial_client
from hashlib import sha256
from asyncio import create_task
from aiohttp import ClientSession
import html
@@ -39,17 +38,17 @@ class ApiHelper:
return
for i in data["items"]:
if (i["id"]["kind"] != "youtube#video"):
if i["id"]["kind"] != "youtube#video":
continue
title_api = html.unescape(i["snippet"]["title"])
artist_api = html.unescape(i["snippet"]["channelTitle"])
if title_api == title and artist_api == artist:
return (i["id"]["videoId"], i["snippet"]["channelId"])
return i["id"]["videoId"], i["snippet"]["channelId"]
return
@AsyncLRU(maxsize=100)
async def is_whitelisted(self, vid_id):
if (self.apikey and self.channel_whitelist):
if self.apikey and self.channel_whitelist:
channel_id = await self.__get_channel_id(vid_id)
# check if channel id is in whitelist
for i in self.channel_whitelist:
@@ -66,7 +65,7 @@ class ApiHelper:
if "error" in data:
return
data = data["items"][0]
if (data["kind"] != "youtube#video"):
if data["kind"] != "youtube#video":
return
return data["snippet"]["channelId"]
@@ -113,11 +112,21 @@ class ApiHelper:
headers = {"Accept": "application/json"}
url = constants.SponsorBlock_api + "skipSegments/" + vid_id_hashed
async with self.web_session.get(url, headers=headers, params=params) as response:
response = await response.json()
for i in response:
response_json = await response.json()
if response.status != 200:
response_text = await response.text()
print(
f"Error getting segments for video {vid_id}, hashed as {vid_id_hashed}. "
f"Code: {response.status} - {response_text}")
return ([], True)
for i in response_json:
if str(i["videoID"]) == str(vid_id):
response = i
response_json = i
break
return self.process_segments(response_json)
@staticmethod
def process_segments(response):
segments = []
ignore_ttl = True
try:
@@ -146,7 +155,8 @@ class ApiHelper:
return (segments, ignore_ttl)
async def mark_viewed_segments(self, UUID):
"""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)"""
"""Marks the segments as viewed in the SponsorBlock API, if skip_count_tracking is enabled.
Lets the contributor know that someone skipped the segment (thanks)"""
if self.skip_count_tracking:
for i in UUID:
url = constants.SponsorBlock_api + "viewedVideoSponsorTime/"

View File

@@ -1,14 +1,13 @@
import json
import asyncio
import sys
import aiohttp
from . import api_helpers, ytlounge
async def pair_device(loop):
async def pair_device():
try:
lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV")
pairing_code = input("Enter pairing code (found in Settings - Link with TV code): ")
pairing_code = int(pairing_code.replace("-", "").replace(" ","")) # remove dashes and spaces
pairing_code = int(pairing_code.replace("-", "").replace(" ", "")) # remove dashes and spaces
print("Pairing...")
paired = await lounge_controller.pair(pairing_code)
if not paired:
@@ -24,6 +23,7 @@ async def pair_device(loop):
print(f"Failed to pair device: {e}")
return
def main(config, debug: bool) -> None:
print("Welcome to the iSponsorBlockTV cli setup wizard")
loop = asyncio.get_event_loop_policy().get_event_loop()
@@ -31,12 +31,14 @@ def main(config, debug: bool) -> None:
loop.set_debug(True)
asyncio.set_event_loop(loop)
if hasattr(config, "atvs"):
print("The atvs config option is deprecated and has stopped working. Please read this for more information on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2")
print(
"The atvs config option is deprecated and has stopped working. Please read this for more information on "
"how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2")
if input("Do you want to remove the legacy 'atvs' entry (the app won't start with it present)? (y/n) ") == "y":
del config["atvs"]
devices = config.devices
while not input(f"Paired with {len(devices)} Device(s). Add more? (y/n) ") == "n":
task = loop.create_task(pair_device(loop))
task = loop.create_task(pair_device())
loop.run_until_complete(task)
device = task.result()
if device:
@@ -61,30 +63,35 @@ def main(config, debug: bool) -> None:
if skip_categories:
if input("Skip categories already specified. Change them? (y/n) ") == "y":
categories = input(
"Enter skip categories (space or comma sepparated) Options: [sponsor selfpromo exclusive_access interaction poi_highlight intro outro preview filler music_offtopic]:\n"
"Enter skip categories (space or comma sepparated) Options: [sponsor selfpromo exclusive_access "
"interaction poi_highlight intro outro preview filler music_offtopic]:\n"
)
skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings
skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings
else:
categories = input(
"Enter skip categories (space or comma sepparated) Options: [sponsor, selfpromo, exclusive_access, interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n"
"Enter skip categories (space or comma sepparated) Options: [sponsor, selfpromo, exclusive_access, "
"interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n"
)
skip_categories = categories.replace(",", " ").split(" ")
skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings
skip_categories = [x for x in skip_categories if x != ''] # Remove empty strings
config.skip_categories = skip_categories
channel_whitelist = config.channel_whitelist
if input("Do you want to whitelist any channels from being ad-blocked? (y/n) ") == "y":
if(not apikey):
print("WARNING: You need to specify an API key to use this function, otherwise the program will fail to start.\nYou can add one by re-running this setup wizard.")
if not apikey:
print(
"WARNING: You need to specify an API key to use this function, otherwise the program will fail to "
"start.\nYou can add one by re-running this setup wizard.")
web_session = aiohttp.ClientSession()
api_helper = api_helpers.ApiHelper(config, web_session)
while True:
channel_info = {}
channel = input("Enter a channel name or \"/exit\" to exit: ")
if channel == "/exit":
break
task = loop.create_task(api_helpers.search_channels(channel, apikey, web_session))
task = loop.create_task(api_helper.search_channels(channel, apikey, web_session))
loop.run_until_complete(task)
results = task.result()
if len(results) == 0:
@@ -118,6 +125,7 @@ def main(config, debug: bool) -> None:
config.channel_whitelist = channel_whitelist
config.skip_count_tracking = not input("Do you want to report skipped segments to sponsorblock. Only the segment UUID will be sent? (y/n) ") == "n"
config.skip_count_tracking = not input(
"Do you want to report skipped segments to sponsorblock. Only the segment UUID will be sent? (y/n) ") == "n"
print("Config finished")
config.save()

View File

@@ -17,3 +17,5 @@ skip_categories = (
('Preview', 'preview'),
('Filler', 'filler'),
)
youtube_client_blacklist = ["TVHTML5_FOR_KIDS"]

View File

@@ -1,24 +1,19 @@
"""Send out a M-SEARCH request and listening for responses."""
import asyncio
import socket
import aiohttp
import ssdp
from ssdp import network
import xmltodict
'''
Redistribution and use of the DIAL DIscovery And Launch protocol specification (the “DIAL Specification”), with or without modification,
are permitted provided that the following conditions are met:
Redistributions of the DIAL Specification must retain the above copyright notice, this list of conditions and the following
disclaimer.
● Redistributions of implementations of the DIAL Specification in source code form must retain the above copyright notice, this
list of conditions and the following disclaimer.
● Redistributions of implementations of the DIAL Specification in binary form must include the above copyright notice.
● The DIAL mark, the NETFLIX mark and the names of contributors to the DIAL Specification may not be used to endorse or
promote specifications, software, products, or any other materials derived from the DIAL Specification without specific prior
written permission. The DIAL mark is owned by Netflix and information on licensing the DIAL mark is available at
www.dial-multiscreen.org.'''
'''Redistribution and use of the DIAL DIscovery And Launch protocol specification (the “DIAL Specification”),
with or without modification, are permitted provided that the following conditions are met: ● Redistributions of the
DIAL Specification must retain the above copyright notice, this list of conditions and the following disclaimer. ●
Redistributions of implementations of the DIAL Specification in source code form must retain the above copyright
notice, this list of conditions and the following disclaimer. ● Redistributions of implementations of the DIAL
Specification in binary form must include the above copyright notice. ● The DIAL mark, the NETFLIX mark and the names
of contributors to the DIAL Specification may not be used to endorse or promote specifications, software, products,
or any other materials derived from the DIAL Specification without specific prior written permission. The DIAL mark
is owned by Netflix and information on licensing the DIAL mark is available at www.dial-multiscreen.org.'''
'''
MIT License
@@ -44,6 +39,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.'''
'''Modified code from https://github.com/codingjoe/ssdp/blob/main/ssdp/__main__.py'''
def get_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0)
@@ -69,6 +65,7 @@ class Handler(ssdp.aio.SSDP):
def __call__(self):
return self
def response_received(self, response: ssdp.messages.SSDPResponse, addr):
headers = response.headers
headers = {k.lower(): v for k, v in headers}

View File

@@ -43,7 +43,8 @@ class Config:
def validate(self):
if hasattr(self, "atvs"):
print(
"The atvs config option is deprecated and has stopped working. Please read this for more information on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2",
"The atvs config option is deprecated and has stopped working. Please read this for more information "
"on how to upgrade to V2: \nhttps://github.com/dmunozv04/iSponsorBlockTV/wiki/Migrate-from-V1-to-V2",
)
print("Exiting in 10 seconds...")
time.sleep(10)

View File

@@ -3,7 +3,6 @@ import aiohttp
import time
import logging
from . import api_helpers, ytlounge
import traceback
class DeviceListener:
@@ -41,7 +40,6 @@ class DeviceListener:
except:
# traceback.print_exc()
await asyncio.sleep(10)
while not self.cancelled:
while not (await self.is_available()) and not self.cancelled:
await asyncio.sleep(10)
@@ -50,6 +48,7 @@ class DeviceListener:
except:
pass
while not lounge_controller.connected() and not self.cancelled:
# Doesn't connect to the device if it's a kids profile (it's broken)
await asyncio.sleep(10)
try:
await lounge_controller.connect()
@@ -57,10 +56,10 @@ class DeviceListener:
pass
print(f"Connected to device {lounge_controller.screen_name} ({self.name})")
try:
#print("Subscribing to lounge")
# print("Subscribing to lounge")
sub = await lounge_controller.subscribe_monitored(self)
await sub
#print("Subscription ended")
await asyncio.sleep(10)
except:
pass
@@ -111,13 +110,12 @@ class DeviceListener:
self.api_helper.mark_viewed_segments(UUID)
) # Don't wait for this to finish
# Stops the connection to the device
async def cancel(self):
self.cancelled = True
try:
self.task.cancel()
except Exception as e:
except Exception:
pass
@@ -143,7 +141,7 @@ def main(config, debug):
tasks.append(loop.create_task(device.refresh_auth_loop()))
try:
loop.run_forever()
except KeyboardInterrupt as e:
except KeyboardInterrupt:
print("Keyboard interrupt detected, cancelling tasks and exiting...")
loop.run_until_complete(finish(devices))
finally:

View File

@@ -1,6 +1,7 @@
import aiohttp
import asyncio
import copy
import aiohttp
# Textual imports (Textual is awesome!)
from textual import on
from textual.app import App, ComposeResult
@@ -12,6 +13,7 @@ from textual.widgets import Button, Footer, Header, Static, Label, Input, Select
RadioSet, RadioButton
from textual.widgets.selection_list import Selection
from textual_slider import Slider
# Local imports
from . import api_helpers, ytlounge
from .constants import skip_categories
@@ -457,7 +459,7 @@ class DevicesManager(Vertical):
@on(Button.Pressed, "#element-remove")
def remove_channel(self, event: Button.Pressed):
channel_to_remove: Element = event.button.parent
self.config.channel_whitelist.remove(channel_to_remove.element_data)
self.config.devices.remove(channel_to_remove.element_data)
channel_to_remove.remove()
@on(Button.Pressed, "#add-device")
@@ -479,7 +481,7 @@ class ApiKeyManager(Vertical):
def compose(self) -> ComposeResult:
yield Label("YouTube Api Key", classes="title")
yield Label(
"You can get a YouTube Api Key from the [link=https://console.developers.google.com/apis/credentials]Google Cloud Console[/link]")
"You can get a YouTube Data API v3 Key from the [link=https://console.developers.google.com/apis/credentials]Google Cloud Console[/link]. This key is only required if you're whitelisting channels.")
with Grid(id="api-key-grid"):
yield Input(placeholder="YouTube Api Key", id="api-key-input", password=True, value=self.config.apikey)
yield Button("Show key", id="api-key-view")
@@ -557,9 +559,9 @@ class AdSkipMuteManager(Vertical):
"This feature allows you to automatically mute and/or skip native YouTube ads. Skipping ads only works if that ad shows the 'Skip Ad' button, if it doesn't then it will only be able to be muted.",
classes="subtitle", id="skip-count-tracking-subtitle")
with Horizontal(id="ad-skip-mute-container"):
yield Checkbox(value=self.config.mute_ads, id="mute-ads-switch",
label="Enable skipping ads")
yield Checkbox(value=self.config.skip_ads, id="skip-ads-switch",
label="Enable skipping ads")
yield Checkbox(value=self.config.mute_ads, id="mute-ads-switch",
label="Enable muting ads")
@on(Checkbox.Changed, "#mute-ads-switch")

View File

@@ -1,6 +1,12 @@
import asyncio
import json
import aiohttp
import pyytlounge
from .constants import youtube_client_blacklist
# Temporary imports
from pyytlounge.api import api_base
from pyytlounge.wrapper import NotLinkedException, desync
create_task = asyncio.create_task
@@ -24,7 +30,7 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
await asyncio.sleep(35) # YouTube sends at least a message every 30 seconds (no-op or any other)
try:
self.subscribe_task.cancel()
except Exception as e:
except Exception:
pass
# Subscribe to the lounge and start the watchdog
@@ -48,11 +54,10 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
pass
finally:
self.subscribe_task_watchdog = asyncio.create_task(self._watchdog())
# A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we can get the segments)
# A bunch of events useful to detect ads playing, and the next video before it starts playing (that way we
# can get the segments)
if event_type == "onStateChange":
data = args[0]
self.state.apply_state(data)
self._update_state()
# print(data)
# Unmute when the video starts playing
if self.mute_ads and data["state"] == "1":
@@ -63,12 +68,12 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self._update_state()
# Unmute when the video starts playing
if self.mute_ads and data.get("state", "0") == "1":
#print("Ad has ended, unmuting")
# print("Ad has ended, unmuting")
create_task(self.mute(False, override=True))
elif self.mute_ads and event_type == "onAdStateChange":
data = args[0]
if data["adState"] == '0': # Ad is not playing
#print("Ad has ended, unmuting")
# print("Ad has ended, unmuting")
create_task(self.mute(False, override=True))
else: # Seen multiple other adStates, assuming they are all ads
print("Ad has started, muting")
@@ -78,10 +83,11 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.volume_state = args[0]
pass
# Gets segments for the next video before it starts playing
elif event_type == "autoplayUpNext":
if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty
print(f"Getting segments for next video: {vid_id}")
create_task(self.api_helper.get_segments(vid_id))
# Comment "fix" since it doesn't seem to work
# elif event_type == "autoplayUpNext":
# if len(args) > 0 and (vid_id := args[0]["videoId"]): # if video id is not empty
# print(f"Getting segments for next video: {vid_id}")
# create_task(self.api_helper.get_segments(vid_id))
# #Used to know if an ad is skippable or not
elif event_type == "adPlaying":
@@ -97,8 +103,20 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
create_task(self.mute(False, override=True))
elif self.mute_ads:
create_task(self.mute(True, override=True))
else:
super()._process_event(event_id, event_type, args)
elif event_type == "loungeStatus":
data = args[0]
devices = json.loads(data["devices"])
for device in devices:
if device["type"] == "LOUNGE_SCREEN":
device_info = json.loads(device.get("deviceInfo", ""))
if device_info.get("clientName", "") in youtube_client_blacklist:
self._sid = None
self._gsession = None # Force disconnect
# elif event_type == "onAutoplayModeChanged":
# data = args[0]
# create_task(self.set_auto_play_mode(data["autoplayMode"] == "ENABLED"))
super()._process_event(event_id, event_type, args)
# Set the volume to a specific value (0-100)
async def set_volume(self, volume: int) -> None:
@@ -117,3 +135,51 @@ class YtLoungeApi(pyytlounge.YtLoungeApi):
self.volume_state["muted"] = mute_str
# YouTube wants the volume when unmuting, so we send it
await super()._command("setVolume", {"volume": self.volume_state.get("volume", 100), "muted": mute_str})
async def set_auto_play_mode(self, enabled: bool):
await super()._command("setAutoplayMode", {"autoplayMode": "ENABLED" if enabled else "DISABLED"})
# Here just temporarily, will be removed once the PR is merged on YTlounge
async def connect(self) -> bool:
"""Attempt to connect using the previously set tokens"""
if not self.linked():
raise NotLinkedException("Not linked")
connect_body = {
"app": "web",
"mdx-version": "3",
"name": self.device_name,
"id": self.auth.screen_id,
"device": "REMOTE_CONTROL",
"capabilities": "que,dsdtr,atp",
"method": "setPlaylist",
"magnaKey": "cloudPairedDevice",
"ui": "false",
"deviceContext": "user_agent=dunno&window_width_points=&window_height_points=&os_name=android&ms=",
"theme": "cl",
"loungeIdToken": self.auth.lounge_id_token,
}
connect_url = (
f"{api_base}/bc/bind?RID=1&VER=8&CVER=1&auth_failure_option=send_error"
)
async with aiohttp.ClientSession() as session:
async with session.post(url=connect_url, data=connect_body) as resp:
try:
text = await resp.text()
if resp.status == 401:
self.auth.lounge_id_token = None
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(desync(lines)):
self._process_events(events)
self._command_offset = 1
return self.connected()
except Exception as ex:
self._logger.exception(ex, resp.status, resp.reason)
return False

View File

@@ -1,7 +1,7 @@
aiohttp==3.8.6
aiohttp==3.9.0
argparse==1.4.0
async-cache==1.1.1
pyytlounge==1.6.2
pyytlounge==1.6.3
rich==13.6.0
ssdp==1.3.0
textual==0.40.0