nightl
This commit is contained in:
118
bin/ffx.py
118
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"""
|
||||
|
||||
46
bin/ffx/audio_layout.py
Normal file
46
bin/ffx/audio_layout.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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, ):
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
28
bin/ffx/model/media_tag.py
Normal file
28
bin/ffx/model/media_tag.py
Normal file
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
@@ -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)}")
|
||||
@@ -161,3 +156,19 @@ class PatternController():
|
||||
|
||||
return result
|
||||
|
||||
|
||||
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()
|
||||
16
bin/ffx/process.py
Normal file
16
bin/ffx/process.py
Normal file
@@ -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
|
||||
|
||||
@@ -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['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
|
||||
|
||||
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 show.getDesciptor()
|
||||
|
||||
return showDescriptor
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShowController.getShowDesciptor(): {repr(ex)}")
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user