diff --git a/bin/ffx.py b/bin/ffx.py index 730b693..8ca861c 100755 --- a/bin/ffx.py +++ b/bin/ffx.py @@ -37,7 +37,7 @@ MKVMERGE_METADATA_KEYS = ['BPS', FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm'] -COMMAND_TOKENS = ['ffmpeg', '-y', '-i'] +COMMAND_TOKENS = ['ffmpeg', '-y'] NULL_TOKENS = ['-f', 'null', '/dev/null'] STREAM_TYPE_VIDEO = 'video' @@ -51,6 +51,9 @@ 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): @@ -408,8 +411,17 @@ def streams(filename): @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('-sl', '--subtitle-label', type=str, default='', help='Subtitle filename prefix') + +@click.option('-ss', '--subtitle-language', type=str, default='', help='Subtitle stream language(s)') +@click.option('-st', '--subtitle-title', type=str, default='', help='Subtitle stream title(s)') + @click.option('-ds', '--default-subtitle', type=int, default=-1, help='Index of default subtitle stream') -@click.option('-fa', '--forced-subtitle', type=int, default=-1, help='Index of forced subtitle stream') # (including default audio stream tag) +@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, default='', help='Audio stream language(s)') +@click.option('-at', '--audio-title', type=str, default='', help='Audio stream title(s)') @click.option('-da', '--default-audio', type=int, default=-1, help='Index of default audio stream') @@ -427,9 +439,32 @@ def streams(filename): @click.option("--dry-run", is_flag=True, default=False) -def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, dts_bitrate, default_subtitle, forced_subtitle, default_audio, crop, output_directory, clear_metadata, denoise, no_jellyfin_tweaks, dry_run): +def convert(ctx, + paths, + label, + video_encoder, + quality, + preset, + stereo_bitrate, + ac3_bitrate, + dts_bitrate, + subtitle_directory, + subtitle_label, + subtitle_language, + subtitle_title, + default_subtitle, + forced_subtitle, + audio_language, + audio_title, + default_audio, + crop, + output_directory, + clear_metadata, + denoise, + no_jellyfin_tweaks, + 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 @@ -457,6 +492,41 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a click.echo(f"AC3 bitrate: {context['bitrates']['ac3']}") click.echo(f"DTS bitrate: {context['bitrates']['dts']}") + # Parse subtitle files + context['import_subtitles'] = (subtitle_directory and subtitle_label) + + se_match = re.compile(SEASON_EPISODE_INDICATOR_MATCH) + e_match = re.compile(EPISODE_INDICATOR_MATCH) + sesl_match = re.compile(SEASON_EPISODE_STREAM_LANGUAGE_MATCH) + + + availableSubtitles = [] + if context['import_subtitles']: + for subtitleFilename in os.listdir(subtitle_directory): + if subtitleFilename.startswith(subtitle_label) and subtitleFilename.endswith('.' + SUBTITLE_FILE_EXTENSION): + sesl_result = sesl_match.search(subtitleFilename) + if sesl_result is not None: + subtitleFilePath = os.path.join(subtitle_directory, 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) + availableSubtitles.append(subtitleFileDescriptor) + + click.echo(f"Found {len(availableSubtitles)} subtitles in files") + + + subtitleLanguages = subtitle_language.split(',') if subtitle_language else [] + subtitleTitles = subtitle_title.split(',') if subtitle_title else [] + + audioLanguages = audio_language.split(',') if audio_language else [] + audioTitles = audio_title.split(',') if audio_title else [] + + + # Process crop parameters context['perform_crop'] = (crop != 'none') if context['perform_crop']: @@ -481,10 +551,6 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a job_index = 0 - se_match = re.compile(SEASON_EPISODE_INDICATOR_MATCH) - e_match = re.compile(EPISODE_INDICATOR_MATCH) - - for sourcePath in existingSourcePaths: sourceDirectory = os.path.dirname(sourcePath) @@ -554,51 +620,133 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a click.echo(f"subtitle stream lang={sStream['language']}") + commandTokens = COMMAND_TOKENS + ['-i', sourcePath] + + subtitleFileTokens = [] + matchingSubtitles = [] + if context['import_subtitles']: + subtitles = [a for a in availableSubtitles 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 default_subtitle == -1: + matchingSubtitles = mSubtitles + else: + matchingSubtitles = getReorderedSubstreams(mSubtitles, default_subtitle) + + - commandTokens = COMMAND_TOKENS + [sourcePath] for q in q_list: click.echo(f"\nRunning job {job_index} file={sourcePath} q={q}") job_index += 1 - mappingVideoTokens = ['-map', 'v:0'] + mappingVideoTokens = ['-map', '0:v:0'] mappingTokens = mappingVideoTokens.copy() dispositionTokens = [] audioTokens = [] + # Source stream descriptors audioStreams = streamDescriptor[STREAM_TYPE_AUDIO] subtitleStreams = streamDescriptor[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 + audioStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_audio else 0 + sourceAudioStreams = getReorderedSubstreams(audioStreams, default_audio) 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 + subtitleStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_subtitle else 0 + sourceSubtitleStreams = getReorderedSubstreams(subtitleStreams, default_subtitle) dispositionTokens += generateDispositionTokens(sourceSubtitleStreams) - for audioStream in sourceAudioStreams: - mappingTokens += ['-map', f"a:{audioStream['src_sub_index']}"] + 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']) - for subtitleStream in sourceSubtitleStreams: - mappingTokens += ['-map', f"s:{subtitleStream['src_sub_index']}"] + 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']: + + for fileIndex in range(len(matchingSubtitles)): + + # Create mapping for subtitle streams when imported from files + mappingTokens += ['-map', f"{fileIndex+1}:s:0"] + + msg = matchingSubtitles[fileIndex] + subtitleMetadataTokens += [f"-metadata:s:s:{fileIndex}", f"language={msg['language']}"] + + 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 @@ -619,8 +767,11 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a if video_encoder == 'av1': commandSequence = (commandTokens + + subtitleFileTokens + mappingTokens + dispositionTokens + + audioMetadataTokens + + subtitleMetadataTokens + audioTokens + generateAV1Tokens(q, preset) + audioTokens) @@ -657,7 +808,10 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a commandSequence2 = (commandTokens + + subtitleFileTokens + mappingTokens + + audioMetadataTokens + + subtitleMetadataTokens + dispositionTokens) if denoise: