diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..44ab99e Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 1bf8f55..288dc02 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,6 @@ MANIFEST # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec # Installer logs pip-log.txt diff --git a/iSponsorBlockTV/__init__.py b/iSponsorBlockTV/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iSponsorBlockTV/config_setup.py b/iSponsorBlockTV/config_setup.py new file mode 100644 index 0000000..d777375 --- /dev/null +++ b/iSponsorBlockTV/config_setup.py @@ -0,0 +1,100 @@ +import pyatv +import json +import asyncio +from pyatv.const import DeviceModel +import sys + + +def save_config(config, config_file): + with open(config_file, "w") as f: + json.dump(config, f) + +#Taken from postlund/pyatv atvremote.py +async def _read_input(loop: asyncio.AbstractEventLoop, prompt: str): + sys.stdout.write(prompt) + sys.stdout.flush() + user_input = await loop.run_in_executor(None, sys.stdin.readline) + return user_input.strip() + +async def find_atvs(loop): + devices = await pyatv.scan(loop) + if not devices: + print("No devices found") + return + atvs = [] + for i in devices: + #Only get Apple TV's + if i.device_info.model in [DeviceModel.Gen4, DeviceModel.Gen4K, DeviceModel.AppleTV4KGen2]: + #if i.device_info.model in [DeviceModel.AppleTV4KGen2]: #FOR TESTING + if input("Found {}. Do you want to add it? (y/n) ".format(i.name)) == "y": + + identifier = i.identifier + + pairing = await pyatv.pair(i, loop=loop, protocol=pyatv.Protocol.AirPlay) + await pairing.begin() + if pairing.device_provides_pin: + pin = await _read_input(loop, "Enter PIN on screen: ") + pairing.pin(pin) + + await pairing.finish() + if pairing.has_paired: + creds = pairing.service.credentials + atvs.append({"identifier": identifier, "airplay_credentials": creds}) + print("Pairing successful") + await pairing.close() + return atvs + + + + +def main(config, config_file, debug): + try: num_atvs = len(config["atvs"]) + except: num_atvs = 0 + if input("Found {} Apple TV(s) in config.json. Add more? (y/n) ".format(num_atvs)) == "y": + loop = asyncio.get_event_loop_policy().get_event_loop() + if debug: + loop.set_debug(True) + asyncio.set_event_loop(loop) + task = loop.create_task(find_atvs(loop)) + loop.run_until_complete(task) + atvs = task.result() + try: + for i in atvs: + config["atvs"].append(i) + print("done adding") + except: + print("rewriting atvs (don't worry if none were saved before)") + config["atvs"] = atvs + + try : apikey = config["apikey"] + except: + apikey = "" + if apikey != "" : + if input("Apikey already specified. Change it? (y/n) ") == "y": + apikey = input("Enter your API key: ") + config["apikey"] = apikey + else: + print("get youtube apikey here: https://developers.google.com/youtube/registering_an_application") + apikey = input("Enter your API key: ") + config["apikey"] = apikey + + try: skip_categories = config["skip_categories"] + except: + skip_categories = [] + + if skip_categories != []: + if input("Skip categories already specified. Change them? (y/n) ") == "y": + categories = input("Enter skip categories (space sepparated) Options: [sponsor, selfpromo, exclusive_access, interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n") + skip_categories = categories.split(" ") + else: + categories = input("Enter skip categories (space sepparated) Options: [sponsor, selfpromo, exclusive_access, interaction, poi_highlight, intro, outro, preview, filler, music_offtopic:\n") + skip_categories = categories.split(" ") + config["skip_categories"] = skip_categories + print("config finished") + save_config(config, config_file) + + + +if __name__ == "__main__": + print("starting") + main() diff --git a/iSponsorBlockTV/helpers.py b/iSponsorBlockTV/helpers.py new file mode 100644 index 0000000..bc4b507 --- /dev/null +++ b/iSponsorBlockTV/helpers.py @@ -0,0 +1,46 @@ +import argparse +from . import config_setup +from . import main +from . import macos_install +import json +import os +import logging + +def load_config(config_file): + try: + with open(config_file) as f: + config = json.load(f) + except: + if os.getenv('iSPBTV_docker'): + print("You are running in docker, you have to mount the config file.\nPlease check the README.md for more information.") + else: + config = {} #Create blank config to setup + return config + + +def app_start(): + parser = argparse.ArgumentParser(description='iSponsorblockTV') + parser.add_argument('--file', '-f', default='config.json', help='config file') + parser.add_argument('--setup', '-s', action='store_true', help='setup the program') + parser.add_argument('--debug', '-d', action='store_true', help='debug mode') + parser.add_argument('--macos_install', action='store_true', help='install in macOS') + args = parser.parse_args() + + config = load_config(args.file) + if args.debug: + logging.basicConfig(level=logging.DEBUG) + if args.setup: #Setup the config file + config_setup.main(config, args.file, args.debug) + if args.macos_install: + macos_install.main() + + else: + try: #Check if config file has the correct structure + config["atvs"], config["apikey"], config["skip_categories"] + except: #If not, ask to setup the program + print("invalid config file, please run with --setup") + os.exit() + main.main(config["atvs"], config["apikey"], config["skip_categories"], args.debug) + +if __name__ == "__main__": + app_start() \ No newline at end of file diff --git a/iSponsorBlockTV/macos_install.py b/iSponsorBlockTV/macos_install.py new file mode 100644 index 0000000..476c090 --- /dev/null +++ b/iSponsorBlockTV/macos_install.py @@ -0,0 +1,46 @@ +import plistlib +import os +from . import helpers, config_setup + +default_plist = {"Label": "com.dmunozv04iSponsorBlockTV", + "RunAtLoad": True, + "StartInterval": 20, + "EnvironmentVariables": {"PYTHONUNBUFFERED": "YES"}, + "StandardErrorPath": "", #Fill later + "StandardOutPath": "", + "ProgramArguments" : "", + "WorkingDirectory": "" + } +def create_plist(path): + plist = default_plist + plist["ProgramArguments"] = [path + "/iSponsorBlockTV"] + 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"): + print("Program is on the right path") + print("The launch daemon will now be installed") + create_plist(correct_path) + run_setup(correct_path + "/config.json") + print("Launch daemon installed. Please restart the computer to enable it or use:\n launchctl load ~/Library/LaunchAgents/com.dmunozv04.iSponsorBlockTV.plist") + else: + if not os.path.exists(correct_path): + os.makedirs(correct_path) + print("Please move the program to the correct path: " + correct_path + "opeing now on finder...") + os.system("open -R " + correct_path) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/iSponsorBlockTV/main.py b/iSponsorBlockTV/main.py new file mode 100644 index 0000000..e59f4c8 --- /dev/null +++ b/iSponsorBlockTV/main.py @@ -0,0 +1,172 @@ +import asyncio +import pyatv +import aiohttp +from cache import AsyncTTL +import time +import logging + +def listToTuple(function): + def wrapper(*args): + args = [tuple(x) if type(x) == list else x for x in args] + result = function(*args) + result = tuple(result) if type(result) == list else result + return result + return wrapper + +class MyPushListener(pyatv.interface.PushListener): + task = None + apikey = None + rc = None + + web_session = None + categories = ["sponsor"] + + def __init__(self, apikey, atv, categories, web_session): + self.apikey = apikey + self.rc = atv.remote_control + self.web_session = web_session + self.categories = categories + self.atv = atv + + + def playstatus_update(self, updater, playstatus): + logging.debug("Playstatus update" + str(playstatus)) + try: + self.task.cancel() + except: + pass + time_start = time.time() + self.task = asyncio.create_task(process_playstatus(playstatus, self.apikey, self.rc, self.web_session, self.categories, self.atv, time_start)) + def playstatus_error(self, updater, exception): + logging.error(exception) + print("stopped") + + + +async def process_playstatus(playstatus, apikey, rc, web_session, categories, atv, time_start): + logging.debug("App playing is:" + str(atv.metadata.app.identifier)) + if playstatus.device_state == playstatus.device_state.Playing and atv.metadata.app.identifier == "com.google.ios.youtube": + vid_id = await get_vid_id(playstatus.title, playstatus.artist, apikey, web_session) + print(vid_id) + segments, duration = await get_segments(vid_id, web_session, categories) + print(segments) + await time_to_segment(segments, playstatus.position, rc, time_start) + + +@AsyncTTL(time_to_live=300, maxsize=5) +async def get_vid_id(title, artist, api_key, web_session): + url = f"https://youtube.googleapis.com/youtube/v3/search?q={title} - {artist}&key={api_key}&maxResults=1" + async with web_session.get(url) as response: + response = await response.json() + vid_id = response["items"][0]["id"]["videoId"] + return vid_id + +@listToTuple +@AsyncTTL(time_to_live=300, maxsize=5) +async def get_segments(vid_id, web_session, categories = ["sponsor"]): + params = {"videoID": vid_id, + "category": categories, + "actionType": "skip", + "service": "youtube"} + headers = {'Accept': 'application/json'} + url = "https://sponsor.ajay.app/api/skipSegments" + async with web_session.get(url, headers = headers, params = params) as response: + response = await response.json() + segments = [] + try: + duration = response[0]["videoDuration"] + for i in response: + segment = i["segment"] + try: + #Get segment before to check if they are too close to each other + segment_before_end = segments[-1][1] + segment_before_start = segments[-1][0] + + except: + segment_before_end = -10 + if segment[0] - segment_before_end < 1: #Less than 1 second appart, combine them and skip them together + segment = [segment_before_start, segment[1]] + segments.pop() + segments.append(segment) + except: + duration = 0 + return segments, duration + + +async def time_to_segment(segments, position, rc, time_start): + position = position + (time.time() - time_start) + for segment in segments: + if position < 2 and (position >= segment[0] and position < segment[1]): + next_segment = [position, segment[1]] + break + if segment[0] > position: + next_segment = segment + break + time_to_next = next_segment[0] - position + await skip(time_to_next, next_segment[1], rc) + +async def skip(time_to, position, rc): + await asyncio.sleep(time_to) + await rc.set_position(position) + + +async def connect_atv(loop, identifier, airplay_credentials): + """Find a device and print what is playing.""" + print("Discovering devices on network...") + atvs = await pyatv.scan(loop, identifier = identifier) + + if not atvs: + print("No device found, will retry") + return + + config = atvs[0] + config.set_credentials(pyatv.Protocol.AirPlay, airplay_credentials) + + print(f"Connecting to {config.address}") + return await pyatv.connect(config, loop) + + +async def loop_atv(event_loop, atv_config, apikey, categories, web_session): + identifier = atv_config["identifier"] + airplay_credentials = atv_config["airplay_credentials"] + atv = await connect_atv(event_loop, identifier, airplay_credentials) + if atv: + listener = MyPushListener(apikey, atv, categories, web_session) + + atv.push_updater.listener = listener + atv.push_updater.start() + print("Push updater started") + while True: + await asyncio.sleep(20) + try: + atv.metadata.app + except: + print("Reconnecting to Apple TV") + #reconnect to apple tv + atv = await connect_atv(event_loop, identifier, airplay_credentials) + if atv: + listener = MyPushListener(apikey, atv, categories, web_session) + + atv.push_updater.listener = listener + atv.push_updater.start() + print("Push updater started") + + + + + + +def main(atv_configs, apikey, categories, debug): + loop = asyncio.get_event_loop_policy().get_event_loop() + if debug: + loop.set_debug(True) + asyncio.set_event_loop(loop) + web_session = aiohttp.ClientSession() + for i in atv_configs: + loop.create_task(loop_atv(loop, i, apikey, categories, web_session)) + loop.run_forever() + + +if __name__ == "__main__": + print("starting") + main() diff --git a/main-macos.py b/main-macos.py new file mode 100644 index 0000000..2264d52 --- /dev/null +++ b/main-macos.py @@ -0,0 +1,7 @@ +from iSponsorBlockTV import helpers +import sys +import os + +if getattr(sys, 'frozen', False): + os.environ['SSL_CERT_FILE'] = os.path.join(sys._MEIPASS, 'lib', 'cert.pem') +helpers.app_start() \ No newline at end of file diff --git a/main-macos.spec b/main-macos.spec new file mode 100644 index 0000000..7f16839 --- /dev/null +++ b/main-macos.spec @@ -0,0 +1,47 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import exec_statement +cert_datas = exec_statement(""" + import ssl + print(ssl.get_default_verify_paths().cafile)""").strip().split() +cert_datas = [(f, 'lib') for f in cert_datas] + +block_cipher = None + +options = [ ('u', None, 'OPTION') ] + +a = Analysis(['main-macos.py'], + pathex=[], + binaries=[], + datas=cert_datas, + hiddenimports=['certifi'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + options, + name='iSponsorBlockTV', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None) \ No newline at end of file diff --git a/main.py b/main.py index f030d5c..2ec464a 100644 --- a/main.py +++ b/main.py @@ -1,173 +1,3 @@ -import sys -import asyncio -import pyatv -import aiohttp -from cache import AsyncTTL -import json -import time +from iSponsorBlockTV import helpers -def listToTuple(function): - def wrapper(*args): - args = [tuple(x) if type(x) == list else x for x in args] - result = function(*args) - result = tuple(result) if type(result) == list else result - return result - return wrapper - -class MyPushListener(pyatv.interface.PushListener): - task = None - apikey = None - rc = None - - web_session = None - categories = ["sponsor"] - - def __init__(self, apikey, atv, categories, web_session): - self.apikey = apikey - self.rc = atv.remote_control - self.web_session = web_session - self.categories = categories - self.atv = atv - - - def playstatus_update(self, updater, playstatus): - try: - self.task.cancel() - except: - pass - time_start = time.time() - self.task = asyncio.create_task(process_playstatus(playstatus, self.apikey, self.rc, self.web_session, self.categories, self.atv, time_start)) - def playstatus_error(self, updater, exception): - print(exception) - print("stopped") - - - -async def process_playstatus(playstatus, apikey, rc, web_session, categories, atv, time_start): - if playstatus.device_state == playstatus.device_state.Playing and atv.metadata.app.identifier == "com.google.ios.youtube": - vid_id = await get_vid_id(playstatus.title, playstatus.artist, apikey, web_session) - print(vid_id) - segments, duration = await get_segments(vid_id, web_session, categories) - print(segments) - await time_to_segment(segments, playstatus.position, rc, time_start) - - -@AsyncTTL(time_to_live=300, maxsize=5) -async def get_vid_id(title, artist, api_key, web_session): - url = f"https://youtube.googleapis.com/youtube/v3/search?q={title} - {artist}&key={api_key}&maxResults=1" - async with web_session.get(url) as response: - response = await response.json() - vid_id = response["items"][0]["id"]["videoId"] - return vid_id - -@listToTuple -@AsyncTTL(time_to_live=300, maxsize=5) -async def get_segments(vid_id, web_session, categories = ["sponsor"]): - params = {"videoID": vid_id, - "category": categories, - "actionType": "skip", - "service": "youtube"} - headers = {'Accept': 'application/json'} - url = "https://sponsor.ajay.app/api/skipSegments" - async with web_session.get(url, headers = headers, params = params) as response: - response = await response.json() - segments = [] - try: - duration = response[0]["videoDuration"] - for i in response: - segment = i["segment"] - try: - #Get segment before to check if they are too close to each other - segment_before_end = segments[-1][1] - segment_before_start = segments[-1][0] - - except: - segment_before_end = -10 - if segment[0] - segment_before_end < 1: #Less than 1 second appart, combine them and skip them together - segment = [segment_before_start, segment[1]] - segments.pop() - segments.append(segment) - except: - duration = 0 - return segments, duration - - -async def time_to_segment(segments, position, rc, time_start): - position = position + (time.time() - time_start) - for segment in segments: - if position < 2 and (position >= segment[0] and position < segment[1]): - next_segment = [position, segment[1]] - break - if segment[0] > position: - next_segment = segment - break - time_to_next = next_segment[0] - position - await skip(time_to_next, next_segment[1], rc) - -async def skip(time_to, position, rc): - await asyncio.sleep(time_to) - await rc.set_position(position) - - -async def connect_atv(loop, identifier, airplay_credentials): - """Find a device and print what is playing.""" - print("Discovering devices on network...") - atvs = await pyatv.scan(loop, identifier = identifier) - - if not atvs: - print("No device found, will retry") - return - - config = atvs[0] - config.set_credentials(pyatv.Protocol.AirPlay, airplay_credentials) - - print(f"Connecting to {config.address}") - return await pyatv.connect(config, loop) - - -async def loop_atv(event_loop, atv_config, apikey, categories, web_session): - identifier = atv_config["identifier"] - airplay_credentials = atv_config["airplay_credentials"] - atv = await connect_atv(event_loop, identifier, airplay_credentials) - if atv: - listener = MyPushListener(apikey, atv, categories, web_session) - - atv.push_updater.listener = listener - atv.push_updater.start() - print("Push updater started") - while True: - await asyncio.sleep(20) - try: - atv.metadata.app - except: - print("Reconnecting to Apple TV") - #reconnect to apple tv - atv = await connect_atv(event_loop, identifier, airplay_credentials) - if atv: - listener = MyPushListener(apikey, atv, categories, web_session) - - atv.push_updater.listener = listener - atv.push_updater.start() - print("Push updater started") - - - - - -def load_config(config_file="config.json"): - with open(config_file) as f: - config = json.load(f) - return config["atvs"], config["apikey"], config["skip_categories"] - -def start_async(): - loop = asyncio.get_event_loop_policy().get_event_loop() - asyncio.set_event_loop(loop) - atv_configs, apikey, categories = load_config() - web_session = aiohttp.ClientSession() - for i in atv_configs: - loop.create_task(loop_atv(loop, i, apikey, categories, web_session)) - loop.run_forever() - -if __name__ == "__main__": - print("starting") - start_async() +helpers.app_start() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 95d8e05..1457d11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ pyatv aiohttp aiodns async-cache +argparse