def __init__(self, manual=False): self.backlog = read_json(self.BACKLOG_PATH) or [] self.clear_interval = config["backlog"]["clear_interval"].get( confuse.Number()) self.expiry = config["backlog"]["expiry"].get(confuse.Number()) self.timer_enabled = not manual if self.timer_enabled: self._make_timer() self.clear()
def test_validate_first_good_choice_in_list(self): config = _root({'foo': 3.14}) valid = config['foo'].get( confuse.OneOf([ confuse.Integer(), confuse.Number(), ])) self.assertEqual(valid, 3)
class VLCMon(WebInterfaceMon): name = 'vlc' URL = "http://{ip}:{port}" STATES = ['stopped', 'paused', 'playing'] CONFIG_TEMPLATE = { "ip": confuse.String(default="localhost"), "port": confuse.String(default="auto-detect"), "password": confuse.String(default="auto-detect"), "poll_interval": confuse.Number(default=10), } def __init__(self, scrobble_queue): try: web_pwd = self.config['password'] self.URL = self.URL.format(**self.config) except KeyError: logger.exception('Check config for correct VLC params.') return super().__init__(scrobble_queue) self.sess.auth = ('', web_pwd) self.status_url = self.URL + '/requests/status.json' self.playlist_url = self.URL + '/requests/playlist.json' @classmethod def read_player_cfg(cls, auto_keys=None): if sys.platform == "darwin": prefs_dir = Path( "~/Library/Preferences/org.videolan.vlc").expanduser() else: prefs_dir = Path( appdirs.user_config_dir("vlc", False, roaming=True)) vlcrc_path = prefs_dir / "vlcrc" vlcrc = ConfigParser(strict=False) vlcrc.optionxform = lambda option: option if not vlcrc.read(vlcrc_path, encoding="utf-8-sig"): raise FileNotFoundError(vlcrc_path) return { "port": lambda: vlcrc.get("core", "http-port", fallback=8080), "password": lambda: vlcrc.get("lua", "http-password"), } def update_status(self): try: status_data = self.sess.get(self.status_url).json() except json.JSONDecodeError: raise requests.ConnectionError if not status_data['length']: self.status = {} return self.status['duration'] = status_data['length'] self.status['position'] = status_data['time'] self.status['state'] = self.STATES.index(status_data['state']) self.status['filepath'] = self._get_filepath() def _get_filepath(self): playlist_data = self.sess.get(self.playlist_url).json() file_data = search_dict_for_current(playlist_data) return file_uri_to_path(file_data['uri'])
class MPCMon(WebInterfaceMon): exclude_import = True URL = "http://{ip}:{port}/variables.html" PATTERN = re.compile(r'\<p id=\"([a-z]+)\"\>(.*?)\<', re.MULTILINE) CONFIG_TEMPLATE = { "ip": confuse.String(default="localhost"), "port": confuse.String(default="auto-detect"), "poll_interval": confuse.Number(default=10), } def __init__(self, scrobble_queue): try: self.URL = self.URL.format(**self.config) except KeyError: logger.exception(f'Check config for correct {self.name} params.') return super().__init__(scrobble_queue) @staticmethod def _read_registry_cfg(path): import winreg try: hkey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) except FileNotFoundError as e: e.filename = path raise return {"port": lambda: winreg.QueryValueEx(hkey, "WebServerPort")[0]} def get_vars(self): response = self.sess.get(self.URL) matches = self.PATTERN.findall(response.text) return dict(matches) def update_status(self): variables = self.get_vars() # when a file has just started, it may happen that the variables page is # pingable, but not populated. So check that variables isn't empty if not variables or variables['duration'] == '0': self.status = {} return self.status['state'] = int(variables['state']) for key in ('position', 'duration'): self.status[key] = int(variables[key]) / 1000 # instead of stopping, mpc pauses the file at the last second if variables['positionstring'] == variables['durationstring']: self.status['state'] = 0 self.status['filepath'] = variables['filepath']
class Config: base_template = { 'twitter': { 'consumer_key': confuse.String(), 'consumer_secret': confuse.String(), 'access_token_key': confuse.String(), 'access_token_secret': confuse.String(), 'min_ratelimit_percent': confuse.Integer(), 'search': { 'queries': confuse.TypeTemplate(list), 'max_queue': confuse.Integer(), 'max_quote_depth': confuse.Integer(), 'min_quote_similarity': confuse.Number(), 'skip_retweeted': confuse.TypeTemplate(bool), # Is updated on runtime 'filter': {}, # Is updated on runtime 'sort': {}, }, # Is updated on runtime 'actions': {}, 'scheduler': { 'search_interval': confuse.Integer(), 'retweet_interval': confuse.Integer(), 'retweet_random_margin': confuse.Integer(), 'blocked_users_update_interval': confuse.Integer(), 'clear_queue_interval': confuse.Integer(), 'rate_limit_update_interval': confuse.Integer(), 'check_mentions_interval': confuse.Integer(), }, }, # Is updated on runtime 'notifiers': {} } _valid = None @staticmethod def get(): """ Gets the static config object :return: """ if Config._valid is None: raise ValueError("Configuration not loaded") return Config._valid @staticmethod def load(filename=None): """ Loads a file and imports the settings :param filename: the file to import """ config = confuse.LazyConfig('Yatcobot', __name__) # Add default config when in egg (using this way because egg is breaking the default way) if len(config.sources) == 0: default_config_text = pkg_resources.resource_string("yatcobot.config", "config_default.yaml") default_config = confuse.ConfigSource(yaml.load(default_config_text, Loader=confuse.Loader), 'pkg/config/config_default.yaml', True) config.add(default_config) # Add user specified config if filename is not None and os.path.isfile(filename): config.set_file(filename) logger.info('Loading config files (From highest priority to lowest):') config.resolve() for i, config_source in enumerate(config.sources): logger.info(f'{i}: Path: {config_source.filename}') # Update template from plugins template = Config.get_template() Config._valid = config.get(template) @staticmethod def get_template(): """ Updates the config template dynamically from plugins :return: config template """ template = Config.base_template # Merge filters templates from yatcobot.plugins.filters import FilterABC template['twitter']['search']['filter'].update(FilterABC.get_config_template()) # Merge ratings templates from yatcobot.plugins.ratings import RatingABC template['twitter']['search']['sort'].update(RatingABC.get_config_template()) # Merge actions templates from yatcobot.plugins.actions import ActionABC template['twitter']['actions'].update(ActionABC.get_config_template()) # Merge notifiers templates from yatcobot.plugins.notifiers import NotifierABC template['notifiers'].update(NotifierABC.get_config_template()) return template
class MPVMon(Monitor): name = 'mpv' exclude_import = True WATCHED_PROPS = [ 'pause', 'path', 'working-directory', 'duration', 'time-pos' ] CONFIG_TEMPLATE = { "ipc_path": confuse.String(default="auto-detect"), "poll_interval": confuse.Number(default=10), } def __init__(self, scrobble_queue): try: self.ipc_path = self.config['ipc_path'] except KeyError: logger.exception('Check config for correct MPV params.') return super().__init__(scrobble_queue) self.buffer = '' self.lock = threading.Lock() self.poll_timer = None self.write_queue = Queue() self.sent_commands = {} self.command_counter = 1 self.vars = {} @classmethod def read_player_cfg(cls, auto_keys=None): conf_path = (Path( appdirs.user_config_dir("mpv", roaming=True, appauthor=False)) / "mpv.conf") mpv_conf = ConfigParser(allow_no_value=True, strict=False) mpv_conf.optionxform = lambda option: option mpv_conf.read_string("[root]\n" + conf_path.read_text()) return {"ipc_path": lambda: mpv_conf.get("root", "input-ipc-server")} def run(self): while True: if self.can_connect(): self.update_vars() self.conn_loop() if self.poll_timer: self.poll_timer.cancel() time.sleep(1) else: logger.info('Unable to connect to MPV. Check ipc path.') time.sleep(10) def update_status(self): fpath = Path(self.vars['working-directory']) / Path(self.vars['path']) # Update last known position if player is stopped pos = self.vars['time-pos'] if self.vars['state'] == 0 and self.status['state'] == 2: pos += round(time.time() - self.status['time'], 3) self.status = { 'state': self.vars['state'], 'filepath': str(fpath), 'position': pos, 'duration': self.vars['duration'], 'time': time.time() } self.handle_status_update() def update_vars(self): """Query mpv for required properties.""" self.updated_props_count = 0 for prop in self.WATCHED_PROPS: self.send_command(['get_property', prop]) if self.poll_timer: self.poll_timer.cancel() self.poll_timer = threading.Timer(10, self.update_vars) self.poll_timer.name = 'mpvpoll' self.poll_timer.start() def handle_event(self, event): if event == 'end-file': self.vars['state'] = 0 self.is_running = False self.update_status() elif event == 'pause': self.vars['state'] = 1 self.update_vars() elif event == 'unpause' or event == 'playback-restart': self.vars['state'] = 2 self.update_vars() def handle_cmd_response(self, resp): command = self.sent_commands[resp['request_id']]['command'] del self.sent_commands[resp['request_id']] if resp['error'] != 'success': logger.error(f'Error with command {command!s}. Response: {resp!s}') return elif command[0] != 'get_property': return param = command[1] data = resp['data'] if param == 'pause': self.vars['state'] = 1 if data else 2 if param in self.WATCHED_PROPS: self.vars[param] = data self.updated_props_count += 1 if self.updated_props_count == len(self.WATCHED_PROPS): self.update_status() def on_data(self, data): self.buffer = self.buffer + data.decode('utf-8') while True: line_end = self.buffer.find('\n') if line_end == -1: # partial line received # self.on_line() is called in next data batch break else: self.on_line(self.buffer[:line_end]) # doesn't include \n self.buffer = self.buffer[line_end + 1:] # doesn't include \n def on_line(self, line): try: mpv_json = json.loads(line) except json.JSONDecodeError: logger.warning('Invalid JSON received. Skipping. ' + line, exc_info=True) return if 'event' in mpv_json: self.handle_event(mpv_json['event']) elif 'request_id' in mpv_json: self.handle_cmd_response(mpv_json) def send_command(self, elements): with self.lock: command = {'command': elements, 'request_id': self.command_counter} self.sent_commands[self.command_counter] = command self.command_counter += 1 self.write_queue.put(str.encode(json.dumps(command) + '\n'))
class MPVMon(Monitor): name = 'mpv' exclude_import = True WATCHED_PROPS = frozenset( ('pause', 'path', 'working-directory', 'duration', 'time-pos')) CONFIG_TEMPLATE = { "ipc_path": confuse.String(default="auto-detect"), "poll_interval": confuse.Number(default=10), # seconds to wait while reading data from mpv "read_timeout": confuse.Number(default=2), # seconds to wait while writing data to mpv "write_timeout": confuse.Number(default=60), # seconds to wait after one file ends to check for the next play # needed to make sure we don't reconnect too soon after end-file "restart_delay": confuse.Number(default=0.1) } def __init__(self, scrobble_queue): super().__init__(scrobble_queue) self.ipc_path = self.config['ipc_path'] self.read_timeout = self.config['read_timeout'] self.write_timeout = self.config['write_timeout'] self.poll_interval = self.config['poll_interval'] self.restart_delay = self.config['restart_delay'] self.buffer: bytes = b'' self.ipc_lock = threading.Lock() # for IPC write queue self.poll_timer = None self.write_queue = Queue() self.sent_commands = {} self.command_counter = 1 self.vars = {} @classmethod def read_player_cfg(cls, auto_keys=None): if sys.platform == "darwin": conf_path = Path.home() / ".config" / "mpv" / "mpv.conf" else: conf_path = (Path( appdirs.user_config_dir("mpv", roaming=True, appauthor=False)) / "mpv.conf") mpv_conf = ConfigParser(allow_no_value=True, strict=False, inline_comment_prefixes="#") mpv_conf.optionxform = lambda option: option mpv_conf.read_string("[root]\n" + conf_path.read_text()) return {"ipc_path": lambda: mpv_conf.get("root", "input-ipc-server")} def run(self): while True: if self.can_connect(): self.update_vars() self.conn_loop() if self.vars.get('state', 0) != 0: # create a 'stop' event in case the player didn't send 'end-file' self.vars['state'] = 0 self.update_status() self.vars = {} if self.poll_timer: self.poll_timer.cancel() time.sleep(self.restart_delay) else: logger.info('Unable to connect to MPV. Check ipc path.') time.sleep(self.poll_interval) def update_status(self): if not self.WATCHED_PROPS.issubset(self.vars): logger.warning("Incomplete media status info") return fpath = self.vars['path'] if not is_url(fpath) and not Path(fpath).is_absolute(): fpath = str(Path(self.vars['working-directory']) / fpath) # Update last known position if player is stopped pos = self.vars['time-pos'] if self.vars['state'] == 0 and self.status['state'] == 2: pos += round(time.time() - self.status['time'], 3) pos = min(pos, self.vars['duration']) self.status = { 'state': self.vars['state'], 'filepath': fpath, 'position': pos, 'duration': self.vars['duration'], 'time': time.time() } self.handle_status_update() def update_vars(self): """Query mpv for required properties.""" self.updated_props_count = 0 for prop in self.WATCHED_PROPS: self.send_command(['get_property', prop]) if self.poll_timer: self.poll_timer.cancel() self.poll_timer = threading.Timer(self.poll_interval, self.update_vars) self.poll_timer.name = 'mpvpoll' self.poll_timer.start() def handle_event(self, event): if event == 'end-file': # Since the player might be shutting down, we can't update vars. # Reuse the previous self.vars and only update the state value. # This might be inaccurate in terms of position, which is why # regular polling is needed self.vars['state'] = 0 self.update_status() elif event == 'pause': self.vars['state'] = 1 self.update_vars() elif event == 'unpause' or event == 'playback-restart': self.vars['state'] = 2 self.update_vars() def handle_cmd_response(self, resp): command = self.sent_commands.pop(resp['request_id']) if resp['error'] != 'success': logger.error(f'Error with command {command!s}. Response: {resp!s}') return elif command[0] != 'get_property': return param = command[1] data = resp['data'] if param == 'pause': self.vars['state'] = 1 if data else 2 if param in self.WATCHED_PROPS: self.vars[param] = data self.updated_props_count += 1 if self.updated_props_count == len(self.WATCHED_PROPS): self.update_status() # resetting self.vars to {} here is a bad idea. def on_data(self, data: bytes): self.buffer += data partial_line = b"" for line in self.buffer.splitlines(keepends=True): if line.endswith(b"\n"): # no need to strip, json.loads will handle it self.on_line(line) else: # partial line received # self.on_line() is called in next data batch partial_line = line self.buffer = partial_line def on_line(self, line: bytes): try: mpv_json = json.loads(line) except json.JSONDecodeError: logger.warning('Invalid JSON received. Skipping.', exc_info=True) logger.debug(line) return if 'event' in mpv_json: self.handle_event(mpv_json['event']) elif 'request_id' in mpv_json: self.handle_cmd_response(mpv_json) def send_command(self, elements): with self.ipc_lock: command = {'command': elements, 'request_id': self.command_counter} self.sent_commands[self.command_counter] = elements self.command_counter += 1 self.write_queue.put(str.encode(json.dumps(command) + '\n'))
class Monitor(Thread): """Generic base class that polls the player for state changes, and sends the info to scrobble queue.""" CONFIG_TEMPLATE = { # min percent jump to consider for scrobbling to trakt 'skip_interval': confuse.Number(default=5), # min progress (in %) at which file should be opened for preview to be started 'preview_threshold': confuse.Number(default=80), # in seconds. How long the monitor should wait to start sending scrobbles 'preview_duration': confuse.Number(default=60), # in seconds. Max time elapsed between a "play->pause" transition to trigger # the "fast_pause" state 'fast_pause_threshold': confuse.Number(default=1), # in seconds. How long the monitor should wait to start sending scrobbles 'fast_pause_duration': confuse.Number(default=5), } def __new__(cls, *args, **kwargs): try: cls.inject_base_config() cls.config = cls.autoload_cfg() except AutoloadError as e: logger.debug(str(e)) logger.error(f"Config value autoload failed for {cls.name}.") except Exception: logger.exception(f"Config value autoload failed for {cls.name}.") else: return super().__new__(cls) @classmethod def inject_base_config(cls): """Inject default values from base config to allow player-specific overrides""" base_config = config['players'].get(Monitor.CONFIG_TEMPLATE) base_template = confuse.as_template(base_config) template = getattr(cls, 'CONFIG_TEMPLATE', {}) updated = {**base_template.subtemplates, **template} cls.CONFIG_TEMPLATE = updated @classmethod def autoload_cfg(cls): template = getattr(cls, 'CONFIG_TEMPLATE', None) monitor_cfg = config['players'][cls.name].get(template) auto_keys = {k for k, v in monitor_cfg.items() if v == "auto-detect"} if not auto_keys: return monitor_cfg try: loaders = getattr(cls, "read_player_cfg")(auto_keys) except AttributeError: logger.debug(f"Auto val not found for {', '.join(auto_keys)}") logger.error(f"Autoload not supported for {cls.name}.") raise AutoloadError except FileNotFoundError as e: raise AutoloadError(src=e.filename) while auto_keys: param = auto_keys.pop() try: param_loader = loaders[param] except KeyError: logger.error(f"Autoload not supported for '{param}'.") raise AutoloadError(param) try: monitor_cfg[param] = param_loader() logger.debug( f"Autoloaded {cls.name} {param} = {monitor_cfg[param]}") except FileNotFoundError as e: raise AutoloadError(src=e.filename) return monitor_cfg def __init__(self, scrobble_queue): super().__init__() logger.info('Started monitor for ' + self.name) self.scrobble_queue = scrobble_queue self.skip_interval = self.config['skip_interval'] self.preview_threshold = self.config['preview_threshold'] self.preview_duration = self.config['preview_duration'] self.fast_pause_threshold = self.config['fast_pause_threshold'] self.fast_pause_duration = self.config['fast_pause_duration'] self.is_running = False self.status = {} self.prev_state = {} self.preview = False self.fast_pause = False self.scrobble_buf = None self.lock = Lock() self.preview_timer: ResumableTimer = None self.fast_pause_timer: ResumableTimer = None def parse_status(self): if ('filepath' not in self.status and 'media_info' not in self.status) or not self.status.get('duration'): return {} if 'filepath' in self.status: media_info = get_media_info(self.status['filepath']) else: media_info = self.status['media_info'] if media_info is None: return {} ep = media_info.get('episode') if isinstance(ep, list): media_info = media_info.copy() num_eps = len(media_info['episode']) self.status['duration'] = self.status['duration'] // num_eps ep_num = int(self.status['position'] // self.status['duration']) media_info['episode'] = media_info['episode'][ep_num] self.status['position'] %= self.status['duration'] elif isinstance(ep, str): media_info['episode'] = int(ep) progress = min( round(self.status['position'] * 100 / self.status['duration'], 2), 100) return { 'state': self.status['state'], 'progress': progress, 'media_info': media_info, 'updated_at': time.time(), } def decide_action(self, prev, current): """ Decide what action(s) to take depending on the prev and current states. """ if not prev and not current: return None transition = Transition(prev, current) if (not prev or not current or not transition.is_same_media() or prev['state'] == State.Stopped): # media changed if self.preview: yield 'exit_preview' elif prev and prev['state'] != State.Stopped: yield 'stop_previous' if self.fast_pause: yield 'exit_fast_pause' if current: if current['progress'] > self.preview_threshold: yield 'enter_preview' elif not prev or not transition.is_same_media(): yield 'scrobble' elif transition.state_changed( ) or transition.progress() > self.skip_interval: # state changed if self.preview: if current['state'] == State.Stopped: yield 'exit_preview' elif transition.is_state_jump(State.Playing, State.Paused): yield 'pause_preview' elif current['state'] == State.Playing: yield 'resume_preview' else: yield 'invalid_state' elif self.fast_pause: if (current['state'] == State.Stopped or transition.progress() > self.skip_interval): yield 'scrobble' yield 'exit_fast_pause' elif current['state'] == State.Paused: yield 'clear_buf' elif current['state'] == State.Playing: yield 'delayed_play' else: # normal state yield 'scrobble' if (transition.is_state_jump(State.Playing, State.Paused) and transition.elapsed_realtime() < self.fast_pause_threshold): yield 'enter_fast_pause' def scrobble_status(self, status): verb = SCROBBLE_VERBS[status['state']] self.scrobble_queue.put((verb, status)) def delayed_scrobble(self, cleanup=None): logger.debug("Delayed scrobble") with self.lock: if self.scrobble_buf: logger.debug(self.scrobble_buf) self.scrobble_status(self.scrobble_buf) if cleanup: cleanup() def clear_timer(self, timer_name): timer = getattr(self, timer_name) if timer is not None: timer.cancel() setattr(self, timer_name, None) def exit_preview(self): logger.debug("Exiting preview") if self.preview: self.preview = False self.scrobble_buf = None self.clear_timer('preview_timer') def exit_fast_pause(self): logger.debug("Exiting fast_pause") if self.fast_pause: self.fast_pause = False self.scrobble_buf = None self.clear_timer('fast_pause_timer') def scrobble_if_state_changed(self, prev, current): """ Possible race conditions: 1) start_preview, after __preview_duration__ secs, stop_preview start_preview starts preview_timer for " secs, with cleanup=exit_preview. the stop_preview also triggers exit_preview, both are run parallelly. """ for action in self.decide_action(prev, current): logger.debug(f"action={action}") if action == "scrobble": logger.debug(current) self.scrobble_status(current) elif action == "stop_previous": self.scrobble_queue.put(("stop", prev)) elif action == "exit_preview": self.exit_preview() elif action == "enter_preview": assert not self.preview and not self.scrobble_buf, "Invalid state" self.preview = True self.scrobble_buf = current self.preview_timer = ResumableTimer(self.preview_duration, self.delayed_scrobble, (self.exit_preview, )) self.preview_timer.start() elif action == "pause_preview": self.scrobble_buf = current self.preview_timer.pause() elif action == "resume_preview": self.scrobble_buf = current self.preview_timer.resume() elif action == "enter_fast_pause": assert not self.fast_pause, "Invalid state" self.fast_pause = True elif action == "clear_buf": self.clear_timer('fast_pause_timer') self.scrobble_buf = None elif action == "delayed_play": self.clear_timer('fast_pause_timer') self.scrobble_buf = current self.fast_pause_timer = ResumableTimer( self.fast_pause_duration, self.delayed_scrobble, (self.exit_fast_pause, ), ) self.fast_pause_timer.start() elif action == "exit_fast_pause": self.exit_fast_pause() else: logger.warning(f"Invalid action {action}") def handle_status_update(self): current_state = self.parse_status() with self.lock: self.scrobble_if_state_changed(self.prev_state, current_state) self.prev_state = current_state
def test_validate_string_as_number(self): config = _root({'foo': 'bar'}) with self.assertRaises(confuse.ConfigTypeError): config['foo'].get(confuse.Number())
def test_validate_float_as_number(self): config = _root({'foo': 3.0}) valid = config['foo'].get(confuse.Number()) self.assertIsInstance(valid, float) self.assertEqual(valid, 3.0)
def test_validate_int_as_number(self): config = _root({'foo': 2}) valid = config['foo'].get(confuse.Number()) self.assertIsInstance(valid, int) self.assertEqual(valid, 2)
class PlexMon(WebInterfaceMon): name = "plex" exclude_import = False URL = "http://{ip}:{port}" STATES = {"stopped": 0, "paused": 1, "buffering": 1, "playing": 2} CONFIG_TEMPLATE = { "ip": confuse.String(default="localhost"), "port": confuse.String(default="32400"), "poll_interval": confuse.Number(default=10), } def __init__(self, scrobble_queue): try: self.token = get_token() self.URL = self.URL.format(**self.config) except KeyError: logger.exception("Check config for correct Plex params.") return if not self.token: logger.error("Unable to retrieve plex token.") return super().__init__(scrobble_queue) self.sess.headers["Accept"] = "application/json" self.sess.headers["X-Plex-Token"] = self.token self.session_url = self.URL + "/status/sessions" self.media_info_cache = {} def get_data(self, url): data = self.sess.get(url).json()["MediaContainer"] if data["size"] > 0: return data["Metadata"][0] def update_status(self): status_data = self.get_data(self.session_url) if not status_data: self.status = {} return self.status["duration"] = int(status_data["duration"]) / 1000 self.status["position"] = int(status_data["viewOffset"]) / 1000 self.status["state"] = self.STATES.get(status_data["Player"]["state"], 0) self.status["media_info"] = self.get_media_info(status_data) def get_media_info(self, status_data): media_info = self.media_info_cache.get(status_data["ratingKey"]) if not media_info: media_info = self._get_media_info(status_data) self.media_info_cache[status_data["ratingKey"]] = media_info return media_info @staticmethod def _get_media_info(status_data): if status_data["type"] == "movie": return {"type": "movie", "title": status_data["title"]} elif status_data["type"] == "episode": return { "type": "episode", "title": status_data["grandparentTitle"], "season": status_data["parentIndex"], "episode": status_data["index"], }
class PlexMon(WebInterfaceMon): name = "plex" exclude_import = False URL = "http://{ip}:{port}" STATES = {"stopped": 0, "paused": 1, "buffering": 1, "playing": 2} CONFIG_TEMPLATE = { "ip": confuse.String(default="localhost"), "port": confuse.String(default="32400"), "poll_interval": confuse.Number(default=10), "scrobble_user": confuse.String(default="") } def __init__(self, scrobble_queue): try: self.token = get_token() self.URL = self.URL.format(**self.config) except KeyError: logger.exception("Check config for correct Plex params.") return if not self.token: logger.error("Unable to retrieve plex token.") return super().__init__(scrobble_queue) self.sess.headers["Accept"] = "application/json" self.sess.headers["X-Plex-Token"] = self.token self.session_url = self.URL + "/status/sessions" self.media_info_cache = {} def get_data(self, url): resp = self.sess.get(url) # TODO: If we get a 401, clear token and restart plex auth flow resp.raise_for_status() data = resp.json()["MediaContainer"] if data["size"] <= 0: return None # no user filter if not self.config["scrobble_user"] or "User" not in data["Metadata"][ 0]: return data["Metadata"][0] for metadata in data["Metadata"]: if metadata["User"]["title"] == self.config["scrobble_user"]: return metadata def update_status(self): status_data = self.get_data(self.session_url) if not status_data: self.status = {} return self.status["duration"] = int(status_data["duration"]) / 1000 self.status["position"] = int(status_data["viewOffset"]) / 1000 self.status["state"] = self.STATES.get(status_data["Player"]["state"], 0) self.status["media_info"] = self.get_media_info(status_data) def get_media_info(self, status_data): media_info = self.media_info_cache.get(status_data["ratingKey"]) if not media_info: if status_data["type"] == "episode": # get the show's data show_key = status_data["grandparentKey"] show_data = self.media_info_cache.get(show_key) if not show_data: show_data = self.get_data(self.URL + show_key) self.media_info_cache[show_key] = show_data else: show_data = None media_info = self._get_media_info(status_data, show_data) self.media_info_cache[status_data["ratingKey"]] = media_info return media_info @staticmethod def _get_media_info(status_data, show_data=None): if status_data["type"] == "movie": info = { "type": "movie", "title": status_data["title"], "year": status_data.get("year"), } elif status_data["type"] == "episode": info = { "type": "episode", "title": status_data["grandparentTitle"], "season": status_data["parentIndex"], "episode": status_data["index"], "year": show_data and show_data.get("year"), } else: logger.warning(f"Unknown media type {status_data['type']}") return None if info["year"] is not None: info["year"] = year = int(info["year"]) # if year is at the end of the title, like "The Boys (2019)", remove it # otherwise it might not show up on Trakt search suffix = f" ({year})" if info["title"].endswith(suffix): info["title"] = info["title"].replace(suffix, "") return cleanup_guess(info)
class Config: template = { 'twitter': { 'consumer_key': confuse.String(), 'consumer_secret': confuse.String(), 'access_token_key': confuse.String(), 'access_token_secret': confuse.String(), 'min_ratelimit_percent': confuse.Integer(), 'search': { 'queries': confuse.TypeTemplate(list), 'max_queue': confuse.Integer(), 'max_quote_depth': confuse.Integer(), 'min_quote_similarity': confuse.Number(), 'skip_retweeted': confuse.TypeTemplate(bool), 'filter': { 'min_retweets': { 'enabled': confuse.TypeTemplate(bool), 'number': confuse.Integer() } }, 'sort': { 'by_keywords': { 'enabled': confuse.TypeTemplate(bool), 'keywords': confuse.StrSeq() }, 'by_age': { 'enabled': confuse.TypeTemplate(bool), }, 'by_retweets_count': { 'enabled': confuse.TypeTemplate(bool), } } }, 'actions': { 'follow': { 'enabled': confuse.TypeTemplate(bool), 'keywords': confuse.StrSeq(), 'max_following': confuse.Integer(), 'multiple': confuse.TypeTemplate(bool) }, 'favorite': { 'enabled': confuse.TypeTemplate(bool), 'keywords': confuse.StrSeq() }, 'tag_friend': { 'enabled': confuse.TypeTemplate(bool), 'friends': confuse.StrSeq(), } }, 'scheduler': { 'search_interval': confuse.Integer(), 'retweet_interval': confuse.Integer(), 'retweet_random_margin': confuse.Integer(), 'blocked_users_update_interval': confuse.Integer(), 'clear_queue_interval': confuse.Integer(), 'rate_limit_update_interval': confuse.Integer(), 'check_mentions_interval': confuse.Integer(), }, }, 'notifiers': { 'mail': { 'enabled': confuse.TypeTemplate(bool), 'host': confuse.String(), 'port': confuse.Integer(), 'tls': confuse.TypeTemplate(bool), 'username': confuse.String(), 'password': confuse.String(), 'recipient': confuse.String() }, 'pushbullet': { 'enabled': confuse.TypeTemplate(bool), 'token': confuse.String() } } } _valid = None @staticmethod def get(): """ Gets the static config object :return: """ if Config._valid is None: raise ValueError("Configuration not loaded") return Config._valid @staticmethod def load(filename=None): """ Loads a file and imports the settings :param filename: the file to import """ config = confuse.LazyConfig('Yatcobot', __name__) # Add default config when in egg (using this way because egg is breaking the default way) if len(config.sources) == 0: default_config_text = pkg_resources.resource_string("yatcobot", "config_default.yaml") default_config = confuse.ConfigSource(yaml.load(default_config_text, Loader=confuse.Loader), 'pkg/config_default.yaml', True) config.add(default_config) # Add user specified config if filename is not None and os.path.isfile(filename): config.set_file(filename) logger.info('Loading config files (From highest priority to lowest):') for i, config_source in enumerate(config.sources): logger.info('{}: Path: {}'.format(i, config_source.filename)) Config._valid = config.get(Config.template)
"target": confuse.TypeTemplate(str, default=None), "paths": { "json_dir": confuse.Filename(default=str(DEFAULT_JSON_DIR)), "conf_dir": confuse.Filename(default=str(DEFAULT_JSON_DIR)), "log_dir": confuse.Filename(default=str(DEFAULT_LOG_DIR)), }, "wobjs": { "picking_wobj_name": str, "placing_wobj_name": str }, "tool": { "tool_name": str, "io_needles_pin": str, "grip_state": int, "release_state": int, "wait_before_io": confuse.Number(default=2), "wait_after_io": confuse.Number(default=0.5), }, "speed_values": { "speed_override": confuse.Number(default=100), "speed_max_tcp": float, "accel": float, "accel_ramp": confuse.Number(default=100), }, "safe_joint_positions": { "start": confuse.Sequence([float] * 6), "end": confuse.Sequence([float] * 6), }, "movement": { "offset_distance": float, "speed_placing": float,