示例#1
0
class PifiServerRequestHandler(BaseHTTPRequestHandler):
    def __init__(self, request, client_address, server):
        self.__root_dir = DirectoryUtils().root_dir + "/app/build"
        self.__api = PifiAPI()
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        BaseHTTPRequestHandler.__init__(self, request, client_address, server)

    def do_OPTIONS(self):
        self.send_response(200, "ok")
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_GET(self):
        try:
            if self.path[:4] == "/api":
                return self.__do_api_GET(self.path[5:])

            return self.__serve_static_asset()
        except Exception:
            self.log_error('Exception: {}'.format(traceback.format_exc()))

    def do_POST(self):
        try:
            if self.path[:4] == "/api":
                return self.__do_api_POST(self.path[5:])
            return self.__serve_static_asset()
        except Exception:
            self.log_error('Exception: {}'.format(traceback.format_exc()))

    def __do_404(self):
        self.send_response(404)
        self.end_headers()

    def __do_api_GET(self, path):
        parsed_path = urllib.parse.urlparse(path)
        get_data = urllib.parse.unquote(parsed_path.query)
        if get_data:
            get_data = json.loads(get_data)

        if parsed_path.path == 'queue':
            response = self.__api.get_queue()
        elif parsed_path.path == 'vol_pct':
            response = self.__api.get_volume()
        elif parsed_path.path == 'high_scores':
            response = self.__api.get_high_scores(get_data)
        elif parsed_path.path == 'snake':
            response = self.__api.get_snake_data()
        elif parsed_path.path == 'youtube_api_key':
            response = self.__api.get_youtube_api_key()
        else:
            self.__do_404()
            return

        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        resp = BytesIO()
        resp.write(bytes(json.dumps(response), 'utf-8'))
        self.wfile.write(resp.getvalue())

    def __do_api_POST(self, path):
        content_length = int(self.headers['Content-Length'])

        post_data = None
        if content_length > 0:
            body = self.rfile.read(content_length)
            post_data = json.loads(body.decode("utf-8"))

        if path == 'queue':
            response = self.__api.enqueue(post_data)
        elif path == 'skip':
            response = self.__api.skip(post_data)
        elif path == 'remove':
            response = self.__api.remove(post_data)
        elif path == 'clear':
            response = self.__api.clear()
        elif path == 'play_next':
            response = self.__api.play_next(post_data)
        elif path == 'screensaver':
            response = self.__api.set_screensaver_enabled(post_data)
        elif path == 'vol_pct':
            response = self.__api.set_vol_pct(post_data)
        elif path == 'enqueue_or_join_game':
            response = self.__api.enqueue_or_join_game(post_data)
        elif path == 'submit_game_score_initials':
            response = self.__api.submit_game_score_initials(post_data)
        else:
            self.__do_404()
            return

        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        resp = BytesIO()
        resp.write(bytes(json.dumps(response), 'utf-8'))
        self.wfile.write(resp.getvalue())

    def __serve_static_asset(self):
        self.path = urlparse(
            self.path
        ).path  # get rid of query parameters e.g. `?foo=bar&baz=1`
        if self.path == '/':
            self.path = '/index.html'

        if self.path == '/snake' or self.path == '/snake/':
            self.path = DirectoryUtils().root_dir + '/assets/snake/snake.html'
        elif self.path.startswith('/assets/'):
            self.path = DirectoryUtils(
            ).root_dir + '/assets/' + self.path[len('/assets/'):]
        else:
            self.path = self.__root_dir + self.path

        try:
            file_to_open = open(self.path, 'rb').read()
            self.send_response(200)
        except Exception as e:
            self.__logger.error(str(e))
            self.log_error('Exception: {}'.format(traceback.format_exc()))
            file_to_open = "File not found"
            self.__do_404()
            return

        if self.path.endswith('.js'):
            self.send_header("Content-Type", "text/javascript")
        elif self.path.endswith('.css'):
            self.send_header("Content-Type", "text/css")
        elif self.path.endswith('.svg') or self.path.endswith('.svgz'):
            self.send_header("Content-Type", "image/svg+xml")
        self.end_headers()

        if type(file_to_open) is bytes:
            self.wfile.write(file_to_open)
        else:
            self.wfile.write(bytes(file_to_open, 'utf-8'))
        return

    def log_request(self, code='-', size='-'):
        if isinstance(code, HTTPStatus):
            code = code.value
        self.log_message('[REQUEST] "%s" %s %s', self.requestline, str(code),
                         str(size))

    def log_error(self, format, *args):
        self.__logger.error("%s - - %s" %
                            (self.client_address[0], format % args))

    def log_message(self, format, *args):
        self.__logger.info("%s - - %s" %
                           (self.client_address[0], format % args))
示例#2
0
    async def server_connect(self, websocket, path):
        # create a logger local to this thread so that the namespace isn't clobbered by another thread
        logger = Logger().set_namespace(self.__class__.__name__ + '__' +
                                        Logger.make_uuid())
        logger.info("websocket server_connect. ws: " + str(websocket) +
                    " path: " + str(path))

        try:
            # The client should send the playlist_video_id of the game they are joining as their first
            # message. The client is obtaining the playlist_video_id (via HTTP POST request) and
            # connecting to the websocket server concurrently, so give up to 5 seconds for the client
            # to get the playlist_video_id and send it to us.
            playlist_video_id_msg = await asyncio.wait_for(websocket.recv(), 5)
        except Exception as e:
            logger.info(
                f"Did not receive playlist_video_id message from client. Exception: {e}"
            )
            self.__end_connection(logger)
            return

        unix_socket_helper = UnixSocketHelper()
        try:
            unix_socket_helper.connect(Queue.UNIX_SOCKET_PATH)
            unix_socket_helper.send_msg(playlist_video_id_msg)
        except Exception:
            logger.error('Caught exception: {}'.format(traceback.format_exc()))
            self.__end_connection(logger, unix_socket_helper)
            return

        while True:
            try:
                # One might think we might not need a timeout here, and that we could wait here indefinitely.
                # But we need to periodically check if the unix socket is ready to read and forward messages
                # to the websocket client. Thus, we need asyncio.wait_for with a timeout here.
                move = await asyncio.wait_for(websocket.recv(), 0.1)
            except asyncio.TimeoutError:
                move = None
            except Exception as e:
                logger.info(
                    f"Exception reading from websocket. Ending game. Exception: {e}"
                )
                break

            if move is not None:
                try:
                    unix_socket_helper.send_msg(move)
                except Exception:
                    logger.error(
                        f'Unable to send move [{move}]: {traceback.format_exc()}'
                    )
                    break

            if unix_socket_helper.is_ready_to_read():
                msg = None
                try:
                    msg = unix_socket_helper.recv_msg()
                except (SocketClosedException, ConnectionResetError):
                    logger.info("Unix socket was closed")
                    break  # server detected game over and closed the socket

                await websocket.send(msg)

        self.__end_connection(logger, unix_socket_helper)
示例#3
0
文件: snake.py 项目: 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)
示例#4
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
示例#5
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)
示例#6
0
文件: database.py 项目: dasl-/pifi
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)")