#! /usr/bin/python3 import os, sys, subprocess, json, click, time, re from textual.app import App, ComposeResult from textual.screen import Screen from textual.widgets import Header, Footer, Placeholder, Label VERSION='0.1.0' DEFAULT_VIDEO_ENCODER = 'vp9' DEFAULT_QUALITY = 23 DEFAULT_AV1_PRESET = 5 DEFAULT_FILE_FORMAT = 'webm' DEFAULT_FILE_EXTENSION = 'webm' DEFAULT_STEREO_BANDWIDTH = "128" DEFAULT_AC3_BANDWIDTH = "256" DEFAULT_DTS_BANDWIDTH = "320" DEFAULT_CROP_START = 60 DEFAULT_CROP_LENGTH = 180 TEMP_FILE_NAME = "ffmpeg2pass-0.log" MKVMERGE_METADATA_KEYS = ['BPS', 'NUMBER_OF_FRAMES', 'NUMBER_OF_BYTES', '_STATISTICS_WRITING_APP', '_STATISTICS_WRITING_DATE_UTC', '_STATISTICS_TAGS'] FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm'] COMMAND_TOKENS = ['ffmpeg', '-y'] NULL_TOKENS = ['-f', 'null', '/dev/null'] STREAM_TYPE_VIDEO = 'video' STREAM_TYPE_AUDIO = 'audio' STREAM_TYPE_SUBTITLE = 'subtitle' STREAM_LAYOUT_6_1 = '6.1' STREAM_LAYOUT_5_1 = '5.1(side)' STREAM_LAYOUT_STEREO = 'stereo' STREAM_LAYOUT_6CH = '6ch' SEASON_EPISODE_INDICATOR_MATCH = '[sS]([0-9]+)[eE]([0-9]+)' EPISODE_INDICATOR_MATCH = '[eE]([0-9]+)' SEASON_EPISODE_STREAM_LANGUAGE_MATCH = '[sS]([0-9]+)[eE]([0-9]+)_([0-9]+)_([a-z]{3})' SUBTITLE_FILE_EXTENSION = 'vtt' class DashboardScreen(Screen): def __init__(self): super().__init__() context = self.app.getContext() context['dashboard'] = 'dashboard' def compose(self) -> ComposeResult: yield Header(show_clock=True) yield Placeholder("Dashboard Screen") yield Footer() class WarningScreen(Screen): def __init__(self): super().__init__() context = self.app.getContext() def compose(self) -> ComposeResult: yield Label("Warning! This file is not compliant to the defined source schema!") yield Footer() class SettingsScreen(Screen): def __init__(self): super().__init__() context = self.app.getContext() def compose(self) -> ComposeResult: yield Placeholder("Settings Screen") yield Footer() class HelpScreen(Screen): def __init__(self): super().__init__() context = self.app.getContext() def compose(self) -> ComposeResult: yield Placeholder("Help Screen") yield Footer() class ModesApp(App): BINDINGS = [ ("q", "quit()", "Quit"), # ("d", "switch_mode('dashboard')", "Dashboard"), # ("s", "switch_mode('settings')", "Settings"), # ("h", "switch_mode('help')", "Help"), ] MODES = { "warning": WarningScreen, "dashboard": DashboardScreen, "settings": SettingsScreen, "help": HelpScreen, } def __init__(self, context = {}): super().__init__() self.context = context def on_mount(self) -> None: self.switch_mode("warning") def getContext(self): return self.context def executeProcess(commandSequence): process = subprocess.Popen(commandSequence, stdout=subprocess.PIPE, stderr=subprocess.PIPE) output, error = process.communicate() return output.decode('utf-8'), error.decode('utf-8'), process.returncode #[{'index': 0, 'codec_name': 'vp9', 'codec_long_name': 'Google VP9', 'profile': 'Profile 0', 'codec_type': 'video', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'width': 1920, 'height': 1080, 'coded_width': 1920, 'coded_height': 1080, 'closed_captions': 0, 'film_grain': 0, 'has_b_frames': 0, 'sample_aspect_ratio': '1:1', 'display_aspect_ratio': '16:9', 'pix_fmt': 'yuv420p', 'level': -99, 'color_range': 'tv', 'chroma_location': 'left', 'field_order': 'progressive', 'refs': 1, 'r_frame_rate': '24000/1001', 'avg_frame_rate': '24000/1001', 'time_base': '1/1000', 'start_pts': 0, 'start_time': '0.000000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'BPS': '7974017', 'NUMBER_OF_FRAMES': '34382', 'NUMBER_OF_BYTES': '1429358655', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libvpx-vp9', 'DURATION': '00:23:54.016000000'}}] #[{'index': 1, 'codec_name': 'opus', 'codec_long_name': 'Opus (Opus Interactive Audio Codec)', 'codec_type': 'audio', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'sample_fmt': 'fltp', 'sample_rate': '48000', 'channels': 2, 'channel_layout': 'stereo', 'bits_per_sample': 0, 'initial_padding': 312, 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'extradata_size': 19, 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'jpn', 'title': 'Japanisch', 'BPS': '128000', 'NUMBER_OF_FRAMES': '61763', 'NUMBER_OF_BYTES': '22946145', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libopus', 'DURATION': '00:23:54.141000000'}}] #[{'index': 2, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'ger', 'title': 'Deutsch [Full]', 'BPS': '118', 'NUMBER_OF_FRAMES': '300', 'NUMBER_OF_BYTES': '21128', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:54.010000000'}}, {'index': 3, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 0, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'eng', 'title': 'Englisch [Full]', 'BPS': '101', 'NUMBER_OF_FRAMES': '276', 'NUMBER_OF_BYTES': '16980', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:53.230000000'}}] def getStreamData(filepath): """Returns ffprobe stream data as array with elements according to the following example { "index": 4, "codec_name": "hdmv_pgs_subtitle", "codec_long_name": "HDMV Presentation Graphic Stream subtitles", "codec_type": "subtitle", "codec_tag_string": "[0][0][0][0]", "codec_tag": "0x0000", "r_frame_rate": "0/0", "avg_frame_rate": "0/0", "time_base": "1/1000", "start_pts": 0, "start_time": "0.000000", "duration_ts": 1421035, "duration": "1421.035000", "disposition": { "default": 1, "dub": 0, "original": 0, "comment": 0, "lyrics": 0, "karaoke": 0, "forced": 0, "hearing_impaired": 0, "visual_impaired": 0, "clean_effects": 0, "attached_pic": 0, "timed_thumbnails": 0, "non_diegetic": 0, "captions": 0, "descriptions": 0, "metadata": 0, "dependent": 0, "still_image": 0 }, "tags": { "language": "ger", "title": "German Full" } } """ ffprobeOutput, ffprobeError, returnCode = executeProcess(["ffprobe", "-show_streams", "-of", "json", filepath]) if 'Invalid data found when processing input' in ffprobeError: raise Exception(f"File {filepath} does not contain valid stream data") if returnCode != 0: raise Exception(f"ffprobe returned with error {returnCode}") return json.loads(ffprobeOutput)['streams'] def getStreamDescriptor(filename): streamData = getStreamData(filename) descriptor = {} descriptor['video'] = [] descriptor['audio'] = [] descriptor['subtitle'] = [] for subStream in streamData: s = subStream.copy() #Defaulting to undefined if tag not defined for stream if 'tags' in subStream.keys() and 'language' in subStream['tags'].keys(): s['language'] = subStream['tags']['language'] else: s['language'] = 'undefined' #Defaulting to undefined if tag not defined for stream if 'tags' in subStream.keys() and 'title' in subStream['tags'].keys(): s['title'] = subStream['tags']['title'] else: s['title'] = 'undefined' if subStream['codec_type'] == STREAM_TYPE_AUDIO: if 'channel_layout' in subStream.keys(): s['layout'] = subStream['channel_layout'] elif subStream['channels'] == 6: s['layout'] = STREAM_LAYOUT_6CH else: s['layout'] = 'undefined' descriptor[s['codec_type']].append(s) descriptor[s['codec_type']][-1]['src_sub_index'] = len(descriptor[s['codec_type']]) - 1 return descriptor def getModifiedStreamOrder(length, last): """This is jellyfin specific as the last stream in the order is set as default""" seq = list(range(length)) if last < 0 or last > length -1: return seq seq.pop(last) seq.append(last) return seq def getReorderedSubstreams(subDescriptor, last): numSubStreams = len(subDescriptor) modifiedOrder = getModifiedStreamOrder(numSubStreams, last) reorderedDescriptor = [] for streamIndex in range(numSubStreams): reorderedDescriptor.append(subDescriptor[modifiedOrder[streamIndex]]) return reorderedDescriptor def generateAV1Tokens(q, p): return ['-c:v:0', 'libsvtav1', '-svtav1-params', f"crf={q}:preset={p}:tune=0:enable-overlays=1:scd=1:scm=0", '-pix_fmt', 'yuv420p10le'] def generateVP9Pass1Tokens(q): return ['-c:v:0', 'libvpx-vp9', '-row-mt', '1', '-crf', str(q), '-pass', '1', '-speed', '4', '-frame-parallel', '0', '-g', '9999', '-aq-mode', '0'] def generateVP9Pass2Tokens(q): return ['-c:v:0', 'libvpx-vp9', '-row-mt', '1', '-crf', str(q), '-pass', '2', '-frame-parallel', '0', '-g', '9999', '-aq-mode', '0', '-auto-alt-ref', '1', '-lag-in-frames', '25'] def generateCropTokens(start, length): return ['-ss', str(start), '-t', str(length)] def generateDenoiseTokens(spatial=5, patch=7, research=7, hw=False): filterName = 'nlmeans_opencl' if hw else 'nlmeans' return ['-vf', f"{filterName}=s={spatial}:p={patch}:r={research}"] def generateOutputTokens(filepath, format, ext): return ['-f', format, f"{filepath}.{ext}"] def generateAudioTokens(context, index, layout): if layout == STREAM_LAYOUT_6_1: return [f"-c:a:{index}", 'libopus', f"-filter:a:{index}", 'channelmap=channel_layout=6.1', f"-b:a:{index}", context['bitrates']['dts']] elif layout == STREAM_LAYOUT_5_1: return [f"-c:a:{index}", 'libopus', f"-filter:a:{index}", "channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1", f"-b:a:{index}", context['bitrates']['ac3']] elif layout == STREAM_LAYOUT_STEREO: return [f"-c:a:{index}", 'libopus', f"-b:a:{index}", context['bitrates']['stereo']] elif layout == STREAM_LAYOUT_6CH: return [f"-c:a:{index}", 'libopus', f"-filter:a:{index}", "channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1", f"-b:a:{index}", context['bitrates']['ac3']] else: return [] def generateClearTokens(streams): clearTokens = [] for s in streams: for k in MKVMERGE_METADATA_KEYS: clearTokens += [f"-metadata:s:{s['type'][0]}:{s['sub_index']}", f"{k}="] return clearTokens def generateDispositionTokens(subDescriptor): """-disposition:s:X default+forced""" dispositionTokens = [] for subStreamIndex in range(len(subDescriptor)): subStream = subDescriptor[subStreamIndex] streamType = subStream['codec_type'][0] # v|a|s dispositionFlags = {k for (k,v) in subStream['disposition'].items() if v == 1} if 'disposition' in subStream.keys() else set() if dispositionFlags: dispositionTokens += [f"-disposition:{streamType}:{subStreamIndex}", '+'.join(dispositionFlags)] else: dispositionTokens += [f"-disposition:{streamType}:{subStreamIndex}", '0'] return dispositionTokens def searchSubtitleFiles(dir, prefix): sesl_match = re.compile(SEASON_EPISODE_STREAM_LANGUAGE_MATCH) availableFileSubtitles = [] for subtitleFilename in os.listdir(dir): if subtitleFilename.startswith(prefix) and subtitleFilename.endswith('.' + SUBTITLE_FILE_EXTENSION): sesl_result = sesl_match.search(subtitleFilename) if sesl_result is not None: subtitleFilePath = os.path.join(dir, subtitleFilename) if os.path.isfile(subtitleFilePath): subtitleFileDescriptor = {} subtitleFileDescriptor['path'] = subtitleFilePath subtitleFileDescriptor['season'] = int(sesl_result.group(1)) subtitleFileDescriptor['episode'] = int(sesl_result.group(2)) subtitleFileDescriptor['stream'] = int(sesl_result.group(3)) subtitleFileDescriptor['language'] = sesl_result.group(4) availableFileSubtitles.append(subtitleFileDescriptor) click.echo(f"Found {len(availableFileSubtitles)} subtitles in files\n") return availableFileSubtitles @click.group() @click.pass_context def ffx(ctx): """FFX""" ctx.obj = {} pass # Define a subcommand @ffx.command() def version(): click.echo(VERSION) # Another subcommand @ffx.command() def help(): click.echo(f"ffx {VERSION}\n") click.echo(f"Usage: ffx [input file] [output file] [vp9|av1] [q=[nn[,nn,...]]] [p=nn] [a=nnn[k]] [ac3=nnn[k]] [dts=nnn[k]] [crop]") @click.argument('filename', nargs=1) @ffx.command() def streams(filename): try: sd = getStreamDescriptor(filename) except Exception as ex: raise click.ClickException(f"This file does not contain any audiovisual data: {ex}") for d in sd: click.echo(f"{d['codec_name']}{' (' + str(d['channels']) + ')' if d['codec_type'] == 'audio' else ''}") @ffx.command() @click.pass_context @click.argument('paths', nargs=-1) @click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix') @click.option('-v', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1) default: {DEFAULT_VIDEO_ENCODER}") @click.option('-q', '--quality', type=str, default=DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder (default: {DEFAULT_QUALITY})") @click.option('-p', '--preset', type=str, default=DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder (default: {DEFAULT_AV1_PRESET})") @click.option('-a', '--stereo-bitrate', type=int, default=DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams (default: {DEFAULT_STEREO_BANDWIDTH})") @click.option('-ac3', '--ac3-bitrate', type=int, default=DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams (default: {DEFAULT_AC3_BANDWIDTH})") @click.option('-dts', '--dts-bitrate', type=int, default=DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams (default: {DEFAULT_DTS_BANDWIDTH})") @click.option('-sd', '--subtitle-directory', type=str, default='', help='Load subtitles from here') @click.option('-sp', '--subtitle-prefix', type=str, default='', help='Subtitle filename prefix') @click.option('-ss', '--subtitle-language', type=str, multiple=True, help='Subtitle stream language(s)') @click.option('-st', '--subtitle-title', type=str, multiple=True, help='Subtitle stream title(s)') @click.option('-ds', '--default-subtitle', type=int, default=-1, help='Index of default subtitle stream') @click.option('-fs', '--forced-subtitle', type=int, default=-1, help='Index of forced subtitle stream') # (including default audio stream tag) @click.option('-as', '--audio-language', type=str, multiple=True, help='Audio stream language(s)') @click.option('-at', '--audio-title', type=str, multiple=True, help='Audio stream title(s)') @click.option('-da', '--default-audio', type=int, default=-1, help='Index of default audio stream') @click.option('-da', '--forced-audio', type=int, default=-1, help='Index of forced audio stream') @click.option("--crop", is_flag=False, flag_value="default", default="none") @click.option("-o", "--output-directory", type=str, default='') @click.option("-c", "--clear-metadata", is_flag=True, default=False) @click.option("-d", "--denoise", is_flag=True, default=False) @click.option("-j", "--jellyfin", is_flag=True, default=False) @click.option("--dry-run", is_flag=True, default=False) def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, dts_bitrate, subtitle_directory, subtitle_prefix, subtitle_language, subtitle_title, default_subtitle, forced_subtitle, audio_language, audio_title, default_audio, forced_audio, crop, output_directory, clear_metadata, denoise, jellyfin, dry_run): """Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin Files found under PATHS will be converted according to parameters. Filename extensions will be changed appropriately. Suffices will we appended to filename in case of multiple created files or if the filename has not changed.""" startTime = time.perf_counter() context = ctx.obj click.echo(f"\nVideo encoder: {video_encoder}") qualityTokens = quality.split(',') q_list = [q for q in qualityTokens if q.isnumeric()] click.echo(f"Qualities: {q_list}") context['bitrates'] = {} context['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k" context['bitrates']['ac3'] = str(ac3_bitrate) if str(ac3_bitrate).endswith('k') else f"{ac3_bitrate}k" context['bitrates']['dts'] = str(dts_bitrate) if str(dts_bitrate).endswith('k') else f"{dts_bitrate}k" click.echo(f"Stereo bitrate: {context['bitrates']['stereo']}") click.echo(f"AC3 bitrate: {context['bitrates']['ac3']}") click.echo(f"DTS bitrate: {context['bitrates']['dts']}") se_match = re.compile(SEASON_EPISODE_INDICATOR_MATCH) e_match = re.compile(EPISODE_INDICATOR_MATCH) # Parse subtitle files context['import_subtitles'] = (subtitle_directory and subtitle_prefix) availableFileSubtitles = searchSubtitleFiles(subtitle_directory, subtitle_prefix) if context['import_subtitles'] else [] # Overwrite audio tags if set audioLanguages = audio_language audioTitles = audio_title # Overwrite subtitle tags if set subtitleLanguages = subtitle_language subtitleTitles = subtitle_title # Process crop parameters context['perform_crop'] = (crop != 'none') if context['perform_crop']: cropTokens = crop.split(',') if cropTokens and len(cropTokens) == 2: context['crop_start'], context['crop_length'] = crop.split(',') else: context['crop_start'] = DEFAULT_CROP_START context['crop_length'] = DEFAULT_CROP_LENGTH click.echo(f"crop start={context['crop_start']} length={context['crop_length']}") job_index = 0 existingSourcePaths = [p for p in paths if os.path.isfile(p)] click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs") for sourcePath in existingSourcePaths: # Separate basedir, basename and extension for current source file sourceDirectory = os.path.dirname(sourcePath) sourceFilename = os.path.basename(sourcePath) sourcePathTokens = sourceFilename.split('.') if sourcePathTokens[-1] in FILE_EXTENSIONS: sourceFileBasename = '.'.join(sourcePathTokens[:-1]) sourceFilenameExtension = sourcePathTokens[-1] else: sourceFileBasename = sourceFilename sourceFilenameExtension = '' click.echo(f"\nProcessing file {sourcePath}") # Determine season and episode if present in current filename season_digits = 2 episode_digits = 2 index_digits = 3 se_result = se_match.search(sourceFilename) e_result = e_match.search(sourceFilename) season = -1 episode = -1 file_index = 0 if se_result is not None: season = int(se_result.group(1)) episode = int(se_result.group(2)) elif e_result is not None: episode = int(e_result.group(1)) else: file_index += 1 print(f"season={season} episode={episode} file={file_index}") # Assemble target filename tokens targetFilenameTokens = [] targetFilenameExtension = DEFAULT_FILE_EXTENSION if label: targetFilenameTokens = [label] if season > -1 and episode > -1: targetFilenameTokens += [f"S{season:0{season_digits}d}E{episode:0{episode_digits}d}"] elif episode > -1: targetFilenameTokens += [f"E{episode:0{episode_digits}d}"] else: targetFilenameTokens += [f"{file_index:0{index_digits}d}"] else: targetFilenameTokens = [sourceFileBasename] try: sourceStreamDescriptor = getStreamDescriptor(sourcePath) targetStreamDescriptor = sourceStreamDescriptor.copy() except Exception: click.echo(f"File with path {sourcePath} does not contain any audiovisual data, skipping ...") continue click.echo('Source streans:') for aStream in sourceStreamDescriptor[STREAM_TYPE_AUDIO]: click.echo(f"audio stream {aStream['src_sub_index']} lang={aStream['language']} title={aStream['title']} default={aStream['disposition']['default']} forced={aStream['disposition']['forced']}") for sStream in sourceStreamDescriptor[STREAM_TYPE_SUBTITLE]: click.echo(f"subtitle stream {sStream['src_sub_index']} lang={sStream['language']} title={sStream['title']} default={sStream['disposition']['default']} forced={sStream['disposition']['forced']}") #NOTE: It is currently expected that all source file have the same substream pattern, e.g. coming from the same encoder defaultAudioStreams = [a for a in sourceStreamDescriptor[STREAM_TYPE_AUDIO] if 'disposition' in a.keys() and 'default' in a['disposition'].keys() and a['disposition']['default'] == 1] if default_audio == -1 and len(defaultAudioStreams) > 1: default_audio = click.prompt("More than one default audio stream detected! Please select stream", type=int) forcedAudioStreams = [a for a in sourceStreamDescriptor[STREAM_TYPE_AUDIO] if 'disposition' in a.keys() and 'default' in a['disposition'].keys() and a['disposition']['forced'] == 1] if forced_audio == -1 and len(forcedAudioStreams) > 1: forced_audio = click.prompt("More than one forced audio stream detected! Please select stream", type=int) defaultSubtitleStreams = [s for s in sourceStreamDescriptor[STREAM_TYPE_SUBTITLE] if 'disposition' in s.keys() and 'default' in s['disposition'].keys() and s['disposition']['default'] == 1] if default_subtitle == -1 and len(defaultSubtitleStreams) > 1: default_subtitle = click.prompt("More than one default subtitle stream detected! Please select stream", type=int) forcedSubtitleStreams = [s for s in sourceStreamDescriptor[STREAM_TYPE_SUBTITLE] if 'disposition' in s.keys() and 'default' in s['disposition'].keys() and s['disposition']['forced'] == 1] if forced_subtitle == -1 and len(forcedSubtitleStreams) > 1: forced_subtitle = click.prompt("More than one forced subtitle stream detected! Please select stream", type=int) #Fix multiple default/forced tags if default_audio != -1: for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_AUDIO])): if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex].keys(): targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition'] = {} targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition']['default'] = 1 if substreamIndex == default_audio else 0 if forced_audio != -1: for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_AUDIO])): if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex].keys(): targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition'] = {} targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition']['forced'] = 1 if substreamIndex == forced_audio else 0 if default_subtitle != -1: for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_SUBTITLE])): if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex].keys(): targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition'] = {} targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition']['default'] = 1 if substreamIndex == default_subtitle else 0 if forced_subtitle != -1: for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_SUBTITLE])): if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex].keys(): targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition'] = {} targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition']['forded'] = 1 if substreamIndex == forced_subtitle else 0 click.echo('Target streans:') for aStream in targetStreamDescriptor[STREAM_TYPE_AUDIO]: click.echo(f"audio stream {aStream['src_sub_index']} lang={aStream['language']} title={aStream['title']} default={aStream['disposition']['default']} forced={aStream['disposition']['forced']}") for sStream in targetStreamDescriptor[STREAM_TYPE_SUBTITLE]: click.echo(f"subtitle stream {sStream['src_sub_index']} lang={sStream['language']} title={sStream['title']} default={sStream['disposition']['default']} forced={sStream['disposition']['forced']}") commandTokens = COMMAND_TOKENS + ['-i', sourcePath] subtitleFileTokens = [] matchingSubtitles = [] if context['import_subtitles']: subtitles = [a for a in availableFileSubtitles if a['season'] == season and a['episode'] == episode] mSubtitles = sorted(subtitles, key=lambda d: d['stream']) for sfd in mSubtitles: subtitleFileTokens += ['-i', sfd['path']] for streamIndex in range(len(mSubtitles)): mSubtitles[streamIndex]['forced'] = 1 if forced_subtitle != -1 and streamIndex == forced_subtitle else 0 mSubtitles[streamIndex]['default'] = 1 if default_subtitle != -1 and streamIndex == default_subtitle else 0 if streamIndex <= len(subtitleTitles) -1: mSubtitles[streamIndex]['title'] = subtitleTitles[streamIndex] if default_subtitle != -1 and jellyfin: matchingSubtitles = getReorderedSubstreams(mSubtitles, default_subtitle) else: matchingSubtitles = mSubtitles for q in q_list: click.echo(f"\nRunning job {job_index} file={sourcePath} q={q}") job_index += 1 mappingVideoTokens = ['-map', '0:v:0'] mappingTokens = mappingVideoTokens.copy() dispositionTokens = [] audioTokens = [] # Source stream descriptors audioStreams = sourceStreamDescriptor[STREAM_TYPE_AUDIO] subtitleStreams = sourceStreamDescriptor[STREAM_TYPE_SUBTITLE] # Set language and title in source stream descriptors if given per command line option for streamIndex in range(len(audioStreams)): if 'tags' not in audioStreams[streamIndex].keys(): audioStreams[streamIndex]['tags'] = {} if streamIndex <= len(audioLanguages) - 1: audioStreams[streamIndex]['tags']['language'] = audioLanguages[streamIndex] if streamIndex <= len(audioTitles) - 1: audioStreams[streamIndex]['tags']['title'] = audioTitles[streamIndex] for streamIndex in range(len(subtitleStreams)): if 'tags' not in subtitleStreams[streamIndex].keys(): subtitleStreams[streamIndex]['tags'] = {} if streamIndex <= len(subtitleLanguages) - 1: subtitleStreams[streamIndex]['tags']['language'] = subtitleLanguages[streamIndex] if streamIndex <= len(subtitleTitles) - 1: subtitleStreams[streamIndex]['tags']['title'] = subtitleTitles[streamIndex] # Reorder audio stream descriptors and create disposition options if default is given per command line option if default_audio == -1: sourceAudioStreams = audioStreams else: for streamIndex in range(len(audioStreams)): audioStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_audio else 0 sourceAudioStreams = getReorderedSubstreams(audioStreams, default_audio) if jellyfin else audioStreams dispositionTokens += generateDispositionTokens(sourceAudioStreams) # Set forced tag in subtitle descriptor if given per command line option if forced_subtitle != -1: for streamIndex in range(len(subtitleStreams)): subtitleStreams[streamIndex]['disposition']['forced'] = 1 if streamIndex == forced_subtitle else 0 # Reorder subtitle stream descriptors and create disposition options if default is given per command line option if default_subtitle == -1: sourceSubtitleStreams = subtitleStreams else: for streamIndex in range(len(subtitleStreams)): subtitleStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_subtitle else 0 sourceSubtitleStreams = getReorderedSubstreams(subtitleStreams, default_subtitle) if jellyfin else subtitleStreams dispositionTokens += generateDispositionTokens(sourceSubtitleStreams) audioMetadataTokens = [] for audioStreamIndex in range(len(sourceAudioStreams)): audioStream = sourceAudioStreams[audioStreamIndex] # Create mapping and ffmpeg options for audio streams mappingTokens += ['-map', f"0:a:{audioStream['src_sub_index']}"] audioTokens += generateAudioTokens(context, audioStream['src_sub_index'], audioStream['layout']) if 'tags' in audioStream.keys(): if 'language' in audioStream['tags'].keys(): audioMetadataTokens += [f"-metadata:s:a:{audioStreamIndex}", f"language={audioStream['tags']['language']}"] if 'title' in audioStream['tags'].keys(): audioMetadataTokens += [f"-metadata:s:a:{audioStreamIndex}", f"title={audioStream['tags']['title']}"] # Create mapping and ffmpeg options for subtitle streams subtitleMetadataTokens = [] if context['import_subtitles']: numMatchingSubtitles = len(matchingSubtitles) if jellyfin and default_subtitle != -1: subtitleSequence = getModifiedStreamOrder(numMatchingSubtitles, default_subtitle) else: subtitleSequence = range(numMatchingSubtitles) for fileIndex in range(numMatchingSubtitles): # Create mapping for subtitle streams when imported from files mappingTokens += ['-map', f"{subtitleSequence[fileIndex]+1}:s:0"] msg = matchingSubtitles[fileIndex] subtitleMetadataTokens += [f"-metadata:s:s:{fileIndex}", f"language={msg['language']}"] if 'title' in matchingSubtitles[fileIndex].keys(): subtitleMetadataTokens += [f"-metadata:s:s:{fileIndex}", f"title={matchingSubtitles[fileIndex]['title']}"] else: for subtitleStreamIndex in range(len(sourceSubtitleStreams)): subtitleStream = sourceSubtitleStreams[subtitleStreamIndex] # Create mapping for subtitle streams mappingTokens += ['-map', f"s:{subtitleStream['src_sub_index']}"] if 'tags' in subtitleStream.keys(): if 'language' in subtitleStream['tags'].keys(): subtitleMetadataTokens += [f"-metadata:s:s:{subtitleStreamIndex}", f"language={subtitleStream['tags']['language']}"] if 'title' in subtitleStream['tags'].keys(): subtitleMetadataTokens += [f"-metadata:s:s:{subtitleStreamIndex}", f"title={subtitleStream['tags']['title']}"] # Job specific tokens targetFilenameJobTokens = targetFilenameTokens.copy() if len(q_list) > 1: targetFilenameJobTokens += [f"q{q}"] # In case source and target filenames are the same add an extension to distinct output from input if not label and sourceFilenameExtension == targetFilenameExtension: targetFilenameJobTokens += ['ffx'] targetFilename = '_'.join(targetFilenameJobTokens) # + '.' + targetFilenameExtension click.echo(f"target filename: {targetFilename}") if video_encoder == 'av1': commandSequence = (commandTokens + subtitleFileTokens + mappingTokens + dispositionTokens + audioMetadataTokens + subtitleMetadataTokens + audioTokens + generateAV1Tokens(q, preset) + audioTokens) if clear_metadata: commandSequence += generateClearTokens(sourceStreamDescriptor) if context['perform_crop']: commandSequence += generateCropTokens(context['crop_start'], context['crop_length']) commandSequence += generateOutputTokens(targetFilename, DEFAULT_FILE_FORMAT, DEFAULT_FILE_EXTENSION) click.echo(f"Command: {' '.join(commandSequence)}") if not dry_run: executeProcess(commandSequence) if video_encoder == 'vp9': commandSequence1 = commandTokens + mappingVideoTokens + generateVP9Pass1Tokens(q) if context['perform_crop']: commandSequence1 += generateCropTokens(context['crop_start'], context['crop_length']) commandSequence1 += NULL_TOKENS click.echo(f"Command 1: {' '.join(commandSequence1)}") if os.path.exists(TEMP_FILE_NAME): os.remove(TEMP_FILE_NAME) if not dry_run: executeProcess(commandSequence1) commandSequence2 = (commandTokens + subtitleFileTokens + mappingTokens + audioMetadataTokens + subtitleMetadataTokens + dispositionTokens) if denoise: commandSequence2 += generateDenoiseTokens() commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens if clear_metadata: commandSequence2 += generateClearTokens(sourceStreamDescriptor) if context['perform_crop']: commandSequence2 += generateCropTokens(context['crop_start'], context['crop_length']) commandSequence2 += generateOutputTokens(targetFilename, DEFAULT_FILE_FORMAT, DEFAULT_FILE_EXTENSION) click.echo(f"Command 2: {' '.join(commandSequence2)}") if not dry_run: executeProcess(commandSequence2) #app = ModesApp(context) #app.run() #click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True) click.echo('\nDONE\n') endTime = time.perf_counter() click.echo(f"Time elapsed {endTime - startTime}") # click.echo(f"app result: {app.getContext()}") if __name__ == '__main__': ffx()