diff --git a/seplis_play_server/routes/hls.py b/seplis_play_server/routes/hls.py index 797010b..370e601 100644 --- a/seplis_play_server/routes/hls.py +++ b/seplis_play_server/routes/hls.py @@ -11,8 +11,22 @@ router = APIRouter() +@router.get('/hls/main.m3u8') +async def get_main( + settings: Transcode_settings = Depends(), + metadata = Depends(get_metadata), +): + if not metadata or settings.source_index > len(metadata): + raise HTTPException(404, 'No metadata') + + transcoder = Hls_transcoder(settings=settings, metadata=metadata[settings.source_index]) + return Response( + content=transcoder.generate_main_playlist(), + media_type='application/x-mpegURL', + ) + @router.get('/hls/media.m3u8') -async def start_media( +async def get_media( settings: Transcode_settings = Depends(), metadata = Depends(get_metadata), ): @@ -24,12 +38,12 @@ async def start_media( else: transcoder = await start_transcode(settings) return Response( - content=transcoder.generate_hls_playlist(), + content=transcoder.generate_media_playlist(), media_type='application/x-mpegURL', ) @router.get('/hls/media{segment}.m4s') -async def get_media( +async def get_media_segment( segment: int, settings: Transcode_settings = Depends(), ): diff --git a/seplis_play_server/routes/request_media.py b/seplis_play_server/routes/request_media.py index 29afb38..d049ee1 100644 --- a/seplis_play_server/routes/request_media.py +++ b/seplis_play_server/routes/request_media.py @@ -17,15 +17,15 @@ async def request_media( settings: Transcode_settings = Depends(), metadata = Depends(get_metadata), ): - if not metadata: + if not metadata or settings.source_index > len(metadata): raise HTTPException(404, 'No metadata') t = Transcoder(settings=settings, metadata=metadata[source_index]) - format_supported = any(fmt in settings.supported_video_containers \ + video_container_supported = any(fmt in settings.supported_video_containers \ for fmt in metadata[source_index]['format']['format_name'].split(',')) return Request_media( direct_play_url=f'/source?play_id={settings.play_id}&source_index={source_index}', - can_direct_play=format_supported and t.can_device_direct_play and t.can_copy_audio(), + can_direct_play=video_container_supported and t.can_device_direct_play and t.can_copy_audio, transcode_url=f'/hls/media.m3u8?{urlencode(settings.to_args_dict())}', ) \ No newline at end of file diff --git a/seplis_play_server/transcoders/hls.py b/seplis_play_server/transcoders/hls.py index c6ef052..32658c9 100644 --- a/seplis_play_server/transcoders/hls.py +++ b/seplis_play_server/transcoders/hls.py @@ -37,9 +37,9 @@ def ffmpeg_extend_args(self) -> None: ]) if self.can_copy_video: - if self.output_codec == 'h264': + if self.video_output_codec == 'h264': self.ffmpeg_args.append({'-bsf:v': 'h264_mp4toannexb'}) - elif self.output_codec == 'hevc': + elif self.video_output_codec == 'hevc': self.ffmpeg_args.append({'-bsf:v': 'hevc_mp4toannexb'}) self.ffmpeg_args.append({self.media_path: None}) @@ -93,7 +93,7 @@ async def is_segment_ready(cls, transcode_folder: str, segment: int): def get_segment_path(transcode_folder: str, segment: int): return os.path.join(transcode_folder, f'media{segment}.m4s') - def generate_hls_playlist(self): + def generate_media_playlist(self): settings_dict = self.settings.to_args_dict() url_settings = urlencode(settings_dict) segments = self.get_segments() @@ -111,6 +111,29 @@ def generate_hls_playlist(self): l.append('#EXT-X-ENDLIST') return '\n'.join(l) + def generate_main_playlist(self): + settings_dict = self.settings.to_args_dict() + url_settings = urlencode(settings_dict) + l = [] + l.append('#EXTM3U') + l.append(f'#EXT-X-STREAM-INF:{self.get_stream_info_string()}') + l.append(f'media.m3u8?{url_settings}') + return '\n'.join(l) + + def get_stream_info_string(self): + info = [] + video_bitrate = self.get_video_bitrate() + info.append(f'BANDWIDTH={video_bitrate}') + info.append(f'AVERAGE-BANDWIDTH={video_bitrate}') + if self.can_copy_video: + info.append(f'VIDEO-RANGE={self.video_color.range}') + else: + info.append('VIDEO-RANGE=SDR') + codecs = self.get_codecs_string() + if codecs: + info.append(f'CODECS="{",".join(codecs)}"') + return ','.join(info) + def get_segments(self): if self.can_copy_video: return self.calculate_keyframe_segments() @@ -159,7 +182,7 @@ def start_segment_from_start_time(self, start_time: Decimal) -> int: return 0 def keyframe_params(self) -> list[dict]: - if self.output_codec_lib == 'copy': + if self.video_output_codec_lib == 'copy': return [] args = [] go_args = [] @@ -177,7 +200,7 @@ def keyframe_params(self) -> list[dict]: ]) # Jellyfin: Unable to force key frames using these encoders, set key frames by GOP. - if self.output_codec_lib in ( + if self.video_output_codec_lib in ( 'h264_qsv', 'h264_nvenc', 'h264_amf', @@ -189,7 +212,7 @@ def keyframe_params(self) -> list[dict]: 'libsvtav1', ): args.extend(go_args) - elif self.output_codec_lib in ( + elif self.video_output_codec_lib in ( 'libx264', 'libx265', 'h264_vaapi', @@ -198,16 +221,90 @@ def keyframe_params(self) -> list[dict]: ): args.extend(keyframe_args) # Jellyfin: Prevent the libx264 from post processing to break the set keyframe. - if self.output_codec_lib == 'libx264': + if self.video_output_codec_lib == 'libx264': args.append({'-sc_threshold:v:0': '0'}) else: args.extend(keyframe_args + go_args) # Jellyfin: Global_header produced by AMD HEVC VA-API encoder causes non-playable fMP4 on iOS - if self.output_codec_lib == 'hevc_vaapi': + if self.video_output_codec_lib == 'hevc_vaapi': args.extend([ {'--flags:v': None}, {'-global_header': None}, ]) - return args \ No newline at end of file + return args + + def get_codecs_string(self): + codecs = [ + self.get_video_codec_string(), + self.get_audio_codec_string(), + ] + return [c for c in codecs if c] + + def get_video_codec_string(self): + if not self.can_copy_video: + return '' + if self.video_output_codec == 'h264': + return self.get_h264_codec_string( + self.video_stream['profile'], + self.video_stream['level'], + ) + elif self.video_output_codec == 'hevc': + return self.get_hevc_codec_string( + self.video_stream['profile'], + self.video_stream['level'], + ) + + def get_audio_codec_string(self): + if self.audio_output_codec == 'aac': + if self.can_copy_audio: + return self.get_aac_codec_string( + self.audio_stream['profile'], + ) + else: + return self.get_aac_codec_string('') + elif self.audio_output_codec == 'ac3': + return 'mp4a.a5' + elif self.audio_output_codec == 'eac3': + return 'mp4a.a6' + elif self.audio_output_codec == 'opus': + return 'Opus' + elif self.audio_output_codec == 'flac': + return 'fLaC' + elif self.audio_output_codec == 'mp3': + return 'mp4a.40.34' + return '' + + def get_h264_codec_string(self, profile: str, level: int): + r = 'avc1' + profile = profile.lower() + if profile == 'high': + r += '.6400' + elif profile == 'main': + r += '.4D40' + elif profile == 'baseline': + r += '.42E0' + else: + r += '.4240' + r+ f'{level:02X}' + return r + + def get_hevc_codec_string(self, profile: str, level: int): + r = 'hvc1' + profile = profile.lower().strip(' ') + if profile == 'main10': + r += '.2.4' + else: + r += '.1.4' + r += f'.L{level}.B0' + return r + + def get_aac_codec_string(self, profile: str): + r = 'mp4a' + profile = profile.lower() + if profile == 'he': + r += '.40.5' + else: + r += '.40.2' + return r \ No newline at end of file diff --git a/seplis_play_server/transcoders/video.py b/seplis_play_server/transcoders/video.py index e04baf1..db0e1bf 100644 --- a/seplis_play_server/transcoders/video.py +++ b/seplis_play_server/transcoders/video.py @@ -89,13 +89,18 @@ def __init__(self, settings: Transcode_settings, metadata: Dict): self.settings = settings self.metadata = metadata self.video_stream = self.get_video_stream() - self.input_codec = self.video_stream['codec_name'] + self.audio_stream = self.get_audio_stream() + self.video_input_codec = self.video_stream['codec_name'] + self.audio_input_codec = self.audio_stream['codec_name'] self.video_color = get_video_color(self.video_stream) self.video_color_bit_depth = get_video_color_bit_depth(self.video_stream) self.can_device_direct_play = self.get_can_device_direct_play() self.can_copy_video = self.get_can_copy_video() - self.output_codec_lib = None - self.output_codec = self.input_codec if self.can_copy_video else self.settings.transcode_video_codec + self.can_copy_audio = self.get_can_copy_audio() + self.video_output_codec_lib = None + self.audio_output_codec_lib = None + self.video_output_codec = self.video_input_codec if self.can_copy_video else self.settings.transcode_video_codec + self.audio_output_codec = self.audio_input_codec if self.can_copy_audio else self.settings.transcode_audio_codec self.ffmpeg_args = None self.transcode_folder = None @@ -194,19 +199,6 @@ async def set_ffmpeg_args(self): self.set_audio() self.ffmpeg_extend_args() - def closest_keyframe_time(self, time: float): - if not self.metadata.get('keyframes'): - logger.debug(f'[{self.settings.session}] No keyframes in metadata') - return time - keyframes = [float(r) for r in self.metadata['keyframes']] - corrected_time = 0 - for t in keyframes: - if t > time: - break - corrected_time = t - logger.debug(f'[{self.settings.session}] Closest keyframe for {time}: {corrected_time}') - return corrected_time - def set_hardware_decoder(self): if not config.ffmpeg_hwaccel_enabled: return @@ -231,7 +223,7 @@ def set_hardware_decoder(self): ]) def set_video(self): - codec = codecs_to_library.get(self.output_codec, self.output_codec) + codec = codecs_to_library.get(self.video_output_codec, self.video_output_codec) if self.can_copy_video: codec = 'copy' @@ -243,19 +235,19 @@ def set_video(self): self.ffmpeg_args.extend([ {'-start_at_zero': None}, {'-avoid_negative_ts': 'disabled'}, - {'-copyts': None}, + #{'-copyts': None}, ]) else: if config.ffmpeg_hwaccel_enabled: codec = f'{self.settings.transcode_video_codec}_{config.ffmpeg_hwaccel}' - self.output_codec_lib = codec + self.video_output_codec_lib = codec self.ffmpeg_args.extend([ {'-map': '0:v:0'}, {'-c:v': codec}, ]) - if self.output_codec == 'hevc': + if self.video_output_codec == 'hevc': if self.can_copy_video and \ self.video_color.range_type == 'dovi' and \ self.video_stream.get('codec_tag_string') in ('dovi', 'dvh1', 'dvhe'): @@ -294,12 +286,12 @@ def get_can_copy_video(self): logger.debug(f'[{self.settings.session}] No key frames in metadata') return False - logger.debug(f'[{self.settings.session}] Can copy video, codec: {self.input_codec}') + logger.debug(f'[{self.settings.session}] Can copy video, codec: {self.video_input_codec}') return True def get_can_device_direct_play(self): - if self.input_codec not in self.settings.supported_video_codecs: - logger.debug(f'[{self.settings.session}] Input codec not supported: {self.input_codec}') + if self.video_input_codec not in self.settings.supported_video_codecs: + logger.debug(f'[{self.settings.session}] Input codec not supported: {self.video_input_codec}') return False if self.video_color_bit_depth > self.settings.supported_video_color_bit_depth: @@ -318,6 +310,7 @@ def get_can_device_direct_play(self): logger.debug(f'[{self.settings.session}] Requested max bitrate is lower than input bitrate ({self.settings.max_video_bitrate} < {self.get_video_transcode_bitrate()})') return False + logger.debug(f'[{self.settings.session}] Can direct play video, codec: {self.video_input_codec}') return True def get_video_filter(self, width: int): @@ -342,7 +335,7 @@ def get_video_filter(self, width: int): return if pix_fmt == 'yuv420p10le': - if self.output_codec_lib == 'h264_qsv': + if self.video_output_codec_lib == 'h264_qsv': pix_fmt = 'yuv420p' format_ = '' @@ -392,7 +385,7 @@ def can_tonemap(self): if self.video_color_bit_depth != 10 or not config.ffmpeg_tonemap_enabled: return False - if self.input_codec == 'hevc' and self.video_color.range == 'hdr' and self.video_color.range_type == 'dovi': + if self.video_input_codec == 'hevc' and self.video_color.range == 'hdr' and self.video_color.range_type == 'dovi': return config.ffmpeg_hwaccel in ('qsv', 'vaapi') return self.video_color.range == 'hdr' and (self.video_color.range_type in ('hdr10', 'hlg')) @@ -440,12 +433,11 @@ def get_quality_params(self, width: int, codec_library: str): return params def set_audio(self): - index = self.stream_index_by_lang('audio', self.settings.audio_lang) - stream = self.metadata['streams'][index.index] + stream = self.audio_stream codec = codecs_to_library.get(stream['codec_name'], '') # Audio goes out of sync if audio copy is used while the video is being transcoded - if self.can_copy_video and self.can_copy_audio(stream): + if self.can_copy_video and self.can_copy_audio: codec = 'copy' else: if not codec or codec not in self.settings.supported_audio_codecs: @@ -459,15 +451,14 @@ def set_audio(self): self.ffmpeg_args.append({'-b:a': bitrate}) if not codec: raise Exception('No audio codec library') + self.audio_output_codec_lib = codec self.ffmpeg_args.extend([ - {'-map': f'0:{index.index}'}, + {'-map': f'0:{stream["index"]}'}, {'-c:a': codec}, ]) - def can_copy_audio(self, stream: dict = None): - if not stream: - index = self.stream_index_by_lang('audio', self.settings.audio_lang) - stream = self.metadata['streams'][index.index] + def get_can_copy_audio(self): + stream = self.audio_stream if self.settings.audio_channels and self.settings.audio_channels < stream['channels']: logger.debug(f'[{self.settings.session}] Requested audio channels is lower than input channels ({self.settings.audio_channels} < {stream["channels"]})') @@ -495,6 +486,12 @@ def get_video_bitrate_params(self, codec_library: str): {'-bufsize': bitrate*2}, ] + def get_video_bitrate(self): + if self.can_copy_video: + return int(self.metadata['format']['bit_rate'] or 0) + else: + return self.get_video_transcode_bitrate() + def get_video_transcode_bitrate(self): bitrate = self.settings.max_video_bitrate or int(self.metadata['format']['bit_rate'] or 0) @@ -504,7 +501,7 @@ def get_video_transcode_bitrate(self): if not upscaling: bitrate = self._min_video_bitrate(int(self.metadata['format']['bit_rate']), bitrate) - bitrate = self._video_scale_bitrate(bitrate, self.input_codec, self.settings.transcode_video_codec) + bitrate = self._video_scale_bitrate(bitrate, self.video_input_codec, self.settings.transcode_video_codec) # don't exceed the requested bitrate if self.settings.max_video_bitrate: @@ -547,6 +544,10 @@ def stream_index_by_lang(self, codec_type: str, lang: str): def get_video_stream(self): return get_video_stream(self.metadata) + + def get_audio_stream(self): + index = self.stream_index_by_lang('audio', self.settings.audio_lang) + return self.metadata['streams'][index.index] def find_ffmpeg_arg(self, key): for a in self.ffmpeg_args: