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)
def __init__(self): self.__cursor = pifi.database.Database().get_cursor() self.__logger = Logger().set_namespace(self.__class__.__name__)
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 Playlist: STATUS_QUEUED = 'STATUS_QUEUED' STATUS_DELETED = 'STATUS_DELETED' # No longer in the queue STATUS_PLAYING = 'STATUS_PLAYING' # This is a "sub-status" of STATUS_PLAYING. This is to support multiplayer games. For all intents and purposes, # we consider the game to be "playing" when in this state, but we are just waiting for the rest of the players # to join the game. STATUS_PLAYING_WAITING_FOR_PLAYERS = 'STATUS_PLAYING_WAITING_FOR_PLAYERS' STATUS_DONE = 'STATUS_DONE' """ The Playlist DB holds a queue of playlist items to play. These items can be either videos or games, such as snake. When a game is requested, we insert a new row in the playlist DB. This gets an autoincremented playlist_video_id, and playlist_video_id is what we use to order the playlist queue. Thus, if we didn't do anything special, the game would only start when the current queue of playlist items had been exhausted. The behavior we actually want though is to skip the current video (if there is one) and immediately start playing the requested game. Thus, we actually order the queue by a combination of `type` and `playlist_video_id`. Rows in the DB with a `game` type (i.e. snake) get precedence in the queue. """ TYPE_VIDEO = 'TYPE_VIDEO' TYPE_GAME = 'TYPE_GAME' # sqlite3's maximum integer value. Higher priority means play the playlist item first. __GAME_PRIORITY = 2**63 - 1 def __init__(self): self.__cursor = pifi.database.Database().get_cursor() self.__logger = Logger().set_namespace(self.__class__.__name__) def construct(self): self.__cursor.execute("DROP TABLE IF EXISTS playlist_videos") self.__cursor.execute(""" CREATE TABLE playlist_videos ( playlist_video_id INTEGER PRIMARY KEY, type VARCHAR(20) DEFAULT 'TYPE_VIDEO', create_date DATETIME DEFAULT CURRENT_TIMESTAMP, url TEXT, thumbnail TEXT, title TEXT, duration VARCHAR(20), color_mode VARCHAR(20), status VARCHAR(20), is_skip_requested INTEGER DEFAULT 0, settings TEXT DEFAULT '', priority INTEGER DEFAULT 0 )""") self.__cursor.execute("DROP INDEX IF EXISTS status_type_priority_idx") self.__cursor.execute( "CREATE INDEX status_type_priority_idx ON playlist_videos (status, type, priority)" ) self.__cursor.execute("DROP INDEX IF EXISTS status_priority_idx") self.__cursor.execute( "CREATE INDEX status_priority_idx ON playlist_videos (status, priority DESC, playlist_video_id ASC)" ) def enqueue(self, url, color_mode, thumbnail, title, duration, video_type, settings): if video_type == self.TYPE_GAME: priority = self.__GAME_PRIORITY else: priority = 0 self.__cursor.execute(( "INSERT INTO playlist_videos " + "(type, url, color_mode, thumbnail, title, duration, status, settings, priority) " + "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)"), [ video_type, url, color_mode, thumbnail, title, duration, self.STATUS_QUEUED, settings, priority ]) return self.__cursor.lastrowid # Re-enqueue a video at the front of the queue. # # Note: this method only works for videos of type TYPE_VIDEO. Attempting to use this for # type TYPE_GAME would result in integer overflow incrementing the priority if we # did not filter for only videos of TYPE_VIDEO in the sub WHERE clause. def reenqueue(self, playlist_video_id): self.__cursor.execute( """ UPDATE playlist_videos set status = ?, is_skip_requested = ?, priority = (SELECT MAX(priority)+1 FROM playlist_videos WHERE type = ? AND status = ?) WHERE playlist_video_id = ?""", [ self.STATUS_QUEUED, 0, self.TYPE_VIDEO, self.STATUS_QUEUED, playlist_video_id ]) return self.__cursor.rowcount >= 1 # Passing the id of the video to skip ensures our skips are "atomic". That is, we can ensure we skip the # video that the user intended to skip. # # Note: technically this method only _requests_ a skip. The actual skipping is asynchronous, handled by # the queue process. def skip(self, playlist_video_id): self.__cursor.execute( "UPDATE playlist_videos set is_skip_requested = 1 WHERE (status = ? OR status = ?) AND playlist_video_id = ?", [ self.STATUS_PLAYING, self.STATUS_PLAYING_WAITING_FOR_PLAYERS, playlist_video_id ]) return self.__cursor.rowcount >= 1 def remove(self, playlist_video_id): self.__cursor.execute( "UPDATE playlist_videos set status = ? WHERE playlist_video_id = ? AND status = ?", [self.STATUS_DELETED, playlist_video_id, self.STATUS_QUEUED]) return self.__cursor.rowcount >= 1 def clear(self): self.__cursor.execute( "UPDATE playlist_videos set status = ? WHERE status = ?", [self.STATUS_DELETED, self.STATUS_QUEUED]) self.__cursor.execute( "UPDATE playlist_videos set is_skip_requested = 1 WHERE (status = ? OR status = ?)", [self.STATUS_PLAYING, self.STATUS_PLAYING_WAITING_FOR_PLAYERS]) def play_next(self, playlist_video_id): self.__cursor.execute( """ UPDATE playlist_videos set priority = ( SELECT MAX(priority)+1 FROM playlist_videos WHERE type = ? AND status = ? ) WHERE playlist_video_id = ? """, [self.TYPE_VIDEO, self.STATUS_QUEUED, playlist_video_id]) return self.__cursor.rowcount >= 1 def get_current_video(self): self.__cursor.execute( "SELECT * FROM playlist_videos WHERE (status = ? OR status = ?) LIMIT 1", [self.STATUS_PLAYING, self.STATUS_PLAYING_WAITING_FOR_PLAYERS]) return self.__cursor.fetchone() def get_next_playlist_item(self): self.__cursor.execute( "SELECT * FROM playlist_videos WHERE status = ? order by priority desc, playlist_video_id asc LIMIT 1", [self.STATUS_QUEUED]) return self.__cursor.fetchone() def get_queue(self): self.__cursor.execute( "SELECT * FROM playlist_videos WHERE status IN (?, ?, ?) order by priority desc, playlist_video_id asc", [ self.STATUS_PLAYING, self.STATUS_PLAYING_WAITING_FOR_PLAYERS, self.STATUS_QUEUED ]) queue = self.__cursor.fetchall() ordered_queue = [] for playlist_item in queue: if playlist_item['status'] == self.STATUS_PLAYING: ordered_queue.insert(0, playlist_item) else: ordered_queue.append(playlist_item) return ordered_queue def get_playlist_item_by_id(self, playlist_video_id): self.__cursor.execute( "SELECT * FROM playlist_videos WHERE playlist_video_id = ? LIMIT 1", [playlist_video_id]) return self.__cursor.fetchone() # Atomically set the requested video to "playing" status. This may fail if in a scenario like: # 1) Next video in the queue is retrieved # 2) Someone deletes the video from the queue # 3) We attempt to set the video to "playing" status def set_current_video(self, playlist_video_id, is_waiting_for_players=False): if is_waiting_for_players: status_to_set = self.STATUS_PLAYING_WAITING_FOR_PLAYERS else: status_to_set = self.STATUS_PLAYING self.__cursor.execute( "UPDATE playlist_videos set status = ? WHERE status = ? AND playlist_video_id = ?", [status_to_set, self.STATUS_QUEUED, playlist_video_id]) if self.__cursor.rowcount == 1: return True return False def set_all_players_ready(self, playlist_video_id): self.__cursor.execute( "UPDATE playlist_videos set status = ? WHERE status = ? AND playlist_video_id = ?", [ self.STATUS_PLAYING, self.STATUS_PLAYING_WAITING_FOR_PLAYERS, playlist_video_id ]) if self.__cursor.rowcount == 1: return True return False def end_video(self, playlist_video_id): self.__cursor.execute( "UPDATE playlist_videos set status=? WHERE playlist_video_id=?", [self.STATUS_DONE, playlist_video_id]) # Clean up any weird state we may have in the DB as a result of unclean shutdowns, etc: # set any existing 'playing' videos to 'done'. def clean_up_state(self): self.__cursor.execute( "UPDATE playlist_videos set status = ? WHERE (status = ? OR status = ?)", [ self.STATUS_DONE, self.STATUS_PLAYING, self.STATUS_PLAYING_WAITING_FOR_PLAYERS ]) def should_skip_video_id(self, playlist_video_id): current_video = self.get_current_video() if current_video and current_video[ 'playlist_video_id'] != playlist_video_id: self.__logger.warning(( "Database and current process disagree about which playlist item is currently playing. " + "Database says playlist_video_id: {}, whereas current process says playlist_video_id: {}." .format(current_video['playlist_video_id'], playlist_video_id))) return False if current_video and current_video["is_skip_requested"]: self.__logger.info("Skipping current playlist item as requested.") return True return False
class Config: CONFIG_PATH = DirectoryUtils().root_dir + '/config.json' __is_loaded = False __config = {} __logger = Logger().set_namespace('Config') __PATH_SEP = '.' # Get a key from config using dot notation: "foo.bar.baz" @staticmethod def get(key, default=None): return Config.__get(key, should_throw=False, default=default) @staticmethod def get_or_throw(key): return Config.__get(key, should_throw=True, default=None) @staticmethod def set(key, value): Config.load_config_if_not_loaded() new_config = Config.__set_nested(key.split(Config.__PATH_SEP), value, Config.__config) Config.__config = new_config @staticmethod def load_config_if_not_loaded(should_set_log_level=True): if Config.__is_loaded: return if not os.path.exists(Config.CONFIG_PATH): raise Exception(f"No config file found at: {Config.CONFIG_PATH}.") Config.__logger.info(f"Found config file at: {Config.CONFIG_PATH}") with open(Config.CONFIG_PATH) as config_json: Config.__config = pyjson5.decode(config_json.read()) if 'log_level' in Config.__config and should_set_log_level: Logger.set_level(Config.__config['log_level']) Config.__is_loaded = True @staticmethod def __get(key, should_throw=False, default=None): Config.load_config_if_not_loaded() config = Config.__config for key in key.split(Config.__PATH_SEP): if key in config: config = config[key] else: if should_throw: raise KeyError(f"{key}") else: return default return config """ keys: list of string keys value: any value my_dict: a dict in which to set the nested list of keys to the given value returns: a dict identical to my_dict, except with the nested dict element identified by the list of keys set to the given value Ex: >>> __set_nested(['foo'], 1, {}) {'foo': 1} >>> __set_nested(['foo', 'bar'], 1, {}) {'foo': {'bar': 1}} >>> __set_nested(['foo'], 1, {'foo': 2}) {'foo': 1} >>> __set_nested(['foo', 'bar'], 1, {'foo': {'baz': 2}}) {'foo': {'baz': 2, 'bar': 1}} """ @staticmethod def __set_nested(keys, value, my_dict): if len(keys) > 1: key = keys[0] if key in my_dict: if isinstance(my_dict[key], dict): new_config = my_dict[key] else: new_config = {} else: new_config = {} return { **my_dict, **{ key: Config.__set_nested(keys[1:], value, new_config) } } elif len(keys) == 1: my_dict[keys[0]] = value return my_dict else: raise Exception("No keys were given.")
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)
def __init__(self): self.__logger = Logger().set_namespace(self.__class__.__name__)
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)")