Пример #1
0
    def __init__(self, ticks_per_second):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__ticks_per_second = ticks_per_second
        self.__control_message_helper = ControlMessageHelper(
        ).setup_for_broadcaster()
        self.__display_mode = DisplayMode()
        self.__animator = Animator()
        self.__vol_controller = VolumeController()
        self.__unmute_vol_pct = None
        self.__config_loader = ConfigLoader()
        self.__logger.info("Connecting to LIRC remote socket...")
        self.__socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.__socket.settimeout(1)
        self.__socket.connect('/var/run/lirc/lircd')
        self.__logger.info("Connected!")

        self.__channel = None
        self.__playlist = Playlist()
        self.__currently_playing_item = None
        if Remote.__CHANNEL_VIDEOS is None:
            Remote.__CHANNEL_VIDEOS = list(
                self.__config_loader.get_raw_config().get(
                    'channel_videos', {}).values())
Пример #2
0
 def __init__(self, request, client_address, server):
     self.__root_dir = DirectoryUtils().root_dir + "/app/build"
     self.__api = Piwall2Api()
     self.__logger = Logger().set_namespace(self.__class__.__name__)
     http.server.BaseHTTPRequestHandler.__init__(self, request, client_address, server)
Пример #3
0
class ServerRequestHandler(http.server.BaseHTTPRequestHandler):

    def __init__(self, request, client_address, server):
        self.__root_dir = DirectoryUtils().root_dir + "/app/build"
        self.__api = Piwall2Api()
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        http.server.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 == 'youtube_api_key':
            response = self.__api.get_youtube_api_key()
        elif parsed_path.path == 'title':
            response = self.__api.get_title()
        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 = io.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 == 'vol_pct':
            response = self.__api.set_vol_pct(post_data)
        elif path == 'display_mode':
            response = self.__api.set_display_mode(post_data)
        elif path == 'animation_mode':
            response = self.__api.set_animation_mode(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 = io.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 = self.__root_dir + '/index.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:
            self.log_error("")
            self.log_error(f'Unable to open file at {self.path}. Exception: {traceback.format_exc()}')
            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, http.server.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))
Пример #4
0
 def __init__(self):
     self.__cursor = piwall2.broadcaster.database.Database().get_cursor()
     self.__logger = Logger().set_namespace(self.__class__.__name__)
     self.__config_loader = ConfigLoader()
Пример #5
0
    def get_video_info(self, assert_data_not_yet_loaded=False):
        if self.__video_info:
            if assert_data_not_yet_loaded:
                raise Exception(
                    'Failed asserting that data was not yet loaded')
            return self.__video_info

        video_url_type = self.__get_video_url_type()
        if video_url_type == self.__VIDEO_URL_TYPE_YOUTUBE:
            self.__logger.info("Downloading and populating video metadata...")
            ydl_opts = {
                'format': self.__config_loader.get_youtube_dl_video_format(),
                'logger': Logger().set_namespace('youtube_dl'),
                'restrictfilenames':
                True,  # get rid of a warning ytdl gives about special chars in file names
            }
            ydl = youtube_dl.YoutubeDL(ydl_opts)

            # Automatically try to update youtube-dl and retry failed youtube-dl operations when we get a youtube-dl
            # error.
            #
            # The youtube-dl package needs updating periodically when youtube make updates. This is
            # handled on a cron once a day:
            # https://github.com/dasl-/piwall2/blob/3aa6dee264102baf2646aab1baebdcae0148b4bc/install/piwall2_cron.sh#L5
            #
            # But we also attempt to update it on the fly here if we get youtube-dl errors when trying to play
            # a video.
            #
            # Example of how this would look in logs: https://gist.github.com/dasl-/09014dca55a2e31bb7d27f1398fd8155
            max_attempts = 2
            for attempt in range(1, (max_attempts + 1)):
                try:
                    self.__video_info = ydl.extract_info(self.__video_url,
                                                         download=False)
                except Exception as e:
                    caught_or_raising = "Raising"
                    if attempt < max_attempts:
                        caught_or_raising = "Caught"
                    self.__logger.warning(
                        "Problem downloading video info during attempt {} of {}. {} exception: {}"
                        .format(attempt, max_attempts, caught_or_raising,
                                traceback.format_exc()))
                    if attempt < max_attempts:
                        self.__logger.warning(
                            "Attempting to update youtube-dl before retrying download..."
                        )
                        self.__update_youtube_dl()
                    else:
                        self.__logger.error(
                            "Unable to download video info after {} attempts.".
                            format(max_attempts))
                        raise e

            self.__logger.info(
                "Done downloading and populating video metadata.")

            self.__logger.info(
                f"Using: {self.__video_info['vcodec']} / {self.__video_info['ext']}@"
                +
                f"{self.__video_info['width']}x{self.__video_info['height']}")
        elif video_url_type == self.__VIDEO_URL_TYPE_LOCAL_FILE:
            # TODO: guard against unsupported video formats
            ffprobe_cmd = (
                'ffprobe -hide_banner -v 0 -of csv=p=0 -select_streams v:0 -show_entries stream=width,height '
                + shlex.quote(self.__video_url))
            ffprobe_output = (subprocess.check_output(
                ffprobe_cmd,
                shell=True,
                executable='/usr/bin/bash',
                stderr=subprocess.STDOUT).decode("utf-8"))
            ffprobe_output = ffprobe_output.split('\n')[0]
            ffprobe_parts = ffprobe_output.split(',')
            self.__video_info = {
                'width': int(ffprobe_parts[0]),
                'height': int(ffprobe_parts[1]),
            }

        return self.__video_info
Пример #6
0
class MulticastHelper:

    ADDRESS = '239.0.1.23'

    # Messages will be sent 'raw' over the video stream port
    VIDEO_PORT = 1234

    # Message will be sent according to the control protocol over the control port.
    # E.g. volume control commands.
    CONTROL_PORT = 1236

    # 2 MB. This will be doubled to 4MB when we set it via setsockopt.
    __VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES = 2097152

    # regarding socket.IP_MULTICAST_TTL
    # ---------------------------------
    # for all packets sent, after two hops on the network the packet will not
    # be re-sent/broadcast (see https://www.tldp.org/HOWTO/Multicast-HOWTO-6.html)
    __TTL = 1

    # max UDP packet size is 65535 bytes
    # IP Header is 20 bytes, UDP header is 8 bytes
    # 65535 - 20 - 8 = 65507
    # Sending a message of any larger will result in: `OSError: [Errno 90] Message too long`
    __MAX_MSG_SIZE = 65507

    __send_socket = None

    def __init__(self):
        self.__logger = Logger().set_namespace(self.__class__.__name__)

    def setup_broadcaster_socket(self):
        # Multiple classes in the broadcaster code will send messages over the socket. By using a static
        # __send_socket variable, ensure no matter how many instances of this class are created, all of
        # them use the same send socket.
        if MulticastHelper.__send_socket is None:
            MulticastHelper.__send_socket = socket.socket(
                socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
            MulticastHelper.__send_socket.setsockopt(socket.IPPROTO_IP,
                                                     socket.IP_MULTICAST_TTL,
                                                     self.__TTL)
            MulticastHelper.__send_socket.setsockopt(socket.IPPROTO_IP,
                                                     socket.IP_MULTICAST_LOOP,
                                                     0)
        return self

    def setup_receiver_video_socket(self):
        self.__setup_socket_receive_buffer_configuration()

        self.__receive_video_socket = self.__make_receive_socket(
            self.ADDRESS, self.VIDEO_PORT)

        # set a higher timeout while we wait for the first packet of the video to be sent
        self.__receive_video_socket.settimeout(60)

        # set a higher receive buffer size to avoid UDP packet loss.
        # see: https://github.com/dasl-/piwall2/blob/main/docs/issues_weve_seen_before.adoc#udp-packet-loss
        self.__receive_video_socket.setsockopt(
            socket.SOL_SOCKET, socket.SO_RCVBUF,
            self.__VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES)

        self.__logger.debug("Using receive buffer size of " + str(
            self.__receive_video_socket.getsockopt(socket.SOL_SOCKET,
                                                   socket.SO_RCVBUF)) +
                            " bytes on receiver video socket.")
        return self

    def setup_receiver_control_socket(self):
        self.__setup_socket_receive_buffer_configuration()
        self.__receive_control_socket = self.__make_receive_socket(
            self.ADDRESS, self.CONTROL_PORT)
        return self

    def send(self, msg, port):
        if port == self.VIDEO_PORT:
            self.__logger.debug(f"Sending video stream message: {msg}")
        elif port == self.CONTROL_PORT:
            self.__logger.debug(f"Sending control message: {msg}")

        address_tuple = (self.ADDRESS, port)
        msg_remainder = msg
        bytes_sent = 0
        while msg_remainder:  # Don't send more than __MAX_MSG_SIZE at a time
            msg_part = msg_remainder[:self.__MAX_MSG_SIZE]
            msg_remainder = msg_remainder[self.__MAX_MSG_SIZE:]
            bytes_sent += MulticastHelper.__send_socket.sendto(
                msg_part, address_tuple)
        if bytes_sent == 0:
            self.__logger.warn(
                f"Unable to send message. Address: {address_tuple}. Message: {msg}"
            )
        elif bytes_sent != len(msg):
            # Not sure if this can ever happen... This post suggests you cannot have partial sends in UDP:
            # https://www.gamedev.net/forums/topic/504256-partial-sendto/4289205/
            self.__logger.warn(
                f"Partial send of message. Sent {bytes_sent} of {len(msg)} bytes. "
                + f"Address: {address_tuple}. Message: {msg}")
        return bytes_sent

    """
    UDP datagram messages cannot be split. One send corresponds to one receive. Having multiple senders
    to the same socket in multiple processes will not clobber each other. Message boundaries will be
    preserved, even when sending a message that is larger than the MTU and making use of the OS's
    UDP fragmentation. The message will be reconstructed fully in the UDP stack layer.
    See: https://stackoverflow.com/questions/8748711/udp-recv-recvfrom-multiple-senders

    Use a receive buffer of the maximum packet size. Since we may be receiving messages of unknown
    lengths, this guarantees that we will not accidentally truncate any messages by using a receiver
    buffer that was too small.
    See `MSG_TRUNC` flag: https://man7.org/linux/man-pages/man2/recv.2.html
    See: https://stackoverflow.com/a/2862176/627663
    """

    def receive(self, port):
        if port == self.VIDEO_PORT:
            return self.__receive_video_socket.recv(self.__MAX_MSG_SIZE)
        elif port == self.CONTROL_PORT:
            return self.__receive_control_socket.recv(self.__MAX_MSG_SIZE)
        else:
            raise Exception(f'Unexpected port: {port}.')

    def get_receive_video_socket(self):
        return self.__receive_video_socket

    def __make_receive_socket(self, address, port):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
                             socket.IPPROTO_UDP)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((address, port))
        mreq = struct.pack("4sl", socket.inet_aton(address), socket.INADDR_ANY)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
        return sock

    # allow a higher receive buffer size to avoid UDP packet loss.
    # see: https://github.com/dasl-/piwall2/blob/main/docs/issues_weve_seen_before.adoc#udp-packet-loss
    def __setup_socket_receive_buffer_configuration(self):
        max_socket_receive_buffer_size = (subprocess.check_output(
            "sudo sysctl --values net.core.rmem_max",
            shell=True,
            executable='/usr/bin/bash',
            stderr=subprocess.STDOUT))
        max_socket_receive_buffer_size = int(
            max_socket_receive_buffer_size.decode().strip())
        if max_socket_receive_buffer_size < self.__VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES:
            output = (subprocess.check_output(
                f"sudo sysctl --write net.core.rmem_max={self.__VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES}",
                shell=True,
                executable='/usr/bin/bash',
                stderr=subprocess.STDOUT).decode().strip())
Пример #7
0
 def __init__(self, config_loader, receiver_config_stanza):
     self.__logger = Logger().set_namespace(self.__class__.__name__)
     self.__config_loader = config_loader
     self.__receiver_config_stanza = receiver_config_stanza
