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