nightl
This commit is contained in:
263
bin/ffx.py
263
bin/ffx.py
@@ -194,11 +194,17 @@ def convert(ctx,
|
|||||||
|
|
||||||
context = ctx.obj
|
context = ctx.obj
|
||||||
|
|
||||||
|
context['dry_run'] = dry_run
|
||||||
|
|
||||||
context['jellyfin'] = jellyfin
|
context['jellyfin'] = jellyfin
|
||||||
context['tmdb'] = tmdb
|
context['tmdb'] = tmdb
|
||||||
|
|
||||||
# click.echo(f"\nVideo encoder: {video_encoder}")
|
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(',')
|
qualityTokens = quality.split(',')
|
||||||
q_list = [q for q in qualityTokens if q.isnumeric()]
|
q_list = [q for q in qualityTokens if q.isnumeric()]
|
||||||
@@ -220,26 +226,22 @@ def convert(ctx,
|
|||||||
if context['perform_crop']:
|
if context['perform_crop']:
|
||||||
cTokens = crop.split(',')
|
cTokens = crop.split(',')
|
||||||
if cTokens and len(cTokens) == 2:
|
if cTokens and len(cTokens) == 2:
|
||||||
cropStart = int(cTokens[0])
|
context['crop_start'] = int(cTokens[0])
|
||||||
cropLength = int(cTokens[1])
|
context['crop_lenght'] = int(cTokens[1])
|
||||||
cropTokens = FfxController.generateCropTokens(cropStart, cropLength)
|
|
||||||
else:
|
|
||||||
cropTokens = FfxController.generateCropTokens()
|
|
||||||
else:
|
|
||||||
cropTokens = []
|
|
||||||
|
|
||||||
click.echo(f"Crop tokens={cropTokens}")
|
click.echo(f"Crop start={context['crop_start']} length={context['crop_length']}")
|
||||||
|
|
||||||
|
|
||||||
# ## Conversion parameters
|
# ## Conversion parameters
|
||||||
#
|
#
|
||||||
# # Parse subtitle files
|
# # Parse subtitle files
|
||||||
# context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
|
#
|
||||||
# availableFileSubtitleDescriptors = searchSubtitleFiles(subtitle_directory, subtitle_prefix) if context['import_subtitles'] else []
|
# availableFileSubtitleDescriptors = searchSubtitleFiles(subtitle_directory, subtitle_prefix) if context['import_subtitles'] else []
|
||||||
|
|
||||||
|
|
||||||
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in FfxController.INPUT_FILE_EXTENSIONS]
|
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")
|
click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
|
||||||
|
jobIndex = 0
|
||||||
|
|
||||||
for sourcePath in existingSourcePaths:
|
for sourcePath in existingSourcePaths:
|
||||||
|
|
||||||
@@ -304,9 +306,17 @@ def convert(ctx,
|
|||||||
|
|
||||||
fc = FfxController(context, sourceMediaDescriptor)
|
fc = FfxController(context, sourceMediaDescriptor)
|
||||||
|
|
||||||
|
# mappingTokens = fc.generateMetadataTokens()
|
||||||
|
# click.echo(f"Metadata Tokens: {mappingTokens}")
|
||||||
|
|
||||||
dispositionTokens = fc.generateDispositionTokens()
|
dispositionTokens = fc.generateDispositionTokens()
|
||||||
click.echo(f"Disposition Tokens: {dispositionTokens}")
|
click.echo(f"Disposition Tokens: {dispositionTokens}")
|
||||||
|
|
||||||
|
audioTokens = fc.generateAudioEncodingTokens()
|
||||||
|
click.echo(f"Audio Tokens: {audioTokens}")
|
||||||
|
|
||||||
|
audioTokens = fc
|
||||||
|
click.echo(f"Audio Tokens: {audioTokens}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
@@ -314,6 +324,9 @@ def convert(ctx,
|
|||||||
|
|
||||||
targetMediaDescriptor = currentPattern.getMediaDescriptor() if currentPattern is not None else None
|
targetMediaDescriptor = currentPattern.getMediaDescriptor() if currentPattern is not None else None
|
||||||
|
|
||||||
|
if context['import_subtitles']:
|
||||||
|
targetMediaDescriptor.importSubtitles(context['subtitle_directory'], context['subtitle_prefix'])
|
||||||
|
|
||||||
targetMediaDescriptor.setJellyfinOrder(context['jellyfin'])
|
targetMediaDescriptor.setJellyfinOrder(context['jellyfin'])
|
||||||
|
|
||||||
click.echo(f"Input mapping tokens: {targetMediaDescriptor.getInputMappingTokens()}")
|
click.echo(f"Input mapping tokens: {targetMediaDescriptor.getInputMappingTokens()}")
|
||||||
@@ -332,236 +345,16 @@ def convert(ctx,
|
|||||||
click.echo(f"Season={mediaFileProperties.getSeason()} Episode={mediaFileProperties.getEpisode()}")
|
click.echo(f"Season={mediaFileProperties.getSeason()} Episode={mediaFileProperties.getEpisode()}")
|
||||||
|
|
||||||
|
|
||||||
# matchingFileSubtitleDescriptors = sorted([d for d in availableFileSubtitleDescriptors if d['season'] == season and d['episode'] == episode], key=lambda d: d['stream']) if availableFileSubtitleDescriptors else []
|
for q in q_list:
|
||||||
#
|
|
||||||
# print(f"season={season} episode={episode} file={file_index}")
|
click.echo(f"\nRunning job {jobIndex} file={sourcePath} q={q}")
|
||||||
#
|
jobIndex += 1
|
||||||
#
|
|
||||||
# # Assemble target filename tokens
|
|
||||||
# targetFilenameTokens = []
|
|
||||||
# targetFilenameExtension = FfxController.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]
|
|
||||||
#
|
|
||||||
# ###
|
|
||||||
# ###
|
|
||||||
#
|
|
||||||
# # Load source stream descriptor
|
|
||||||
# try:
|
|
||||||
# ###
|
|
||||||
# sourceStreamDescriptor = getStreamDescriptor(sourcePath)
|
|
||||||
# ###
|
|
||||||
#
|
|
||||||
# except Exception:
|
|
||||||
# click.echo(f"File with path {sourcePath} does not contain any audiovisual data, skipping ...")
|
|
||||||
# continue
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# ## ## ##
|
|
||||||
# targetStreamDescriptor = sourceStreamDescriptor.copy()
|
|
||||||
# ## ## ##
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# commandTokens = COMMAND_TOKENS + ['-i', sourcePath]
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# # matchingSubtitles = []
|
|
||||||
# # if context['import_subtitles']:
|
|
||||||
# #
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# #
|
|
||||||
# # for streamIndex in range(len(mSubtitles)):
|
|
||||||
# # mSubtitles[streamIndex]['forced'] = 1 if forcedSubtitle != -1 and streamIndex == forcedSubtitle else 0
|
|
||||||
# # mSubtitles[streamIndex]['default'] = 1 if defaultSubtitle != -1 and streamIndex == defaultSubtitle else 0
|
|
||||||
# #
|
|
||||||
# # if streamIndex <= len(subtitleTitles) -1:
|
|
||||||
# # mSubtitles[streamIndex]['title'] = subtitleTitles[streamIndex]
|
|
||||||
# #
|
|
||||||
# # if defaultSubtitle != -1 and jellyfin:
|
|
||||||
# # matchingSubtitles = getReorderedSubstreams(mSubtitles, defaultSubtitle)
|
|
||||||
# # else:
|
|
||||||
# # matchingSubtitles = mSubtitles
|
|
||||||
#
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# for q in q_list:
|
|
||||||
#
|
|
||||||
# click.echo(f"\nRunning job {job_index} file={sourcePath} q={q}")
|
|
||||||
# job_index += 1
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# # # Reorder audio stream descriptors and create disposition options if default is given per command line option
|
|
||||||
# # if defaultAudio == -1:
|
|
||||||
# # sourceAudioStreams = audioStreams
|
|
||||||
# # else:
|
|
||||||
# # for streamIndex in range(len(audioStreams)):
|
|
||||||
# # audioStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == defaultAudio else 0
|
|
||||||
# #
|
|
||||||
# # sourceAudioStreams = getReorderedSubstreams(audioStreams, defaultAudio) if jellyfin else audioStreams
|
|
||||||
# #
|
|
||||||
# # dispositionTokens += generateDispositionTokens(sourceAudioStreams)
|
|
||||||
# #
|
|
||||||
# # # Set forced tag in subtitle descriptor if given per command line option
|
|
||||||
# # if forcedSubtitle != -1:
|
|
||||||
# # for streamIndex in range(len(subtitleStreams)):
|
|
||||||
# # subtitleStreams[streamIndex]['disposition']['forced'] = 1 if streamIndex == forcedSubtitle else 0
|
|
||||||
# #
|
|
||||||
# # # Reorder subtitle stream descriptors and create disposition options if default is given per command line option
|
|
||||||
# # if defaultSubtitle == -1:
|
|
||||||
# # sourceSubtitleStreams = subtitleStreams
|
|
||||||
# # else:
|
|
||||||
# # for streamIndex in range(len(subtitleStreams)):
|
|
||||||
# # subtitleStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == defaultSubtitle else 0
|
|
||||||
# #
|
|
||||||
# # sourceSubtitleStreams = getReorderedSubstreams(subtitleStreams, defaultSubtitle) if jellyfin else subtitleStreams
|
|
||||||
# #
|
|
||||||
# # dispositionTokens += generateDispositionTokens(sourceSubtitleStreams)
|
|
||||||
# #
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# # # Create mapping and ffmpeg options for subtitle streams
|
|
||||||
#
|
|
||||||
# # if context['import_subtitles']:
|
|
||||||
# #
|
|
||||||
# # numMatchingSubtitles = len(matchingSubtitles)
|
|
||||||
# #
|
|
||||||
# # if jellyfin and defaultSubtitle != -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
|
|
||||||
# + subtitleImportFileTokens
|
|
||||||
# + mappingTokens
|
|
||||||
# + audioMetadataTokens
|
|
||||||
# + subtitleMetadataTokens
|
|
||||||
# + audioDispositionTokens
|
|
||||||
# + subtitleDispositionTokens
|
|
||||||
# + audioEncodingTokens
|
|
||||||
# + generateAV1Tokens(q, preset) + audioEncodingTokens)
|
|
||||||
#
|
|
||||||
# if clear_metadata:
|
|
||||||
# commandSequence += generateClearTokens(sourceStreamDescriptor)
|
|
||||||
#
|
|
||||||
# commandSequence += cropTokens
|
|
||||||
#
|
|
||||||
# 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)
|
|
||||||
#
|
|
||||||
# commandSequence1 += cropTokens
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
# + subtitleImportFileTokens
|
|
||||||
# + mappingTokens
|
|
||||||
# + audioMetadataTokens
|
|
||||||
# + subtitleMetadataTokens
|
|
||||||
# + audioDispositionTokens
|
|
||||||
# + subtitleDispositionTokens
|
|
||||||
# + dispositionTokens)
|
|
||||||
#
|
|
||||||
# if denoise:
|
|
||||||
# commandSequence2 += generateDenoiseTokens()
|
|
||||||
#
|
|
||||||
# commandSequence2 += generateVP9Pass2Tokens(q) + audioEncodingTokens
|
|
||||||
#
|
|
||||||
# if clear_metadata:
|
|
||||||
# commandSequence2 += generateClearTokens(sourceStreamDescriptor)
|
|
||||||
#
|
|
||||||
# commandSequence2 += cropTokens
|
|
||||||
#
|
|
||||||
# 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.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()
|
endTime = time.perf_counter()
|
||||||
click.echo(f"Time elapsed {endTime - startTime}")
|
click.echo(f"\nDONE\nTime elapsed {endTime - startTime}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import click
|
import os, click, re
|
||||||
|
|
||||||
from ffx.media_descriptor import MediaDescriptor
|
from ffx.media_descriptor import MediaDescriptor
|
||||||
from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY, DIFF_CHANGED_KEY
|
from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY, DIFF_CHANGED_KEY
|
||||||
@@ -6,6 +6,9 @@ from ffx.track_descriptor import TrackDescriptor
|
|||||||
from ffx.model.track import Track
|
from ffx.model.track import Track
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
|
from ffx.video_encoder import VideoEncoder
|
||||||
|
from ffx.process import executeProcess
|
||||||
|
from ffx.file_properties import FileProperties
|
||||||
|
|
||||||
class FfxController():
|
class FfxController():
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ class FfxController():
|
|||||||
|
|
||||||
INPUT_FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
|
INPUT_FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
|
||||||
|
|
||||||
|
CHANNEL_MAP_5_1 = 'FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1'
|
||||||
|
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
context : dict,
|
context : dict,
|
||||||
targetMediaDescriptor : MediaDescriptor,
|
targetMediaDescriptor : MediaDescriptor,
|
||||||
@@ -48,26 +54,18 @@ class FfxController():
|
|||||||
self.__targetMediaDescriptor = targetMediaDescriptor
|
self.__targetMediaDescriptor = targetMediaDescriptor
|
||||||
|
|
||||||
|
|
||||||
# def getReorderedSubstreams(subDescriptor, last):
|
def generateAV1Tokens(self, quality, preset, subIndex : int = 0):
|
||||||
# numSubStreams = len(subDescriptor)
|
|
||||||
# modifiedOrder = getModifiedStreamOrder(numSubStreams, last)
|
|
||||||
# reorderedDescriptor = []
|
|
||||||
# for streamIndex in range(numSubStreams):
|
|
||||||
# reorderedDescriptor.append(subDescriptor[modifiedOrder[streamIndex]])
|
|
||||||
# return reorderedDescriptor
|
|
||||||
|
|
||||||
|
return [f"-c:v:{int(subIndex)}", 'libsvtav1',
|
||||||
def generateAV1Tokens(self, quality, preset):
|
|
||||||
|
|
||||||
return ['-c:v:0', 'libsvtav1',
|
|
||||||
'-svtav1-params', f"crf={quality}:preset={preset}:tune=0:enable-overlays=1:scd=1:scm=0",
|
'-svtav1-params', f"crf={quality}:preset={preset}:tune=0:enable-overlays=1:scd=1:scm=0",
|
||||||
'-pix_fmt', 'yuv420p10le']
|
'-pix_fmt', 'yuv420p10le']
|
||||||
|
|
||||||
|
|
||||||
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0
|
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0
|
||||||
def generateVP9Pass1Tokens(self, quality):
|
def generateVP9Pass1Tokens(self, quality, subIndex : int = 0):
|
||||||
|
|
||||||
return ['-c:v:0', 'libvpx-vp9',
|
return [f"-c:v:{int(subIndex)}",
|
||||||
|
'libvpx-vp9',
|
||||||
'-row-mt', '1',
|
'-row-mt', '1',
|
||||||
'-crf', str(quality),
|
'-crf', str(quality),
|
||||||
'-pass', '1',
|
'-pass', '1',
|
||||||
@@ -77,9 +75,10 @@ class FfxController():
|
|||||||
'-aq-mode', '0']
|
'-aq-mode', '0']
|
||||||
|
|
||||||
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 2 -frame-parallel 0 -g 9999 -aq-mode 0 -auto-alt-ref 1 -lag-in-frames 25
|
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 2 -frame-parallel 0 -g 9999 -aq-mode 0 -auto-alt-ref 1 -lag-in-frames 25
|
||||||
def generateVP9Pass2Tokens(self, quality):
|
def generateVP9Pass2Tokens(self, quality, subIndex : int = 0):
|
||||||
|
|
||||||
return ['-c:v:0', 'libvpx-vp9',
|
return [f"-c:v:{int(subIndex)}",
|
||||||
|
'libvpx-vp9',
|
||||||
'-row-mt', '1',
|
'-row-mt', '1',
|
||||||
'-crf', str(quality),
|
'-crf', str(quality),
|
||||||
'-pass', '2',
|
'-pass', '2',
|
||||||
@@ -90,14 +89,16 @@ class FfxController():
|
|||||||
'-lag-in-frames', '25']
|
'-lag-in-frames', '25']
|
||||||
|
|
||||||
|
|
||||||
|
def generateCropTokens(self):
|
||||||
|
|
||||||
@staticmethod
|
if 'crop_start' in self.__context.keys() and 'crop_length' in self.__context.keys():
|
||||||
def generateCropTokens(cropStart : int = -1, cropLength : int = -1):
|
cropStart = int(self.__context['crop_start'])
|
||||||
|
cropLength = int(self.__context['crop_length'])
|
||||||
|
else:
|
||||||
|
cropStart = FfxController.DEFAULT_CROP_START
|
||||||
|
cropLength = FfxController.DEFAULT_CROP_LENGTH
|
||||||
|
|
||||||
start = int(cropStart if cropStart > -1 else FfxController.DEFAULT_CROP_START)
|
return ['-ss', str(cropStart), '-t', str(cropLength)]
|
||||||
length = int(cropLength if cropLength > -1 else FfxController.DEFAULT_CROP_LENGTH)
|
|
||||||
|
|
||||||
return ['-ss', str(start), '-t', str(length)]
|
|
||||||
|
|
||||||
|
|
||||||
def generateDenoiseTokens(self, spatial=5, patch=7, research=7, hw=False):
|
def generateDenoiseTokens(self, spatial=5, patch=7, research=7, hw=False):
|
||||||
@@ -143,7 +144,7 @@ class FfxController():
|
|||||||
audioTokens += [f"-c:a:{trackSubIndex}",
|
audioTokens += [f"-c:a:{trackSubIndex}",
|
||||||
'libopus',
|
'libopus',
|
||||||
f"-filter:a:{trackSubIndex}",
|
f"-filter:a:{trackSubIndex}",
|
||||||
"channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1",
|
f"channelmap={FfxController.CHANNEL_MAP_5_1}",
|
||||||
f"-b:a:{trackSubIndex}",
|
f"-b:a:{trackSubIndex}",
|
||||||
self.__context['bitrates']['ac3']]
|
self.__context['bitrates']['ac3']]
|
||||||
|
|
||||||
@@ -157,24 +158,15 @@ class FfxController():
|
|||||||
audioTokens += [f"-c:a:{trackSubIndex}",
|
audioTokens += [f"-c:a:{trackSubIndex}",
|
||||||
'libopus',
|
'libopus',
|
||||||
f"-filter:a:{trackSubIndex}",
|
f"-filter:a:{trackSubIndex}",
|
||||||
"channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1",
|
f"channelmap={FfxController.CHANNEL_MAP_5_1}",
|
||||||
f"-b:a:{trackSubIndex}",
|
f"-b:a:{trackSubIndex}",
|
||||||
self.__context['bitrates']['ac3']]
|
self.__context['bitrates']['ac3']]
|
||||||
trackSubIndex += 1
|
trackSubIndex += 1
|
||||||
return audioTokens
|
return audioTokens
|
||||||
|
|
||||||
|
|
||||||
# def generateClearTokens(self, streams):
|
|
||||||
# clearTokens = []
|
|
||||||
# for s in streams:
|
|
||||||
# for k in FfxController.MKVMERGE_METADATA_KEYS:
|
|
||||||
# clearTokens += [f"-metadata:s:{s['type'][0]}:{s['sub_index']}", f"{k}="]
|
|
||||||
# return clearTokens
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def generateDispositionTokens(self):
|
def generateDispositionTokens(self):
|
||||||
"""-disposition:s:X default+forced"""
|
"""Source media descriptor is optional"""
|
||||||
|
|
||||||
sourceTrackDescriptors = [] if self.__sourceMediaDescriptor is None else self.__sourceMediaDescriptor.getAllTrackDescriptors()
|
sourceTrackDescriptors = [] if self.__sourceMediaDescriptor is None else self.__sourceMediaDescriptor.getAllTrackDescriptors()
|
||||||
targetTrackDescriptors = self.__targetMediaDescriptor.getReorderedTrackDescriptors()
|
targetTrackDescriptors = self.__targetMediaDescriptor.getReorderedTrackDescriptors()
|
||||||
@@ -203,8 +195,8 @@ class FfxController():
|
|||||||
return dispositionTokens
|
return dispositionTokens
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def generateMetadataTokens(self):
|
def generateMetadataTokens(self):
|
||||||
|
"""Source media descriptor is mandatory"""
|
||||||
|
|
||||||
mappingTokens = []
|
mappingTokens = []
|
||||||
|
|
||||||
@@ -338,13 +330,13 @@ class FfxController():
|
|||||||
f"{changedTagKey}={changedTagValue}"]
|
f"{changedTagKey}={changedTagValue}"]
|
||||||
|
|
||||||
# if TrackDescriptor.DISPOSITION_SET_KEY in changedTrackDiff.keys():
|
# if TrackDescriptor.DISPOSITION_SET_KEY in changedTrackDiff.keys():
|
||||||
#
|
|
||||||
# if DIFF_ADDED_KEY in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY]:
|
# if DIFF_ADDED_KEY in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY]:
|
||||||
# for addedDisposition in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY][DIFF_ADDED_KEY]:
|
# for addedDisposition in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY][DIFF_ADDED_KEY]:
|
||||||
# # row = (f"changed {changedTargetTrackDescriptor.getType().label()} track index={changedTrackIndex} added disposition={addedDisposition.label()}",)
|
# # row = (f"changed {changedTargetTrackDescriptor.getType().label()} track index={changedTrackIndex} added disposition={addedDisposition.label()}",)
|
||||||
# # self.differencesTable.add_row(*map(str, row))
|
# # self.differencesTable.add_row(*map(str, row))
|
||||||
# pass
|
# pass
|
||||||
#
|
|
||||||
# if DIFF_REMOVED_KEY in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY]:
|
# if DIFF_REMOVED_KEY in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY]:
|
||||||
# for removedDisposition in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY][DIFF_REMOVED_KEY]:
|
# for removedDisposition in changedTrackDiff[TrackDescriptor.DISPOSITION_SET_KEY][DIFF_REMOVED_KEY]:
|
||||||
# # row = (f"changed {changedTargetTrackDescriptor.getType().label()} track index={changedTrackIndex} removed disposition={removedDisposition.label()}",)
|
# # row = (f"changed {changedTargetTrackDescriptor.getType().label()} track index={changedTrackIndex} removed disposition={removedDisposition.label()}",)
|
||||||
@@ -352,3 +344,86 @@ class FfxController():
|
|||||||
# pass
|
# pass
|
||||||
|
|
||||||
return mappingTokens
|
return mappingTokens
|
||||||
|
|
||||||
|
|
||||||
|
def runJob(self,
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
videoEncoder : VideoEncoder = VideoEncoder.VP9,
|
||||||
|
quality : int = DEFAULT_QUALITY,
|
||||||
|
preset : int = DEFAULT_AV1_PRESET,
|
||||||
|
denoise : bool = False):
|
||||||
|
|
||||||
|
|
||||||
|
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath]
|
||||||
|
|
||||||
|
if videoEncoder == VideoEncoder.AV1:
|
||||||
|
|
||||||
|
commandSequence = (commandTokens
|
||||||
|
#+ subtitleImportFileTokens
|
||||||
|
+ self.__targetMediaDescriptor.getInputMappingTokens()
|
||||||
|
+ self.generateDispositionTokens())
|
||||||
|
|
||||||
|
if not self.__sourceMediaDescriptor is None:
|
||||||
|
commandSequence += self.generateMetadataTokens()
|
||||||
|
|
||||||
|
commandSequence += (self.generateAudioEncodingTokens()
|
||||||
|
+ self.generateAV1Tokens(quality, preset)
|
||||||
|
+ self.generateAudioEncodingTokens())
|
||||||
|
|
||||||
|
if self.__context['perform_crop']:
|
||||||
|
commandSequence += FfxController.generateCropTokens()
|
||||||
|
|
||||||
|
commandSequence += self.generateOutputTokens(targetPath,
|
||||||
|
FfxController.DEFAULT_FILE_FORMAT,
|
||||||
|
FfxController.DEFAULT_FILE_EXTENSION)
|
||||||
|
|
||||||
|
click.echo(f"Command: {' '.join(commandSequence)}")
|
||||||
|
|
||||||
|
if not self.__context['dry_run']:
|
||||||
|
executeProcess(commandSequence)
|
||||||
|
|
||||||
|
|
||||||
|
if videoEncoder == VideoEncoder.VP9:
|
||||||
|
|
||||||
|
commandSequence1 = (commandTokens
|
||||||
|
+ self.__targetMediaDescriptor.getInputMappingTokens()
|
||||||
|
+ self.generateVP9Pass1Tokens(quality))
|
||||||
|
|
||||||
|
if self.__context['perform_crop']:
|
||||||
|
commandSequence1 += FfxController.generateCropTokens()
|
||||||
|
|
||||||
|
commandSequence1 += FfxController.NULL_TOKENS
|
||||||
|
|
||||||
|
click.echo(f"Command 1: {' '.join(commandSequence1)}")
|
||||||
|
|
||||||
|
if os.path.exists(FfxController.TEMP_FILE_NAME):
|
||||||
|
os.remove(FfxController.TEMP_FILE_NAME)
|
||||||
|
|
||||||
|
if not self.__context['dry_run']:
|
||||||
|
executeProcess(commandSequence1)
|
||||||
|
|
||||||
|
commandSequence2 = (commandTokens
|
||||||
|
#+ subtitleImportFileTokens
|
||||||
|
+ self.__targetMediaDescriptor.getInputMappingTokens()
|
||||||
|
+ self.generateDispositionTokens())
|
||||||
|
|
||||||
|
if not self.__sourceMediaDescriptor is None:
|
||||||
|
commandSequence += self.generateMetadataTokens()
|
||||||
|
|
||||||
|
if denoise:
|
||||||
|
commandSequence2 += self.generateDenoiseTokens()
|
||||||
|
|
||||||
|
commandSequence2 += self.generateVP9Pass2Tokens(quality) + self.generateAudioEncodingTokens()
|
||||||
|
|
||||||
|
if self.__context['perform_crop']:
|
||||||
|
commandSequence2 += FfxController.generateCropTokens()
|
||||||
|
|
||||||
|
commandSequence2 += self.generateOutputTokens(targetPath,
|
||||||
|
FfxController.DEFAULT_FILE_FORMAT,
|
||||||
|
FfxController.DEFAULT_FILE_EXTENSION)
|
||||||
|
|
||||||
|
click.echo(f"Command 2: {' '.join(commandSequence2)}")
|
||||||
|
|
||||||
|
if not self.__context['dry_run']:
|
||||||
|
executeProcess(commandSequence2)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from .pattern_controller import PatternController
|
|||||||
from .process import executeProcess
|
from .process import executeProcess
|
||||||
|
|
||||||
from ffx.model.pattern import Pattern
|
from ffx.model.pattern import Pattern
|
||||||
|
from ffx.ffx_controller import FfxController
|
||||||
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
|
|
||||||
|
|
||||||
class FileProperties():
|
class FileProperties():
|
||||||
@@ -15,10 +17,10 @@ class FileProperties():
|
|||||||
SEASON_EPISODE_INDICATOR_MATCH = '[sS]([0-9]+)[eE]([0-9]+)'
|
SEASON_EPISODE_INDICATOR_MATCH = '[sS]([0-9]+)[eE]([0-9]+)'
|
||||||
EPISODE_INDICATOR_MATCH = '[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})'
|
SEASON_EPISODE_STREAM_LANGUAGE_MATCH = '[sS]([0-9]+)[eE]([0-9]+)_([0-9]+)_([a-z]{3})'
|
||||||
SUBTITLE_FILE_EXTENSION = 'vtt'
|
SUBTITLE_FILE_EXTENSION = 'vtt'
|
||||||
|
|
||||||
|
DEFAULT_INDEX_DIGITS = 3
|
||||||
|
|
||||||
def __init__(self, context, sourcePath):
|
def __init__(self, context, sourcePath):
|
||||||
|
|
||||||
@@ -62,59 +64,6 @@ class FileProperties():
|
|||||||
self.__episode = int(e_match.group(1))
|
self.__episode = int(e_match.group(1))
|
||||||
|
|
||||||
|
|
||||||
# click.echo(pattern)
|
|
||||||
# matchingFileSubtitleDescriptors = sorted([d for d in availableFileSubtitleDescriptors if d['season'] == season and d['episode'] == episode], key=lambda d: d['stream']) if availableFileSubtitleDescriptors else []
|
|
||||||
#
|
|
||||||
# print(f"season={season} episode={episode} file={file_index}")
|
|
||||||
#
|
|
||||||
|
|
||||||
def assembleTargetFilename(self):
|
|
||||||
|
|
||||||
targetFilenameTokens = []
|
|
||||||
# targetFilenameExtension = DEFAULT_FILE_EXTENSION
|
|
||||||
#
|
|
||||||
# if label:
|
|
||||||
# targetFilenameTokens = [label]
|
|
||||||
#
|
|
||||||
# if self.__season > -1 and self.__episode > -1:
|
|
||||||
# targetFilenameTokens += [f"S{self.__season:0{season_digits}d}E{self.__episode:0{episode_digits}d}"]
|
|
||||||
# elif self.__episode > -1:
|
|
||||||
# targetFilenameTokens += [f"E{self.__episode:0{episode_digits}d}"]
|
|
||||||
# else:
|
|
||||||
# targetFilenameTokens += [f"{file_index:0{index_digits}d}"]
|
|
||||||
#
|
|
||||||
# else:
|
|
||||||
# targetFilenameTokens = [self.__sourceFileBasename]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def searchSubtitleFiles(dir, prefix):
|
|
||||||
|
|
||||||
sesl_match = re.compile(FileProperties.SEASON_EPISODE_STREAM_LANGUAGE_MATCH)
|
|
||||||
|
|
||||||
availableFileSubtitleDescriptors = []
|
|
||||||
for subtitleFilename in os.listdir(dir):
|
|
||||||
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith('.' + FileProperties.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)
|
|
||||||
|
|
||||||
availableFileSubtitleDescriptors.append(subtitleFileDescriptor)
|
|
||||||
|
|
||||||
click.echo(f"Found {len(availableFileSubtitleDescriptors)} subtitles in files\n")
|
|
||||||
|
|
||||||
return availableFileSubtitleDescriptors
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def getFormatData(self):
|
def getFormatData(self):
|
||||||
"""
|
"""
|
||||||
"format": {
|
"format": {
|
||||||
@@ -240,3 +189,47 @@ class FileProperties():
|
|||||||
return int(self.__episode)
|
return int(self.__episode)
|
||||||
|
|
||||||
|
|
||||||
|
def assembleTargetFilename(self,
|
||||||
|
label = None,
|
||||||
|
quality : int = -1,
|
||||||
|
fileIndex : int = -1,
|
||||||
|
indexDigits : int = DEFAULT_INDEX_DIGITS,
|
||||||
|
extension : str = None):
|
||||||
|
|
||||||
|
if 'show_descriptor' in self.context['show_descriptor'].keys():
|
||||||
|
season_digits = self.context['show_descriptor'][ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
|
||||||
|
episode_digits = self.context['show_descriptor'][ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||||
|
else:
|
||||||
|
season_digits = ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS
|
||||||
|
episode_digits = ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS
|
||||||
|
|
||||||
|
targetFilenameTokens = []
|
||||||
|
|
||||||
|
targetFilenameExtension = FfxController.DEFAULT_FILE_EXTENSION if extension is None else str(extension)
|
||||||
|
|
||||||
|
if label is None:
|
||||||
|
targetFilenameTokens = [self.__sourceFileBasename]
|
||||||
|
|
||||||
|
else:
|
||||||
|
targetFilenameTokens = [label]
|
||||||
|
|
||||||
|
if fileIndex > -1:
|
||||||
|
targetFilenameTokens += [f"{fileIndex:0{indexDigits}d}"]
|
||||||
|
|
||||||
|
elif self.__season > -1 and self.__episode > -1:
|
||||||
|
targetFilenameTokens += [f"S{self.__season:0{season_digits}d}E{self.__episode:0{episode_digits}d}"]
|
||||||
|
elif self.__episode > -1:
|
||||||
|
targetFilenameTokens += [f"E{self.__episode:0{episode_digits}d}"]
|
||||||
|
|
||||||
|
if len(quality) != 1:
|
||||||
|
targetFilenameTokens += [f"q{quality}"]
|
||||||
|
|
||||||
|
# In case source and target filenames are the same add an extension to distinct output from input
|
||||||
|
if label is None and self.__sourceFilenameExtension == targetFilenameExtension:
|
||||||
|
targetFilenameTokens += ['ffx']
|
||||||
|
|
||||||
|
targetFilename = '_'.join(targetFilenameTokens)
|
||||||
|
|
||||||
|
click.echo(f"Target filename: {targetFilename}")
|
||||||
|
|
||||||
|
return targetFilename
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import click
|
import os, re, click
|
||||||
|
|
||||||
from typing import List, Self
|
from typing import List, Self
|
||||||
|
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
|
from ffx.file_properties import FileProperties
|
||||||
|
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
|
|
||||||
@@ -300,3 +301,39 @@ class MediaDescriptor():
|
|||||||
|
|
||||||
return inputMappingTokens
|
return inputMappingTokens
|
||||||
|
|
||||||
|
|
||||||
|
# matchingFileSubtitleDescriptors = sorted([d for d in availableFileSubtitleDescriptors if d['season'] == season and d['episode'] == episode], key=lambda d: d['stream']) if availableFileSubtitleDescriptors else []
|
||||||
|
|
||||||
|
|
||||||
|
def searchSubtitleFiles(searchDirectory, prefix):
|
||||||
|
|
||||||
|
sesl_match = re.compile(FileProperties.SEASON_EPISODE_STREAM_LANGUAGE_MATCH)
|
||||||
|
|
||||||
|
availableFileSubtitleDescriptors = []
|
||||||
|
for subtitleFilename in os.listdir(searchDirectory):
|
||||||
|
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith('.' + FileProperties.SUBTITLE_FILE_EXTENSION):
|
||||||
|
sesl_result = sesl_match.search(subtitleFilename)
|
||||||
|
if sesl_result is not None:
|
||||||
|
subtitleFilePath = os.path.join(searchDirectory, 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)
|
||||||
|
|
||||||
|
availableFileSubtitleDescriptors.append(subtitleFileDescriptor)
|
||||||
|
|
||||||
|
click.echo(f"Found {len(availableFileSubtitleDescriptors)} subtitles in files\n")
|
||||||
|
|
||||||
|
return availableFileSubtitleDescriptors
|
||||||
|
|
||||||
|
|
||||||
|
def importSubtitles(self, searchDirectory, prefix):
|
||||||
|
|
||||||
|
availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix)
|
||||||
|
|
||||||
|
if len(availableFileSubtitleDescriptors) != len(self.getSubtitleTracks()):
|
||||||
|
raise click.ClickException(f"MediaDescriptor.importSubtitles(): Number if subtitle files not matching number of subtitle tracks")
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
|
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
|
||||||
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
||||||
|
|
||||||
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
|
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
class Show(Base):
|
class Show(Base):
|
||||||
@@ -35,10 +38,10 @@ class Show(Base):
|
|||||||
# v2.0
|
# v2.0
|
||||||
# patterns: Mapped[List["Pattern"]] = relationship(back_populates="show", cascade="all, delete")
|
# patterns: Mapped[List["Pattern"]] = relationship(back_populates="show", cascade="all, delete")
|
||||||
|
|
||||||
index_season_digits = Column(Integer, default=2)
|
index_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_SEASON_DIGITS)
|
||||||
index_episode_digits = Column(Integer, default=2)
|
index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS)
|
||||||
indicator_season_digits = Column(Integer, default=2)
|
indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS)
|
||||||
indicator_episode_digits = Column(Integer, default=2)
|
indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS)
|
||||||
|
|
||||||
|
|
||||||
def getDesciptor(self):
|
def getDesciptor(self):
|
||||||
@@ -55,3 +58,16 @@ class Show(Base):
|
|||||||
descriptor['indicator_episode_digits'] = int(self.indicator_episode_digits)
|
descriptor['indicator_episode_digits'] = int(self.indicator_episode_digits)
|
||||||
|
|
||||||
return descriptor
|
return descriptor
|
||||||
|
|
||||||
|
# def getDescriptor(self):
|
||||||
|
#
|
||||||
|
# kwargs = {}
|
||||||
|
#
|
||||||
|
# kwargs[]
|
||||||
|
# kwargs[]
|
||||||
|
# kwargs[]
|
||||||
|
#
|
||||||
|
# kwargs[]
|
||||||
|
# kwargs[]
|
||||||
|
# kwargs[]
|
||||||
|
# kwargs[]
|
||||||
334
bin/ffx/show_descriptor.py
Normal file
334
bin/ffx/show_descriptor.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import click
|
||||||
|
|
||||||
|
from typing import List, Self
|
||||||
|
|
||||||
|
from ffx.track_type import TrackType
|
||||||
|
from ffx.track_disposition import TrackDisposition
|
||||||
|
|
||||||
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
|
|
||||||
|
from ffx.helper import dictDiff, DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
||||||
|
|
||||||
|
|
||||||
|
class ShowDescriptor():
|
||||||
|
"""This class represents the structural content of a media file including streams and metadata"""
|
||||||
|
|
||||||
|
CONTEXT_KEY = 'context'
|
||||||
|
|
||||||
|
ID_KEY = 'id'
|
||||||
|
NAME_KEY = 'name'
|
||||||
|
YEAR_KEY = 'year'
|
||||||
|
|
||||||
|
INDEX_SEASON_DIGITS_KEY = 'index_season_digits'
|
||||||
|
INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits'
|
||||||
|
INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits'
|
||||||
|
INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits'
|
||||||
|
|
||||||
|
DEFAULT_INDEX_SEASON_DIGITS = 2
|
||||||
|
DEFAULT_INDEX_EPISODE_DIGITS = 2
|
||||||
|
DEFAULT_INDICATOR_SEASON_DIGITS = 2
|
||||||
|
DEFAULT_INDICATOR_EPISODE_DIGITS = 2
|
||||||
|
|
||||||
|
def getDesciptor(self):
|
||||||
|
|
||||||
|
descriptor = {}
|
||||||
|
|
||||||
|
descriptor['id'] = int(self.id)
|
||||||
|
descriptor['name'] = str(self.name)
|
||||||
|
descriptor['year'] = int(self.year)
|
||||||
|
|
||||||
|
descriptor['index_season_digits'] = int(self.index_season_digits)
|
||||||
|
descriptor['index_episode_digits'] = int(self.index_episode_digits)
|
||||||
|
descriptor['indicator_season_digits'] = int(self.indicator_season_digits)
|
||||||
|
descriptor['indicator_episode_digits'] = int(self.indicator_episode_digits)
|
||||||
|
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
|
||||||
|
if ShowDescriptor.ID_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.ID_KEY]) is not dict:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.ID_KEY} is required to be of type dict")
|
||||||
|
self.__showId = kwargs[ShowDescriptor.ID_KEY]
|
||||||
|
else:
|
||||||
|
self.__showId = {}
|
||||||
|
|
||||||
|
if ShowDescriptor.NAME_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.NAME_KEY]) is not dict:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.NAME_KEY} is required to be of type dict")
|
||||||
|
self.__showName = kwargs[ShowDescriptor.NAME_KEY]
|
||||||
|
else:
|
||||||
|
self.__showName = {}
|
||||||
|
|
||||||
|
if ShowDescriptor.YEAR_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.YEAR_KEY]) is not dict:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.YEAR_KEY} is required to be of type dict")
|
||||||
|
self.__showYear = kwargs[ShowDescriptor.YEAR_KEY]
|
||||||
|
else:
|
||||||
|
self.__showYear = {}
|
||||||
|
|
||||||
|
if ShowDescriptor.INDEX_SEASON_DIGITS_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]) is not dict:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDEX_SEASON_DIGITS_KEY} is required to be of type dict")
|
||||||
|
self.__indexSeasonDigits = kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
|
||||||
|
else:
|
||||||
|
self.__indexSeasonDigits = {}
|
||||||
|
|
||||||
|
if ShowDescriptor.INDEX_EPISODE_DIGITS_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]) is not dict:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDEX_EPISODE_DIGITS_KEY} is required to be of type dict")
|
||||||
|
self.__indexEpisodeDigits = kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]
|
||||||
|
else:
|
||||||
|
self.__indexEpisodeDigits = {}
|
||||||
|
|
||||||
|
if ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]) is not dict:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY} is required to be of type dict")
|
||||||
|
self.__indicatorSeasonDigits = kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
|
||||||
|
else:
|
||||||
|
self.__indicatorSeasonDigits = {}
|
||||||
|
|
||||||
|
if ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]) is not dict:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY} is required to be of type dict")
|
||||||
|
self.__indicatorEpisodeDigits = kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||||
|
else:
|
||||||
|
self.__indicatorEpisodeDigits = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def getDefaultVideoTrack(self):
|
||||||
|
videoDefaultTracks = [v for v in self.getVideoTracks() if TrackDisposition.DEFAULT in v.getDispositionSet()]
|
||||||
|
if len(videoDefaultTracks) > 1:
|
||||||
|
raise ValueError('ShowDescriptor.getDefaultVideoTrack(): More than one default video track is not supported')
|
||||||
|
return videoDefaultTracks[0] if videoDefaultTracks else None
|
||||||
|
|
||||||
|
def getForcedVideoTrack(self):
|
||||||
|
videoForcedTracks = [v for v in self.getVideoTracks() if TrackDisposition.FORCED in v.getDispositionSet()]
|
||||||
|
if len(videoForcedTracks) > 1:
|
||||||
|
raise ValueError('ShowDescriptor.getForcedVideoTrack(): More than one forced video track is not supported')
|
||||||
|
return videoForcedTracks[0] if videoForcedTracks else None
|
||||||
|
|
||||||
|
def getDefaultAudioTrack(self):
|
||||||
|
audioDefaultTracks = [a for a in self.getAudioTracks() if TrackDisposition.DEFAULT in a.getDispositionSet()]
|
||||||
|
if len(audioDefaultTracks) > 1:
|
||||||
|
raise ValueError('ShowDescriptor.getDefaultAudioTrack(): More than one default audio track is not supported')
|
||||||
|
return audioDefaultTracks[0] if audioDefaultTracks else None
|
||||||
|
|
||||||
|
def getForcedAudioTrack(self):
|
||||||
|
audioForcedTracks = [a for a in self.getAudioTracks() if TrackDisposition.FORCED in a.getDispositionSet()]
|
||||||
|
if len(audioForcedTracks) > 1:
|
||||||
|
raise ValueError('ShowDescriptor.getForcedAudioTrack(): More than one forced audio track is not supported')
|
||||||
|
return audioForcedTracks[0] if audioForcedTracks else None
|
||||||
|
|
||||||
|
def getDefaultSubtitleTrack(self):
|
||||||
|
subtitleDefaultTracks = [s for s in self.getSubtitleTracks() if TrackDisposition.DEFAULT in s.getDispositionSet()]
|
||||||
|
if len(subtitleDefaultTracks) > 1:
|
||||||
|
raise ValueError('ShowDescriptor.getDefaultSubtitleTrack(): More than one default subtitle track is not supported')
|
||||||
|
return subtitleDefaultTracks[0] if subtitleDefaultTracks else None
|
||||||
|
|
||||||
|
def getForcedSubtitleTrack(self):
|
||||||
|
subtitleForcedTracks = [s for s in self.getSubtitleTracks() if TrackDisposition.FORCED in s.getDispositionSet()]
|
||||||
|
if len(subtitleForcedTracks) > 1:
|
||||||
|
raise ValueError('ShowDescriptor.getForcedSubtitleTrack(): More than one forced subtitle track is not supported')
|
||||||
|
return subtitleForcedTracks[0] if subtitleForcedTracks else None
|
||||||
|
|
||||||
|
|
||||||
|
def setDefaultSubTrack(self, trackType : TrackType, subIndex : int):
|
||||||
|
for t in self.getAllTrackDescriptors():
|
||||||
|
if t.getType() == trackType:
|
||||||
|
t.setDispositionFlag(TrackDisposition.DEFAULT, t.getSubIndex() == int(subIndex))
|
||||||
|
|
||||||
|
def setForcedSubTrack(self, trackType : TrackType, subIndex : int):
|
||||||
|
for t in self.getAllTrackDescriptors():
|
||||||
|
if t.getType() == trackType:
|
||||||
|
t.setDispositionFlag(TrackDisposition.FORCED, t.getSubIndex() == int(subIndex))
|
||||||
|
|
||||||
|
|
||||||
|
def getReorderedTrackDescriptors(self):
|
||||||
|
|
||||||
|
videoTracks = self.sortSubIndices(self.getVideoTracks())
|
||||||
|
audioTracks = self.sortSubIndices(self.getAudioTracks())
|
||||||
|
subtitleTracks = self.sortSubIndices(self.getSubtitleTracks())
|
||||||
|
|
||||||
|
videoDefaultTrack = self.getDefaultVideoTrack()
|
||||||
|
self.getForcedVideoTrack()
|
||||||
|
audioDefaultTrack = self.getDefaultAudioTrack()
|
||||||
|
self.getForcedAudioTrack()
|
||||||
|
subtitleDefaultTrack = self.getDefaultSubtitleTrack()
|
||||||
|
self.getForcedSubtitleTrack()
|
||||||
|
|
||||||
|
if self.__jellyfinOrder:
|
||||||
|
if not videoDefaultTrack is None:
|
||||||
|
videoTracks.append(videoTracks.pop(videoTracks.index(videoDefaultTrack)))
|
||||||
|
if not audioDefaultTrack is None:
|
||||||
|
audioTracks.append(audioTracks.pop(audioTracks.index(audioDefaultTrack)))
|
||||||
|
if not subtitleDefaultTrack is None:
|
||||||
|
subtitleTracks.append(subtitleTracks.pop(subtitleTracks.index(subtitleDefaultTrack)))
|
||||||
|
|
||||||
|
reorderedTrackDescriptors = videoTracks + audioTracks + subtitleTracks
|
||||||
|
orderedSourceTrackSequence = [t.getSourceIndex() for t in reorderedTrackDescriptors]
|
||||||
|
|
||||||
|
if len(set(orderedSourceTrackSequence)) < len(orderedSourceTrackSequence):
|
||||||
|
raise ValueError(f"Multiple streams originating from the same source stream not supported")
|
||||||
|
|
||||||
|
return reorderedTrackDescriptors
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fromFfprobe(cls, formatData, streamData):
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
if ShowDescriptor.FFPROBE_TAGS_KEY in formatData.keys():
|
||||||
|
kwargs[ShowDescriptor.TAGS_KEY] = formatData[ShowDescriptor.FFPROBE_TAGS_KEY]
|
||||||
|
|
||||||
|
kwargs[ShowDescriptor.TRACK_DESCRIPTOR_LIST_KEY] = []
|
||||||
|
|
||||||
|
#TODO: Evtl obsolet
|
||||||
|
subIndexCounters = {}
|
||||||
|
|
||||||
|
for streamObj in streamData:
|
||||||
|
|
||||||
|
ffprobeCodecType = streamObj[ShowDescriptor.FFPROBE_CODEC_TYPE_KEY]
|
||||||
|
trackType = TrackType.fromLabel(ffprobeCodecType)
|
||||||
|
|
||||||
|
if trackType != TrackType.UNKNOWN:
|
||||||
|
|
||||||
|
if trackType not in subIndexCounters.keys():
|
||||||
|
subIndexCounters[trackType] = 0
|
||||||
|
|
||||||
|
kwargs[ShowDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(TrackDescriptor.fromFfprobe(streamObj,
|
||||||
|
subIndex=subIndexCounters[trackType]))
|
||||||
|
subIndexCounters[trackType] += 1
|
||||||
|
|
||||||
|
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def getTags(self):
|
||||||
|
return self.__mediaTags
|
||||||
|
|
||||||
|
|
||||||
|
def sortSubIndices(self, descriptors : List[TrackDescriptor]) -> List[TrackDescriptor]:
|
||||||
|
subIndex = 0
|
||||||
|
for t in descriptors:
|
||||||
|
t.setSubIndex(subIndex)
|
||||||
|
subIndex += 1
|
||||||
|
return descriptors
|
||||||
|
|
||||||
|
|
||||||
|
def getAllTrackDescriptors(self) -> List[TrackDescriptor]:
|
||||||
|
return self.getVideoTracks() + self.getAudioTracks() + self.getSubtitleTracks()
|
||||||
|
|
||||||
|
def getVideoTracks(self) -> List[TrackDescriptor]:
|
||||||
|
return [v for v in self.__trackDescriptors.copy() if v.getType() == TrackType.VIDEO]
|
||||||
|
|
||||||
|
|
||||||
|
def getAudioTracks(self) -> List[TrackDescriptor]:
|
||||||
|
return [a for a in self.__trackDescriptors.copy() if a.getType() == TrackType.AUDIO]
|
||||||
|
|
||||||
|
def getSubtitleTracks(self) -> List[TrackDescriptor]:
|
||||||
|
return [s for s in self.__trackDescriptors.copy() if s.getType() == TrackType.SUBTITLE]
|
||||||
|
|
||||||
|
def getJellyfin(self):
|
||||||
|
return self.__jellyfinOrder
|
||||||
|
|
||||||
|
def setJellyfinOrder(self, state):
|
||||||
|
self.__jellyfinOrder = state
|
||||||
|
|
||||||
|
def getClearTags(self):
|
||||||
|
return self.__clearTags
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def compare(self, vsShowDescriptor : Self):
|
||||||
|
|
||||||
|
if not isinstance(vsShowDescriptor, self.__class__):
|
||||||
|
raise click.ClickException(f"ShowDescriptor.compare(): Argument is required to be of type {self.__class__}")
|
||||||
|
|
||||||
|
vsTags = vsShowDescriptor.getTags()
|
||||||
|
tags = self.getTags()
|
||||||
|
|
||||||
|
#HINT: Some tags differ per file, for example creation_time, so these are removed before diff
|
||||||
|
for emt in ShowDescriptor.EXCLUDED_MEDIA_TAGS:
|
||||||
|
if emt in tags.keys():
|
||||||
|
del tags[emt]
|
||||||
|
if emt in vsTags.keys():
|
||||||
|
del vsTags[emt]
|
||||||
|
|
||||||
|
tagsDiff = dictDiff(vsTags, tags)
|
||||||
|
|
||||||
|
compareResult = {}
|
||||||
|
|
||||||
|
if tagsDiff:
|
||||||
|
compareResult[ShowDescriptor.TAGS_KEY] = tagsDiff
|
||||||
|
|
||||||
|
|
||||||
|
# Target track configuration (from DB)
|
||||||
|
#tracks = self.getAllTrackDescriptors()
|
||||||
|
tracks = self.getReorderedTrackDescriptors()
|
||||||
|
numTracks = len(tracks)
|
||||||
|
|
||||||
|
# Current track configuration (of file)
|
||||||
|
vsTracks = vsShowDescriptor.getAllTrackDescriptors()
|
||||||
|
numVsTracks = len(vsTracks)
|
||||||
|
|
||||||
|
maxNumOfTracks = max(numVsTracks, numTracks)
|
||||||
|
|
||||||
|
|
||||||
|
trackCompareResult = {}
|
||||||
|
|
||||||
|
for tp in range(maxNumOfTracks):
|
||||||
|
|
||||||
|
# inspect/update funktionier nur so
|
||||||
|
if self.__jellyfinOrder:
|
||||||
|
vsTrackIndex = tracks[tp].getSourceIndex()
|
||||||
|
else:
|
||||||
|
vsTrackIndex = tp
|
||||||
|
# vsTrackIndex = tracks[tp].getSourceIndex()
|
||||||
|
|
||||||
|
# Will trigger if tracks are missing in file
|
||||||
|
if tp > (numVsTracks - 1):
|
||||||
|
if DIFF_ADDED_KEY not in trackCompareResult.keys():
|
||||||
|
trackCompareResult[DIFF_ADDED_KEY] = set()
|
||||||
|
trackCompareResult[DIFF_ADDED_KEY].add(tracks[tp].getIndex())
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Will trigger if tracks are missing in DB definition
|
||||||
|
# New tracks will be added per update via this way
|
||||||
|
if tp > (numTracks - 1):
|
||||||
|
if DIFF_REMOVED_KEY not in trackCompareResult.keys():
|
||||||
|
trackCompareResult[DIFF_REMOVED_KEY] = {}
|
||||||
|
trackCompareResult[DIFF_REMOVED_KEY][vsTracks[vsTrackIndex].getIndex()] = vsTracks[vsTrackIndex]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# assumption is made here that the track order will not change for all files of a sequence
|
||||||
|
trackDiff = tracks[tp].compare(vsTracks[vsTrackIndex])
|
||||||
|
|
||||||
|
if trackDiff:
|
||||||
|
if DIFF_CHANGED_KEY not in trackCompareResult.keys():
|
||||||
|
trackCompareResult[DIFF_CHANGED_KEY] = {}
|
||||||
|
trackCompareResult[DIFF_CHANGED_KEY][vsTracks[vsTrackIndex].getIndex()] = trackDiff
|
||||||
|
|
||||||
|
if trackCompareResult:
|
||||||
|
compareResult[ShowDescriptor.TRACKS_KEY] = trackCompareResult
|
||||||
|
|
||||||
|
return compareResult
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def getInputMappingTokens(self, use_sub_index : bool = True):
|
||||||
|
|
||||||
|
reorderedTrackDescriptors = self.getReorderedTrackDescriptors()
|
||||||
|
inputMappingTokens = []
|
||||||
|
|
||||||
|
for rtd in reorderedTrackDescriptors:
|
||||||
|
trackType = rtd.getType()
|
||||||
|
if use_sub_index:
|
||||||
|
inputMappingTokens += ['-map', f"0:{trackType.indicator()}:{rtd.getSubIndex()}"]
|
||||||
|
else:
|
||||||
|
inputMappingTokens += ['-map', f"0:{rtd.getIndex()}"]
|
||||||
|
|
||||||
|
return inputMappingTokens
|
||||||
|
|
||||||
32
bin/ffx/video_encoder.py
Normal file
32
bin/ffx/video_encoder.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class VideoEncoder(Enum):
|
||||||
|
|
||||||
|
AV1 = {'label': 'av1', 'index': 1}
|
||||||
|
VP9 = {'label': 'vp9', 'index': 2}
|
||||||
|
|
||||||
|
UNDEFINED = {'label': 'undefined', 'index': 0}
|
||||||
|
|
||||||
|
def label(self):
|
||||||
|
"""Returns the stream type as string"""
|
||||||
|
return str(self.value['label'])
|
||||||
|
|
||||||
|
def index(self):
|
||||||
|
"""Returns the stream type index"""
|
||||||
|
return int(self.value['index'])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fromLabel(label : str):
|
||||||
|
tlist = [t for t in VideoEncoder if t.value['label'] == str(label)]
|
||||||
|
if tlist:
|
||||||
|
return tlist[0]
|
||||||
|
else:
|
||||||
|
return VideoEncoder.UNDEFINED
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fromIndex(index : int):
|
||||||
|
tlist = [t for t in VideoEncoder if t.value['index'] == int(index)]
|
||||||
|
if tlist:
|
||||||
|
return tlist[0]
|
||||||
|
else:
|
||||||
|
return VideoEncoder.UNDEFINED
|
||||||
Reference in New Issue
Block a user