Пример #8
0
class Queue:

    __TICKS_PER_SECOND = 10
    __RECEIVER_VOLUME_SETS_PER_SECOND = 0.5

    def __init__(self):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__logger.info("Starting queue...")
        self.__config_loader = ConfigLoader()
        self.__playlist = Playlist()
        self.__settings_db = SettingsDb()
        self.__volume_controller = VolumeController()
        self.__control_message_helper = ControlMessageHelper(
        ).setup_for_broadcaster()
        self.__last_tick_time = 0
        self.__last_set_receiver_vol_time = 0
        self.__broadcast_proc = None
        self.__playlist_item = None
        self.__is_broadcast_in_progress = False
        self.__animator = Animator(self.__TICKS_PER_SECOND)
        self.__remote = Remote(self.__TICKS_PER_SECOND)

        # house keeping
        self.__volume_controller.set_vol_pct(50)
        self.__playlist.clean_up_state()

    def run(self):
        while True:
            if self.__is_broadcast_in_progress:
                self.__maybe_skip_broadcast()
                if self.__broadcast_proc and self.__broadcast_proc.poll(
                ) is not None:
                    self.__logger.info(
                        "Ending broadcast because broadcast proc is no longer running..."
                    )
                    self.__stop_broadcast_if_broadcasting()
            else:
                next_item = self.__playlist.get_next_playlist_item()
                if next_item:
                    self.__play_playlist_item(next_item)
                else:
                    self.__play_screensaver()
            self.__tick_animation_and_set_receiver_state()
            self.__remote.check_for_input_and_handle(self.__playlist_item)

            time.sleep(0.050)

    def __play_playlist_item(self, playlist_item):
        if not self.__playlist.set_current_video(
                playlist_item["playlist_video_id"]):
            # Someone deleted the item from the queue in between getting the item and starting it.
            return
        log_uuid = Logger.make_uuid()
        Logger.set_uuid(log_uuid)
        self.__logger.info(
            f"Starting broadcast for playlist_video_id: {playlist_item['playlist_video_id']}"
        )
        msg = {
            'log_uuid': log_uuid,
            'loading_screen_data': self.__choose_random_loading_screen()
        }
        self.__control_message_helper.send_msg(
            ControlMessageHelper.TYPE_SHOW_LOADING_SCREEN, msg)
        self.__do_broadcast(playlist_item['url'], log_uuid)
        self.__playlist_item = playlist_item

    def __choose_random_loading_screen(self):
        loading_screens_config = self.__config_loader.get_raw_config(
        )['loading_screens']
        if self.__config_loader.is_any_receiver_dual_video_output():
            options = loading_screens_config['720p']
        else:
            options = loading_screens_config['1080p']
        loading_screen_data = random.choice(list(options.values()))
        return loading_screen_data

    def __play_screensaver(self):
        log_uuid = 'SCREENSAVER__' + Logger.make_uuid()
        Logger.set_uuid(log_uuid)
        # choose random screensaver video to play
        screensavers_config = self.__config_loader.get_raw_config(
        )['screensavers']
        if self.__config_loader.is_any_receiver_dual_video_output():
            options = screensavers_config['720p']
        else:
            options = screensavers_config['1080p']
        screensaver_data = random.choice(list(options.values()))
        path = DirectoryUtils().root_dir + '/' + screensaver_data['video_path']
        self.__logger.info("Starting broadcast of screensaver...")
        self.__do_broadcast(path, log_uuid)

    def __do_broadcast(self, url, log_uuid):
        cmd = (
            f"{DirectoryUtils().root_dir}/bin/broadcast --url {shlex.quote(url)} "
            + f"--log-uuid {shlex.quote(log_uuid)} --no-show-loading-screen")
        # Using start_new_session = False here because it is not necessary to start a new session here (though
        # it should not hurt if we were to set it to True either)
        self.__broadcast_proc = subprocess.Popen(cmd,
                                                 shell=True,
                                                 executable='/usr/bin/bash',
                                                 start_new_session=False)
        self.__is_broadcast_in_progress = True

    def __maybe_skip_broadcast(self):
        if not self.__is_broadcast_in_progress:
            return

        should_skip = False
        if self.__playlist_item:
            try:
                # Might result in: `sqlite3.OperationalError: database is locked`, when DB is under load
                should_skip = self.__playlist.should_skip_video_id(
                    self.__playlist_item['playlist_video_id'])
            except Exception as e:
                self.__logger.info(f"Caught exception: {e}.")
        elif self.__is_screensaver_broadcast_in_progress():
            should_skip = self.__playlist.get_next_playlist_item() is not None

        if should_skip:
            self.__stop_broadcast_if_broadcasting(was_skipped=True)
            return True

        return False

    def __is_screensaver_broadcast_in_progress(self):
        return self.__is_broadcast_in_progress and self.__playlist_item is None

    def __stop_broadcast_if_broadcasting(self, was_skipped=False):
        if not self.__is_broadcast_in_progress:
            return

        if self.__broadcast_proc:
            self.__logger.info(
                "Killing broadcast proc (if it's still running)...")
            was_killed = True
            try:
                os.kill(self.__broadcast_proc.pid, signal.SIGTERM)
            except Exception:
                # might raise: `ProcessLookupError: [Errno 3] No such process`
                was_killed = False
            exit_status = self.__broadcast_proc.wait()
            if exit_status != 0:
                if was_killed and exit_status == signal.SIGTERM:
                    pass  # We expect a specific non-zero exit code if the broadcast was killed.
                else:
                    self.__logger.error(
                        f'Got non-zero exit_status for broadcast proc: {exit_status}'
                    )

        self.__control_message_helper.send_msg(
            ControlMessageHelper.TYPE_SKIP_VIDEO, {})

        if self.__playlist_item:
            if self.__should_reenqueue_current_playlist_item(was_skipped):
                self.__playlist.reenqueue(
                    self.__playlist_item["playlist_video_id"])
            else:
                self.__playlist.end_video(
                    self.__playlist_item["playlist_video_id"])

        self.__logger.info("Ended video broadcast.")
        Logger.set_uuid('')
        self.__broadcast_proc = None
        self.__playlist_item = None
        self.__is_broadcast_in_progress = False

    """
    Starting a channel video causes the currently playing video to immediately be skipped. Playing a lot of channel
    videos in quick succession could therefore cause the playlist queue to become depleted without the videos even
    having had a chance to play.

    Thus, when we are skipping a video, we check if a channel video is the next item in the queue. If so, we
    reenqueue the video so as not to deplete the queue when a lot of channel videos are being played.
    """

    def __should_reenqueue_current_playlist_item(
            self, was_current_playlist_item_skipped):
        if self.__playlist_item["type"] != Playlist.TYPE_VIDEO:
            return False

        if not was_current_playlist_item_skipped:
            return False

        next_playlist_item = self.__playlist.get_next_playlist_item()
        if next_playlist_item and next_playlist_item[
                "type"] == Playlist.TYPE_CHANNEL_VIDEO:
            return True

        return False

    # Set all receiver state on an interval to ensure eventual consistency.
    # We already set all state from the server in response to user UI actions (adjusting volume, toggling display mode)
    #
    # Possible failure scenarios:
    # 1) A UDP packet was dropped, so a receiver missed setting some state adjustment. This seems unlikely given that
    #   we tuned everything to minimize UDP packet loss (very important for a successful video broadcast).
    # 2) We ignored setting state the first time due to throttling to avoid being overwhelmed with user state modification.
    #   See: OmxplayerController.__MAX_IN_FLIGHT_PROCS
    # 3) A receiver process was restarted and thus lost its state.
    def __tick_animation_and_set_receiver_state(self):
        now = time.time()
        if (now - self.__last_tick_time) > (1 / self.__TICKS_PER_SECOND):
            # sets the display_mode of the TVs
            self.__animator.tick()
            self.__last_tick_time = now

        # maybe set volume
        if (now - self.__last_set_receiver_vol_time) > (
                1 / self.__RECEIVER_VOLUME_SETS_PER_SECOND):
            vol_pct = self.__volume_controller.get_vol_pct()
            self.__control_message_helper.send_msg(
                ControlMessageHelper.TYPE_VOLUME, vol_pct)
            self.__last_set_receiver_vol_time = now
