#! /usr/bin/python3 import os, sys, subprocess, json, click, time, re from ffx.file_properties import FileProperties from ffx.ffx_app import FfxApp from ffx.ffx_controller import FfxController from ffx.show_controller import ShowController from ffx.tmdb_controller import TmdbController from ffx.database import databaseContext from ffx.track_type import TrackType from ffx.video_encoder import VideoEncoder VERSION='0.1.1' # 0.1.1 # Bugfixes, TMBD identify shows @click.group() @click.pass_context def ffx(ctx): """FFX""" ctx.obj = {} ctx.obj['database'] = databaseContext() # 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]") @ffx.command() @click.pass_context @click.argument('filename', nargs=1) def inspect(ctx, filename): ctx.obj['command'] = 'inspect' ctx.obj['arguments'] = {} ctx.obj['arguments']['filename'] = filename app = FfxApp(ctx.obj) app.run() # @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('-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("-o", "--output-directory", type=str, default='') # # @click.option("--dry-run", is_flag=True, default=False) # # # def unmux(ctx, # label, # paths, # subtitle_directory, # subtitle_prefix, # output_directory, # dry_run): # # existingSourcePaths = [p for p in paths if os.path.isfile(p)] # click.echo(f"\nUnmuxing {len(existingSourcePaths)} files") # # for sourcePath in existingSourcePaths: # # sd = getStreamDescriptor(sourcePath) # # print(f"\nFile {sourcePath}\n") # # for v in sd['video']: # # if v['codec_name'] == 'h264': # # commandSequence = ['ffmpeg', '-i', sourcePath, '-map', '0:v:0', '-c', 'copy', '-f', 'h264'] # executeProcess() # # for a in sd['audio']: # print(f"A: {a}\n") # for s in sd['subtitle']: # print(f"S: {s}\n") @ffx.command() @click.pass_context def shows(ctx): ctx.obj['command'] = 'shows' app = FfxApp(ctx.obj) app.run() @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=FfxController.DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1) default: {FfxController.DEFAULT_VIDEO_ENCODER}") @click.option('-q', '--quality', type=str, default=FfxController.DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder (default: {FfxController.DEFAULT_QUALITY})") @click.option('-p', '--preset', type=str, default=FfxController.DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder (default: {FfxController.DEFAULT_AV1_PRESET})") @click.option('-a', '--stereo-bitrate', type=int, default=FfxController.DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams (default: {FfxController.DEFAULT_STEREO_BANDWIDTH})") @click.option('-ac3', '--ac3-bitrate', type=int, default=FfxController.DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams (default: {FfxController.DEFAULT_AC3_BANDWIDTH})") @click.option('-dts', '--dts-bitrate', type=int, default=FfxController.DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams (default: {FfxController.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('-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('-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("--crop", is_flag=False, flag_value="default", default="none") @click.option("-o", "--output-directory", type=str, default='') @click.option("-d", "--denoise", is_flag=True, default=False) @click.option("-t", "--no-tmdb", is_flag=True, default=False) @click.option("-j", "--no-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, audio_language, audio_title, default_audio, forced_audio, subtitle_language, subtitle_title, default_subtitle, forced_subtitle, crop, output_directory, denoise, no_tmdb, no_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 context['dry_run'] = True # dry_run context['video_encoder'] = VideoEncoder.fromLabel(video_encoder) context['jellyfin'] = not no_jellyfin context['tmdb'] = not no_tmdb context['import_subtitles'] = (subtitle_directory and subtitle_prefix) if context['import_subtitles']: context['subtitle_directory'] = subtitle_directory context['subtitle_prefix'] = subtitle_prefix # 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']}") # Process crop parameters context['perform_crop'] = (crop != 'none') if context['perform_crop']: cTokens = crop.split(',') if cTokens and len(cTokens) == 2: context['crop_start'] = int(cTokens[0]) context['crop_lenght'] = int(cTokens[1]) click.echo(f"Crop start={context['crop_start']} length={context['crop_length']}") tc = TmdbController() existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in FfxController.INPUT_FILE_EXTENSIONS] click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs") jobIndex = 0 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('.') sourceFileBasename = '.'.join(sourcePathTokens[:-1]) sourceFilenameExtension = sourcePathTokens[-1] click.echo(f"\nProcessing file {sourcePath}") mediaFileProperties = FileProperties(context, sourceFilename) sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor() #HINT: This is None if the filename did not match anything in database currentPattern = mediaFileProperties.getPattern() click.echo(f"Pattern matching: {'No' if currentPattern is None else 'Yes'}") fileBasename = '' if currentPattern is None: # Case no pattern matching # Check for multiple default or forced dispositions if not set by user input or database requirements # # Query user for the correct sub indices, then configure flags in track descriptors associated with media descriptor accordingly. # The correct tokens should then be created by try: sourceMediaDescriptor.getDefaultVideoTrack() except ValueError: defaultVideoTrackSubIndex = click.prompt("More than one default video stream detected! Please select stream", type=int) sourceMediaDescriptor.setDefaultSubTrack(TrackType.VIDEO, defaultVideoTrackSubIndex) try: sourceMediaDescriptor.getForcedVideoTrack() except ValueError: forcedVideoTrackSubIndex = click.prompt("More than one forced video stream detected! Please select stream", type=int) sourceMediaDescriptor.setForcedSubTrack(TrackType.VIDEO, forcedVideoTrackSubIndex) try: sourceMediaDescriptor.getDefaultAudioTrack() except ValueError: defaultAudioTrackSubIndex = click.prompt("More than one default audio stream detected! Please select stream", type=int) sourceMediaDescriptor.setDefaultSubTrack(TrackType.AUDIO, defaultAudioTrackSubIndex) try: sourceMediaDescriptor.getForcedAudioTrack() except ValueError: forcedAudioTrackSubIndex = click.prompt("More than one forced audio stream detected! Please select stream", type=int) sourceMediaDescriptor.setForcedSubTrack(TrackType.AUDIO, forcedAudioTrackSubIndex) try: sourceMediaDescriptor.getDefaultSubtitleTrack() except ValueError: defaultSubtitleTrackSubIndex = click.prompt("More than one default subtitle stream detected! Please select stream", type=int) sourceMediaDescriptor.setDefaultSubTrack(TrackType.SUBTITLE, defaultSubtitleTrackSubIndex) try: sourceMediaDescriptor.getForcedSubtitleTrack() except ValueError: forcedSubtitleTrackSubIndex = click.prompt("More than one forced subtitle stream detected! Please select stream", type=int) sourceMediaDescriptor.setForcedSubTrack(TrackType.SUBTITLE, forcedSubtitleTrackSubIndex) if context['import_subtitles']: sourceMediaDescriptor.importSubtitles(context['subtitle_directory'], context['subtitle_prefix']) fc = FfxController(context, sourceMediaDescriptor) # mappingTokens = fc.generateMetadataTokens() # click.echo(f"Metadata Tokens: {mappingTokens}") dispositionTokens = fc.generateDispositionTokens() click.echo(f"Disposition Tokens: {dispositionTokens}") audioTokens = fc.generateAudioEncodingTokens() click.echo(f"Audio Tokens: {audioTokens}") else: # Case pattern matching targetMediaDescriptor = currentPattern.getMediaDescriptor() currentShowDescriptor = currentPattern.getShowDescriptor() if context['tmdb']: tmdbEpisodeResult = tc.queryEpisode(currentShowDescriptor.getId(), mediaFileProperties.getSeason(), mediaFileProperties.getEpisode()) # click.echo(f"{tmdbEpisodeResult}") if tmdbEpisodeResult: fileBasename = tc.getEpisodeFileBasename(currentShowDescriptor.getFilenamePrefix(), tmdbEpisodeResult['name'], mediaFileProperties.getSeason(), mediaFileProperties.getEpisode(), currentShowDescriptor.getIndexSeasonDigits(), currentShowDescriptor.getIndexEpisodeDigits(), currentShowDescriptor.getIndicatorSeasonDigits(), currentShowDescriptor.getIndicatorEpisodeDigits()) else: fileBasename = currentShowDescriptor.getFilenamePrefix() click.echo(f"fileBasename={fileBasename}") if context['import_subtitles']: targetMediaDescriptor.importSubtitles(context['subtitle_directory'], context['subtitle_prefix']) targetMediaDescriptor.setJellyfinOrder(context['jellyfin']) click.echo(f"Input mapping tokens: {targetMediaDescriptor.getInputMappingTokens()}") fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor) mappingTokens = fc.generateMetadataTokens() click.echo(f"Metadata Tokens: {mappingTokens}") dispositionTokens = fc.generateDispositionTokens() click.echo(f"Disposition Tokens: {dispositionTokens}") audioTokens = fc.generateAudioEncodingTokens() click.echo(f"Audio Tokens: {audioTokens}") click.echo(f"Season={mediaFileProperties.getSeason()} Episode={mediaFileProperties.getEpisode()}") for q in q_list: click.echo(f"\nRunning job {jobIndex} file={sourcePath} q={q}") jobIndex += 1 extra = ['ffx'] if sourceFilenameExtension == FfxController.DEFAULT_FILE_EXTENSION else [] targetFilename = fileBasename if context['tmdb'] else mediaFileProperties.assembleTargetFileBasename(label if label else fileBasename, q if len(q_list) > 1 else -1, extraTokens = extra) targetPath = os.path.join(output_directory if output_directory else sourceDirectory, targetFilename) fc.runJob(sourcePath, targetPath, context['video_encoder'], q, preset, denoise) # #click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True) endTime = time.perf_counter() click.echo(f"\nDONE\nTime elapsed {endTime - startTime}") if __name__ == '__main__': ffx()