Example #1
0
File: snake.py Project: dasl-/pifi
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)
Example #2
0
File: scores.py Project: dasl-/pifi
 def __init__(self):
     self.__cursor = pifi.database.Database().get_cursor()
     self.__logger = Logger().set_namespace(self.__class__.__name__)
Example #3
0
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
Example #4
0
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
Example #5
0
File: config.py Project: dasl-/pifi
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.")
Example #6
0
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)
Example #7
0
 def __init__(self):
     self.__logger = Logger().set_namespace(self.__class__.__name__)
Example #8
0
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)")