Пример #9
0
class Database:

    __DB_PATH = DirectoryUtils().root_dir + '/piwall2.db'

    # Zero indexed schema_version (first version is v0).
    __SCHEMA_VERSION = 2

    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'))

    # Schema change how-to:
    # 1) Update all DB classes with 'virgin' sql (i.e. Playlist().construct(), etc)
    # 2) Increment self.__SCHEMA_VERSION
    # 3) Implement self.__update_schema_to_vN for the incremented SCHEMA_VERSION, call this method in
    #   the below for loop.
    # 4) Run ./install/install.sh
    def construct(self):
        self.get_cursor().execute("BEGIN TRANSACTION")
        try:
            self.get_cursor().execute("SELECT version FROM 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_schema_version()
            piwall2.broadcaster.playlist.Playlist().construct()
            piwall2.broadcaster.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()
                # When next schema change happens, do something like this:
                # elif i == 2:
                #     self.__update_schema_to_v2()
                else:
                    msg = "No update schema method defined for version: {}.".format(
                        i)
                    self.__logger.error(msg)
                    raise Exception(msg)
                self.get_cursor().execute(
                    "UPDATE 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_schema_version(self):
        self.get_cursor().execute("DROP TABLE IF EXISTS schema_version")
        self.get_cursor().execute(
            "CREATE TABLE schema_version (version INTEGER)")
        self.get_cursor().execute(
            "INSERT INTO schema_version (version) VALUES(?)",
            [self.__SCHEMA_VERSION])

    # Add new table for storing settings
    def __update_schema_to_v1(self):
        piwall2.broadcaster.settingsdb.SettingsDb().construct()

    def __update_schema_to_v2(self):
        self.get_cursor().execute(
            "ALTER TABLE playlist_videos ADD COLUMN type VARCHAR(20) DEFAULT 'TYPE_VIDEO'"
        )
        self.get_cursor().execute("DROP INDEX IF EXISTS status_idx")
        self.get_cursor().execute(
            "CREATE INDEX status_type_idx ON playlist_videos (status, type ASC, playlist_video_id ASC)"
        )
Пример #10
0
class Remote:

    __VOLUME_INCREMENT = 1
    __CHANNEL_VIDEOS = None

    # This defines the order in which we will cycle through the animation modes by pressing
    # the KEY_BRIGHTNESSUP / KEY_BRIGHTNESSDOWN buttons
    __ANIMATION_MODES = (
        Animator.ANIMATION_MODE_TILE,
        Animator.ANIMATION_MODE_REPEAT,
        Animator.ANIMATION_MODE_TILE_REPEAT,
        Animator.ANIMATION_MODE_SPIRAL,
    )

    def __init__(self, ticks_per_second):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__ticks_per_second = ticks_per_second
        self.__control_message_helper = ControlMessageHelper(
        ).setup_for_broadcaster()
        self.__display_mode = DisplayMode()
        self.__animator = Animator()
        self.__vol_controller = VolumeController()
        self.__unmute_vol_pct = None
        self.__config_loader = ConfigLoader()
        self.__logger.info("Connecting to LIRC remote socket...")
        self.__socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.__socket.settimeout(1)
        self.__socket.connect('/var/run/lirc/lircd')
        self.__logger.info("Connected!")

        self.__channel = None
        self.__playlist = Playlist()
        self.__currently_playing_item = None
        if Remote.__CHANNEL_VIDEOS is None:
            Remote.__CHANNEL_VIDEOS = list(
                self.__config_loader.get_raw_config().get(
                    'channel_videos', {}).values())

    def check_for_input_and_handle(self, currently_playing_item):
        start_time = time.time()
        self.__currently_playing_item = currently_playing_item
        data = b''
        while True:
            is_ready_to_read, ignore1, ignore2 = select.select([self.__socket],
                                                               [], [], 0)
            if not is_ready_to_read:
                return

            # The raw data will look something like (this is from holding down the volume button):
            #   b'0000000000000490 00 KEY_VOLUMEUP RM-729A\n'
            #   b'0000000000000490 01 KEY_VOLUMEUP RM-729A\n'
            #   etc...
            #
            # Socket data for multiple button presses can be received in a single recv call. Unfortunately
            # a fixed width protocol is not used, so we have to check for the presence of the expected line
            # ending character (newline).
            raw_data = self.__socket.recv(128)
            data += raw_data
            self.__logger.debug(
                f"Received remote data ({len(raw_data)}): {raw_data}")
            if raw_data != data:
                self.__logger.debug(f"Using remote data ({len(data)}): {data}")
            data_lines = data.decode('utf-8').split('\n')
            num_lines = len(data_lines)
            full_lines = data_lines[:num_lines - 1]

            # If we had a "partial read" of the remote data, ensure we read the rest of the data in the
            # next iteration.
            if data_lines[num_lines - 1] == '':
                """
                This means we read some data like:

                    0000000000000490 00 KEY_VOLUMEUP RM-729A\n
                    0000000000000490 01 KEY_VOLUMEUP RM-729A\n

                The lines we read ended with a newline. This means the most recent line we read was complete,
                so we can use it.
                """
                data = b''
            else:
                """
                This means we read some data like:

                    0000000000000490 00 KEY_VOLUMEUP RM-729A\n
                    0000000000000490 01 KEY_VOLU

                The lines we read did not end with a newline. This means it was a partial read of the last line,
                so we can't use it.

                Since the last line was a partial read, don't reset the `data` variable.
                We'll read the remainder of the line in the next loop.
                """
                data = data_lines[num_lines - 1].encode()

            # If we read data for multiple button presses in this iteration, only "use" one of those button
            # presses. Here we have logic to determine which one to use. `sequence` is a hex digit that increments
            # when you hold a button down on the remote. Use the 'first' button press (sequence == '00') whenever
            # possible -- for most buttons we don't do anything special when you hold the button down, aside from
            # the volume button. If there is no line with a sequence of '00', then use the last line.
            sequence = key_name = remote = None
            for line in full_lines:
                try:
                    ignore, sequence, key_name, remote = line.split(' ')
                except Exception as e:
                    self.__logger.warning(
                        f'Got exception parsing remote data: {e}')
                if sequence == '00':
                    break

            if not sequence:
                continue

            self.__handle_input(sequence, key_name, remote)

            # don't let reading remote input steal control from the main queue loop for too long
            if (time.time() - start_time) > (
                (1 / self.__ticks_per_second) / 2):
                return

    def __handle_input(self, sequence, key_name, remote):
        if key_name == 'KEY_MUTE' and sequence == '00':
            current_vol_pct = self.__vol_controller.get_vol_pct()
            if current_vol_pct > 0:
                # mute
                self.__unmute_vol_pct = current_vol_pct
                self.__vol_controller.set_vol_pct(0)
                self.__control_message_helper.send_msg(
                    ControlMessageHelper.TYPE_VOLUME, 0)
            else:
                # unmute
                if not self.__unmute_vol_pct:
                    # handle case where someone manually adjusted the volume to zero.
                    self.__unmute_vol_pct = 50
                self.__vol_controller.set_vol_pct(self.__unmute_vol_pct)
                self.__control_message_helper.send_msg(
                    ControlMessageHelper.TYPE_VOLUME, self.__unmute_vol_pct)
                self.__unmute_vol_pct = None
        elif (key_name in ('KEY_1', 'KEY_2', 'KEY_3', 'KEY_4', 'KEY_5',
                           'KEY_6', 'KEY_7', 'KEY_8', 'KEY_9', 'KEY_0')
              and sequence == '00'):
            key_num = int(key_name.split('_')[1])
            tv_ids = self.__config_loader.get_tv_ids_list()
            tv_id = tv_ids[key_num % len(tv_ids)]
            self.__logger.info(f'toggle_display_mode {tv_id}')
            self.__display_mode.toggle_display_mode((tv_id, ))
        elif key_name == 'KEY_SCREEN' and sequence == '00':
            animation_mode = self.__animator.get_animation_mode()
            if animation_mode == Animator.ANIMATION_MODE_REPEAT:
                self.__animator.set_animation_mode(
                    Animator.ANIMATION_MODE_TILE)
            else:
                self.__animator.set_animation_mode(
                    Animator.ANIMATION_MODE_REPEAT)
        elif key_name == 'KEY_ENTER' and sequence == '00':
            if self.__currently_playing_item:
                self.__playlist.skip(
                    self.__currently_playing_item['playlist_video_id'])
        elif key_name == 'KEY_VOLUMEUP':
            new_volume_pct = self.__vol_controller.increment_vol_pct(
                inc=self.__VOLUME_INCREMENT)
            self.__control_message_helper.send_msg(
                ControlMessageHelper.TYPE_VOLUME, new_volume_pct)
        elif key_name == 'KEY_VOLUMEDOWN':
            new_volume_pct = self.__vol_controller.increment_vol_pct(
                inc=-self.__VOLUME_INCREMENT)
            self.__control_message_helper.send_msg(
                ControlMessageHelper.TYPE_VOLUME, new_volume_pct)
        elif (key_name == 'KEY_CHANNELUP'
              or key_name == 'KEY_CHANNELDOWN') and sequence == '00':
            if len(Remote.__CHANNEL_VIDEOS) <= 0:
                return

            if self.__channel is None:
                if key_name == 'KEY_CHANNELUP':
                    self.__channel = 0
                else:
                    self.__channel = len(Remote.__CHANNEL_VIDEOS) - 1
            else:
                if key_name == 'KEY_CHANNELUP':
                    self.__channel = (self.__channel + 1) % len(
                        Remote.__CHANNEL_VIDEOS)
                else:
                    self.__channel = (self.__channel - 1) % len(
                        Remote.__CHANNEL_VIDEOS)

            self.__play_video_for_channel()
        elif (key_name == 'KEY_BRIGHTNESSUP'
              or key_name == 'KEY_BRIGHTNESSDOWN') and sequence == '00':
            old_animation_mode = self.__animator.get_animation_mode()
            try:
                old_animation_index = self.__ANIMATION_MODES.index(
                    old_animation_mode)
            except Exception:
                # unknown animation mode, or could also be ANIMATION_MODE_NONE
                old_animation_index = None

            if old_animation_index is None:
                new_animation_index = 0
            else:
                increment = 1
                if key_name == 'KEY_BRIGHTNESSDOWN':
                    increment = -1
                new_animation_index = (old_animation_index + increment) % len(
                    self.__ANIMATION_MODES)
            self.__animator.set_animation_mode(
                self.__ANIMATION_MODES[new_animation_index])

    def __play_video_for_channel(self):
        channel_data = Remote.__CHANNEL_VIDEOS[self.__channel]
        video_path = DirectoryUtils(
        ).root_dir + '/' + channel_data['video_path']
        thumbnail_path = '/' + channel_data['thumbnail_path']
        """
        Why is this necessary? One might think the `skip` call below would be sufficient.

        We could be reading multiple channel up / down button presses in here before returning
        control back to the queue. If we didn't skip all videos with TYPE_CHANNEL_VIDEO, then for each channel up /
        down button press we handle before returning control back to the queue, we'd be marking only the previously
        playing video as skipped. That is, we wouldn't mark the previous channel video we had just enqueued for skip.
        """
        self.__playlist.skip_videos_of_type(Playlist.TYPE_CHANNEL_VIDEO)

        if self.__currently_playing_item and self.__currently_playing_item[
                'type'] != Playlist.TYPE_CHANNEL_VIDEO:
            self.__playlist.skip(
                self.__currently_playing_item['playlist_video_id'])

        self.__playlist.enqueue(video_path, thumbnail_path,
                                channel_data['title'],
                                channel_data['duration'], '',
                                Playlist.TYPE_CHANNEL_VIDEO)
Пример #11
0
 def __init__(self):
     self.__logger = Logger().set_namespace(self.__class__.__name__)
     self.__load_config_if_not_loaded()
Пример #12
0
class ConfigLoader:

    # Keep this in sync with the CONFIG_PATH variable in the
    # install/setup_broadcaster_and_receivers script.
    CONFIG_PATH = DirectoryUtils().root_dir + '/config.toml'

    DUAL_VIDEO_OUTPUT_YTDL_VIDEO_FORMAT = 'bestvideo[vcodec^=avc1][height<=720]'
    SINGLE_VIDEO_OUTPUT_YTDL_VIDEO_FORMAT = 'bestvideo[vcodec^=avc1][height<=1080]'

    __is_loaded = False
    __receivers_config = None
    __raw_config = None
    __receivers = None
    __tv_config = None
    __wall_width = None
    __wall_height = None
    __youtube_dl_video_format = None
    __is_any_receiver_dual_video_output = None
    __hostname = None
    __local_ip_address = None
    __wall_rows = None
    __wall_columns = None

    __APP_TV_CONFIG_FILE = DirectoryUtils().root_dir + "/app/src/tv_config.json"

    def __init__(self):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__load_config_if_not_loaded()

    # returns dict keyed by receiver hostname, one item per receiver, even if the receiver has two TVs.
    def get_receivers_config(self):
        return ConfigLoader.__receivers_config

    # returns the portion of the receivers config stanza for this host's config, i.e. the portion
    # keyed by this hostname. Returns None if no matching stanza is found. This generally only makes
    # sense to run on a receiver host.
    def get_own_receiver_config_stanza(self):
        receivers_config = self.get_receivers_config()
        if ConfigLoader.__hostname in receivers_config:
            return receivers_config[ConfigLoader.__hostname]
        elif ConfigLoader.__local_ip_address in receivers_config:
            return receivers_config[ConfigLoader.__local_ip_address]
        else:
            return None

    # returns a list of all the receiver hostnames
    def get_receivers_list(self):
        return ConfigLoader.__receivers

    def get_raw_config(self):
        return ConfigLoader.__raw_config

    # returns a dict that has a key 'tvs'. This key maps to a dict of TVs and their configuration, and is
    # keyed by tv_id. A single receiver may be present in the 'tvs' dict twice if it has two TVs.
    def get_tv_config(self):
        return ConfigLoader.__tv_config

    def get_tv_ids_list(self):
        return list(ConfigLoader.__tv_config['tvs'])

    def get_wall_width(self):
        return ConfigLoader.__wall_width

    def get_wall_height(self):
        return ConfigLoader.__wall_height

    def get_num_wall_rows(self):
        return ConfigLoader.__raw_config.get('rows', 1)

    def get_num_wall_columns(self):
        return ConfigLoader.__raw_config.get('columns', 1)

    # Returns a 0-indexed array where each element of the array is an array of tv_ids
    # e.g. [[tv_id1, tv_id2], [tv_id3, tv_id4]]
    def get_wall_rows(self):
        return ConfigLoader.__wall_rows

    # Returns a 0-indexed array where each element of the array is an array of tv_ids
    # e.g. [[tv_id1, tv_id3], [tv_id2, tv_id4]]
    def get_wall_columns(self):
        return ConfigLoader.__wall_columns

    # youtube-dl video format depends on whether any receiver has dual video output
    # see: https://github.zm/dasl-/piwall2/blob/main/docs/tv_output_options.adoc#one-vs-two-tvs-per-receiver-raspberry-pi
    def get_youtube_dl_video_format(self):
        return ConfigLoader.__youtube_dl_video_format

    def is_any_receiver_dual_video_output(self):
        return ConfigLoader.__is_any_receiver_dual_video_output

    def write_tv_config_for_web_app(self):
        tv_config_json = json.dumps(self.get_tv_config())
        file = open(self.__APP_TV_CONFIG_FILE, "w")
        file.write(tv_config_json)
        file.close()

    def __load_config_if_not_loaded(self):
        if ConfigLoader.__is_loaded:
            return

        self.__logger.info(f"Loading piwall2 config from: {self.CONFIG_PATH}.")
        raw_config = toml.load(self.CONFIG_PATH)
        self.__logger.info(f"Validating piwall2 config: {raw_config}")

        is_any_receiver_dual_video_out = False
        receivers = []
        receivers_config = {}

        # The wall width and height will be computed based on the configuration measurements of each receiver.
        wall_width = None
        wall_height = None

        if 'receivers' not in raw_config:
            raise Exception("Config is missing 'receivers' stanza.")

        for receiver, receiver_config in raw_config['receivers'].items():
            is_this_receiver_dual_video_out = False
            for key in receiver_config:
                if key.endswith('2'):
                    is_this_receiver_dual_video_out = True
                    is_any_receiver_dual_video_out = True
                    break

            self.__assert_receiver_config_valid(receiver, receiver_config, is_this_receiver_dual_video_out)

            receiver_config['is_dual_video_output'] = is_this_receiver_dual_video_out

            wall_width_at_this_receiver = receiver_config['x'] + receiver_config['width']
            wall_height_at_this_receiver = receiver_config['y'] + receiver_config['height']
            if wall_width is None or wall_width < wall_width_at_this_receiver:
                wall_width = wall_width_at_this_receiver
            if wall_height is None or wall_height < wall_height_at_this_receiver:
                wall_height = wall_height_at_this_receiver

            receivers.append(receiver)
            receivers_config[receiver] = receiver_config

        ConfigLoader.__receivers_config = receivers_config
        ConfigLoader.__receivers = receivers
        self.__logger.info(f"Found receivers: {ConfigLoader.__receivers} and config: {ConfigLoader.__receivers_config}")

        ConfigLoader.__wall_width = wall_width
        ConfigLoader.__wall_height = wall_height
        self.__logger.info(f"Computed wall dimensions: {ConfigLoader.__wall_width}x{ConfigLoader.__wall_height}.")

        if is_any_receiver_dual_video_out:
            ConfigLoader.__youtube_dl_video_format = self.DUAL_VIDEO_OUTPUT_YTDL_VIDEO_FORMAT
        else:
            ConfigLoader.__youtube_dl_video_format = self.SINGLE_VIDEO_OUTPUT_YTDL_VIDEO_FORMAT
        self.__logger.info(f"Using youtube-dl video format: {ConfigLoader.__youtube_dl_video_format}")

        self.__generate_tv_config()
        ConfigLoader.__hostname = socket.gethostname() + ".local"
        ConfigLoader.__local_ip_address = self.__get_local_ip()
        ConfigLoader.__is_any_receiver_dual_video_output = is_any_receiver_dual_video_out
        ConfigLoader.__raw_config = raw_config
        ConfigLoader.__wall_rows, ConfigLoader.__wall_columns = self.__compute_wall_rows_and_columns()

        if 'log_level' in raw_config:
            log_level = raw_config['log_level']
            self.__logger.info(f'Setting log_level to {log_level}.')
            Logger.set_level(log_level)

        ConfigLoader.__is_loaded = True

    def __assert_receiver_config_valid(self, receiver, receiver_config, is_this_receiver_dual_video_out):
        if 'x' not in receiver_config:
            raise Exception(f"Config missing field 'x' for receiver: {receiver}.")
        if 'y' not in receiver_config:
            raise Exception(f"Config missing field 'y' for receiver: {receiver}.")
        if 'width' not in receiver_config:
            raise Exception(f"Config missing field 'width' for receiver: {receiver}.")
        if 'height' not in receiver_config:
            raise Exception(f"Config missing field 'height' for receiver: {receiver}.")
        if 'audio' not in receiver_config:
            raise Exception(f"Config missing field 'audio' for receiver: {receiver}.")
        if 'video' not in receiver_config:
            raise Exception(f"Config missing field 'video' for receiver: {receiver}.")

        if is_this_receiver_dual_video_out:
            if 'x2' not in receiver_config:
                raise Exception(f"Config missing field 'x2' for receiver: {receiver}.")
            if 'y2' not in receiver_config:
                raise Exception(f"Config missing field 'y2' for receiver: {receiver}.")
            if 'width2' not in receiver_config:
                raise Exception(f"Config missing field 'width2' for receiver: {receiver}.")
            if 'height2' not in receiver_config:
                raise Exception(f"Config missing field 'height2' for receiver: {receiver}.")
            if 'audio2' not in receiver_config:
                raise Exception(f"Config missing field 'audio2' for receiver: {receiver}.")
            if 'video2' not in receiver_config:
                raise Exception(f"Config missing field 'video2' for receiver: {receiver}.")

    # Config read by the react app
    def __generate_tv_config(self):
        tvs = {}
        for receiver, cfg in ConfigLoader.__receivers_config.items():
            tv_id = Tv(receiver, 1).tv_id
            tvs[tv_id] = {
                'x': cfg['x'],
                'y': cfg['y'],
                'width': cfg['width'],
                'height': cfg['height'],
                'tv_id': tv_id,
            }
            if cfg['is_dual_video_output']:
                tv_id = Tv(receiver, 2).tv_id
                tvs[tv_id] = {
                    'x': cfg['x2'],
                    'y': cfg['y2'],
                    'width': cfg['width2'],
                    'height': cfg['height2'],
                    'tv_id': tv_id,
                }

        ConfigLoader.__tv_config = {
            'tvs': tvs,
            'wall_width': self.get_wall_width(),
            'wall_height': self.get_wall_height(),
        }

    # returns a tuple (rows, columns). Each part of the tuple will be a 0-indexed array
    # where each element of the array is an array of tv_ids
    #
    # e.g.
    # (
    #   rows = [[tv_id1, tv_id2], [tv_id3, tv_id4]],
    #   columns = [[tv_id1, tv_id3], [tv_id2, tv_id4]]
    # )
    def __compute_wall_rows_and_columns(self):
        num_rows = self.get_num_wall_rows()
        num_columns = self.get_num_wall_columns()
        wall_width = self.get_wall_width()
        wall_height = self.get_wall_height()
        rows = [[] for i in range(num_rows)]
        columns = [[] for i in range(num_columns)]

        row_height = wall_height / num_rows
        column_width = wall_width / num_columns
        for tv_id, tv_config in self.get_tv_config()['tvs'].items():
            tv_center_x = tv_config['x'] + tv_config['width'] / 2
            tv_center_y = tv_config['y'] + tv_config['height'] / 2
            tv_row = math.floor(tv_center_y / row_height)
            tv_column = math.floor(tv_center_x / column_width)

            rows[tv_row].append(tv_id)
            columns[tv_column].append(tv_id)

        return rows, columns

    def __get_local_ip(self):
        private_ip = (subprocess
            .check_output(
                'set -o pipefail && sudo ifconfig | grep -Eo \'inet (addr:)?([0-9]*\.){3}[0-9]*\' | ' +
                'grep -Eo \'([0-9]*\.){3}[0-9]*\' | grep -v \'127.0.0.1\'',
                stderr = subprocess.STDOUT, shell = True, executable = '/bin/bash'
            )
            .decode("utf-8")
            .strip()
        )
        self.__logger.info(f"This device's private IP is: {private_ip}")
        return private_ip
Пример #13
0
    def __load_config_if_not_loaded(self):
        if ConfigLoader.__is_loaded:
            return

        self.__logger.info(f"Loading piwall2 config from: {self.CONFIG_PATH}.")
        raw_config = toml.load(self.CONFIG_PATH)
        self.__logger.info(f"Validating piwall2 config: {raw_config}")

        is_any_receiver_dual_video_out = False
        receivers = []
        receivers_config = {}

        # The wall width and height will be computed based on the configuration measurements of each receiver.
        wall_width = None
        wall_height = None

        if 'receivers' not in raw_config:
            raise Exception("Config is missing 'receivers' stanza.")

        for receiver, receiver_config in raw_config['receivers'].items():
            is_this_receiver_dual_video_out = False
            for key in receiver_config:
                if key.endswith('2'):
                    is_this_receiver_dual_video_out = True
                    is_any_receiver_dual_video_out = True
                    break

            self.__assert_receiver_config_valid(receiver, receiver_config, is_this_receiver_dual_video_out)

            receiver_config['is_dual_video_output'] = is_this_receiver_dual_video_out

            wall_width_at_this_receiver = receiver_config['x'] + receiver_config['width']
            wall_height_at_this_receiver = receiver_config['y'] + receiver_config['height']
            if wall_width is None or wall_width < wall_width_at_this_receiver:
                wall_width = wall_width_at_this_receiver
            if wall_height is None or wall_height < wall_height_at_this_receiver:
                wall_height = wall_height_at_this_receiver

            receivers.append(receiver)
            receivers_config[receiver] = receiver_config

        ConfigLoader.__receivers_config = receivers_config
        ConfigLoader.__receivers = receivers
        self.__logger.info(f"Found receivers: {ConfigLoader.__receivers} and config: {ConfigLoader.__receivers_config}")

        ConfigLoader.__wall_width = wall_width
        ConfigLoader.__wall_height = wall_height
        self.__logger.info(f"Computed wall dimensions: {ConfigLoader.__wall_width}x{ConfigLoader.__wall_height}.")

        if is_any_receiver_dual_video_out:
            ConfigLoader.__youtube_dl_video_format = self.DUAL_VIDEO_OUTPUT_YTDL_VIDEO_FORMAT
        else:
            ConfigLoader.__youtube_dl_video_format = self.SINGLE_VIDEO_OUTPUT_YTDL_VIDEO_FORMAT
        self.__logger.info(f"Using youtube-dl video format: {ConfigLoader.__youtube_dl_video_format}")

        self.__generate_tv_config()
        ConfigLoader.__hostname = socket.gethostname() + ".local"
        ConfigLoader.__local_ip_address = self.__get_local_ip()
        ConfigLoader.__is_any_receiver_dual_video_output = is_any_receiver_dual_video_out
        ConfigLoader.__raw_config = raw_config
        ConfigLoader.__wall_rows, ConfigLoader.__wall_columns = self.__compute_wall_rows_and_columns()

        if 'log_level' in raw_config:
            log_level = raw_config['log_level']
            self.__logger.info(f'Setting log_level to {log_level}.')
            Logger.set_level(log_level)

        ConfigLoader.__is_loaded = True
Пример #14
0
 def __init__(self):
     self.__logger = Logger().set_namespace(self.__class__.__name__)
     self.__logger.info('Starting up server...')
     self.__server = Piwall2ThreadingHTTPServer(('0.0.0.0', 80), ServerRequestHandler)
     self.__config_loader = ConfigLoader()
Пример #15
0
class Receiver:

    VIDEO_PLAYBACK_MBUFFER_SIZE_BYTES = 1024 * 1024 * 400 # 400 MB

    def __init__(self):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__logger.info("Started receiver!")

        self.__hostname = socket.gethostname() + ".local"

        # The current crop modes for up to two TVs that may be hooked up to this receiver
        self.__display_mode = DisplayMode.DISPLAY_MODE_TILE
        self.__display_mode2 = DisplayMode.DISPLAY_MODE_TILE

        self.__video_crop_args = None
        self.__video_crop_args2 = None
        self.__loading_screen_crop_args = None
        self.__loading_screen_crop_args2 = None

        config_loader = ConfigLoader()
        self.__receiver_config_stanza = config_loader.get_own_receiver_config_stanza()
        self.__receiver_command_builder = ReceiverCommandBuilder(config_loader, self.__receiver_config_stanza)
        self.__tv_ids = self.__get_tv_ids_by_tv_num()

        self.__control_message_helper = ControlMessageHelper().setup_for_receiver()

        # Store the PGIDs 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.__is_video_playback_in_progress = False
        self.__receive_and_play_video_proc = None
        self.__receive_and_play_video_proc_pgid = None

        self.__is_loading_screen_playback_in_progress = False
        self.__loading_screen_proc = None
        self.__loading_screen_pgid = None

        # house keeping
        # Set the video player volume to 50%, but set the hardware volume to 100%.
        self.__video_player_volume_pct = 50
        (VolumeController()).set_vol_pct(100)
        self.__disable_terminal_output()
        self.__play_warmup_video()

        # This must come after the warmup video. When run as a systemd service, omxplayer wants to
        # start new dbus sessions / processes every time the service is restarted. This means it will
        # create new dbus files in /tmp when the first video is played after the service is restarted
        # But the old files will still be in /tmp. So if we initialize the OmxplayerController before
        # the new dbus files are created by playing the first video since restarting the service, we
        # will be reading stale dbus info from the files in /tmp.
        self.__omxplayer_controller = OmxplayerController()

    def run(self):
        while True:
            try:
                self.__run_internal()
            except Exception:
                self.__logger.error('Caught exception: {}'.format(traceback.format_exc()))

    def __run_internal(self):
        ctrl_msg = None
        ctrl_msg = self.__control_message_helper.receive_msg() # This blocks until a message is received!
        self.__logger.debug(f"Received control message {ctrl_msg}.")

        if self.__is_video_playback_in_progress:
            if self.__receive_and_play_video_proc and self.__receive_and_play_video_proc.poll() is not None:
                self.__logger.info("Ending video playback because receive_and_play_video_proc is no longer running...")
                self.__stop_video_playback_if_playing(stop_loading_screen_playback = True)

        if self.__is_loading_screen_playback_in_progress:
            if self.__loading_screen_proc and self.__loading_screen_proc.poll() is not None:
                self.__logger.info("Ending loading screen playback because loading_screen_proc is no longer running...")
                self.__stop_loading_screen_playback_if_playing(reset_log_uuid = False)

        msg_type = ctrl_msg[ControlMessageHelper.CTRL_MSG_TYPE_KEY]
        if msg_type == ControlMessageHelper.TYPE_INIT_VIDEO:
            self.__stop_video_playback_if_playing(stop_loading_screen_playback = False)
            self.__receive_and_play_video_proc = self.__receive_and_play_video(ctrl_msg)
            self.__receive_and_play_video_proc_pgid = os.getpgid(self.__receive_and_play_video_proc.pid)
        if msg_type == ControlMessageHelper.TYPE_PLAY_VIDEO:
            if self.__is_video_playback_in_progress:
                if self.__receiver_config_stanza['is_dual_video_output']:
                    dbus_names = [OmxplayerController.TV1_VIDEO_DBUS_NAME, OmxplayerController.TV2_VIDEO_DBUS_NAME]
                else:
                    dbus_names = [OmxplayerController.TV1_VIDEO_DBUS_NAME]
                self.__omxplayer_controller.play(dbus_names)
        elif msg_type == ControlMessageHelper.TYPE_SKIP_VIDEO:
            self.__stop_video_playback_if_playing(stop_loading_screen_playback = True)
        elif msg_type == ControlMessageHelper.TYPE_VOLUME:
            self.__video_player_volume_pct = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
            vol_pairs = {}
            if self.__is_video_playback_in_progress:
                vol_pairs[OmxplayerController.TV1_VIDEO_DBUS_NAME] = self.__video_player_volume_pct
                if self.__receiver_config_stanza['is_dual_video_output']:
                    vol_pairs[OmxplayerController.TV2_VIDEO_DBUS_NAME] = self.__video_player_volume_pct
            if self.__is_loading_screen_playback_in_progress:
                vol_pairs[OmxplayerController.TV1_LOADING_SCREEN_DBUS_NAME] = self.__video_player_volume_pct
                if self.__receiver_config_stanza['is_dual_video_output']:
                    vol_pairs[OmxplayerController.TV2_LOADING_SCREEN_DBUS_NAME] = self.__video_player_volume_pct
            self.__omxplayer_controller.set_vol_pct(vol_pairs)
        elif msg_type == ControlMessageHelper.TYPE_DISPLAY_MODE:
            display_mode_by_tv_id = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
            should_set_tv1 = False
            should_set_tv2 = False
            for tv_num, tv_id in self.__tv_ids.items():
                if tv_id in display_mode_by_tv_id:
                    display_mode_to_set = display_mode_by_tv_id[tv_id]
                    if display_mode_to_set not in DisplayMode.DISPLAY_MODES:
                        display_mode_to_set = DisplayMode.DISPLAY_MODE_TILE
                    if tv_num == 1:
                        should_set_tv1 = True
                        self.__display_mode = display_mode_to_set
                    else:
                        should_set_tv2 = True
                        self.__display_mode2 = display_mode_to_set

            crop_pairs = {}
            if self.__is_video_playback_in_progress:
                if should_set_tv1 and self.__video_crop_args:
                    crop_pairs[OmxplayerController.TV1_VIDEO_DBUS_NAME] = self.__video_crop_args[self.__display_mode]
                if should_set_tv2 and self.__receiver_config_stanza['is_dual_video_output'] and self.__video_crop_args2:
                    crop_pairs[OmxplayerController.TV2_VIDEO_DBUS_NAME] = self.__video_crop_args2[self.__display_mode2]
            if self.__is_loading_screen_playback_in_progress:
                if should_set_tv1 and self.__loading_screen_crop_args:
                    crop_pairs[OmxplayerController.TV1_LOADING_SCREEN_DBUS_NAME] = self.__loading_screen_crop_args[self.__display_mode]
                if should_set_tv2 and self.__receiver_config_stanza['is_dual_video_output'] and self.__loading_screen_crop_args2:
                    crop_pairs[OmxplayerController.TV2_LOADING_SCREEN_DBUS_NAME] = self.__loading_screen_crop_args2[self.__display_mode2]
            if crop_pairs:
                self.__omxplayer_controller.set_crop(crop_pairs)
        elif msg_type == ControlMessageHelper.TYPE_SHOW_LOADING_SCREEN:
            self.__loading_screen_proc = self.__show_loading_screen(ctrl_msg)
            self.__loading_screen_pgid = os.getpgid(self.__loading_screen_proc.pid)
        elif msg_type == ControlMessageHelper.TYPE_END_LOADING_SCREEN:
            self.__stop_loading_screen_playback_if_playing(reset_log_uuid = False)

    def __receive_and_play_video(self, ctrl_msg):
        ctrl_msg_content = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
        Logger.set_uuid(ctrl_msg_content['log_uuid'])
        cmd, self.__video_crop_args, self.__video_crop_args2 = (
            self.__receiver_command_builder.build_receive_and_play_video_command_and_get_crop_args(
                ctrl_msg_content['log_uuid'], ctrl_msg_content['video_width'],
                ctrl_msg_content['video_height'], self.__video_player_volume_pct,
                self.__display_mode, self.__display_mode2
            )
        )
        self.__logger.info(f"Running receive_and_play_video command: {cmd}")
        self.__is_video_playback_in_progress = True
        proc = subprocess.Popen(
            cmd, shell = True, executable = '/usr/bin/bash', start_new_session = True
        )
        return proc

    def __show_loading_screen(self, ctrl_msg):
        ctrl_msg_content = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
        Logger.set_uuid(ctrl_msg_content['log_uuid'])
        cmd, self.__loading_screen_crop_args, self.__loading_screen_crop_args2 = (
            self.__receiver_command_builder.build_loading_screen_command_and_get_crop_args(
                self.__video_player_volume_pct, self.__display_mode, self.__display_mode2,
                ctrl_msg_content['loading_screen_data']
            )
        )
        self.__logger.info(f"Showing loading screen with command: {cmd}")
        self.__is_loading_screen_playback_in_progress = True
        proc = subprocess.Popen(
            cmd, shell = True, executable = '/usr/bin/bash', start_new_session = True
        )
        return proc

    def __stop_video_playback_if_playing(self, stop_loading_screen_playback):
        if stop_loading_screen_playback:
            self.__stop_loading_screen_playback_if_playing(reset_log_uuid = False)
        if not self.__is_video_playback_in_progress:
            if stop_loading_screen_playback:
                Logger.set_uuid('')
            return
        if self.__receive_and_play_video_proc_pgid:
            self.__logger.info("Killing receive_and_play_video proc (if it's still running)...")
            try:
                os.killpg(self.__receive_and_play_video_proc_pgid, signal.SIGTERM)
            except Exception:
                # might raise: `ProcessLookupError: [Errno 3] No such process`
                pass
        Logger.set_uuid('')
        self.__is_video_playback_in_progress = False
        self.__video_crop_args = None
        self.__video_crop_args2 = None

    def __stop_loading_screen_playback_if_playing(self, reset_log_uuid):
        if not self.__is_loading_screen_playback_in_progress:
            return
        if self.__loading_screen_pgid:
            self.__logger.info("Killing loading_screen proc (if it's still running)...")
            try:
                os.killpg(self.__loading_screen_pgid, signal.SIGTERM)
            except Exception:
                # might raise: `ProcessLookupError: [Errno 3] No such process`
                pass
        if reset_log_uuid:
            Logger.set_uuid('')
        self.__is_loading_screen_playback_in_progress = False
        self.__loading_screen_crop_args = None
        self.__loading_screen_crop_args2 = None

    # The first video that is played after a system restart appears to have a lag in starting,
    # which can affect video synchronization across the receivers. Ensure we have played at
    # least one video since system startup. This is a short, one-second video.
    #
    # Perhaps one thing this warmup video does is start the various dbus processes for the first
    # time, which can involve some sleeps:
    # https://github.com/popcornmix/omxplayer/blob/1f1d0ccd65d3a1caa86dc79d2863a8f067c8e3f8/omxplayer#L50-L59
    #
    # When the receiver is run as as a systemd service, the first time a video is played after the service
    # is restarted, it seems that omxplayer must initialize dbus. Thus, it is important to run the warmup
    # video whenever the service is restarted.
    #
    # This is as opposed to when running the service as a regular user / process -- the dbus stuff stays
    # initialized until the pi is rebooted.
    def __play_warmup_video(self):
        self.__logger.info("Playing receiver warmup video...")
        warmup_cmd = f'omxplayer --vol 0 {DirectoryUtils().root_dir}/utils/short_black_screen.ts'
        proc = subprocess.Popen(
            warmup_cmd, shell = True, executable = '/usr/bin/bash'
        )
        while proc.poll() is None:
            time.sleep(0.1)
        if proc.returncode != 0:
            raise Exception(f"The process for cmd: [{warmup_cmd}] exited non-zero: " +
                f"{proc.returncode}.")

    # Display a black image so that terminal text output doesn't show up on the TVs in between videos.
    # Basically this black image will be on display all the time "underneath" any videos that are playing.
    def __disable_terminal_output(self):
        subprocess.check_output(
            f"sudo fbi -T 1 --noverbose --autozoom {DirectoryUtils().root_dir}/assets/black_screen.jpg",
            shell = True, executable = '/usr/bin/bash', stderr = subprocess.STDOUT
        )
        atexit.register(self.__enable_terminal_output)

    def __enable_terminal_output(self):
        try:
            subprocess.check_output(
                "sudo pkill fbi", shell = True, executable = '/usr/bin/bash', stderr = subprocess.STDOUT
            )
        except Exception:
            pass

    # Get the tv_ids for this receiver
    def __get_tv_ids_by_tv_num(self):
        tv_ids = {
            1: Tv(self.__hostname, 1).tv_id
        }
        if self.__receiver_config_stanza['is_dual_video_output']:
            tv_ids[2] = Tv(self.__hostname, 2).tv_id
        return tv_ids
Пример #16
0
class OmxplayerController:

    TV1_VIDEO_DBUS_NAME = 'piwall.tv1.video'
    TV1_LOADING_SCREEN_DBUS_NAME = 'piwall.tv1.loadingscreen'
    TV2_VIDEO_DBUS_NAME = 'piwall.tv2.video'
    TV2_LOADING_SCREEN_DBUS_NAME = 'piwall.tv2.loadingscreen'

    __DBUS_TIMEOUT_MS = 2000
    __PARALLEL_CMD_TEMPLATE_PREFIX = (
        f"parallel --will-cite --link --max-procs 0 " +
        # Run all jobs even if one or more failed.
        # Exit status: 1-100 Some of the jobs failed. The exit status gives the number of failed jobs.
        "--halt never ")

    # Ensure we don't have too many processes in flight that could overload CPU.
    # Need to track the limits separately because in Queue.__maybe_set_receiver_state,
    # we set volume and display_mode in quick succession. If we had a global limit of 1,
    # we'd risk that the display_mode never gets set due to throttling.
    __MAX_IN_FLIGHT_VOL_PROCS = 1
    __MAX_IN_FLIGHT_CROP_PROCS = 1

    def __init__(self):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__user = getpass.getuser()
        self.__dbus_addr = None
        self.__dbus_pid = None
        self.__load_dbus_session_info()
        self.__in_flight_vol_procs = []
        self.__in_flight_crop_procs = []

    # gets a perceptual loudness %
    # returns a float in the range [0, 100]
    # TODO: update this to account for multiple dbus names
    def get_vol_pct(self):
        cmd = (
            f"sudo -u {self.__user} DBUS_SESSION_BUS_ADDRESS={self.__dbus_addr} DBUS_SESSION_BUS_PID={self.__dbus_pid} dbus-send "
            +
            f"--print-reply=literal --session --reply-timeout={self.__DBUS_TIMEOUT_MS} "
            +
            f"--dest={self.TV1_VIDEO_DBUS_NAME} /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties.Get "
            + "string:'org.mpris.MediaPlayer2.Player' string:'Volume'")
        vol_cmd_output = None
        try:
            vol_cmd_output = (subprocess.check_output(
                cmd,
                shell=True,
                executable='/usr/bin/bash',
                stderr=subprocess.STDOUT).decode("utf-8"))
        except Exception:
            return 0

        m = re.search(r"\s+double\s+(\d+(\.\d*)?)", vol_cmd_output)
        if m is None:
            return 0
        vol_pct = 100 * float(m.group(1))
        vol_pct = max(0, vol_pct)
        vol_pct = min(100, vol_pct)
        return vol_pct

    # pairs: a dict where each key is a dbus name and each value is a vol_pct.
    # vol_pct should be a float in the range [0, 100]. This is a perceptual loudness %.
    # e.g.: {'piwall.tv1.video': 99.8}
    def set_vol_pct(self, pairs):
        num_pairs = len(pairs)
        if num_pairs <= 0:
            return

        if self.__are_too_many_procs_in_flight(self.__in_flight_vol_procs,
                                               self.__MAX_IN_FLIGHT_VOL_PROCS):
            self.__logger.warning(
                "Too many in-flight dbus processes; bailing without setting volume."
            )
            return

        vol_template = (
            self.__get_dbus_cmd_template_prefix() +
            "org.freedesktop.DBus.Properties.Set string:'org.mpris.MediaPlayer2.Player' "
            + "string:'Volume' double:{1} >/dev/null 2>&1")

        if num_pairs == 1:
            dbus_name, vol_pct = list(pairs.items())[0]
            omx_vol_pct = self.__vol_pct_to_omx_vol_pct(vol_pct)
            cmd = vol_template.format(dbus_name, omx_vol_pct)
        else:
            parallel_vol_template = shlex.quote(
                vol_template.format('{1}', '{2}'))
            dbus_names = ''
            omx_vol_pcts = ''
            for dbus_name, vol_pct in pairs.items():
                dbus_names += dbus_name + ' '
                omx_vol_pcts += str(
                    self.__vol_pct_to_omx_vol_pct(vol_pct)) + ' '
            dbus_names = dbus_names.strip()
            omx_vol_pcts = omx_vol_pcts.strip()
            cmd = (
                f"{self.__PARALLEL_CMD_TEMPLATE_PREFIX} {parallel_vol_template} ::: {dbus_names} "
                + f"::: {omx_vol_pcts}")

        self.__logger.debug(f"dbus_cmd vol_cmd: {cmd}")

        # Send dbus commands in non-blocking fashion so that the receiver process is free to handle other input.
        # Dbus can sometimes take a while to execute. Starting the subprocess takes about 3-20ms
        proc = subprocess.Popen(cmd, shell=True, executable='/usr/bin/bash')
        self.__in_flight_vol_procs.append(proc)

    # pairs: a dict where each key is a dbus name and each value is a list of crop coordinates
    # e.g.: {'piwall.tv1.video': (0, 0, 100, 100)}
    def set_crop(self, pairs):
        num_pairs = len(pairs)
        if num_pairs <= 0:
            return

        if self.__are_too_many_procs_in_flight(
                self.__in_flight_crop_procs, self.__MAX_IN_FLIGHT_CROP_PROCS):
            self.__logger.warning(
                "Too many in-flight dbus processes; bailing without setting crop."
            )
            return

        crop_template = (
            self.__get_dbus_cmd_template_prefix() +
            "org.mpris.MediaPlayer2.Player.SetVideoCropPos objpath:/not/used string:'{1}' >/dev/null 2>&1"
        )

        if num_pairs == 1:
            dbus_name, crop_coords = list(pairs.items())[0]
            cmd = crop_template.format(
                dbus_name,
                OmxplayerController.crop_coordinate_list_to_string(
                    crop_coords))
        else:
            parallel_crop_template = shlex.quote(
                crop_template.format('{1}', '{2} {3} {4} {5}'))
            dbus_names = ''
            crop_x1s = crop_y1s = crop_x2s = crop_y2s = ''
            for dbus_name, crop_coords in pairs.items():
                dbus_names += dbus_name + ' '
                x1, y1, x2, y2 = crop_coords
                crop_x1s += f'{x1} '
                crop_y1s += f'{y1} '
                crop_x2s += f'{x2} '
                crop_y2s += f'{y2} '
            dbus_names = dbus_names.strip()
            crop_x1s = crop_x1s.strip()
            crop_y1s = crop_y1s.strip()
            crop_x2s = crop_x2s.strip()
            crop_y2s = crop_y2s.strip()
            cmd = (
                f"{self.__PARALLEL_CMD_TEMPLATE_PREFIX} {parallel_crop_template} ::: {dbus_names} "
                +
                f"::: {crop_x1s} ::: {crop_y1s} ::: {crop_x2s} ::: {crop_y2s}")

        self.__logger.debug(f"dbus_cmd crop_cmd: {cmd}")

        # Send dbus commands in non-blocking fashion so that the receiver process is free to handle other input.
        # Dbus can sometimes take a while to execute. Starting the subprocess takes about 3-20ms
        proc = subprocess.Popen(cmd, shell=True, executable='/usr/bin/bash')
        self.__in_flight_crop_procs.append(proc)

    @staticmethod
    def crop_coordinate_list_to_string(crop_coord_list):
        if not crop_coord_list:
            return ''
        return ' '.join([str(e) for e in crop_coord_list])

    # start playback / unpause the video
    def play(self, dbus_names):
        num_dbus_names = len(dbus_names)
        if num_dbus_names <= 0:
            return

        # Don't check if too many procs are in flight, because we never want to ignore a play command.
        # This is used to start the video playback in sync across all the TVs.

        play_template = (self.__get_dbus_cmd_template_prefix() +
                         "org.mpris.MediaPlayer2.Player.Play >/dev/null 2>&1")
        if num_dbus_names == 1:
            cmd = play_template.format(dbus_names[0])
        else:
            parallel_play_template = shlex.quote(play_template.format('{1}'))
            dbus_names_str = ' '.join(dbus_names.keys())
            cmd = f"{self.__PARALLEL_CMD_TEMPLATE_PREFIX} {parallel_play_template} ::: {dbus_names_str} "

        self.__logger.debug(f"dbus_cmd play_cmd: {cmd}")

        # Send dbus commands in non-blocking fashion so that the receiver process is free to handle other input.
        # Dbus can sometimes take a while to execute. Starting the subprocess takes about 3-20ms
        proc = subprocess.Popen(cmd, shell=True, executable='/usr/bin/bash')

    # omxplayer uses a different algorithm for computing volume percentage from the original millibels than
    # our VolumeController class uses. Convert to omxplayer's equivalent percentage for a smoother volume
    # adjustment experience.
    def __vol_pct_to_omx_vol_pct(self, vol_pct):
        # See: https://github.com/popcornmix/omxplayer#volume-rw
        millibels = VolumeController.pct_to_millibels(vol_pct)
        omx_vol_pct = math.pow(10, millibels / 2000)
        omx_vol_pct = max(omx_vol_pct, 0)
        omx_vol_pct = min(omx_vol_pct, 1)
        return round(omx_vol_pct, 2)

    def __are_too_many_procs_in_flight(self, in_flight_procs, max_procs):
        updated_in_flight_procs = []
        for proc in in_flight_procs:
            if proc.poll() is None:
                updated_in_flight_procs.append(proc)

        # modify in_flight_procs in place so that all references are updated
        in_flight_procs.clear()
        in_flight_procs.extend(updated_in_flight_procs)
        if len(in_flight_procs) >= max_procs:
            return True
        return False

    def __get_dbus_cmd_template_prefix(self):
        dbus_timeout_s = self.__DBUS_TIMEOUT_MS / 1000 + 0.1
        dbus_kill_after_timeout_s = dbus_timeout_s + 0.1
        dbus_prefix = (
            f'sudo timeout --kill-after={dbus_kill_after_timeout_s} {dbus_timeout_s} '
            'sudo -u ' + self.__user + ' ' + 'DBUS_SESSION_BUS_ADDRESS=' +
            self.__dbus_addr + ' ' + 'DBUS_SESSION_BUS_PID=' +
            self.__dbus_pid + ' ' +
            'dbus-send --print-reply=literal --session --reply-timeout=' +
            str(self.__DBUS_TIMEOUT_MS) + ' ' +
            '--dest={0} /org/mpris/MediaPlayer2 ')
        return dbus_prefix

    # Returns a boolean. True if the session was loaded or already loaded, false if we failed to load.
    def __load_dbus_session_info(self):
        if self.__dbus_addr and self.__dbus_pid:
            return True  # already loaded

        dbus_addr_file_path = f"/tmp/omxplayerdbus.{self.__user}"
        dbus_pid_file_path = f"/tmp/omxplayerdbus.{self.__user}.pid"
        self.__logger.info(
            f"Reading dbus info from files {dbus_addr_file_path} and {dbus_pid_file_path}."
        )

        # Omxplayer creates these files on its first run after a reboot.
        # These files might not yet exist if omxplayer has not been started since the pi
        # was last rebooted.
        dbus_addr_file = None
        try:
            dbus_addr_file = open(dbus_addr_file_path)
        except Exception:
            self.__logger.debug(f"Unable to open {dbus_addr_file_path}")
            return False

        dbus_pid_file = None
        try:
            dbus_pid_file = open(dbus_pid_file_path)
        except Exception:
            self.__logger.debug(f"Unable to open {dbus_pid_file_path}")
            return False

        self.__dbus_addr = dbus_addr_file.read().strip()
        self.__dbus_pid = dbus_pid_file.read().strip()
        if self.__dbus_addr and self.__dbus_pid:
            return True
Пример #17
0
class CmdRunner:

    # For passwordless ssh from the broadcaster to the receivers.
    # See: https://github.com/dasl-/piwall2/blob/main/utils/setup_broadcaster_and_receivers
    SSH_KEY_PATH = '/home/pi/.ssh/piwall2_broadcaster/id_ed25519'
    # Lack of space after `-i` is necessary: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=578683#10
    SSH_KEY_PATH_FLAG = f'-i{SSH_KEY_PATH}'
    STANDARD_SSH_OPTS = [
        '-o UserKnownHostsFile=/dev/null', '-o StrictHostKeyChecking=no',
        '-o LogLevel=ERROR', '-o ConnectTimeout=5'
    ]
    __CONCURRENCY_LIMIT = 16

    def __init__(self):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        config_loader = ConfigLoader()
        self.__receivers_list = config_loader.get_receivers_list()

        # populate self.__broadcaster_and_receivers_list
        broadcaster_hostname = self.get_broadcaster_hostname()
        broadcaster_ip = socket.gethostbyname(broadcaster_hostname)
        self.__broadcaster_and_receivers_list = list(self.__receivers_list)
        if (broadcaster_hostname not in self.__broadcaster_and_receivers_list
                and broadcaster_ip
                not in self.__broadcaster_and_receivers_list):
            self.__broadcaster_and_receivers_list.insert(
                0, broadcaster_hostname)

    # If wait_for_proc is True, returns the command's return code (integer)
    # If wait_for_proc is False, returns the process object
    def run_dsh(self,
                cmd,
                include_broadcaster=True,
                raise_on_failure=True,
                wait_for_proc=True):
        machines_list = self.__receivers_list
        if include_broadcaster:
            machines_list = self.__broadcaster_and_receivers_list

        machines_string = ''
        for machine in machines_list:
            machines_string += f'pi@{machine},'
        machines_string = machines_string.rstrip(',')
        ssh_opts = ''
        for ssh_opt in self.STANDARD_SSH_OPTS:
            ssh_opts += f"--remoteshellopt '{ssh_opt}' "
        dsh_cmd = (
            f"dsh -r ssh --forklimit {self.__CONCURRENCY_LIMIT} {ssh_opts}" +
            f'--remoteshellopt "{self.SSH_KEY_PATH_FLAG}" ' +
            f"--show-machine-names --machine {machines_string} {shlex.quote(cmd)}"
        )

        cmd_return_code_or_proc = self.run_cmd_with_realtime_output(
            dsh_cmd, raise_on_failure, wait_for_proc)
        if not wait_for_proc:
            return cmd_return_code_or_proc

        if cmd_return_code_or_proc != 0 and raise_on_failure:
            raise Exception(f"The process for cmd: [{cmd}] exited non-zero: " +
                            f"{cmd_return_code_or_proc}.")
        return cmd_return_code_or_proc

    def run_parallel(self, cmd, include_broadcaster=True):
        machines_list = self.__receivers_list
        if include_broadcaster:
            machines_list = self.__broadcaster_and_receivers_list
        machines_string = ' '.join(machines_list)

        parallel_cmd = (
            f"parallel --will-cite --max-procs {self.__CONCURRENCY_LIMIT} " +
            # exit when the first job fails. Kill running jobs.
            # If fail=1 is used, the exit status will be the exit status of the failing job.
            "--halt now,fail=1 " + f"{shlex.quote(cmd)} ::: {machines_string}")
        self.run_cmd_with_realtime_output(parallel_cmd)

    # If wait_for_proc is True, returns the command's return code (integer)
    # If wait_for_proc is False, returns the process object
    def run_cmd_with_realtime_output(self,
                                     cmd,
                                     raise_on_failure=True,
                                     wait_for_proc=True):
        self.__logger.info(f"Running command: {cmd}")
        proc = subprocess.Popen(cmd, shell=True, executable='/usr/bin/bash')

        if not wait_for_proc:
            return proc

        while proc.poll() is None:
            time.sleep(0.1)
        if proc.returncode != 0 and raise_on_failure:
            raise Exception(f"The process for cmd: [{cmd}] exited non-zero: " +
                            f"{proc.returncode}.")
        return proc.returncode

    def get_broadcaster_and_receivers_hostname_list(self):
        return self.__broadcaster_and_receivers_list

    def get_receivers_hostname_list(self):
        return self.__receivers_list

    # this is intended to be run from the broadcaster
    def get_broadcaster_hostname(self):
        return socket.gethostname() + ".local"
Пример #18
0
class ReceiverCommandBuilder:

    # See: https://github.com/dasl-/piwall2/blob/main/docs/configuring_omxplayer.adoc
    __OMX_CMD_TEMPLATE = ('omxplayer --crop {0} --adev {1} --display {2} --vol {3} --dbus_name {4} --layer {5} ' +
            '--aspect-mode stretch --no-keys --timeout 30 --threshold 0.2 --video_fifo 10 pipe:0')

    def __init__(self, config_loader, receiver_config_stanza):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__config_loader = config_loader
        self.__receiver_config_stanza = receiver_config_stanza

    def build_receive_and_play_video_command_and_get_crop_args(
        self, log_uuid, video_width, video_height, volume_pct, display_mode, display_mode2
    ):
        adev, adev2 = self.__get_video_command_adev_args()
        display, display2 = self.__get_video_command_display_args()
        crop_args, crop_args2 = self.__get_video_command_crop_args(video_width, video_height)
        crop = OmxplayerController.crop_coordinate_list_to_string(crop_args[display_mode])
        crop2 = OmxplayerController.crop_coordinate_list_to_string(crop_args2[display_mode2])
        volume_millibels = self.__get_video_command_volume_arg(volume_pct)

        """
        We use mbuffer in the receiver command. The mbuffer is here to solve two problems:

        1) Sometimes the python receiver process would get blocked writing directly to omxplayer. When this happens,
        the receiver's writes would occur rather slowly. While the receiver is blocked on writing, it cannot read
        incoming data from the UDP socket. The kernel's UDP buffers would then fill up, causing UDP packets to be
        dropped.

        Unlike python, mbuffer is multithreaded, meaning it can read and write simultaneously in two separate
        threads. Thus, while mbuffer is writing to omxplayer, it can still read the incoming data from python at
        full speed. Slow writes will not block reads.

        2) I am not sure how exactly omxplayer's various buffers work. There are many options:

            % omxplayer --help
            ...
             --audio_fifo  n         Size of audio output fifo in seconds
             --video_fifo  n         Size of video output fifo in MB
             --audio_queue n         Size of audio input queue in MB
             --video_queue n         Size of video input queue in MB
            ...

        More info: https://github.com/popcornmix/omxplayer/issues/256#issuecomment-57907940

        I am not sure which I would need to adjust to ensure enough buffering is available. By using mbuffer,
        we effectively have a single buffer that accounts for any possible source of delays, whether it's from audio,
        video, and no matter where in the pipeline the delay is coming from. Using mbuffer seems simpler, and it is
        easier to monitor. By checking its logs, we can see how close the mbuffer gets to becoming full.
        """
        mbuffer_cmd = ('mbuffer -q -l /tmp/mbuffer.out -m ' +
            f'{piwall2.receiver.receiver.Receiver.VIDEO_PLAYBACK_MBUFFER_SIZE_BYTES}b')

        omx_cmd_tempate = self.__OMX_CMD_TEMPLATE + ' --start-paused'
        omx_cmd = omx_cmd_tempate.format(
            shlex.quote(crop), shlex.quote(adev), shlex.quote(display), shlex.quote(str(volume_millibels)),
            OmxplayerController.TV1_VIDEO_DBUS_NAME, '1'
        )
        cmd = 'set -o pipefail && export SHELLOPTS && '
        if self.__receiver_config_stanza['is_dual_video_output']:
            omx_cmd2 = omx_cmd_tempate.format(
                shlex.quote(crop2), shlex.quote(adev2), shlex.quote(display2), shlex.quote(str(volume_millibels)),
                OmxplayerController.TV2_VIDEO_DBUS_NAME, '1'
            )
            cmd += f'{mbuffer_cmd} | tee >({omx_cmd}) >({omx_cmd2}) >/dev/null'
        else:
            cmd += f'{mbuffer_cmd} | {omx_cmd}'

        receiver_cmd = (f'{DirectoryUtils().root_dir}/bin/receive_and_play_video --command {shlex.quote(cmd)} ' +
            f'--log-uuid {shlex.quote(log_uuid)}')
        return (receiver_cmd, crop_args, crop_args2)

    def build_loading_screen_command_and_get_crop_args(
        self, volume_pct, display_mode, display_mode2, loading_screen_data
    ):
        video_path = DirectoryUtils().root_dir + '/' + loading_screen_data['video_path']
        adev, adev2 = self.__get_video_command_adev_args()
        display, display2 = self.__get_video_command_display_args()
        crop_args, crop_args2 = self.__get_video_command_crop_args(loading_screen_data['width'], loading_screen_data['height'])
        crop = OmxplayerController.crop_coordinate_list_to_string(crop_args[display_mode])
        crop2 = OmxplayerController.crop_coordinate_list_to_string(crop_args2[display_mode2])
        volume_millibels = self.__get_video_command_volume_arg(volume_pct)

        omx_cmd = self.__OMX_CMD_TEMPLATE.format(
            shlex.quote(crop), shlex.quote(adev), shlex.quote(display), shlex.quote(str(volume_millibels)),
            OmxplayerController.TV1_LOADING_SCREEN_DBUS_NAME, '0'
        )
        cat_cmd = f'cat {video_path}'
        loading_screen_cmd = 'set -o pipefail && export SHELLOPTS && '
        if self.__receiver_config_stanza['is_dual_video_output']:
            omx_cmd2 = self.__OMX_CMD_TEMPLATE.format(
                shlex.quote(crop2), shlex.quote(adev2), shlex.quote(display2), shlex.quote(str(volume_millibels)),
                OmxplayerController.TV2_LOADING_SCREEN_DBUS_NAME, '1'
            )
            loading_screen_cmd += f'{cat_cmd} | tee >({omx_cmd}) >({omx_cmd2}) >/dev/null'
        else:
            loading_screen_cmd += f'{cat_cmd} | {omx_cmd}'

        return (loading_screen_cmd, crop_args, crop_args2)

    def __get_video_command_adev_args(self):
        receiver_config = self.__receiver_config_stanza
        adev = None
        if receiver_config['audio'] == 'hdmi' or receiver_config['audio'] == 'hdmi0':
            adev = 'hdmi'
        elif receiver_config['audio'] == 'headphone':
            adev = 'local'
        elif receiver_config['audio'] == 'hdmi_alsa' or receiver_config['audio'] == 'hdmi0_alsa':
            adev = 'alsa:default:CARD=b1'
        else:
            raise Exception(f"Unexpected audio config value: {receiver_config['audio']}")

        adev2 = None
        if receiver_config['is_dual_video_output']:
            if receiver_config['audio2'] == 'hdmi1':
                adev2 = 'hdmi1'
            elif receiver_config['audio2'] == 'headphone':
                adev2 = 'local'
            elif receiver_config['audio'] == 'hdmi1_alsa':
                adev2 = 'alsa:default:CARD=b2'
            else:
                raise Exception(f"Unexpected audio2 config value: {receiver_config['audio2']}")

        return (adev, adev2)

    def __get_video_command_display_args(self):
        receiver_config = self.__receiver_config_stanza
        display = None
        if receiver_config['video'] == 'hdmi' or receiver_config['video'] == 'hdmi0':
            display = '2'
        elif receiver_config['video'] == 'composite':
            display = '3'
        else:
            raise Exception(f"Unexpected video config value: {receiver_config['video']}")

        display2 = None
        if receiver_config['is_dual_video_output']:
            if receiver_config['video2'] == 'hdmi1':
                display2 = '7'
            else:
                raise Exception(f"Unexpected video2 config value: {receiver_config['video2']}")

        return (display, display2)

    """
    Returns a set of crop args supporting two display modes: tile mode and repeat mode.
    Tile mode is like this: https://i.imgur.com/BBrA1Cr.png
    Repeat mode is like this: https://i.imgur.com/cpS61s8.png

    We return four crop settings because for each mode, we calculate the crop arguments
    for each of two TVs (each receiver can have at most two TVs hooked up to it).
    """
    def __get_video_command_crop_args(self, video_width, video_height):
        receiver_config = self.__receiver_config_stanza

        #####################################################################################
        # Do tile mode calculations first ###################################################
        #####################################################################################
        wall_width = self.__config_loader.get_wall_width()
        wall_height = self.__config_loader.get_wall_height()

        displayable_video_width, displayable_video_height = (
            self.__get_displayable_video_dimensions_for_screen(
                video_width, video_height, wall_width, wall_height
            )
        )
        x_offset = (video_width - displayable_video_width) / 2
        y_offset = (video_height - displayable_video_height) / 2

        x0 = round(x_offset + ((receiver_config['x'] / wall_width) * displayable_video_width))
        y0 = round(y_offset + ((receiver_config['y'] / wall_height) * displayable_video_height))
        x1 = round(x_offset + (((receiver_config['x'] + receiver_config['width']) / wall_width) * displayable_video_width))
        y1 = round(y_offset + (((receiver_config['y'] + receiver_config['height']) / wall_height) * displayable_video_height))

        if x0 > video_width:
            self.__logger.warn(f"The crop x0 coordinate ({x0}) " +
                f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
        if x1 > video_width:
            self.__logger.warn(f"The crop x1 coordinate ({x1}) " +
                f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
        if y0 > video_height:
            self.__logger.warn(f"The crop y0 coordinate ({y0}) " +
                f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")
        if y1 > video_height:
            self.__logger.warn(f"The crop y1 coordinate ({y1}) " +
                f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")

        tile_mode_crop = (x0, y0, x1, y1)

        tile_mode_crop2 = None
        if receiver_config['is_dual_video_output']:
            x0_2 = round(x_offset + ((receiver_config['x2'] / wall_width) * displayable_video_width))
            y0_2 = round(y_offset + ((receiver_config['y2'] / wall_height) * displayable_video_height))
            x1_2 = round(x_offset + (((receiver_config['x2'] + receiver_config['width2']) / wall_width) * displayable_video_width))
            y1_2 = round(y_offset + (((receiver_config['y2'] + receiver_config['height2']) / wall_height) * displayable_video_height))

            if x0_2 > video_width:
                self.__logger.warn(f"The crop x0_2 coordinate ({x0_2}) " +
                    f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
            if x1_2 > video_width:
                self.__logger.warn(f"The crop x1_2 coordinate ({x1_2}) " +
                    f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
            if y0_2 > video_height:
                self.__logger.warn(f"The crop y0_2 coordinate ({y0_2}) " +
                    f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")
            if y1_2 > video_height:
                self.__logger.warn(f"The crop y1_2 coordinate ({y1_2}) " +
                    f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")

            tile_mode_crop2 = (x0_2, y0_2, x1_2, y1_2)

        #####################################################################################
        # Do repeat mode calculations second ################################################
        #####################################################################################
        displayable_video_width, displayable_video_height = (
            self.__get_displayable_video_dimensions_for_screen(
                video_width, video_height, receiver_config['width'], receiver_config['height']
            )
        )
        x_offset = (video_width - displayable_video_width) / 2
        y_offset = (video_height - displayable_video_height) / 2
        x0 = round(x_offset)
        y0 = round(y_offset)
        x1 = round(x_offset + displayable_video_width)
        y1 = round(y_offset + displayable_video_height)
        repeat_mode_crop = (x0, y0, x1, y1)

        repeat_mode_crop2 = None
        if receiver_config['is_dual_video_output']:
            displayable_video_width, displayable_video_height = (
                self.__get_displayable_video_dimensions_for_screen(
                    video_width, video_height, receiver_config['width2'], receiver_config['height2']
                )
            )
            x_offset = (video_width - displayable_video_width) / 2
            y_offset = (video_height - displayable_video_height) / 2
            x0 = round(x_offset)
            y0 = round(y_offset)
            x1 = round(x_offset + displayable_video_width)
            y1 = round(y_offset + displayable_video_height)
            repeat_mode_crop2 = (x0, y0, x1, y1)

        crop_args = {
            DisplayMode.DISPLAY_MODE_TILE: tile_mode_crop,
            DisplayMode.DISPLAY_MODE_REPEAT: repeat_mode_crop,
        }
        crop_args2 = {
            DisplayMode.DISPLAY_MODE_TILE: tile_mode_crop2,
            DisplayMode.DISPLAY_MODE_REPEAT: repeat_mode_crop2,
        }
        return (crop_args, crop_args2)

    def __get_video_command_volume_arg(self, volume_pct):
        # See: https://github.com/popcornmix/omxplayer/#volume-rw
        volume_pct = VolumeController.normalize_vol_pct(volume_pct)
        if volume_pct == 0:
            volume_millibels = VolumeController.GLOBAL_MIN_VOL_VAL
        else:
            volume_millibels = 2000 * math.log(volume_pct, 10)
        return volume_millibels

    """
    The displayable width and height represents the section of the video that the wall will be
    displaying. A section of these dimensions will be taken from the center of the original
    video.

    Currently, the piwall only supports displaying videos in "fill" mode (as opposed to
    "letterbox" or "stretch"). This means that every portion of the TVs will be displaying
    some section of the video (i.e. there will be no letterboxing). Furthermore, there will be
    no warping of the video's aspect ratio. Instead, regions of the original video will be
    cropped or stretched if necessary.

    The units of the width and height arguments are not important. We are just concerned with
    the aspect ratio. Thus, as long as video_width and video_height are in the same units
    (probably pixels), and as long as screen_width and screen_height are in the same units
    (probably inches or centimeters), everything will work.

    The returned dimensions will be in the units of the inputted video_width and video_height
    (probably pixels).
    """
    def __get_displayable_video_dimensions_for_screen(self, video_width, video_height, screen_width, screen_height):
        video_aspect_ratio = video_width / video_height
        screen_aspect_ratio = screen_width / screen_height
        if screen_aspect_ratio >= video_aspect_ratio:
            displayable_video_width = video_width
            displayable_video_height = video_width / screen_aspect_ratio
            """
            Note that `video_width = video_aspect_ratio * video_height`.
            Thus, via substitution, we have:
                displayable_video_height = (video_aspect_ratio * video_height) / screen_aspect_ratio
                displayable_video_height = (video_aspect_ratio / screen_aspect_ratio) * video_height

            And because of the above inequality, we know that:
                (video_aspect_ratio / screen_aspect_ratio) <= 1

            Thus, in this case, we have: `displayable_video_height <= video_height`. The video height
            will be "cropped" such that when the video is proportionally stretched, it will fill the
            screen size.
            """

        else:
            displayable_video_height = video_height
            displayable_video_width = screen_aspect_ratio * video_height
            """
            Note that `video_height = video_width / video_aspect_ratio`.
            Thus, via substitution, we have:
                displayable_video_width = screen_aspect_ratio * (video_width / video_aspect_ratio)
                displayable_video_width = video_width * (screen_aspect_ratio / video_aspect_ratio)

            And because of the above inequality for which we are now in the `else` clause, we know that:
                (screen_aspect_ratio / video_aspect_ratio) <= 1

            Thus, in this case, we have: `displayable_video_width <= video_width`. The video width
            will be "cropped" such that when the video is proportionally stretched, it will fill the
            screen size.
            """

        if displayable_video_width > video_width:
            self.__logger.warn(f"The displayable_video_width ({displayable_video_width}) " +
                f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
        if displayable_video_height > video_height:
            self.__logger.warn(f"The displayable_video_height ({displayable_video_height}) " +
                f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")

        return (displayable_video_width, displayable_video_height)
Пример #19
0
class VideoBroadcaster:

    END_OF_VIDEO_MAGIC_BYTES = b'PIWALL2_END_OF_VIDEO_MAGIC_BYTES'

    __VIDEO_URL_TYPE_YOUTUBE = 'video_url_type_youtube'
    __VIDEO_URL_TYPE_LOCAL_FILE = 'video_url_type_local_file'
    __AUDIO_FORMAT = 'bestaudio'

    # Touch this file when video playing is done.
    # We check for its existence to determine when video playback is over.
    __VIDEO_PLAYBACK_DONE_FILE = '/tmp/video_playback_done.file'

    # video_url may be a youtube url or a path to a file on disk
    # Loading screen may also get shown by the queue process. Sending the signal to show it from
    # the queue is faster than showing it in the videobroadcaster process. But one may still wish
    # to show a loading screen when playing videos via the command line.
    def __init__(self, video_url, log_uuid, show_loading_screen):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        if log_uuid:
            Logger.set_uuid(log_uuid)
        else:
            Logger.set_uuid(Logger.make_uuid())

        self.__config_loader = ConfigLoader()
        self.__video_url = video_url
        self.__show_loading_screen = show_loading_screen

        # Store the PGIDs 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.__video_broadcast_proc_pgid = None
        self.__download_and_convert_video_proc_pgid = None

        # Metadata about the video we are using, such as title, resolution, file extension, etc
        # Access should go through self.get_video_info() to populate it lazily
        self.__video_info = None

        # Bind multicast traffic to eth0. Otherwise it might send over wlan0 -- multicast doesn't work well over wifi.
        # `|| true` to avoid 'RTNETLINK answers: File exists' if the route has already been added.
        (subprocess.check_output(
            f"sudo ip route add {MulticastHelper.ADDRESS}/32 dev eth0 || true",
            shell=True,
            executable='/usr/bin/bash',
            stderr=subprocess.STDOUT))

        self.__control_message_helper = ControlMessageHelper(
        ).setup_for_broadcaster()
        self.__do_housekeeping(for_end_of_video=False)
        self.__register_signal_handlers()

    def broadcast(self):
        attempt = 1
        max_attempts = 2
        while attempt <= max_attempts:
            try:
                self.__broadcast_internal()
                break
            except YoutubeDlException as e:
                if attempt < max_attempts:
                    self.__logger.warning(
                        "Caught exception in VideoBroadcaster.__broadcast_internal: "
                        + traceback.format_exc())
                    self.__logger.warning(
                        "Updating youtube-dl and retrying broadcast...")
                    self.__update_youtube_dl()
                if attempt >= max_attempts:
                    raise e
            finally:
                self.__do_housekeeping(for_end_of_video=True)
            attempt += 1

    def __broadcast_internal(self):
        self.__logger.info(f"Starting broadcast for: {self.__video_url}")
        if self.__show_loading_screen:
            self.__control_message_helper.send_msg(
                ControlMessageHelper.TYPE_SHOW_LOADING_SCREEN, {})
        """
        What's going on here? We invoke youtube-dl (ytdl) three times in the broadcast code:
        1) To populate video metadata, including dimensions which allow us to know how much to crop the video
        2) To download the proper video format (which generally does not have sound included) and mux it with (3)
        3) To download the best audio quality

        Ytdl takes couple of seconds to be invoked. Luckily, (2) and (3) happen in parallel
        (see self.__get_ffmpeg_input_clause). But that would still leave us with effectively two groups of ytdl
        invocations which are happening serially: the group consisting of "1" and the group consisting of "2 and 3".
        Note that (1) happens in self.get_video_info.

        By starting a separate process for "2 and 3", we can actually ensure that all three of these invocations
        happen in parallel. This separate process is started in self.__start_download_and_convert_video_proc.
        This shaves 2-3 seconds off of video start up time -- although this time saving is partially canceled out
        by the `time.sleep(2)` we had to add below.

        This requires that we break up the original single pipeline into two halves. Originally, a single
        pipeline was responsible for downloading, converting, and broadcasting the video. Now we have two
        pipelines that we start separately:
        1) download_and_convert_video_proc, which downloads and converts the video
        2) video_broadcast_proc, which broadcasts the converted video

        We connect the stdout of (1) to the stdin of (2).

        In order to run all the ytdl invocations in parallel, we had to break up the original single pipeline
        into these two halves, because broadcasting the video requires having started the receivers first.
        And starting the receivers requires knowing how much to crop, which requires knowing the video dimensions.
        Thus, we need to know the video dimensions before broadcasting the video. Without breaking up the pipeline,
        we wouldn't be able to enforce that we don't start broadcasting the video before knowing the dimensions.
        """
        download_and_convert_video_proc = self.start_download_and_convert_video_proc(
        )
        self.get_video_info(assert_data_not_yet_loaded=True)
        self.__start_receivers()
        """
        This `sleep` makes the videos more likely to start in-sync across all the TVs, but I'm not totally
        sure why. My current theory is that this give the receivers enough time to start before the broadcast
        command starts sending its data.

        Another potential solution is making use of delay_buffer in video_broadcast_cmd, although I have
        abandoned that approach for now: https://gist.github.com/dasl-/9ed9d160384a8dd77382ce6a07c43eb6

        Another thing I tried was only sending the data once a few megabytes have been read, in case it was a
        problem with the first few megabytes of the video being downloaded slowly, but this approach resulted in
        occasional very brief video artifacts (green screen, etc) within the first 30 seconds or so of playback:
        https://gist.github.com/dasl-/f3fcc941e276d116320d6fa9e4de25de

        And another thing I tried is starting the receivers early without any crop args to the invocation of
        omxplayer. I would only send the crop args later via dbus. This allowed me to get rid of the sleep
        below. I wasn't 100%, but it may have made things *slightly* less likely to start in sync. Hard to
        know. Very rarely, you would see the crop change at the very start of the video if it couldn't complete
        the dbus message before the video started playing. See the approach here:
        https://gist.github.com/dasl-/db3ce584ba90802ba390ac0f07611dea

        See data collected on the effectiveness of this sleep:
        https://gist.github.com/dasl-/e5c05bf89c7a92d43881a2ff978dc889
        """
        time.sleep(2)
        video_broadcast_proc = self.__start_video_broadcast_proc(
            download_and_convert_video_proc)

        self.__logger.info(
            "Waiting for download_and_convert_video and video_broadcast procs to end..."
        )
        has_download_and_convert_video_proc_ended = False
        has_video_broadcast_proc_ended = False
        while True:  # Wait for the download_and_convert_video and video_broadcast procs to end...
            if not has_download_and_convert_video_proc_ended and download_and_convert_video_proc.poll(
            ) is not None:
                has_download_and_convert_video_proc_ended = True
                if download_and_convert_video_proc.returncode != 0:
                    raise YoutubeDlException(
                        "The download_and_convert_video process exited non-zero: "
                        +
                        f"{download_and_convert_video_proc.returncode}. This could mean an issue with youtube-dl; "
                        + "it may require updating.")
                self.__logger.info(
                    "The download_and_convert_video proc ended.")

            if not has_video_broadcast_proc_ended and video_broadcast_proc.poll(
            ) is not None:
                has_video_broadcast_proc_ended = True
                if video_broadcast_proc.returncode != 0:
                    raise Exception(
                        f"The video broadcast process exited non-zero: {video_broadcast_proc.returncode}"
                    )
                self.__logger.info("The video_broadcast proc ended.")

            if has_download_and_convert_video_proc_ended and has_video_broadcast_proc_ended:
                break

            time.sleep(0.1)

        while not os.path.isfile(self.__VIDEO_PLAYBACK_DONE_FILE):
            time.sleep(0.1)

        # Wait to ensure video playback is done. Data collected suggests one second is sufficient:
        # https://docs.google.com/spreadsheets/d/1YzxsD3GPzsIeKYliADN3af7ORys5nXHCRBykSnHaaxk/edit#gid=0
        time.sleep(1)
        self.__logger.info("Video playback is likely over.")

    """
    Process to download video via youtube-dl and convert it to proper format via ffmpeg.
    Note that we only download the video if the input was a youtube_url. If playing a local file, no
    download is necessary.
    """

    def start_download_and_convert_video_proc(self, ytdl_video_format=None):
        if self.__get_video_url_type() == self.__VIDEO_URL_TYPE_LOCAL_FILE:
            cmd = f"cat {shlex.quote(self.__video_url)}"
        else:
            # Mix the best audio with the video and send via multicast
            # See: https://github.com/dasl-/piwall2/blob/main/docs/best_video_container_format_for_streaming.adoc
            # See: https://github.com/dasl-/piwall2/blob/main/docs/streaming_high_quality_videos_from_youtube-dl_to_stdout.adoc
            ffmpeg_input_clause = self.__get_ffmpeg_input_clause(
                ytdl_video_format)
            # TODO: can we use mp3 instead of mp2?
            cmd = (
                f"set -o pipefail && export SHELLOPTS && {self.__get_standard_ffmpeg_cmd()} {ffmpeg_input_clause} "
                + "-c:v copy -c:a mp2 -b:a 192k -f mpegts -")
        self.__logger.info(
            f"Running download_and_convert_video_proc command: {cmd}")

        # Info on start_new_session: https://gist.github.com/dasl-/1379cc91fb8739efa5b9414f35101f5f
        # Allows killing all processes (subshells, children, grandchildren, etc as a group)
        download_and_convert_video_proc = subprocess.Popen(
            cmd,
            shell=True,
            executable='/usr/bin/bash',
            start_new_session=True,
            stdout=subprocess.PIPE)
        self.__download_and_convert_video_proc_pgid = os.getpgid(
            download_and_convert_video_proc.pid)
        return download_and_convert_video_proc

    def __start_video_broadcast_proc(self, download_and_convert_video_proc):
        # See: https://github.com/dasl-/piwall2/blob/main/docs/controlling_video_broadcast_speed.adoc
        mbuffer_size = round(Receiver.VIDEO_PLAYBACK_MBUFFER_SIZE_BYTES / 2)
        burst_throttling_clause = (
            f'mbuffer -q -l /tmp/mbuffer.out -m {mbuffer_size}b | ' +
            f'{self.__get_standard_ffmpeg_cmd()} -re -i pipe:0 -c:v copy -c:a copy -f mpegts - >/dev/null ; '
            + f'touch {self.__VIDEO_PLAYBACK_DONE_FILE}')
        broadcasting_clause = (
            f"{DirectoryUtils().root_dir}/bin/msend_video " +
            f'--log-uuid {shlex.quote(Logger.get_uuid())} ' +
            f'--end-of-video-magic-bytes {self.END_OF_VIDEO_MAGIC_BYTES.decode()}'
        )

        # Mix the best audio with the video and send via multicast
        # See: https://github.com/dasl-/piwall2/blob/main/docs/best_video_container_format_for_streaming.adoc
        #
        # Use `pv` to rate limit how fast we send the video. This is especially important when playing back
        # local files. Without `pv`, they may send as fast as network bandwidth permits, which would prevent
        # control messages from being received in a timely manner. Without `pv` here, when playing local files,
        # we observed that a control message could be sent over the network and received ~10 seconds later --
        # a delay because the tubes were clogged.
        video_broadcast_cmd = (
            "set -o pipefail && export SHELLOPTS && " +
            f"pv --rate-limit 4M | tee >({burst_throttling_clause}) >({broadcasting_clause}) >/dev/null"
        )
        self.__logger.info(f"Running broadcast command: {video_broadcast_cmd}")

        # Info on start_new_session: https://gist.github.com/dasl-/1379cc91fb8739efa5b9414f35101f5f
        # Allows killing all processes (subshells, children, grandchildren, etc as a group)
        video_broadcast_proc = subprocess.Popen(
            video_broadcast_cmd,
            shell=True,
            executable='/usr/bin/bash',
            start_new_session=True,
            stdin=download_and_convert_video_proc.stdout)
        self.__video_broadcast_proc_pgid = os.getpgid(video_broadcast_proc.pid)
        return video_broadcast_proc

    def __start_receivers(self):
        msg = {
            'log_uuid': Logger.get_uuid(),
            'video_width': self.get_video_info()['width'],
            'video_height': self.get_video_info()['height'],
        }
        self.__control_message_helper.send_msg(
            ControlMessageHelper.TYPE_INIT_VIDEO, msg)
        self.__logger.info(
            f"Sent {ControlMessageHelper.TYPE_INIT_VIDEO} control message.")

    def __get_standard_ffmpeg_cmd(self):
        # 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_ffmpeg_input_clause(self, ytdl_video_format):
        video_url_type = self.__get_video_url_type()
        if video_url_type == self.__VIDEO_URL_TYPE_YOUTUBE:
            """
            Pipe to mbuffer to avoid video drop outs when youtube-dl temporarily loses its connection
            and is trying to reconnect:

                [download] Got server HTTP error: [Errno 104] Connection reset by peer. Retrying (attempt 1 of 10)...
                [download] Got server HTTP error: [Errno 104] Connection reset by peer. Retrying (attempt 2 of 10)...
                [download] Got server HTTP error: [Errno 104] Connection reset by peer. Retrying (attempt 3 of 10)...

            This can happen from time to time when downloading long videos.
            Youtube-dl should download quickly until it fills the mbuffer. After the mbuffer is filled,
            ffmpeg will apply backpressure to youtube-dl because of ffmpeg's `-re` flag

            --retries infinite: using this to avoid scenarios where all of the retries (10 by default) were
            exhausted on long video downloads. After a while, retries would be necessary to reconnect. The
            retries would be successful, but the connection errors would happen again a few minutes later.
            This allows us to keep retrying whenever it is necessary.

            Use yt-dlp, a fork of youtube-dl that has a workaround (for now) for an issue where youtube has been
            throttling youtube-dl’s download speed:
            https://github.com/ytdl-org/youtube-dl/issues/29326#issuecomment-879256177
            """
            youtube_dl_cmd_template = (
                "yt-dlp {0} --retries infinite --format {1} --output - {2} | "
                + "mbuffer -q -Q -m {3}b")

            log_opts = '--no-progress'
            if Logger.get_level() <= Logger.DEBUG:
                log_opts = ''  # show video download progress
            if not sys.stderr.isatty():
                log_opts += ' --newline'

            if not ytdl_video_format:
                ytdl_video_format = self.__config_loader.get_youtube_dl_video_format(
                )

            # 50 MB. Based on one video, 1080p avc1 video consumes about 0.36 MB/s. So this should
            # be enough buffer for ~139s
            video_buffer_size = 1024 * 1024 * 50
            youtube_dl_video_cmd = youtube_dl_cmd_template.format(
                shlex.quote(self.__video_url), shlex.quote(ytdl_video_format),
                log_opts, video_buffer_size)

            # 5 MB. Based on one video, audio consumes about 0.016 MB/s. So this should
            # be enough buffer for ~312s
            audio_buffer_size = 1024 * 1024 * 5
            youtube_dl_audio_cmd = youtube_dl_cmd_template.format(
                shlex.quote(self.__video_url),
                shlex.quote(self.__AUDIO_FORMAT), log_opts, audio_buffer_size)

            return f"-i <({youtube_dl_video_cmd}) -i <({youtube_dl_audio_cmd})"
        elif video_url_type == self.__VIDEO_URL_TYPE_LOCAL_FILE:
            return f"-i {shlex.quote(self.__video_url)} "

    # Lazily populate video_info from youtube. This takes a couple seconds, as it invokes youtube-dl on the video.
    # Must return a dict containing the keys: width, height
    def get_video_info(self, assert_data_not_yet_loaded=False):
        if self.__video_info:
            if assert_data_not_yet_loaded:
                raise Exception(
                    'Failed asserting that data was not yet loaded')
            return self.__video_info

        video_url_type = self.__get_video_url_type()
        if video_url_type == self.__VIDEO_URL_TYPE_YOUTUBE:
            self.__logger.info("Downloading and populating video metadata...")
            ydl_opts = {
                'format': self.__config_loader.get_youtube_dl_video_format(),
                'logger': Logger().set_namespace('youtube_dl'),
                'restrictfilenames':
                True,  # get rid of a warning ytdl gives about special chars in file names
            }
            ydl = youtube_dl.YoutubeDL(ydl_opts)

            # Automatically try to update youtube-dl and retry failed youtube-dl operations when we get a youtube-dl
            # error.
            #
            # The youtube-dl package needs updating periodically when youtube make updates. This is
            # handled on a cron once a day:
            # https://github.com/dasl-/piwall2/blob/3aa6dee264102baf2646aab1baebdcae0148b4bc/install/piwall2_cron.sh#L5
            #
            # But we also attempt to update it on the fly here if we get youtube-dl errors when trying to play
            # a video.
            #
            # Example of how this would look in logs: https://gist.github.com/dasl-/09014dca55a2e31bb7d27f1398fd8155
            max_attempts = 2
            for attempt in range(1, (max_attempts + 1)):
                try:
                    self.__video_info = ydl.extract_info(self.__video_url,
                                                         download=False)
                except Exception as e:
                    caught_or_raising = "Raising"
                    if attempt < max_attempts:
                        caught_or_raising = "Caught"
                    self.__logger.warning(
                        "Problem downloading video info during attempt {} of {}. {} exception: {}"
                        .format(attempt, max_attempts, caught_or_raising,
                                traceback.format_exc()))
                    if attempt < max_attempts:
                        self.__logger.warning(
                            "Attempting to update youtube-dl before retrying download..."
                        )
                        self.__update_youtube_dl()
                    else:
                        self.__logger.error(
                            "Unable to download video info after {} attempts.".
                            format(max_attempts))
                        raise e

            self.__logger.info(
                "Done downloading and populating video metadata.")

            self.__logger.info(
                f"Using: {self.__video_info['vcodec']} / {self.__video_info['ext']}@"
                +
                f"{self.__video_info['width']}x{self.__video_info['height']}")
        elif video_url_type == self.__VIDEO_URL_TYPE_LOCAL_FILE:
            # TODO: guard against unsupported video formats
            ffprobe_cmd = (
                'ffprobe -hide_banner -v 0 -of csv=p=0 -select_streams v:0 -show_entries stream=width,height '
                + shlex.quote(self.__video_url))
            ffprobe_output = (subprocess.check_output(
                ffprobe_cmd,
                shell=True,
                executable='/usr/bin/bash',
                stderr=subprocess.STDOUT).decode("utf-8"))
            ffprobe_output = ffprobe_output.split('\n')[0]
            ffprobe_parts = ffprobe_output.split(',')
            self.__video_info = {
                'width': int(ffprobe_parts[0]),
                'height': int(ffprobe_parts[1]),
            }

        return self.__video_info

    def __get_video_url_type(self):
        if self.__video_url.startswith(
                'http://') or self.__video_url.startswith('https://'):
            return self.__VIDEO_URL_TYPE_YOUTUBE
        else:
            return self.__VIDEO_URL_TYPE_LOCAL_FILE

    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))

    # for_end_of_video: whether we are doing housekeeping before or after playing a video
    def __do_housekeeping(self, for_end_of_video):
        if self.__download_and_convert_video_proc_pgid:
            self.__logger.info(
                "Killing download and convert video process group (PGID: " +
                f"{self.__download_and_convert_video_proc_pgid})...")
            try:
                os.killpg(self.__download_and_convert_video_proc_pgid,
                          signal.SIGTERM)
            except Exception:
                # might raise: `ProcessLookupError: [Errno 3] No such process`
                pass
        if self.__video_broadcast_proc_pgid:
            self.__logger.info(
                "Killing video broadcast process group (PGID: " +
                f"{self.__video_broadcast_proc_pgid})...")
            try:
                os.killpg(self.__video_broadcast_proc_pgid, signal.SIGTERM)
            except Exception:
                # might raise: `ProcessLookupError: [Errno 3] No such process`
                pass
        if for_end_of_video:
            # sending a skip signal at the beginning of a video could skip the loading screen
            self.__control_message_helper.send_msg(
                ControlMessageHelper.TYPE_SKIP_VIDEO, {})
        try:
            os.remove(self.__VIDEO_PLAYBACK_DONE_FILE)
        except Exception:
            pass
        self.__video_info = None

    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(for_end_of_video=True)
        sys.exit(sig)
Пример #20
0
 def __init__(self):
     self.__logger = Logger().set_namespace(self.__class__.__name__)
Пример #21
0
class Playlist:

    STATUS_QUEUED = 'STATUS_QUEUED'
    STATUS_DELETED = 'STATUS_DELETED'  # No longer in the queue
    STATUS_PLAYING = 'STATUS_PLAYING'
    STATUS_DONE = 'STATUS_DONE'
    """
    The Playlist DB holds a queue of playlist items to play. These items can be either regular videos or "channel"
    videos, which are queued when the channel up / down buttons on the remote are pressed.
    When a channel video 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
    channel video 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 channel video. Thus, we actually order the queue by a combination of `type` and `playlist_video_id`. Rows in the
    DB with a `channel_video` type get precedence in the queue.
    """
    TYPE_VIDEO = 'TYPE_VIDEO'
    TYPE_CHANNEL_VIDEO = 'TYPE_CHANNEL_VIDEO'

    def __init__(self):
        self.__cursor = piwall2.broadcaster.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),
                status VARCHAR(20),
                is_skip_requested INTEGER DEFAULT 0,
                settings TEXT DEFAULT ''
            )""")

        self.__cursor.execute("DROP INDEX IF EXISTS status_type_idx")
        self.__cursor.execute(
            "CREATE INDEX status_type_idx ON playlist_videos (status, type ASC, playlist_video_id ASC)"
        )

    def enqueue(self, url, thumbnail, title, duration, settings, type):
        self.__cursor.execute(
            ("INSERT INTO playlist_videos " +
             "(url, thumbnail, title, duration, status, settings, type) " +
             "VALUES(?, ?, ?, ?, ?, ?, ?)"), [
                 url, thumbnail, title, duration, self.STATUS_QUEUED, settings,
                 type
             ])
        return self.__cursor.lastrowid

    def reenqueue(self, playlist_video_id):
        self.__cursor.execute(
            "UPDATE playlist_videos set status = ?, is_skip_requested = ? WHERE playlist_video_id = ?",
            [self.STATUS_QUEUED, 0, 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.
    def skip(self, playlist_video_id):
        self.__cursor.execute(
            "UPDATE playlist_videos set is_skip_requested = 1 WHERE status = ? AND playlist_video_id = ?",
            [self.STATUS_PLAYING, playlist_video_id])
        return self.__cursor.rowcount >= 1

    def skip_videos_of_type(self, type):
        self.__cursor.execute(
            "UPDATE playlist_videos set is_skip_requested = 1 WHERE type = ?",
            [self.TYPE_CHANNEL_VIDEO])
        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 = ?",
            [self.STATUS_PLAYING])

    def get_current_video(self):
        self.__cursor.execute(
            "SELECT * FROM playlist_videos WHERE status = ? LIMIT 1",
            [self.STATUS_PLAYING])
        return self.__cursor.fetchone()

    def get_next_playlist_item(self):
        self.__cursor.execute(
            "SELECT * FROM playlist_videos WHERE status = ? order by type asc, 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 type asc, playlist_video_id asc",
            [self.STATUS_PLAYING, self.STATUS_QUEUED])
        return self.__cursor.fetchall()

    # 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):
        self.__cursor.execute(
            "UPDATE playlist_videos set status = ? WHERE status = ? AND playlist_video_id = ?",
            [self.STATUS_PLAYING, self.STATUS_QUEUED, 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 = ?",
            [self.STATUS_DONE, self.STATUS_PLAYING])

    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. "
                +
                f"Database says playlist_video_id: {current_video['playlist_video_id']}, whereas current "
                + f"process says 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