This commit is contained in:
Maveno
2024-10-03 23:22:32 +02:00
parent 123d8659e1
commit 5e017a8373
16 changed files with 442 additions and 219 deletions

View File

@@ -41,11 +41,6 @@ STREAM_TYPE_VIDEO = 'video'
STREAM_TYPE_AUDIO = 'audio' STREAM_TYPE_AUDIO = 'audio'
STREAM_TYPE_SUBTITLE = 'subtitle' 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]+)' 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})'
@@ -53,119 +48,6 @@ SEASON_EPISODE_STREAM_LANGUAGE_MATCH = '[sS]([0-9]+)[eE]([0-9]+)_([0-9]+)_([a-z]
SUBTITLE_FILE_EXTENSION = 'vtt' 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): def getModifiedStreamOrder(length, last):
"""This is jellyfin specific as the last stream in the order is set as default""" """This is jellyfin specific as the last stream in the order is set as default"""

46
bin/ffx/audio_layout.py Normal file
View 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

View File

@@ -10,6 +10,9 @@ from ffx.model.show import Base, Show
from ffx.model.pattern import Pattern from ffx.model.pattern import Pattern
from ffx.model.track import Track 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 .shows_screen import ShowsScreen
from .warning_screen import WarningScreen from .warning_screen import WarningScreen
from .dashboard_screen import DashboardScreen from .dashboard_screen import DashboardScreen

View File

@@ -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(): class FileProperties():
@@ -7,15 +17,18 @@ 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]+)'
def ___init__(self, sourcePath, ): def ___init__(self, sourcePath, ):
# Separate basedir, basename and extension for current source file # Separate basedir, basename and extension for current source file
self.__sourceDirectory = os.path.dirname(sourcePath) self.__sourcePath = sourcePath
self.__sourceFilename = os.path.basename(sourcePath)
self.__sourceDirectory = os.path.dirname(self.__sourcePath)
self.__sourceFilename = os.path.basename(self.__sourcePath)
sourcePathTokens = self.__sourceFilename.split('.') sourcePathTokens = self.__sourceFilename.split('.')
if sourcePathTokens[-1] in FilenameController.FILE_EXTENSIONS: if sourcePathTokens[-1] in FileProperties.FILE_EXTENSIONS:
self.__sourceFileBasename = '.'.join(sourcePathTokens[:-1]) self.__sourceFileBasename = '.'.join(sourcePathTokens[:-1])
self.__sourceFilenameExtension = sourcePathTokens[-1] self.__sourceFilenameExtension = sourcePathTokens[-1]
else: else:
@@ -23,8 +36,8 @@ class FileProperties():
self.__sourceFilenameExtension = '' self.__sourceFilenameExtension = ''
se_match = re.compile(FilenameController.SEASON_EPISODE_INDICATOR_MATCH) se_match = re.compile(FileProperties.SEASON_EPISODE_INDICATOR_MATCH)
e_match = re.compile(FilenameController.EPISODE_INDICATOR_MATCH) e_match = re.compile(FileProperties.EPISODE_INDICATOR_MATCH)
se_result = se_match.search(self.__sourceFilename) se_result = se_match.search(self.__sourceFilename)
e_result = e_match.search(self.__sourceFilename) e_result = e_match.search(self.__sourceFilename)
@@ -64,3 +77,145 @@ class FileProperties():
else: else:
targetFilenameTokens = [sourceFileBasename] 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

View File

@@ -1,2 +1,29 @@
from ffx.track_type import TrackType
class MediaDescriptor(): 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

View 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')

View File

@@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship, sessionmaker, Mapped, backref
from .show import Base from .show import Base
from .track import Track from .track import Track
from ffx.media_descriptor import MediaDescriptor
class Pattern(Base): class Pattern(Base):
@@ -26,3 +27,30 @@ class Pattern(Base):
# show: Mapped["Show"] = relationship(back_populates="patterns") # show: Mapped["Show"] = relationship(back_populates="patterns")
tracks = relationship('Track', back_populates='pattern', cascade="all, delete") 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

View File

