From 5e017a8373bcdd60880e80253c1cd852f284cfb8 Mon Sep 17 00:00:00 2001 From: Maveno Date: Thu, 3 Oct 2024 23:22:32 +0200 Subject: [PATCH] nightl --- bin/ffx.py | 118 ----------------- bin/ffx/audio_layout.py | 46 +++++++ bin/ffx/ffx_app.py | 3 + bin/ffx/file_properties.py | 169 ++++++++++++++++++++++++- bin/ffx/media_descriptor.py | 29 ++++- bin/ffx/model/media_tag.py | 28 ++++ bin/ffx/model/pattern.py | 28 ++++ bin/ffx/model/show.py | 16 +++ bin/ffx/model/track.py | 68 +++++++++- bin/ffx/model/{tag.py => track_tag.py} | 8 +- bin/ffx/pattern_controller.py | 31 +++-- bin/ffx/process.py | 16 +++ bin/ffx/show_controller.py | 21 +-- bin/ffx/track_controller.py | 15 +-- bin/ffx/track_descriptor.py | 57 +-------- bin/ffx/track_disposition.py | 8 ++ 16 files changed, 442 insertions(+), 219 deletions(-) create mode 100644 bin/ffx/audio_layout.py create mode 100644 bin/ffx/model/media_tag.py rename bin/ffx/model/{tag.py => track_tag.py} (85%) create mode 100644 bin/ffx/process.py diff --git a/bin/ffx.py b/bin/ffx.py index aa2be95..9d6210a 100755 --- a/bin/ffx.py +++ b/bin/ffx.py @@ -41,11 +41,6 @@ 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})' @@ -53,119 +48,6 @@ SEASON_EPISODE_STREAM_LANGUAGE_MATCH = '[sS]([0-9]+)[eE]([0-9]+)_([0-9]+)_([a-z] SUBTITLE_FILE_EXTENSION = 'vtt' -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: - - if subStream['codec_type'] in ['video', 'audio', 'subtitle']: - - if not 'disposition' in subStream.keys(): - subStream['disposition'] = {} - if not 'default' in subStream['disposition'].keys(): - subStream['disposition']['default'] = 0 - if not 'forced' in subStream['disposition'].keys(): - subStream['disposition']['forced'] = 0 - if not 'tags' in subStream.keys(): - subStream['tags'] = {} - if not 'language' in subStream['tags'].keys(): - subStream['tags']['language'] = 'undefined' - if not 'title' in subStream['tags'].keys(): - subStream['tags']['title'] = 'undefined' - - if subStream['codec_type'] == STREAM_TYPE_AUDIO: - if 'channel_layout' in subStream.keys(): - subStream['audio_layout'] = subStream['channel_layout'] - elif subStream['channels'] == 6: - subStream['audio_layout'] = STREAM_LAYOUT_6CH - else: - subStream['audio_layout'] = 'undefined' - - descriptor[subStream['codec_type']].append(subStream) - descriptor[subStream['codec_type']][-1]['sub_index'] = len(descriptor[subStream['codec_type']]) - 1 - - return descriptor - def getModifiedStreamOrder(length, last): """This is jellyfin specific as the last stream in the order is set as default""" diff --git a/bin/ffx/audio_layout.py b/bin/ffx/audio_layout.py new file mode 100644 index 0000000..30f9865 --- /dev/null +++ b/bin/ffx/audio_layout.py @@ -0,0 +1,46 @@ +from enum import Enum +from .track_type import TrackType + +class AudioLayout(Enum): + + LAYOUT_STEREO = {"layout": "stereo", "index": 1} + LAYOUT_5_1 = {"layout": "5.1(side)", "index": 2} + LAYOUT_6_1 = {"layout": "6.1", "index": 3} + LAYOUT_7_1 = {"layout": "7.1", "index": 4} #TODO: Does this exist? + + LAYOUT_6CH = {"layout": "6ch", "index": 5} + + LAYOUT_UNDEFINED = {"layout": "undefined", "index": 0} + + + def layout(self): + """Returns the layout as string""" + return self.value['layout'] + + def index(self): + """Returns the layout as string""" + return self.value['layout'] + + + def identify(self, streamObj): + + FFPROBE_LAYOUT_KEY = 'channel_layout' + FFPROBE_CHANNELS_KEY = 'channels' + FFPROBE_CODEC_TYPE_KEY = 'codec_type' + + if (type(streamObj) is not dict + or FFPROBE_CODEC_TYPE_KEY not in streamObj.keys() + or streamObj[FFPROBE_CODEC_TYPE_KEY] != TrackType.AUDIO.label()): + raise Exception('Not an ffprobe audio stream object') + + if FFPROBE_LAYOUT_KEY in streamObj.keys(): + matchingLayouts = [l for l in AudioLayout if l.value['layout'] == streamObj[FFPROBE_LAYOUT_KEY]] + if matchingLayouts: + return matchingLayouts[0] + + if (FFPROBE_CHANNELS_KEY in streamObj.keys() + and int(streamObj[FFPROBE_CHANNELS_KEY]) == 6): + + return AudioLayout.LAYOUT_6CH + + return AudioLayout.LAYOUT_UNDEFINED diff --git a/bin/ffx/ffx_app.py b/bin/ffx/ffx_app.py index 5c21b1d..7012801 100644 --- a/bin/ffx/ffx_app.py +++ b/bin/ffx/ffx_app.py @@ -10,6 +10,9 @@ from ffx.model.show import Base, Show from ffx.model.pattern import Pattern from ffx.model.track import Track +from ffx.model.media_tag import MediaTag +from ffx.model.track_tag import TrackTag + from .shows_screen import ShowsScreen from .warning_screen import WarningScreen from .dashboard_screen import DashboardScreen diff --git a/bin/ffx/file_properties.py b/bin/ffx/file_properties.py index 4e2521a..edc859d 100644 --- a/bin/ffx/file_properties.py +++ b/bin/ffx/file_properties.py @@ -1,4 +1,14 @@ -import os, re, click +import os, re, click, json + +from .media_descriptor import MediaDescriptor + +from .track_type import TrackType +from .audio_layout import AudioLayout + +from .track_disposition import TrackDisposition + +from .process import executeProcess + class FileProperties(): @@ -7,15 +17,18 @@ class FileProperties(): SEASON_EPISODE_INDICATOR_MATCH = '[sS]([0-9]+)[eE]([0-9]+)' EPISODE_INDICATOR_MATCH = '[eE]([0-9]+)' - def ___init__(self, sourcePath, ): + def ___init__(self, sourcePath, ): # Separate basedir, basename and extension for current source file - self.__sourceDirectory = os.path.dirname(sourcePath) - self.__sourceFilename = os.path.basename(sourcePath) + self.__sourcePath = sourcePath + + self.__sourceDirectory = os.path.dirname(self.__sourcePath) + self.__sourceFilename = os.path.basename(self.__sourcePath) + sourcePathTokens = self.__sourceFilename.split('.') - if sourcePathTokens[-1] in FilenameController.FILE_EXTENSIONS: + if sourcePathTokens[-1] in FileProperties.FILE_EXTENSIONS: self.__sourceFileBasename = '.'.join(sourcePathTokens[:-1]) self.__sourceFilenameExtension = sourcePathTokens[-1] else: @@ -23,8 +36,8 @@ class FileProperties(): self.__sourceFilenameExtension = '' - se_match = re.compile(FilenameController.SEASON_EPISODE_INDICATOR_MATCH) - e_match = re.compile(FilenameController.EPISODE_INDICATOR_MATCH) + se_match = re.compile(FileProperties.SEASON_EPISODE_INDICATOR_MATCH) + e_match = re.compile(FileProperties.EPISODE_INDICATOR_MATCH) se_result = se_match.search(self.__sourceFilename) e_result = e_match.search(self.__sourceFilename) @@ -64,3 +77,145 @@ class FileProperties(): else: targetFilenameTokens = [sourceFileBasename] + + + + def getFormatData(self): + """ + "format": { + "filename": "Downloads/nagatoro_s02/nagatoro_s01e02.mkv", + "nb_streams": 18, + "nb_programs": 0, + "nb_stream_groups": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "1420.063000", + "size": "1489169824", + "bit_rate": "8389316", + "probe_score": 100, + "tags": { + "PUBLISHER": "Crunchyroll", + "ENCODER": "Lavf58.29.100" + } + } + """ + + ffprobeOutput, ffprobeError, returnCode = executeProcess(["ffprobe", + "-hide_banner", + "-show_format", + "-of", "json", + self.__sourcePath]) + + + if 'Invalid data found when processing input' in ffprobeError: + raise Exception(f"File {self.__sourcePath} does not contain valid stream data") + + + if returnCode != 0: + raise Exception(f"ffprobe returned with error {returnCode}") + + + return json.loads(ffprobeOutput)['format'] + + #[{'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(self): + """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", + "-hide_banner", + "-show_streams", + "-of", "json", + self.__sourcePath]) + + if 'Invalid data found when processing input' in ffprobeError: + raise Exception(f"File {self.__sourcePath} does not contain valid stream data") + + + if returnCode != 0: + raise Exception(f"ffprobe returned with error {returnCode}") + + + return json.loads(ffprobeOutput)['streams'] + + + def getTrackDescriptor(self, streamObj): + """Convert the stream describing json object into a track descriptor""" + + trackType = streamObj['codec_type'] + + descriptor = {} + + if trackType in [t.label() for t in TrackType]: + + descriptor['type'] = trackType + + descriptor = {} + descriptor['disposition_list'] = [t for d in (k for (k,v) in streamObj['disposition'].items() if v) if (t := TrackDisposition.find(d)) if t is not None] + + descriptor['tags'] = streamObj['tags'] if 'tags' in streamObj.keys() else {} + + if trackType == TrackType.AUDIO.label(): + descriptor['layout'] = AudioLayout.identify(streamObj) + + return descriptor + + + def getMediaDescriptor(self): + + formatData = self.getFormatData() + streamData = self.getStreamData() + + md = MediaDescriptor(tags=formatData['tags'] if 'tags' in formatData.keys() else {}) + + for streamObj in streamData: + + md.appendTrack(streamObj) + + return md diff --git a/bin/ffx/media_descriptor.py b/bin/ffx/media_descriptor.py index 9a6569c..24b2881 100644 --- a/bin/ffx/media_descriptor.py +++ b/bin/ffx/media_descriptor.py @@ -1,2 +1,29 @@ +from ffx.track_type import TrackType + class MediaDescriptor(): - pass + + def __init__(self, tags = {}, clear_tags = False, tracks = []): + + # self.__metaTags = mediaDescriptor['tags'] if 'tags' in mediaDescriptor.keys() else {} + self.__tags = tags + + self.__tracks = {} + + # self.__videoTracks = mediaDescriptor[TrackType.VIDEO.label()] if TrackType.VIDEO.label() in mediaDescriptor.keys() else [] + # self.__audioTracks = mediaDescriptor[TrackType.AUDIO.label()] if TrackType.AUDIO.label() in mediaDescriptor.keys() else [] + # self.__subtitleTracks = mediaDescriptor[TrackType.SUBTITLE.label()] if TrackType.SUBTITLE.label() in mediaDescriptor.keys() else [] + + self.__clearTags = clear_tags + + for t in tracks: + self.appendTrack(t) + + + def appendTrack(self, trackDescriptor): + + ttype = trackDescriptor['type'].label() + + if ttype not in self.__tracks.keys(): + self.__tracks[ttype] = [] + + self.__tracks[ttype] = trackDescriptor diff --git a/bin/ffx/model/media_tag.py b/bin/ffx/model/media_tag.py new file mode 100644 index 0000000..c93e5e2 --- /dev/null +++ b/bin/ffx/model/media_tag.py @@ -0,0 +1,28 @@ +# from typing import List +from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, Enum +from sqlalchemy.orm import relationship, declarative_base, sessionmaker + +from .show import Base + + +class MediaTag(Base): + """ + relationship(argument, opt1, opt2, ...) + argument is string of class or Mapped class of the target entity + backref creates a bi-directional corresponding relationship (back_populates preferred) + back_populates points to the corresponding relationship (the actual class attribute identifier) + + See: https://docs.sqlalchemy.org/en/(14|20)/orm/basic_relationships.html + """ + + __tablename__ = 'media_tags' + + # v1.x + id = Column(Integer, primary_key=True) + + key = Column(String) + value = Column(String) + + # v1.x + pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE")) + pattern = relationship('Pattern', back_populates='media_tags') diff --git a/bin/ffx/model/pattern.py b/bin/ffx/model/pattern.py index 4db1a6c..dde4ddf 100644 --- a/bin/ffx/model/pattern.py +++ b/bin/ffx/model/pattern.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship, sessionmaker, Mapped, backref from .show import Base from .track import Track +from ffx.media_descriptor import MediaDescriptor class Pattern(Base): @@ -26,3 +27,30 @@ class Pattern(Base): # show: Mapped["Show"] = relationship(back_populates="patterns") tracks = relationship('Track', back_populates='pattern', cascade="all, delete") + + + media_tags = relationship('MediaTag', back_populates='pattern', cascade="all, delete") + + + def getDescriptor(self): + + descriptor = {} + descriptor['id'] = int(self.id) + descriptor['pattern'] = str(self.pattern) + descriptor['show_id'] = int(self.show_id) + + descriptor['tags'] = {} + for t in self.media_tags: + descriptor['tags'][str(t.key)] = str(t.value) + + return descriptor + + + def getMediaDescriptor(self): + + md = MediaDescriptor(tags = self.getDescriptor()['tags']) + + for t in self.tracks: + md.appendTrack(t.getDescriptor()) + + return md diff --git a/bin/ffx/model/show.py b/bin/ffx/model/show.py index 5ef1433..864a0e6 100644 --- a/bin/ffx/model/show.py +++ b/bin/ffx/model/show.py @@ -39,3 +39,19 @@ class Show(Base): index_episode_digits = Column(Integer, default=2) indicator_season_digits = Column(Integer, default=2) indicator_episode_digits = Column(Integer, default=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 diff --git a/bin/ffx/model/track.py b/bin/ffx/model/track.py index 2c3f90a..f75cee8 100644 --- a/bin/ffx/model/track.py +++ b/bin/ffx/model/track.py @@ -8,8 +8,7 @@ from ffx.track_type import TrackType from ffx.iso_language import IsoLanguage -from ffx.model.tag import Tag - +from ffx.track_disposition import TrackDisposition class Track(Base): @@ -39,11 +38,70 @@ class Track(Base): language = Column(String) # IsoLanguage threeLetter title = Column(String) - tags = relationship('Tag', back_populates='track', cascade="all, delete") + + track_tags = relationship('TrackTag', back_populates='track', cascade="all, delete") disposition_flags = Column(Integer) - def getDescriptor(self): - pass + def __init__(self, **kwargs): + + trackType = kwargs.pop('track_type', None) + if trackType is not None: + self.track_type = int(trackType.value) + + language = kwargs.pop('language', None) + if language is not None: + self.language = str(language.threeLetter()) + + dispositionList = kwargs.pop('disposition_flags', None) + if dispositionList is not None: + self.disposition_flags = int(TrackDisposition.toFlags(dispositionList)) + + super().__init__(**kwargs) + + +# def getDescriptor(self): +# +# descriptor = {} +# descriptor['id'] = int(self.id) +# descriptor['pattern_id'] = int(self.pattern_id) +# descriptor['type'] = TrackType(self.track_type) +# descriptor['sub_index'] = int(self.sub_index) +# +# descriptor['language'] = IsoLanguage.findThreeLetter(self.language) +# descriptor['title'] = str(self.title) +# +# descriptor['disposition_list'] = TrackDisposition.toList(self.disposition_flags) +# +# descriptor['tags'] = {} +# for t in self.track_tags: +# descriptor['tags'][str(t.key)] = str(t.value) +# +# return descriptor + + + def getId(self): + return int(self.id) + + def getPatternId(self): + return int(self.pattern_id) + + def getType(self): + return TrackType(self.track_type) + + def getSubIndex(self): + return int(self.sub_index) + + def getLanguage(self): + return IsoLanguage.findThreeLetter(self.language) + + def getTitle(self): + return str(self.title) + + def getDispositionList(self): + return TrackDisposition.toList(self.disposition_flags) + + def getTags(self): + return {str(k.value):str(v.value) for (k,v) in self.track_tags} diff --git a/bin/ffx/model/tag.py b/bin/ffx/model/track_tag.py similarity index 85% rename from bin/ffx/model/tag.py rename to bin/ffx/model/track_tag.py index 61aaf5d..983fe80 100644 --- a/bin/ffx/model/tag.py +++ b/bin/ffx/model/track_tag.py @@ -4,10 +4,8 @@ from sqlalchemy.orm import relationship, declarative_base, sessionmaker from .show import Base -from ffx.track_type import TrackType - -class Tag(Base): +class TrackTag(Base): """ relationship(argument, opt1, opt2, ...) argument is string of class or Mapped class of the target entity @@ -17,7 +15,7 @@ class Tag(Base): See: https://docs.sqlalchemy.org/en/(14|20)/orm/basic_relationships.html """ - __tablename__ = 'tags' + __tablename__ = 'track_tags' # v1.x id = Column(Integer, primary_key=True) @@ -27,4 +25,4 @@ class Tag(Base): # v1.x track_id = Column(Integer, ForeignKey('tracks.id', ondelete="CASCADE")) - track = relationship('Track', back_populates='tags') + track = relationship('Track', back_populates='track_tags') diff --git a/bin/ffx/pattern_controller.py b/bin/ffx/pattern_controller.py index fa030f4..6b48380 100644 --- a/bin/ffx/pattern_controller.py +++ b/bin/ffx/pattern_controller.py @@ -2,6 +2,8 @@ import click, re from ffx.model.pattern import Pattern +from .media_descriptor import MediaDescriptor + class PatternController(): @@ -77,14 +79,6 @@ class PatternController(): s.close() - def getPatternDict(self, pattern): - patternDescriptor = {} - patternDescriptor['id'] = int(pattern.id) - patternDescriptor['pattern'] = str(pattern.pattern) - patternDescriptor['show_id'] = int(pattern.show_id) - return patternDescriptor - - def getPatternDescriptor(self, patternId): try: @@ -93,7 +87,8 @@ class PatternController(): if q.count(): pattern = q.first() - return self.getPatternDict(pattern) + #return self.getPatternDict(pattern) + return pattern.getDescriptor() except Exception as ex: raise click.ClickException(f"PatternController.getPatternDescriptor(): {repr(ex)}") @@ -160,4 +155,20 @@ class PatternController(): s.close() return result - \ No newline at end of file + + + def getMediaDescriptor(self, patternId): + + try: + s = self.Session() + q = s.query(Pattern).filter(Pattern.id == int(patternId)) + + if q.count(): + pattern = q.first() + #return self.getPatternDict(pattern) + return pattern.getMediaDescriptor() + + except Exception as ex: + raise click.ClickException(f"PatternController.getPatternDescriptor(): {repr(ex)}") + finally: + s.close() \ No newline at end of file diff --git a/bin/ffx/process.py b/bin/ffx/process.py new file mode 100644 index 0000000..b671c10 --- /dev/null +++ b/bin/ffx/process.py @@ -0,0 +1,16 @@ +import subprocess + + +#class ProcessController(): + + #def __init__(self, commandSequence): + # self.__commandSequence = commandSequence + +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 + diff --git a/bin/ffx/show_controller.py b/bin/ffx/show_controller.py index e50bed0..5de5a84 100644 --- a/bin/ffx/show_controller.py +++ b/bin/ffx/show_controller.py @@ -22,16 +22,19 @@ class ShowController(): if q.count(): show = q.first() - showDescriptor['id'] = int(show.id) - showDescriptor['name'] = str(show.name) - showDescriptor['year'] = int(show.year) - - showDescriptor['index_season_digits'] = int(show.index_season_digits) - showDescriptor['index_episode_digits'] = int(show.index_episode_digits) - showDescriptor['indicator_season_digits'] = int(show.indicator_season_digits) - showDescriptor['indicator_episode_digits'] = int(show.indicator_episode_digits) +# showDescriptor['id'] = int(show.id) +# showDescriptor['name'] = str(show.name) +# showDescriptor['year'] = int(show.year) +# +# showDescriptor['index_season_digits'] = int(show.index_season_digits) +# showDescriptor['index_episode_digits'] = int(show.index_episode_digits) +# showDescriptor['indicator_season_digits'] = int(show.indicator_season_digits) +# showDescriptor['indicator_episode_digits'] = int(show.indicator_episode_digits) +# +# return showDescriptor + + return show.getDesciptor() - return showDescriptor except Exception as ex: raise click.ClickException(f"ShowController.getShowDesciptor(): {repr(ex)}") diff --git a/bin/ffx/track_controller.py b/bin/ffx/track_controller.py index cc8243a..846c58d 100644 --- a/bin/ffx/track_controller.py +++ b/bin/ffx/track_controller.py @@ -118,18 +118,6 @@ class TrackController(): s.close() - def getTrackDict(self, track): - trackDescriptor = {} - trackDescriptor['id'] = int(track.id) - trackDescriptor['pattern_id'] = int(track.pattern_id) - trackDescriptor['type'] = TrackType(track.track_type) - trackDescriptor['sub_index'] = int(track.sub_index) - trackDescriptor['language'] = IsoLanguage.findThreeLetter(track.language) - trackDescriptor['title'] = str(track.title) - trackDescriptor['disposition_list'] = TrackDisposition.toList(track.disposition_flags) - return trackDescriptor - - def getTrackDescriptor(self, trackId): try: @@ -138,7 +126,8 @@ class TrackController(): if q.count(): track = q.first() - return self.getTrackDict(track) + #return self.getTrackDict(track) + return track.getDescriptor() else: return {} diff --git a/bin/ffx/track_descriptor.py b/bin/ffx/track_descriptor.py index 6933f5e..4db72e8 100644 --- a/bin/ffx/track_descriptor.py +++ b/bin/ffx/track_descriptor.py @@ -1,56 +1,11 @@ -from language_data import LanguageData -from stream_type import StreamType +from .iso_language import IsoLanguage +from .track_type import TrackType +from .audio_layout import AudioLayout class StreamDescriptor(): + pass - def __init__(self, - streamType : StreamType, - language : LanguageData, - title : str, - codec : str, - subIndex : int = -1): - self.__streamType = streamType - self.__subIndex = subIndex - self.__streamLanguage = language - self.__streamTitle = title - - self.__codecName = codec - -# "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" + def getTrack(self): + pass diff --git a/bin/ffx/track_disposition.py b/bin/ffx/track_disposition.py index b26eebe..828fafc 100644 --- a/bin/ffx/track_disposition.py +++ b/bin/ffx/track_disposition.py @@ -47,3 +47,11 @@ class TrackDisposition(Enum): if flags & int(2 ** d.index()): dispositionList += [d] return dispositionList + + @staticmethod + def find(disposition): + matchingDispositions = [d for d in TrackDisposition if d.label() == str(disposition)] + if matchingDispositions: + return matchingDispositions[0] + else: + return None