class PifiServerRequestHandler(BaseHTTPRequestHandler): def __init__(self, request, client_address, server): self.__root_dir = DirectoryUtils().root_dir + "/app/build" self.__api = PifiAPI() self.__logger = Logger().set_namespace(self.__class__.__name__) BaseHTTPRequestHandler.__init__(self, request, client_address, server) def do_OPTIONS(self): self.send_response(200, "ok") self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') self.send_header("Access-Control-Allow-Headers", "X-Requested-With") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() def do_GET(self): try: if self.path[:4] == "/api": return self.__do_api_GET(self.path[5:]) return self.__serve_static_asset() except Exception: self.log_error('Exception: {}'.format(traceback.format_exc())) def do_POST(self): try: if self.path[:4] == "/api": return self.__do_api_POST(self.path[5:]) return self.__serve_static_asset() except Exception: self.log_error('Exception: {}'.format(traceback.format_exc())) def __do_404(self): self.send_response(404) self.end_headers() def __do_api_GET(self, path): parsed_path = urllib.parse.urlparse(path) get_data = urllib.parse.unquote(parsed_path.query) if get_data: get_data = json.loads(get_data) if parsed_path.path == 'queue': response = self.__api.get_queue() elif parsed_path.path == 'vol_pct': response = self.__api.get_volume() elif parsed_path.path == 'high_scores': response = self.__api.get_high_scores(get_data) elif parsed_path.path == 'snake': response = self.__api.get_snake_data() elif parsed_path.path == 'youtube_api_key': response = self.__api.get_youtube_api_key() else: self.__do_404() return self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Type", "application/json") self.end_headers() resp = BytesIO() resp.write(bytes(json.dumps(response), 'utf-8')) self.wfile.write(resp.getvalue()) def __do_api_POST(self, path): content_length = int(self.headers['Content-Length']) post_data = None if content_length > 0: body = self.rfile.read(content_length) post_data = json.loads(body.decode("utf-8")) if path == 'queue': response = self.__api.enqueue(post_data) elif path == 'skip': response = self.__api.skip(post_data) elif path == 'remove': response = self.__api.remove(post_data) elif path == 'clear': response = self.__api.clear() elif path == 'play_next': response = self.__api.play_next(post_data) elif path == 'screensaver': response = self.__api.set_screensaver_enabled(post_data) elif path == 'vol_pct': response = self.__api.set_vol_pct(post_data) elif path == 'enqueue_or_join_game': response = self.__api.enqueue_or_join_game(post_data) elif path == 'submit_game_score_initials': response = self.__api.submit_game_score_initials(post_data) else: self.__do_404() return self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Type", "application/json") self.end_headers() resp = BytesIO() resp.write(bytes(json.dumps(response), 'utf-8')) self.wfile.write(resp.getvalue()) def __serve_static_asset(self): self.path = urlparse( self.path ).path # get rid of query parameters e.g. `?foo=bar&baz=1` if self.path == '/': self.path = '/index.html' if self.path == '/snake' or self.path == '/snake/': self.path = DirectoryUtils().root_dir + '/assets/snake/snake.html' elif self.path.startswith('/assets/'): self.path = DirectoryUtils( ).root_dir + '/assets/' + self.path[len('/assets/'):] else: self.path = self.__root_dir + self.path try: file_to_open = open(self.path, 'rb').read() self.send_response(200) except Exception as e: self.__logger.error(str(e)) self.log_error('Exception: {}'.format(traceback.format_exc())) file_to_open = "File not found" self.__do_404() return if self.path.endswith('.js'): self.send_header("Content-Type", "text/javascript") elif self.path.endswith('.css'): self.send_header("Content-Type", "text/css") elif self.path.endswith('.svg') or self.path.endswith('.svgz'): self.send_header("Content-Type", "image/svg+xml") self.end_headers() if type(file_to_open) is bytes: self.wfile.write(file_to_open) else: self.wfile.write(bytes(file_to_open, 'utf-8')) return def log_request(self, code='-', size='-'): if isinstance(code, HTTPStatus): code = code.value self.log_message('[REQUEST] "%s" %s %s', self.requestline, str(code), str(size)) def log_error(self, format, *args): self.__logger.error("%s - - %s" % (self.client_address[0], format % args)) def log_message(self, format, *args): self.__logger.info("%s - - %s" % (self.client_address[0], format % args))
async def server_connect(self, websocket, path): # create a logger local to this thread so that the namespace isn't clobbered by another thread logger = Logger().set_namespace(self.__class__.__name__ + '__' + Logger.make_uuid()) logger.info("websocket server_connect. ws: " + str(websocket) + " path: " + str(path)) try: # The client should send the playlist_video_id of the game they are joining as their first # message. The client is obtaining the playlist_video_id (via HTTP POST request) and # connecting to the websocket server concurrently, so give up to 5 seconds for the client # to get the playlist_video_id and send it to us. playlist_video_id_msg = await asyncio.wait_for(websocket.recv(), 5) except Exception as e: logger.info( f"Did not receive playlist_video_id message from client. Exception: {e}" ) self.__end_connection(logger) return unix_socket_helper = UnixSocketHelper() try: unix_socket_helper.connect(Queue.UNIX_SOCKET_PATH) unix_socket_helper.send_msg(playlist_video_id_msg) except Exception: logger.error('Caught exception: {}'.format(traceback.format_exc())) self.__end_connection(logger, unix_socket_helper) return while True: try: # One might think we might not need a timeout here, and that we could wait here indefinitely. # But we need to periodically check if the unix socket is ready to read and forward messages # to the websocket client. Thus, we need asyncio.wait_for with a timeout here. move = await asyncio.wait_for(websocket.recv(), 0.1) except asyncio.TimeoutError: move = None except Exception as e: logger.info( f"Exception reading from websocket. Ending game. Exception: {e}" ) break if move is not None: try: unix_socket_helper.send_msg(move) except Exception: logger.error( f'Unable to send move [{move}]: {traceback.format_exc()}' ) break if unix_socket_helper.is_ready_to_read(): msg = None try: msg = unix_socket_helper.recv_msg() except (SocketClosedException, ConnectionResetError): logger.info("Unix socket was closed") break # server detected game over and closed the socket await websocket.send(msg) self.__end_connection(logger, unix_socket_helper)
class Snake: GAME_TITLE = "snake" __GAME_OVER_REASON_SNAKE_STATE = 'game_over_reason_snake_state' __GAME_OVER_REASON_SKIPPED = 'game_over_reason_skipped' __GAME_OVER_REASON_CLIENT_SOCKET_SIGNAL = 'game_over_reason_client_socket_signal' ELIMINATED_SNAKE_BLINK_TICK_COUNT = 8 __APPLE_COLOR_CHANGE_FREQ = 0.2 __VICTORY_SOUND_FILE = DirectoryUtils( ).root_dir + "/assets/snake/SFX_LEVEL_UP_40_pct_vol.wav" __APPLE_SOUND = simpleaudio.WaveObject.from_wave_file( DirectoryUtils().root_dir + "/assets/snake/sfx_coin_double7_75_pct_vol.wav") # settings: dict shaped like return value of Snake.make_settings_from_playlist_item def __init__(self, server_unix_socket_fd, playlist_video, settings): self.__logger = Logger().set_namespace(self.__class__.__name__) self.__num_ticks = 0 self.__eliminated_snake_count = 0 self.__last_eliminated_snake_sound = None self.__apple = None self.__apples_eaten_count = 0 self.__game_color_helper = GameColorHelper() self.__playlist_video = playlist_video self.__players = [] self.__settings = settings server_unix_socket = socket.socket(fileno=server_unix_socket_fd) # The timeout is not "inherited" from the socket_fd that was given to us, thus we have to set it again. UnixSocketHelper().set_server_unix_socket_timeout(server_unix_socket) for i in range(self.__settings['num_players']): self.__players.append( SnakePlayer(i, server_unix_socket, self, settings)) # why do we use both simpleaudio and pygame mixer? see: https://github.com/dasl-/pifi/blob/main/utils/sound_test.py mixer.init(frequency=22050, buffer=512) background_music_file = secrets.choice([ 'dragon_quest4_05_town.wav', 'dragon_quest4_04_solitary_warrior.wav', 'dragon_quest4_19_a_pleasant_casino.wav', 'radia_senki_reimei_hen_06_unknown_village_elfas.wav', 'the_legend_of_zelda_links_awakening_04_mabe_village_loop.wav', ]) self.__background_music = mixer.Sound( DirectoryUtils().root_dir + "/assets/snake/{}".format(background_music_file)) self.__game_color_mode = GameColorHelper.determine_game_color_mode( Config.get('snake.game_color_mode')) self.__led_frame_player = LedFramePlayer() self.__register_signal_handlers() def play_snake(self): for i in range(self.__settings['num_players']): self.__players[i].place_snake_at_starting_location() self.__place_apple() self.__show_board() if not self.__accept_sockets(): self.__end_game(self.__GAME_OVER_REASON_CLIENT_SOCKET_SIGNAL) return self.__background_music.play(loops=-1) while True: # TODO : sleep for a variable amount depending on how long each loop iteration took. Should # lead to more consistent tick durations? self.__tick_sleep() for i in range(self.__settings['num_players']): self.__players[i].read_move_and_set_direction() self.__tick() self.__maybe_eliminate_snakes() if self.__is_game_over(): self.__end_game(self.__GAME_OVER_REASON_SNAKE_STATE) break def get_num_ticks(self): return self.__num_ticks def get_apple(self): return self.__apple def get_game_color_helper(self): return self.__game_color_helper def get_game_color_mode(self): return self.__game_color_mode @staticmethod def make_settings_from_playlist_item(playlist_item): difficulty = None num_players = None apple_count = None try: snake_record_settings = json.loads(playlist_item['settings']) difficulty = int(snake_record_settings['difficulty']) num_players = int(snake_record_settings['num_players']) apple_count = int(snake_record_settings['apple_count']) except Exception: Logger().set_namespace("Snake").error( f'Caught exception: {traceback.format_exc()}') # validation if difficulty is None or difficulty < 0 or difficulty > 9: difficulty = 7 if num_players is None or num_players < 1: num_players = 1 if num_players > 4: num_players = 4 if apple_count is None or apple_count < 1: apple_count = 15 if apple_count > 999: apple_count = 999 return { 'difficulty': difficulty, 'num_players': num_players, 'apple_count': apple_count, } def __tick(self): self.__increment_tick_counters() was_apple_eaten = False for i in range(self.__settings['num_players']): if (self.__players[i].tick()): was_apple_eaten = True if was_apple_eaten: self.__eat_apple() self.__show_board() def __eat_apple(self): self.__apples_eaten_count += 1 self.__APPLE_SOUND.play() self.__place_apple() player_scores = [] for i in range(self.__settings['num_players']): player_scores.append(self.__players[i].get_score()) score_message = None if self.__settings['num_players'] <= 1: score_message = json.dumps({ 'message_type': 'single_player_score', 'player_scores': player_scores, }) else: apples_left = self.__settings[ 'apple_count'] - self.__apples_eaten_count score_message = json.dumps({ 'message_type': 'multi_player_score', 'player_scores': player_scores, 'apples_left': apples_left, }) for i in range(self.__settings['num_players']): try: self.__players[i].send_socket_msg(score_message) except Exception: self.__logger.info( 'Unable to send score message to player {}'.format(i)) def __place_apple(self): display_width = Config.get_or_throw('leds.display_width') display_height = Config.get_or_throw('leds.display_height') while True: x = random.randint(0, display_width - 1) y = random.randint(0, display_height - 1) is_coordinate_occupied_by_a_snake = False for i in range(self.__settings['num_players']): is_coordinate_occupied_by_a_snake = self.__players[ i].is_coordinate_occupied(y, x) if is_coordinate_occupied_by_a_snake: break if not is_coordinate_occupied_by_a_snake: break self.__apple = (y, x) def __maybe_eliminate_snakes(self): eliminated_snakes = set() # Eliminate snakes that: # 1) overlapped themselves # 2) overlapped other snakes # 3) were previously marked for elimination for i in range(self.__settings['num_players']): if self.__players[i].is_eliminated(): continue this_snake_head = self.__players[i].get_snake_linked_list()[0] for j in range(self.__settings['num_players']): if self.__players[j].is_eliminated(): continue if i == j: # Check if this snake overlapped itself if len(self.__players[i].get_snake_set()) < len( self.__players[i].get_snake_linked_list()): eliminated_snakes.add(i) else: # Check if this snake's head overlapped that snake (any other snake) that_snake_set = self.__players[j].get_snake_set() if this_snake_head in that_snake_set: eliminated_snakes.add(i) # Eliminate snakes that were previously marked for elimination if self.__players[i].is_marked_for_elimination(): if i not in eliminated_snakes: eliminated_snakes.add(i) self.__players[i].unmark_for_elimination() for i in eliminated_snakes: self.__players[i].eliminate() self.__eliminated_snake_count += len(eliminated_snakes) # Play snake death sound in multiplayer if any snakes were eliminated if len(eliminated_snakes) > 0 and self.__settings['num_players'] > 1: self.__last_eliminated_snake_sound = simpleaudio.WaveObject.from_wave_file( DirectoryUtils().root_dir + "/assets/snake/sfx_sound_nagger1_50_pct_vol.wav").play() def __is_game_over(self): if self.__settings['num_players'] > 1: if self.__eliminated_snake_count >= ( self.__settings['num_players'] - 1): return True if self.__apples_eaten_count >= self.__settings['apple_count']: return True elif self.__settings['num_players'] == 1: if self.__eliminated_snake_count > 0: return True return False def __show_board(self): frame = np.zeros([ Config.get_or_throw('leds.display_height'), Config.get_or_throw('leds.display_width'), 3 ], np.uint8) for i in range(self.__settings['num_players']): if (not self.__players[i].should_show_snake()): # Blink snakes for the first few ticks after they are eliminated. continue for (y, x) in self.__players[i].get_snake_linked_list(): frame[y, x] = self.__players[i].get_snake_rgb() if self.__apple is not None: apple_rgb = self.__game_color_helper.get_rgb( GameColorHelper.GAME_COLOR_MODE_RAINBOW, self.__APPLE_COLOR_CHANGE_FREQ, self.__num_ticks) frame[self.__apple[0], self.__apple[1]] = apple_rgb self.__led_frame_player.play_frame(frame) def __end_game(self, reason): if self.__background_music: self.__background_music.fadeout(500) score = None if self.__settings[ 'num_players'] == 1: # only do scoring in single player score = self.__players[0].get_score() if reason == self.__GAME_OVER_REASON_SNAKE_STATE: self.__do_scoring(score) elif self.__settings[ 'num_players'] > 1 and reason == self.__GAME_OVER_REASON_SNAKE_STATE: if self.__eliminated_snake_count == self.__settings['num_players']: # The last N players died at the same time in a head to head collision. There was no winner. for i in range(self.ELIMINATED_SNAKE_BLINK_TICK_COUNT + 1): self.__tick_sleep() self.__show_board() self.__increment_tick_counters() self.__tick_sleep() else: # We have a winner / winners winners = self.__determine_multiplayer_winners() winner_message = json.dumps({ 'message_type': 'multiplayer_winners', 'winners': winners, }) for i in range(self.__settings['num_players']): try: self.__players[i].send_socket_msg(winner_message) except Exception: self.__logger.info( 'Unable to send winner message to player {}'. format(i)) did_play_victory_sound = False victory_sound = None while_counter = 0 max_loops = 100 while True: self.__tick_sleep() self.__show_board() self.__increment_tick_counters() if (not did_play_victory_sound and (self.__last_eliminated_snake_sound is None or not self.__last_eliminated_snake_sound.is_playing())): # Wait for eliminated snake sound to finish before playing victory sound victory_sound = (simpleaudio.WaveObject.from_wave_file( self.__VICTORY_SOUND_FILE).play()) did_play_victory_sound = True # Exit after playing the victory sound and waiting for the snakes to blink enough times, whichever # takes longer. if ((did_play_victory_sound and not victory_sound.is_playing() and while_counter > (2 * self.ELIMINATED_SNAKE_BLINK_TICK_COUNT)) or (while_counter > max_loops)): break while_counter += 1 for i in range(self.__settings['num_players']): self.__players[i].end_game() self.__clear_board() mixer.quit() simpleaudio.stop_all() self.__logger.info("game over. score: {}. Reason: {}".format( score, reason)) def __do_scoring(self, score): simpleaudio.WaveObject.from_wave_file( DirectoryUtils().root_dir + "/assets/snake/LOZ_Link_Die.wav").play() scores = Scores() is_high_score = scores.is_high_score(score, self.GAME_TITLE) score_id = scores.insert_score(score, self.GAME_TITLE) if is_high_score: highscore_message = json.dumps({ 'message_type': 'high_score', 'score_id': score_id }) try: self.__players[0].send_socket_msg(highscore_message) except Exception: # If game ended due to the player closing the tab, we will be unable to send the message to their websocket. # We will still insert their high score into the DB, and their initials will be "AAA". self.__logger.error( 'Unable to send high score message: {}'.format( traceback.format_exc())) time.sleep(0.3) for x in range(self.ELIMINATED_SNAKE_BLINK_TICK_COUNT + 1): # blink board time.sleep(0.1) self.__show_board() self.__increment_tick_counters() time.sleep(0.1) score_color = [255, 0, 0] # red score_tick = 0 if is_high_score: score_color = self.__game_color_helper.get_rgb( game_color_mode=GameColorHelper.GAME_COLOR_MODE_RAINBOW, color_change_freq=0.2, num_ticks=score_tick) (simpleaudio.WaveObject.from_wave_file( self.__VICTORY_SOUND_FILE).play()) score_displayer = ScoreDisplayer(self.__led_frame_player, score) score_displayer.display_score(score_color) for i in range(1, 100): # if someone clicks "New Game" while the score is being displayed, immediately start a new game # instead of waiting for the score to stop being displayed # # also make the score display in rainbow if it was a high score. time.sleep(0.05) if is_high_score: score_tick += 1 score_color = self.__game_color_helper.get_rgb( game_color_mode=GameColorHelper.GAME_COLOR_MODE_RAINBOW, color_change_freq=0.2, num_ticks=score_tick) score_displayer.display_score(score_color) # Win a multiplayer game by either: # 1) Being the last player remaining or # 2) Eating the most apples out of the non-eliminated snakes. In this case, there may be more than one winner. def __determine_multiplayer_winners(self): winners = [] if self.__settings['num_players'] == self.__eliminated_snake_count + 1: # Case 1 for i in range(self.__settings['num_players']): if not self.__players[i].is_eliminated(): self.__players[i].set_multiplayer_winner() winners.append(i) break elif self.__apples_eaten_count >= self.__settings['apple_count']: # Case 2 longest_snake_length = None longest_snake_indexes = [] for i in range(self.__settings['num_players']): if self.__players[i].is_eliminated(): continue snake_length = len(self.__players[i].get_snake_linked_list()) if longest_snake_length is None or snake_length > longest_snake_length: longest_snake_length = snake_length longest_snake_indexes = [i] elif snake_length == longest_snake_length: longest_snake_indexes.append(i) for i in longest_snake_indexes: self.__players[i].set_multiplayer_winner() winners.append(i) return winners def __clear_board(self): frame = np.zeros([ Config.get_or_throw('leds.display_height'), Config.get_or_throw('leds.display_width'), 3 ], np.uint8) self.__led_frame_player.play_frame(frame) def __tick_sleep(self): time.sleep(-0.02 * self.__settings['difficulty'] + 0.21) def __increment_tick_counters(self): self.__num_ticks += 1 for i in range(self.__settings['num_players']): self.__players[i].increment_tick_counters() # returns boolean success def __accept_sockets(self): max_accept_sockets_wait_time_s = UnixSocketHelper.MAX_SINGLE_PLAYER_JOIN_TIME_S accept_loop_start_time = time.time() if self.__settings['num_players'] > 1: max_accept_sockets_wait_time_s = UnixSocketHelper.MAX_MULTI_PLAYER_JOIN_TIME_S + 1 # give a 1s extra buffer for i in range(self.__settings['num_players']): if not (self.__players[i].accept_socket( self.__playlist_video['playlist_video_id'], accept_loop_start_time, max_accept_sockets_wait_time_s)): return False if self.__settings['num_players'] > 1: return Playlist().set_all_players_ready( self.__playlist_video['playlist_video_id']) return True def __register_signal_handlers(self): signal.signal(signal.SIGINT, self.__signal_handler) signal.signal(signal.SIGHUP, self.__signal_handler) signal.signal(signal.SIGQUIT, self.__signal_handler) signal.signal(signal.SIGABRT, self.__signal_handler) signal.signal(signal.SIGFPE, self.__signal_handler) signal.signal(signal.SIGSEGV, self.__signal_handler) signal.signal(signal.SIGPIPE, self.__signal_handler) signal.signal(signal.SIGTERM, self.__signal_handler) def __signal_handler(self, sig, frame): self.__logger.info(f"Caught signal {sig}, exiting gracefully...") self.__end_game(self.__GAME_OVER_REASON_SKIPPED) sys.exit(sig)
class SnakePlayer: UP = 1 DOWN = 2 LEFT = 3 RIGHT = 4 __SNAKE_STARTING_LENGTH = 4 __SNAKE_COLOR_CHANGE_FREQ = 0.05 __MULTIPLAYER_SNAKE_WINNER_COLOR_CHANGE_FREQ = 0.5 # settings: dict shaped like return value of Snake.make_settings_from_playlist_item def __init__(self, player_index, server_unix_socket, snake_game, settings): self.__logger = Logger().set_namespace(self.__class__.__name__ + '_' + str(player_index)) self.__snake_game = snake_game self.__player_index = player_index self.__is_multiplayer_winner = False self.__is_eliminated = False # If we can't read from the snake's socket, mark it for elimination the next time __maybe_eliminate_snakes is called self.__is_marked_for_elimination = False self.__num_ticks_since_elimination = 0 # Doubly linked list, representing all the coordinate pairs in the snake self.__snake_linked_list = collections.deque() # Set representing all the coordinate pairs in the snake self.__snake_set = set() self.__unix_socket_helper = UnixSocketHelper().set_server_socket( server_unix_socket) self.__direction = None self.__settings = settings def should_show_snake(self): if (self.__is_eliminated and (self.__num_ticks_since_elimination % 2 == 0 or self.__num_ticks_since_elimination >= pifi.games.snake.Snake.ELIMINATED_SNAKE_BLINK_TICK_COUNT)): return False return True def get_score(self): score = 0 if (self.__settings['num_players'] == 1): score = ( len(self.__snake_linked_list) - self.__SNAKE_STARTING_LENGTH) * self.__settings['difficulty'] else: score = (len(self.__snake_linked_list) - self.__SNAKE_STARTING_LENGTH) return score def get_snake_linked_list(self): return self.__snake_linked_list def get_snake_set(self): return self.__snake_set def is_eliminated(self): return self.__is_eliminated def is_marked_for_elimination(self): return self.__is_marked_for_elimination def eliminate(self): self.__is_eliminated = True def unmark_for_elimination(self): self.__is_marked_for_elimination = False def set_multiplayer_winner(self): self.__is_multiplayer_winner = True def read_move_and_set_direction(self): if self.__is_eliminated: return move = None if self.__unix_socket_helper.is_ready_to_read(): try: move = self.__unix_socket_helper.recv_msg() except (SocketClosedException, ConnectionResetError): self.__logger.info("socket closed for player") self.__is_marked_for_elimination = True self.__unix_socket_helper.close() return move, client_send_time = move.split() client_send_time = float(client_send_time) elapsed_s = (time.time() - client_send_time) # You can analyze this data for instance via: # cat /var/log/pifi/queue.log | grep 'Total elapsed' | awk '{print $(NF-1)}' | datamash max 1 min 1 mean 1 median 1 q1 1 q3 1 self.__logger.debug( f"Total elapsed from move start to registering: {elapsed_s * 1000} ms" ) move = int(move) if move not in (self.UP, self.DOWN, self.LEFT, self.RIGHT): move = self.UP if move is not None: new_direction = move if ((self.__direction == self.UP or self.__direction == self.DOWN) and (new_direction == self.UP or new_direction == self.DOWN)): pass elif ((self.__direction == self.LEFT or self.__direction == self.RIGHT) and (new_direction == self.LEFT or new_direction == self.RIGHT)): pass else: self.__direction = new_direction # returns boolean was_apple_eaten def tick(self): was_apple_eaten = False if self.__is_eliminated: return was_apple_eaten old_head_y, old_head_x = self.__snake_linked_list[0] display_width = Config.get_or_throw('leds.display_width') display_height = Config.get_or_throw('leds.display_height') if self.__direction == self.UP: new_head = ((old_head_y - 1) % display_height, old_head_x) elif self.__direction == self.DOWN: new_head = ((old_head_y + 1) % display_height, old_head_x) elif self.__direction == self.LEFT: new_head = (old_head_y, (old_head_x - 1) % display_width) elif self.__direction == self.RIGHT: new_head = (old_head_y, (old_head_x + 1) % display_width) self.__snake_linked_list.insert(0, new_head) # Must call this before placing the apple to ensure the apple is not placed on the new head self.__snake_set.add(new_head) if new_head == self.__snake_game.get_apple(): was_apple_eaten = True else: old_tail = self.__snake_linked_list[-1] del self.__snake_linked_list[-1] if old_tail != new_head: # Prevent edge case when the head is "following" the tail. # If the old_tail is the same as the new_head, we don't want to remove the old_tail from the set # because the call to `self.__snake_set.add(new_head)` would have been a no-op above. self.__snake_set.remove(old_tail) return was_apple_eaten def increment_tick_counters(self): if self.__is_eliminated: self.__num_ticks_since_elimination += 1 def is_coordinate_occupied(self, y, x): if self.__is_eliminated: return False if (y, x) in self.__snake_set: return True return False def get_snake_rgb(self): if self.__settings['num_players'] <= 1: return self.__snake_game.get_game_color_helper().get_rgb( self.__snake_game.get_game_color_mode(), self.__SNAKE_COLOR_CHANGE_FREQ, self.__snake_game.get_num_ticks()) else: if self.__is_multiplayer_winner: return self.__snake_game.get_game_color_helper().get_rgb( GameColorHelper.GAME_COLOR_MODE_RAINBOW, self.__MULTIPLAYER_SNAKE_WINNER_COLOR_CHANGE_FREQ, self.__snake_game.get_num_ticks()) if self.__player_index == 0: return self.__snake_game.get_game_color_helper().get_rgb( GameColorHelper.GAME_COLOR_MODE_GREEN, self.__SNAKE_COLOR_CHANGE_FREQ, self.__snake_game.get_num_ticks()) if self.__player_index == 1: return self.__snake_game.get_game_color_helper().get_rgb( GameColorHelper.GAME_COLOR_MODE_BLUE, self.__SNAKE_COLOR_CHANGE_FREQ, self.__snake_game.get_num_ticks()) if self.__player_index == 2: return self.__snake_game.get_game_color_helper().get_rgb( GameColorHelper.GAME_COLOR_MODE_RED, self.__SNAKE_COLOR_CHANGE_FREQ, self.__snake_game.get_num_ticks()) if self.__player_index == 3: return self.__snake_game.get_game_color_helper().get_rgb( GameColorHelper.GAME_COLOR_MODE_BW, self.__SNAKE_COLOR_CHANGE_FREQ, self.__snake_game.get_num_ticks()) return self.__snake_game.get_game_color_helper().get_rgb( GameColorHelper.GAME_COLOR_MODE_RAINBOW, self.__SNAKE_COLOR_CHANGE_FREQ, self.__snake_game.get_num_ticks()) # If one snake, place it's head in the center pixel, with it's body going to the left. # # If more than one snake, divide the grid into three columns. Alternate placing snake # heads on the left and right borders of the middle column. Snakes heads placed on the # left border will have their bodies jut to the left. Snake heads placed on the right # border will have their bodies jut to the right. # # When placing N snakes, divide the grid into N + 1 rows. Snakes will placed one per # row border. def place_snake_at_starting_location(self): display_width = Config.get_or_throw('leds.display_width') display_height = Config.get_or_throw('leds.display_height') starting_height = int( round((self.__player_index + 1) * (display_height / (self.__settings['num_players'] + 1)), 1)) if self.__settings['num_players'] == 1: starting_width = int(round(display_width / 2, 1)) else: if self.__player_index % 2 == 0: starting_width = int(round(display_width / 3, 1)) else: starting_width = int(round((2 / 3) * display_width, 1)) if self.__player_index % 2 == 0: self.__direction = self.RIGHT else: self.__direction = self.LEFT for x in range(self.__SNAKE_STARTING_LENGTH): if self.__player_index % 2 == 0: coordinate = (starting_height, starting_width - x) else: coordinate = (starting_height, starting_width + x) self.__snake_linked_list.append(coordinate) self.__snake_set.add(coordinate) def end_game(self): self.__unix_socket_helper.close() # Break circular reference (not totally sure if it works /shrug) # https://rushter.com/blog/python-garbage-collector/ self.__snake_game = None # Returns true on success, false if the socket is closed. If we were unable to send the message, # may also throw an exception. def send_socket_msg(self, msg): if self.__unix_socket_helper.is_connection_socket_open(): self.__unix_socket_helper.send_msg(msg) return True return False # returns boolean success def accept_socket(self, playlist_video_id, accept_loop_start_time, max_accept_sockets_wait_time_s): while True: if (time.time() - accept_loop_start_time) > max_accept_sockets_wait_time_s: # Make sure we don't wait indefinitely for players to join the game self.__logger.info( 'Not all players joined within the time limit for game joining.' ) return False try: self.__unix_socket_helper.accept() except socket.timeout: # Keep trying to accept until max_accept_sockets_wait_time_s expires... continue except SocketConnectionHandshakeException: # Error during handshake, there may be other websocket initiated connections in the backlog that want accepting. # Try again to avoid a situation where we accidentally had more backlogged requests than we ever call # accept on. For example, if people spam the "new game" button, we may have several websockets that called # `connect` on the unix socket, but only one instance of the snake process will ever call `accept` (the rest got # skipped by the playlist). Thus, if we did not loop through and accept all these queued requests, we would never # eliminate this backlog of queued stale requests. self.__logger.info( 'Calling accept again due to handshake error: {}'.format( traceback.format_exc())) continue except Exception: # Error during `accept`, so no indication that there are other connections that want accepting. # The backlog is probably empty. Not sure what would trigger this error. self.__logger.error( 'Caught exception during accept: {}'.format( traceback.format_exc())) return False # Sanity check that the client that ended up connected to our socket is the one that was actually intended. # This could be mismatched if two client new game requests happened in quick succession. # 1) First "new game" request opens websocket and calls `connect` on the unix socket # 2) second "new game" request opens websocket and calls `connect` on unix socket # 3) First "new game" queues up in the playlist table # 4) Second "new game" queues up in the playlist table, causing the one queued in (3) to be skipped # 5) Snake process starts running, corresponding to the playlist item queued up in (4) # 6) Snake calls `accept` and is now connected via the unix socket to the websocket from (1) # 7) observe that client is from first "new game" request and server is from second "new game" request. Mismatch. try: client_playlist_video_id = json.loads( self.__unix_socket_helper.recv_msg())['playlist_video_id'] if client_playlist_video_id != playlist_video_id: self.__logger.warning( f"Server was playing playlist_video_id: {playlist_video_id}, but client was " + f"playing playlist_video_id: {client_playlist_video_id}. Calling accept again due to " + "playlist_video_id mismatch issue.") except Exception: self.__logger.error( f'Error reading playlist_video_id from client: {traceback.format_exc()}' ) continue # send client msg indicating its player index if (self.__settings['num_players'] > 1): player_index_message = json.dumps({ 'message_type': 'player_index_message', 'player_index': self.__player_index }) try: if not self.send_socket_msg(player_index_message): self.__logger.info( 'Could not send player_index_message, call returned False.' ) return False except Exception: self.__logger.info( 'Could not send player_index_message: {}'.format( traceback.format_exc())) return False return True
class VideoProcessor: __DATA_DIRECTORY = 'data' __DEFAULT_VIDEO_EXTENSION = '.mp4' __TEMP_VIDEO_DOWNLOAD_SUFFIX = '.dl_part' __FIFO_PREFIX = 'pifi_fifo' __FPS_READY_FILE = '/tmp/fps_ready.file' __FRAMES_BUFFER_LENGTH = 1024 # clear_screen: boolean. If False, then we won't clear the screen during the init phase. # This can be useful because the Queue process starts the loading screen. If we cleared # it in the VideoProcessor before showing the loading screen again, there'd be a brief # flicker in the loading screen image. def __init__(self, url, clear_screen): self.__logger = Logger().set_namespace(self.__class__.__name__) self.__url = url self.__led_frame_player = LedFramePlayer( clear_screen=clear_screen, video_color_mode=Config.get('video.color_mode', VideoColorMode.COLOR_MODE_COLOR)) self.__process_and_play_vid_proc_pgid = None self.__init_time = time.time() # True if the video already exists (see config value: "video.should_save_video") self.__is_video_already_downloaded = False self.__do_housekeeping(clear_screen) self.__register_signal_handlers() def process_and_play(self): self.__logger.info(f"Starting process_and_play for url: {self.__url}") self.__led_frame_player.show_loading_screen() video_save_path = self.__get_video_save_path() if os.path.isfile(video_save_path): self.__logger.info( f'Video has already been downloaded. Using saved video: {video_save_path}' ) self.__is_video_already_downloaded = True elif Config.get('video.should_predownload_video', False): download_command = self.__get_streaming_video_download_cmd( ) + ' > ' + shlex.quote(self.__get_video_save_path()) self.__logger.info(f'Downloading video: {download_command}') subprocess.call(download_command, shell=True, executable='/usr/bin/bash') self.__logger.info(f'Video download complete: {video_save_path}') self.__is_video_already_downloaded = True attempt = 1 max_attempts = 2 clear_screen = True while attempt <= max_attempts: try: self.__process_and_play_video() clear_screen = True break except YoutubeDlException as e: if attempt < max_attempts: self.__logger.warning( "Caught exception in VideoProcessor.__process_and_play_video: " + traceback.format_exc()) self.__logger.warning( "Updating youtube-dl and retrying video...") self.__led_frame_player.show_loading_screen() clear_screen = False self.__update_youtube_dl() if attempt >= max_attempts: clear_screen = True raise e finally: self.__do_housekeeping(clear_screen=clear_screen) attempt += 1 self.__logger.info("Finished process_and_play") def __get_video_save_path(self): return (self.__get_data_directory() + '/' + hashlib.md5(self.__url.encode('utf-8')).hexdigest() + self.__DEFAULT_VIDEO_EXTENSION) def __get_data_directory(self): save_dir = DirectoryUtils().root_dir + '/' + self.__DATA_DIRECTORY os.makedirs(save_dir, exist_ok=True) return save_dir def __process_and_play_video(self): ffmpeg_to_python_fifo_name = self.__make_fifo( additional_prefix='ffmpeg_to_python') fps_fifo_name = self.__make_fifo(additional_prefix='fps') process_and_play_vid_cmd = self.__get_process_and_play_vid_cmd( ffmpeg_to_python_fifo_name, fps_fifo_name) self.__logger.info('executing process and play cmd: ' + process_and_play_vid_cmd) process_and_play_vid_proc = subprocess.Popen( process_and_play_vid_cmd, shell=True, executable='/usr/bin/bash', start_new_session=True) # Store the PGID separately, because attempting to get the PGID later via `os.getpgid` can # raise `ProcessLookupError: [Errno 3] No such process` if the process is no longer running self.__process_and_play_vid_proc_pgid = os.getpgid( process_and_play_vid_proc.pid) display_width = Config.get_or_throw('leds.display_width') display_height = Config.get_or_throw('leds.display_height') bytes_per_frame = display_width * display_height np_array_shape = [display_height, display_width] if VideoColorMode.is_color_mode_rgb( Config.get('video.color_mode', VideoColorMode.COLOR_MODE_COLOR)): bytes_per_frame = bytes_per_frame * 3 np_array_shape.append(3) vid_start_time = None last_frame = None vid_processing_lag_counter = 0 is_ffmpeg_done_outputting = False frames = ReadOnceCircularBuffer(self.__FRAMES_BUFFER_LENGTH) ffmpeg_to_python_fifo = open(ffmpeg_to_python_fifo_name, 'rb') fps = self.__read_fps_from_fifo(fps_fifo_name) frame_length = 1 / fps pathlib.Path(self.__FPS_READY_FILE).touch() while True: if is_ffmpeg_done_outputting or frames.is_full(): pass else: is_ffmpeg_done_outputting, vid_start_time = self.__populate_frames( frames, ffmpeg_to_python_fifo, vid_start_time, bytes_per_frame, np_array_shape) if vid_start_time is None: # video has not started being processed yet pass else: if self.__init_time: self.__logger.info( f"Started playing video after {round(time.time() - self.__init_time, 3)} s." ) self.__init_time = None is_video_done_playing, last_frame, vid_processing_lag_counter = self.__play_video( frames, vid_start_time, frame_length, is_ffmpeg_done_outputting, last_frame, vid_processing_lag_counter) if is_video_done_playing: break self.__logger.info("Waiting for process_and_play_vid_proc to end...") while True: # Wait for proc to end if process_and_play_vid_proc.poll() is not None: if process_and_play_vid_proc.returncode != 0: raise YoutubeDlException( "The process_and_play_vid_proc process exited non-zero: " + f"{process_and_play_vid_proc.returncode}. This could mean an issue with youtube-dl; " + "it may require updating.") self.__logger.info("The process_and_play_vid_proc proc ended.") break time.sleep(0.1) def __populate_frames(self, frames, ffmpeg_to_python_fifo, vid_start_time, bytes_per_frame, np_array_shape): is_ready_to_read, ignore1, ignore2 = select.select( [ffmpeg_to_python_fifo], [], [], 0) if not is_ready_to_read: return [False, vid_start_time] ffmpeg_output = ffmpeg_to_python_fifo.read(bytes_per_frame) if ffmpeg_output and len(ffmpeg_output) < bytes_per_frame: raise Exception( 'Expected {} bytes from ffmpeg output, but got {}.'.format( bytes_per_frame, len(ffmpeg_output))) if not ffmpeg_output: self.__logger.info("no ffmpeg_output, end of video processing.") if vid_start_time is None: # under rare circumstances, youtube-dl might fail and we end up in this code path. self.__logger.error( "No vid_start_time set. Possible yt-dl crash. See: https://github.com/ytdl-org/youtube-dl/issues/24780" ) vid_start_time = 0 # set this so that __process_and_play_video doesn't endlessly loop return [True, vid_start_time] if vid_start_time is None: # Start the video clock as soon as we see ffmpeg output. Ffplay probably sent its # first audio data at around the same time so they stay in sync. # Add time for better audio / video sync vid_start_time = time.time() + (0.075 if Config.get( 'video.should_play_audio', True) else 0) frames.append( np.frombuffer(ffmpeg_output, np.uint8).reshape(np_array_shape)) return [False, vid_start_time] def __play_video(self, frames, vid_start_time, frame_length, is_ffmpeg_done_outputting, last_frame, vid_processing_lag_counter): cur_frame = max( math.floor((time.time() - vid_start_time) / frame_length), 0) if cur_frame >= len(frames): if is_ffmpeg_done_outputting: self.__logger.info( "Video done playing. Video processing lag counter: {}.". format(vid_processing_lag_counter)) return [True, cur_frame, vid_processing_lag_counter] else: vid_processing_lag_counter += 1 if vid_processing_lag_counter % 1000 == 0 or vid_processing_lag_counter == 1: self.__logger.error( f"Video processing is lagging. Counter: {vid_processing_lag_counter}. " + f"Frames available: {frames.unread_length()}.") cur_frame = len( frames) - 1 # play the most recent frame we have if cur_frame == last_frame: # We don't need to play a frame since we're still supposed to be playing the last frame we played return [False, cur_frame, vid_processing_lag_counter] # Play the new frame num_skipped_frames = 0 if last_frame is None: if cur_frame != 0: num_skipped_frames = cur_frame elif cur_frame - last_frame > 1: num_skipped_frames = cur_frame - last_frame - 1 if num_skipped_frames > 0: self.__logger.error(( "Video playing unable to keep up in real-time. Skipped playing {} frame(s)." .format(num_skipped_frames))) self.__led_frame_player.play_frame(frames[cur_frame]) return [False, cur_frame, vid_processing_lag_counter] def __get_process_and_play_vid_cmd(self, ffmpeg_to_python_fifo_name, fps_fifo_name): video_save_path = self.__get_video_save_path() vid_data_cmd = None if self.__is_video_already_downloaded: vid_data_cmd = '< {} '.format(shlex.quote(video_save_path)) else: vid_data_cmd = self.__get_streaming_video_download_cmd() + ' | ' # Explanation of the FPS calculation pipeline: # # cat - >/dev/null: Prevent tee from exiting uncleanly (SIGPIPE) after ffprobe has finished probing. # # mbuffer: use mbuffer so that writes to ffprobe are not blocked by shell pipeline backpressure. # Note: ffprobe may need to read a number of bytes proportional to the video size, thus there may # be no buffer size that works for all videos (see: https://stackoverflow.com/a/70707003/627663 ) # But our current buffer size works for videos that are ~24 hours long, so it's good enough in # most cases. Something fails for videos that are 100h+ long, but I believe it's unrelated to # mbuffer size -- those videos failed even with our old model of calculating FPS separately from # the video playback pipeline. See: https://github.com/yt-dlp/yt-dlp/issues/3390 # # while true ... : The pipeline will wait until a signal is given (the existence of the __FPS_READY_FILE) # before data is emitted downstream. The signal will be given once the videoprocessor has finished # calculating the FPS of the video. The FPS is calculated by ffprobe and communicated to the # videoprocessor via the fps_fifo_name fifo. ffprobe_cmd = f'ffprobe -v 0 -of csv=p=0 -select_streams v:0 -show_entries stream=r_frame_rate - > {fps_fifo_name}' ffprobe_mbuffer = self.__get_mbuffer_cmd(1024 * 1024 * 50, '/tmp/mbuffer-ffprobe.out') fps_cmd = ( f'tee >( {ffprobe_cmd} && cat - >/dev/null ) | {ffprobe_mbuffer} | ' + f'{{ while true ; do [ -f {self.__FPS_READY_FILE} ] && break || sleep 0.1 ; done && cat - ; }} | ' ) maybe_play_audio_tee = '' if Config.get('video.should_play_audio', True): # Add mbuffer because otherwise the ffplay command blocks the whole pipeline. Because # audio can only play in real-time, this would block ffmpeg from processing the frames # as fast as it otherwise could. This prevents us from building up a big enough buffer # in the frames circular buffer to withstand blips in performance. This # ensures the circular buffer will generally get filled, rather than lingering around # only ~70 frames full. Makes it less likely that we will fall behind in video # processing. maybe_play_audio_tee = (">( " + self.__get_mbuffer_cmd( 1024 * 1024 * 10, '/tmp/mbuffer-ffplay.out') + ' | ' + self.__get_ffplay_cmd() + " ) ") ffmpeg_tee = f'>( {self.__get_ffmpeg_pixel_conversion_cmd()} > {ffmpeg_to_python_fifo_name} ) ' maybe_save_video_tee = '' maybe_mv_saved_video_cmd = '' if Config.get('video.should_save_video', False) and not self.__is_video_already_downloaded: self.__logger.info(f'Video will be saved to: {video_save_path}') temp_video_save_path = video_save_path + self.__TEMP_VIDEO_DOWNLOAD_SUFFIX maybe_save_video_tee = shlex.quote(temp_video_save_path) + ' ' maybe_mv_saved_video_cmd = '&& mv ' + shlex.quote( temp_video_save_path) + ' ' + shlex.quote(video_save_path) process_and_play_vid_cmd = ('set -o pipefail && export SHELLOPTS && ' + vid_data_cmd + fps_cmd + "tee " + maybe_play_audio_tee + ffmpeg_tee + maybe_save_video_tee + "> /dev/null " + maybe_mv_saved_video_cmd) return process_and_play_vid_cmd # Download the worst video and the best audio with youtube-dl, and mux them together with ffmpeg. # See: https://github.com/dasl-/piwall2/blob/53f5e0acf1894b71d180cee12ae49ddd3736d96a/docs/streaming_high_quality_videos_from_youtube-dl_to_stdout.adoc#solution-muxing-a-streaming-download def __get_streaming_video_download_cmd(self): # --retries infinite: in case downloading has transient errors youtube_dl_cmd_template = "yt-dlp {0} --retries infinite --format {1} --output - {2} | {3}" log_opts = '--no-progress' if Logger.get_level() <= Logger.DEBUG: log_opts = '' # show video download progress if not sys.stderr.isatty(): log_opts += ' --newline' # 50 MB. Based on one video, 1080p avc1 video consumes about 0.36 MB/s. So this should # be enough buffer for ~139s for a 1080p video, which is a lot higher resolution than we # are ever likely to use. video_buffer_size = 1024 * 1024 * 50 # Choose 'worst' video because we want our pixel ffmpeg video scaler to do less work when we # scale the video down to the LED matrix size. # # But the height should be at least the height of the LED matrix (this probably only matters # if someone made a very large LED matrix such that the worst quality video was lower resolution # than the LED matrix pixel dimensions). Filter on only height so that vertical videos don't # result in a super large resolution being chosen? /shrug ... could consider adding a filter on # width too. # # Use avc1 because this means h264, and the pi has hardware acceleration for this format. # See: https://github.com/dasl-/piwall2/blob/88030a47790e5ae208d2c9fe19f9c623fc736c83/docs/video_formats_and_hardware_acceleration.adoc#youtube--youtube-dl # # Fallback onto 'worst' rather than 'worstvideo', because some videos (live videos) only # have combined video + audio formats. Thus, 'worstvideo' would fail for them. video_format = ( f'worstvideo[vcodec^=avc1][height>={Config.get_or_throw("leds.display_height")}]/' + f'worst[vcodec^=avc1][height>={Config.get_or_throw("leds.display_height")}]' ) youtube_dl_video_cmd = youtube_dl_cmd_template.format( shlex.quote(self.__url), shlex.quote(video_format), log_opts, self.__get_mbuffer_cmd(video_buffer_size)) # Also use a 50MB buffer, because in some cases, the audio stream we download may also contain video. audio_buffer_size = 1024 * 1024 * 50 youtube_dl_audio_cmd = youtube_dl_cmd_template.format( shlex.quote(self.__url), # bestaudio: try to select the best audio-only format # bestaudio*: this is the fallback option -- select the best quality format that contains audio. # It may also contain video, e.g. in the case that there are no audio-only formats available. # Some videos (live videos) only have combined video + audio formats. Thus 'bestaudio' would # fail for them. shlex.quote('bestaudio/bestaudio*'), log_opts, self.__get_mbuffer_cmd(audio_buffer_size)) # Mux video from the first input with audio from the second input: https://stackoverflow.com/a/12943003/627663 # We need to specify, because in some cases, either input could contain both audio and video. But in most # cases, the first input will have only video, and the second input will have only audio. return ( f"{self.get_standard_ffmpeg_cmd()} -i <({youtube_dl_video_cmd}) -i <({youtube_dl_audio_cmd}) " + "-c copy -map 0:v:0 -map 1:a:0 -shortest -f mpegts -") def __get_ffmpeg_pixel_conversion_cmd(self): pix_fmt = 'gray' if VideoColorMode.is_color_mode_rgb( Config.get('video.color_mode', VideoColorMode.COLOR_MODE_COLOR)): pix_fmt = 'rgb24' return (self.get_standard_ffmpeg_cmd() + ' ' '-i pipe:0 ' + # read input video from stdin '-filter:v ' + shlex.quote( # resize video 'scale=' + str(Config.get_or_throw('leds.display_width')) + 'x' + str(Config.get_or_throw('leds.display_height'))) + " " '-c:a copy ' + # don't process the audio at all '-f rawvideo -pix_fmt ' + shlex.quote(pix_fmt) + " " # output in numpy compatible byte format 'pipe:1' # output to stdout ) @staticmethod def get_standard_ffmpeg_cmd(): # unfortunately there's no way to make ffmpeg output its stats progress stuff with line breaks log_opts = '-nostats ' if sys.stderr.isatty(): log_opts = '-stats ' if Logger.get_level() <= Logger.DEBUG: pass # don't change anything, ffmpeg is pretty verbose by default else: log_opts += '-loglevel error' # Note: don't use ffmpeg's `-xerror` flag: # https://gist.github.com/dasl-/1ad012f55f33f14b44393960f66c6b00 return f"ffmpeg -hide_banner {log_opts} " def __get_ffplay_cmd(self): return ("ffplay " + "-nodisp " + # Disable graphical display. "-vn " + # Disable video "-autoexit " + # Exit when video is done playing "-i pipe:0 " + # play input from stdin "-v quiet" # supress verbose ffplay output ) def __get_mbuffer_cmd(self, buffer_size_bytes, log_file=None): log_file_clause = ' -Q ' if log_file: log_file_clause = f' -l {log_file} ' return f'mbuffer -q {log_file_clause} -m ' + shlex.quote( str(buffer_size_bytes)) + 'b' def __read_fps_from_fifo(self, fps_fifo_name): fps = None try: fps_fifo = open(fps_fifo_name, 'r') # Need to call .read() rather than .readline() because in some cases, the output could # contain multiple lines. We're only interested in the first line. Closing the fifo # after only reading the first line when it has multi-line output would result in # SIGPIPE errors fps_parts = fps_fifo.read().splitlines()[0].strip().split('/') fps_fifo.close() fps = float(fps_parts[0]) / float(fps_parts[1]) except Exception as ex: self.__logger.error("Got an error determining the fps: " + str(ex)) self.__logger.error("Assuming 30 fps for this video.") fps = 30 self.__logger.info(f'Calculated video fps: {fps}') return fps def __make_fifo(self, additional_prefix=None): prefix = self.__FIFO_PREFIX + '__' if additional_prefix: prefix += additional_prefix + '__' make_fifo_cmd = ( 'fifo_name=$(mktemp --tmpdir={} --dry-run {}) && mkfifo -m 600 "$fifo_name" && printf $fifo_name' .format(tempfile.gettempdir(), prefix + 'XXXXXXXXXX')) self.__logger.info('Making fifo...') fifo_name = (subprocess.check_output( make_fifo_cmd, shell=True, executable='/usr/bin/bash').decode("utf-8")) return fifo_name def __update_youtube_dl(self): update_youtube_dl_output = (subprocess.check_output( 'sudo ' + DirectoryUtils().root_dir + '/utils/update_youtube-dl.sh', shell=True, executable='/usr/bin/bash', stderr=subprocess.STDOUT).decode("utf-8")) self.__logger.info( "Update youtube-dl output: {}".format(update_youtube_dl_output)) def __do_housekeeping(self, clear_screen=True): if clear_screen: self.__led_frame_player.clear_screen() if self.__process_and_play_vid_proc_pgid: self.__logger.info( "Killing process and play video process group (PGID: " + f"{self.__process_and_play_vid_proc_pgid})...") try: os.killpg(self.__process_and_play_vid_proc_pgid, signal.SIGTERM) except Exception: # might raise: `ProcessLookupError: [Errno 3] No such process` pass self.__logger.info( f"Deleting fifos, incomplete video downloads, and {self.__FPS_READY_FILE} ..." ) fifos_path_glob = shlex.quote(tempfile.gettempdir() + "/" + self.__FIFO_PREFIX) + '*' incomplete_video_downloads_path_glob = f'*{shlex.quote(self.__TEMP_VIDEO_DOWNLOAD_SUFFIX)}' cleanup_files_cmd = f'sudo rm -rf {fifos_path_glob} {incomplete_video_downloads_path_glob} {self.__FPS_READY_FILE}' subprocess.check_output(cleanup_files_cmd, shell=True, executable='/usr/bin/bash') def __register_signal_handlers(self): signal.signal(signal.SIGINT, self.__signal_handler) signal.signal(signal.SIGHUP, self.__signal_handler) signal.signal(signal.SIGQUIT, self.__signal_handler) signal.signal(signal.SIGABRT, self.__signal_handler) signal.signal(signal.SIGFPE, self.__signal_handler) signal.signal(signal.SIGSEGV, self.__signal_handler) signal.signal(signal.SIGPIPE, self.__signal_handler) signal.signal(signal.SIGTERM, self.__signal_handler) def __signal_handler(self, sig, frame): self.__logger.info(f"Caught signal {sig}, exiting gracefully...") self.__do_housekeeping() sys.exit(sig)
class Database: __DB_PATH = DirectoryUtils().root_dir + '/pifi.db' # Zero indexed schema_version (first version is v0). __SCHEMA_VERSION = 4 def __init__(self): self.__logger = Logger().set_namespace(self.__class__.__name__) @staticmethod def database_date_to_unix_time(database_date): return time.mktime(time.strptime(database_date, '%Y-%m-%d %H:%M:%S')) # returns False on error @staticmethod def video_duration_to_unix_time(video_duration): # video durations are of the form HH:MM:SS, may have leading zeroes, etc parts = video_duration.split(':') num_parts = len(parts) if num_parts == 1: # just seconds try: return int(parts[0]) except Exception: return False if num_parts == 2: # minutes and seconds try: return int(parts[0]) * 60 + int(parts[1]) except Exception: return False if num_parts == 3: # hours, minutes, and seconds try: return int(parts[0]) * 60 * 60 + int(parts[1]) * 60 + int(parts[2]) except Exception: return False return False # Schema change how-to: # 1) Update all DB classes with 'virgin' sql (i.e. Playlist().construct(), Scores.construct()) # 2) Increment self.__SCHEMA_VERSION # 3) Implement self.__update_schema_to_vN method for the incremented SCHEMA_VERSION # 4) Call the method in the below for loop. # 5) Run ./install/install.sh def construct(self): self.get_cursor().execute("BEGIN TRANSACTION") try: self.get_cursor().execute("SELECT version FROM pifi_schema_version") current_schema_version = int(self.get_cursor().fetchone()['version']) except Exception: current_schema_version = -1 self.__logger.info("current_schema_version: {}".format(current_schema_version)) if current_schema_version == -1: # construct from scratch self.__logger.info("Constructing database schema from scratch...") self.__construct_pifi_schema_version() pifi.playlist.Playlist().construct() pifi.games.scores.Scores().construct() pifi.settingsdb.SettingsDb().construct() elif current_schema_version < self.__SCHEMA_VERSION: self.__logger.info( f"Database schema is outdated. Updating from version {current_schema_version} to " + f"{self.__SCHEMA_VERSION}." ) for i in range(current_schema_version + 1, self.__SCHEMA_VERSION + 1): self.__logger.info( "Running database schema change to update from version {} to {}.".format(i - 1, i) ) if i == 1: self.__update_schema_to_v1() elif i == 2: self.__update_schema_to_v2() elif i == 3: self.__update_schema_to_v3() elif i == 4: self.__update_schema_to_v4() else: msg = "No update schema method defined for version: {}.".format(i) self.__logger.error(msg) raise Exception(msg) self.get_cursor().execute("UPDATE pifi_schema_version set version = ?", [i]) elif current_schema_version == self.__SCHEMA_VERSION: self.__logger.info("Database schema is already up to date!") return else: msg = ("Database schema is newer than should be possible. This should never happen. " + "current_schema_version: {}. Tried to update to version: {}." .format(current_schema_version, self.__SCHEMA_VERSION)) self.__logger.error(msg) raise Exception(msg) self.get_cursor().execute("COMMIT") self.__logger.info("Database schema constructed successfully.") def get_cursor(self): cursor = getattr(thread_local, 'database_cursor', None) if cursor is None: # `isolation_level = None` specifies autocommit mode. conn = sqlite3.connect(self.__DB_PATH, isolation_level = None) conn.row_factory = dict_factory cursor = conn.cursor() thread_local.database_cursor = cursor return cursor def __construct_pifi_schema_version(self): self.get_cursor().execute("DROP TABLE IF EXISTS pifi_schema_version") self.get_cursor().execute("CREATE TABLE pifi_schema_version (version INTEGER)") self.get_cursor().execute( "INSERT INTO pifi_schema_version (version) VALUES(?)", [self.__SCHEMA_VERSION] ) # Updates schema from v0 to v1. def __update_schema_to_v1(self): pifi.settingsdb.SettingsDb().construct() # Updates schema from v1 to v2. def __update_schema_to_v2(self): # Adding the update_date column. Trying to set the column's default value to CURRENT_TIMESTAMP results in: # sqlite3.OperationalError: Cannot add a column with non-constant default # Thus, just blow the table away and re-create it. pifi.settingsdb.SettingsDb().construct() # Updates schema from v2 to v3. def __update_schema_to_v3(self): self.get_cursor().execute("DROP INDEX IF EXISTS game_type_score_idx") self.get_cursor().execute("CREATE INDEX game_type_score_idx ON scores (game_type, score)") def __update_schema_to_v4(self): self.get_cursor().execute("ALTER TABLE playlist_videos ADD COLUMN priority INTEGER DEFAULT 0") self.get_cursor().execute("DROP INDEX IF EXISTS status_type_idx") self.get_cursor().execute("DROP INDEX IF EXISTS status_type_priority_idx") self.get_cursor().execute("CREATE INDEX status_type_priority_idx ON playlist_videos (status, type, priority)") self.get_cursor().execute("DROP INDEX IF EXISTS status_priority_idx") self.get_cursor().execute("CREATE INDEX status_priority_idx ON playlist_videos (status, priority DESC, playlist_video_id ASC)")