@@ -39,3 +39,19 @@ class Show(Base):
index_episode_digits = Column(Integer, default=2) index_episode_digits = Column(Integer, default=2)
indicator_season_digits = Column(Integer, default=2) indicator_season_digits = Column(Integer, default=2)
indicator_episode_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

View File

@@ -8,8 +8,7 @@ from ffx.track_type import TrackType
from ffx.iso_language import IsoLanguage from ffx.iso_language import IsoLanguage
from ffx.model.tag import Tag from ffx.track_disposition import TrackDisposition
class Track(Base): class Track(Base):
@@ -39,11 +38,70 @@ class Track(Base):
language = Column(String) # IsoLanguage threeLetter language = Column(String) # IsoLanguage threeLetter
title = Column(String) 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) disposition_flags = Column(Integer)
def getDescriptor(self): def __init__(self, **kwargs):
pass
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}

View File

@@ -4,10 +4,8 @@ from sqlalchemy.orm import relationship, declarative_base, sessionmaker
from .show import Base from .show import Base
from ffx.track_type import TrackType
class TrackTag(Base):
class Tag(Base):
""" """
relationship(argument, opt1, opt2, ...) relationship(argument, opt1, opt2, ...)
argument is string of class or Mapped class of the target entity 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 See: https://docs.sqlalchemy.org/en/(14|20)/orm/basic_relationships.html
""" """
__tablename__ = 'tags' __tablename__ = 'track_tags'
# v1.x # v1.x
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
@@ -27,4 +25,4 @@ class Tag(Base):
# v1.x # v1.x
track_id = Column(Integer, ForeignKey('tracks.id', ondelete="CASCADE")) track_id = Column(Integer, ForeignKey('tracks.id', ondelete="CASCADE"))
track = relationship('Track', back_populates='tags') track = relationship('Track', back_populates='track_tags')

View File

@@ -2,6 +2,8 @@ import click, re
from ffx.model.pattern import Pattern from ffx.model.pattern import Pattern
from .media_descriptor import MediaDescriptor
class PatternController(): class PatternController():
@@ -77,14 +79,6 @@ class PatternController():
s.close() 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): def getPatternDescriptor(self, patternId):
try: try:
@@ -93,7 +87,8 @@ class PatternController():
if q.count(): if q.count():
pattern = q.first() pattern = q.first()
return self.getPatternDict(pattern) #return self.getPatternDict(pattern)
return pattern.getDescriptor()
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.getPatternDescriptor(): {repr(ex)}") raise click.ClickException(f"PatternController.getPatternDescriptor(): {repr(ex)}")
@@ -161,3 +156,19 @@ class PatternController():
return result 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
View 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

View File

@@ -22,16 +22,19 @@ class ShowController():
if q.count(): if q.count():
show = q.first() show = q.first()
showDescriptor['id'] = int(show.id) # showDescriptor['id'] = int(show.id)
showDescriptor['name'] = str(show.name) # showDescriptor['name'] = str(show.name)
showDescriptor['year'] = int(show.year) # 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) return show.getDesciptor()
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
except Exception as ex: except Exception as ex:
raise click.ClickException(f"ShowController.getShowDesciptor(): {repr(ex)}") raise click.ClickException(f"ShowController.getShowDesciptor(): {repr(ex)}")

View File

@@ -118,18 +118,6 @@ class TrackController():
s.close() 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): def getTrackDescriptor(self, trackId):
try: try:
@@ -138,7 +126,8 @@ class TrackController():
if q.count(): if q.count():
track = q.first() track = q.first()
return self.getTrackDict(track) #return self.getTrackDict(track)
return track.getDescriptor()
else: else:
return {} return {}

View File

@@ -1,56 +1,11 @@
from language_data import LanguageData from .iso_language import IsoLanguage
from stream_type import StreamType from .track_type import TrackType
from .audio_layout import AudioLayout
class StreamDescriptor(): 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 def getTrack(self):
self.__streamTitle = title pass
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"

View File

@@ -47,3 +47,11 @@ class TrackDisposition(Enum):
if flags & int(2 ** d.index()): if flags & int(2 ** d.index()):
dispositionList += [d] dispositionList += [d]
return dispositionList 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