50 Commits

Author SHA1 Message Date
123d8659e1 inc 2024-10-01 22:21:14 +02:00
82b257d809 inc 2024-10-01 12:27:23 +00:00
Maveno
8ec7f9c2d1 inc 2024-10-01 14:23:43 +02:00
Maveno
7cc6efb9f1 nightly 2024-09-29 23:00:17 +02:00
Maveno
87ccb7e8a6 fix pattern edit 2024-09-29 21:42:42 +02:00
Maveno
3765f25fd8 ff 2024-09-29 16:15:56 +02:00
Maveno
74dfbe30d7 Fix delete tracks 2024-09-29 16:13:14 +02:00
Maveno
84baeb2d87 add selection exceptions 2024-09-29 13:12:37 +02:00
Maveno
9df5973676 inc delete tracks 2024-09-29 13:07:34 +02:00
Maveno
b492ebdab9 inc edit tracks 2024-09-29 12:02:44 +02:00
Maveno
1ae52399b9 nightly 2024-09-28 19:44:12 +02:00
3008d66dfe ninc 2024-09-27 22:10:21 +02:00
9f22c70e89 inc 2024-09-27 16:33:57 +02:00
a5d568ba34 inc streams ui 2024-09-27 15:51:34 +02:00
73c957c9bb streams ui stub 2024-09-27 13:52:48 +02:00
7c899e32bb Rework MVC 2024-09-27 12:51:23 +02:00
e325aaa529 tmdb controller mwe 2024-09-27 11:15:19 +02:00
fdc8f8f602 nightly 2024-09-26 19:10:36 +02:00
7fc025821b Fix delete cascade 2024-09-26 17:50:48 +02:00
Maveno
322321b1ed inc 2024-09-26 15:07:45 +02:00
Maveno
a46a2b421e Delete Show and confirmation 2024-09-26 12:59:07 +02:00
Maveno
0cda6390cd edit show mwe 2024-09-26 11:02:23 +02:00
Maveno
c963c2c675 nightly 2024-09-26 00:18:53 +02:00
Maveno
37786f56b5 mwe full cycle form input 2024-09-25 19:24:27 +02:00
Maveno
24e85d7005 Shows Editor Screen vis 2024-09-24 19:01:58 +02:00
Maveno
9b57007d5e textual mwe 2024-09-22 21:50:16 +02:00
Maveno
59503b566c inc stream handling 2024-09-21 23:01:45 +02:00
Maveno
b390eaf2a0 inc 2024-09-20 17:52:04 +02:00
Maveno
dcfc0f734b inc stream management 2024-09-20 08:09:02 +02:00
Maveno
ab0df0fed3 impl MSO 2024-09-19 07:50:33 +02:00
Maveno
8ad50fd881 decon/inc stream parsing 2024-09-18 07:39:42 +02:00
Maveno
5ed97e0b9b inc 2024-09-17 08:26:29 +02:00
Maveno
7d85b45d68 ff 2024-09-16 07:19:19 +02:00
Maveno
de56e85d2e impl check multiple default/forced streams 2024-09-16 07:10:38 +02:00
d84797e6a5 inc 2024-09-15 16:09:01 +02:00
Maveno
e3964b0002 xx 2024-09-15 09:21:43 +02:00
Maveno
54a3df7f7c ff 2024-09-15 09:00:38 +02:00
Maveno
f381fad31f inc dispo 2024-09-14 22:10:16 +02:00
Maveno
e6734cb4ef inc handling dispositions 2024-09-14 14:12:27 +02:00
Maveno
dcd79b74fd impl disposition rewrite 2024-09-14 13:03:47 +02:00
Maveno
4d7f728f25 impl multifile handling 2024-09-14 09:25:03 +02:00
Maveno
ad7562f387 inc multifile handling 2024-09-13 07:48:40 +02:00
e7426398ee inc 2024-09-12 13:52:49 +00:00
20ae4e763f initial 2024-09-11 19:38:34 +02:00
6fdcd20176 textual initial 2024-09-11 16:47:37 +00:00
ab864e472b file extensions 2024-09-11 05:52:14 +00:00
Maveno
45ba438c3d inc 2024-09-11 07:49:56 +02:00
84d7a9b14b prep multi 2024-09-10 16:08:23 +02:00
Maveno
494ae89034 on par crude 2024-09-09 08:39:05 +02:00
Maveno
0a8d3a8f85 mig nach click 2024-09-08 18:31:26 +02:00
35 changed files with 4373 additions and 177 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__
junk/

View File

@@ -0,0 +1,32 @@
import os
from ffx.pattern_controller import PatternController
from ffx.model.show import Base
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, sessionmaker, Mapped, backref
filename = 'Boruto.Naruto.Next.Generations.S01E256.GerEngSub.AAC.1080p.WebDL.x264-Tanuki.mkv'
# Data 'input' variable
context = {}
# Initialize DB
homeDir = os.path.expanduser("~")
ffxVarDir = os.path.join(homeDir, '.local', 'var', 'ffx')
if not os.path.exists(ffxVarDir):
os.makedirs(ffxVarDir)
context['database_url'] = f"sqlite:///{os.path.join(ffxVarDir, 'ffx.db')}"
context['database_engine'] = create_engine(context['database_url'])
context['database_session'] = sessionmaker(bind=context['database_engine'])
Base.metadata.create_all(context['database_engine'])
pc = PatternController(context)
print(pc.matchFilename(filename))

View File

@@ -0,0 +1,554 @@
#! /usr/bin/python3
import os, sys, subprocess, json, click, time, re
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label
VERSION='0.1.0'
DEFAULT_VIDEO_ENCODER = 'vp9'
DEFAULT_QUALITY = 23
DEFAULT_AV1_PRESET = 5
DEFAULT_LABEL='output'
DEFAULT_FILE_SUFFIX = 'webm'
DEFAULT_STEREO_BANDWIDTH = "128"
DEFAULT_AC3_BANDWIDTH = "256"
DEFAULT_DTS_BANDWIDTH = "320"
DEFAULT_CROP_START = 60
DEFAULT_CROP_LENGTH = 180
TEMP_FILE_NAME = "ffmpeg2pass-0.log"
MKVMERGE_METADATA_KEYS = ['BPS',
'NUMBER_OF_FRAMES',
'NUMBER_OF_BYTES',
'_STATISTICS_WRITING_APP',
'_STATISTICS_WRITING_DATE_UTC',
'_STATISTICS_TAGS']
FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
COMMAND_TOKENS = ['ffmpeg', '-y', '-i']
NULL_TOKENS = ['-f', 'null', '/dev/null']
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]+)'
SEASON_INDICATOR_MATCH = '([sS][0-9]+)'
EPISODE_INDICATOR_MATCH = '([eE][0-9]+)'
class DashboardScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
context['dashboard'] = 'dashboard'
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Placeholder("Dashboard Screen")
yield Footer()
class WarningScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Label("Warning! This file is not compliant to the defined source schema!")
yield Footer()
class SettingsScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Placeholder("Settings Screen")
yield Footer()
class HelpScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Placeholder("Help Screen")
yield Footer()
class ModesApp(App):
BINDINGS = [
("q", "quit()", "Quit"),
# ("d", "switch_mode('dashboard')", "Dashboard"),
# ("s", "switch_mode('settings')", "Settings"),
# ("h", "switch_mode('help')", "Help"),
]
MODES = {
"warning": WarningScreen,
"dashboard": DashboardScreen,
"settings": SettingsScreen,
"help": HelpScreen,
}
def __init__(self, context = {}):
super().__init__()
self.context = context
def on_mount(self) -> None:
self.switch_mode("warning")
def getContext(self):
return self.context
def executeProcess(commandSequence):
process = subprocess.Popen(commandSequence, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
return output
#[{'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 getStreamDescriptor(filename):
ffprobeOutput = executeProcess(["ffprobe",
"-show_streams",
"-of", "json",
filename])
streamData = json.loads(ffprobeOutput)['streams']
descriptor = []
i = 0
for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_VIDEO]:
descriptor.append({
'index': d['index'],
'sub_index': i,
'type': STREAM_TYPE_VIDEO,
'codec': d['codec_name']
})
i += 1
i = 0
for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_AUDIO]:
streamDescriptor = {
'index': d['index'],
'sub_index': i,
'type': STREAM_TYPE_AUDIO,
'codec': d['codec_name'],
'channels': d['channels']
}
if 'channel_layout' in d.keys():
streamDescriptor['layout'] = d['channel_layout']
elif d['channels'] == 6:
streamDescriptor['layout'] = STREAM_LAYOUT_6CH
else:
streamDescriptor['layout'] = 'undefined'
descriptor.append(streamDescriptor)
i += 1
i = 0
for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_SUBTITLE]:
descriptor.append({
'index': d['index'],
'sub_index': i,
'type': STREAM_TYPE_SUBTITLE,
'codec': d['codec_name']
})
i += 1
return descriptor
def generateAV1Tokens(q, p):
return ['-c:v:0', 'libsvtav1',
'-svtav1-params', f"crf={q}:preset={p}:tune=0:enable-overlays=1:scd=1:scm=0",
'-pix_fmt', 'yuv420p10le']
def generateVP9Pass1Tokens(q):
return ['-c:v:0', 'libvpx-vp9',
'-row-mt', '1',
'-crf', str(q),
'-pass', '1',
'-speed', '4',
'-frame-parallel', '0',
'-g', '9999',
'-aq-mode', '0']
def generateVP9Pass2Tokens(q):
return ['-c:v:0', 'libvpx-vp9',
'-row-mt', '1',
'-crf', str(q),
'-pass', '2',
'-frame-parallel', '0',
'-g', '9999',
'-aq-mode', '0',
'-auto-alt-ref', '1',
'-lag-in-frames', '25']
def generateCropTokens(start, length):
return ['-ss', str(start), '-t', str(length)]
def generateDenoiseTokens(spatial=5, patch=7, research=7, hw=False):
filterName = 'nlmeans_opencl' if hw else 'nlmeans'
return ['-vf', f"{filterName}=s={spatial}:p={patch}:r={research}"]
def generateOutputTokens(f, suffix, q=None):
if q is None:
return ['-f', 'webm', f"{f}.{suffix}"]
else:
return ['-f', 'webm', f"{f}_q{q}.{suffix}"]
# preset = DEFAULT_AV1_PRESET
# presetTokens = [p for p in sys.argv if p.startswith('p=')]
# if presetTokens:
# preset = int(presetTokens[0].split('=')[1])
# cropStart = ''
# cropLength = ''
# cropTokens = [c for c in sys.argv if c.startswith('crop')]
# if cropTokens:
# if '=' in cropTokens[0]:
# cropString = cropTokens[0].split('=')[1]
# cropStart, cropLength = cropString.split(',')
# else:
# cropStart = 60
# cropLength = 180
#
# denoiseTokens = [d for d in sys.argv if d.startswith('denoise')]
#
# for aStream in audioStreams:
# if 'channel_layout' in aStream:
# print(f"audio stream: {aStream['channel_layout']}") #channel_layout
# else:
# print(f"unknown audio stream with {aStream['channels']} channels") #channel_layout
def generateAudioTokens(context, index, layout):
if layout == STREAM_LAYOUT_6_1:
return [f"-c:a:{index}",
'libopus',
f"-filter:a:{index}",
'channelmap=channel_layout=6.1',
f"-b:a:{index}",
context['bitrates']['dts']]
elif layout == STREAM_LAYOUT_5_1:
return [f"-c:a:{index}",
'libopus',
f"-filter:a:{index}",
"channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1",
f"-b:a:{index}",
context['bitrates']['ac3']]
elif layout == STREAM_LAYOUT_STEREO:
return [f"-c:a:{index}",
'libopus',
f"-b:a:{index}",
context['bitrates']['stereo']]
elif layout == STREAM_LAYOUT_6CH:
return [f"-c:a:{index}",
'libopus',
f"-filter:a:{index}",
"channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1",
f"-b:a:{index}",
context['bitrates']['ac3']]
else:
return []
def generateClearTokens(streams):
clearTokens = []
for s in streams:
for k in MKVMERGE_METADATA_KEYS:
clearTokens += [f"-metadata:s:{s['type'][0]}:{s['sub_index']}", f"{k}="]
return clearTokens
@click.group()
@click.pass_context
def ffx(ctx):
"""FFX"""
ctx.obj = {}
pass
# Define a subcommand
@ffx.command()
def version():
click.echo(VERSION)
# Another subcommand
@ffx.command()
def help():
click.echo(f"ffx {VERSION}\n")
click.echo(f"Usage: ffx [input file] [output file] [vp9|av1] [q=[nn[,nn,...]]] [p=nn] [a=nnn[k]] [ac3=nnn[k]] [dts=nnn[k]] [crop]")
@click.argument('filename', nargs=1)
@ffx.command()
def streams(filename):
for d in getStreamDescriptor(filename):
click.echo(f"{d['codec']}{' (' + str(d['channels']) + ')' if d['type'] == 'audio' else ''}")
@ffx.command()
@click.pass_context
@click.argument('paths', nargs=-1)
@click.option('-l', '--label', type=str, default=DEFAULT_LABEL, help='Label to be used as filename prefix')
@click.option('-v', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER, help='Target video encoder (vp9 or av1) default: vp9')
@click.option('-q', '--quality', type=str, default=DEFAULT_QUALITY, help='Quality settings to be used with VP9 encoder (default: 23)')
@click.option('-p', '--preset', type=str, default=DEFAULT_QUALITY, help='Quality preset to be used with AV1 encoder (default: 5)')
@click.option('-a', '--stereo-bitrate', type=int, default=DEFAULT_STEREO_BANDWIDTH, help='Bitrate in kbit/s to be used to encode stereo audio streams')
@click.option('-ac3', '--ac3-bitrate', type=int, default=DEFAULT_AC3_BANDWIDTH, help='Bitrate in kbit/s to be used to encode 5.1 audio streams')
@click.option('-dts', '--dts-bitrate', type=int, default=DEFAULT_DTS_BANDWIDTH, help='Bitrate in kbit/s to be used to encode 6.1 audio streams')
@click.option('-ds', '--default-subtitle', type=int, help='Index of default subtitle stream')
@click.option('-fa', '--forced-audio', type=int, help='Index of forced audio stream (including default audio stream tag)')
@click.option('-da', '--default-audio', type=int, help='Index of default audio stream')
@click.option("--crop", is_flag=False, flag_value="default", default="none")
@click.option("-c", "--clear-metadata", is_flag=True, default=False)
@click.option("-d", "--denoise", is_flag=True, default=False)
@click.option("-o", "--output-directory", type=str, default='')
def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, dts_bitrate, crop, clear_metadata, default_subtitle, forced_audio, default_audio, denoise, output_directory):
"""Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin
Files found under PATHS will be converted according to parameters.
Filename extensions will be changed appropriately.
Suffices will we appended to filename in case of multiple created files
or if the filename has not changed."""
startTime = time.perf_counter()
context = ctx.obj
click.echo(f"\nVideo encoder: {video_encoder}")
qualityTokens = quality.split(',')
q_list = [q for q in qualityTokens if q.isnumeric()]
click.echo(f"Qualities: {q_list}")
ctx.obj['bitrates'] = {}
ctx.obj['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k"
ctx.obj['bitrates']['ac3'] = str(ac3_bitrate) if str(ac3_bitrate).endswith('k') else f"{ac3_bitrate}k"
ctx.obj['bitrates']['dts'] = str(dts_bitrate) if str(dts_bitrate).endswith('k') else f"{dts_bitrate}k"
click.echo(f"Stereo bitrate: {ctx.obj['bitrates']['stereo']}")
click.echo(f"AC3 bitrate: {ctx.obj['bitrates']['ac3']}")
click.echo(f"DTS bitrate: {ctx.obj['bitrates']['dts']}")
ctx.obj['perform_crop'] = (crop != 'none')
if ctx.obj['perform_crop']:
cropTokens = crop.split(',')
if cropTokens and len(cropTokens) == 2:
ctx.obj['crop_start'], ctx.obj['crop_length'] = crop.split(',')
else:
ctx.obj['crop_start'] = DEFAULT_CROP_START
ctx.obj['crop_length'] = DEFAULT_CROP_LENGTH
click.echo(f"crop start={ctx.obj['crop_start']} length={ctx.obj['crop_length']}")
click.echo(f"\nRunning {len(paths) * len(q_list)} jobs")
se_match = re.compile(SEASON_EPISODE_INDICATOR_MATCH)
s_match = re.compile(SEASON_INDICATOR_MATCH)
e_match = re.compile(EPISODE_INDICATOR_MATCH)
for sourcePath in paths:
if not os.path.isfile(sourcePath):
click.echo(f"There is no file with path {sourcePath}, skipping ...")
continue
sourceDirectory = os.path.dirname(sourcePath)
sourceFilename = os.path.basename(sourcePath)
sourcePathTokens = sourceFilename.split('.')
if sourcePathTokens[-1] in FILE_EXTENSIONS:
sourceFileBasename = '.'.join(sourcePathTokens[:-1])
sourceFilenameExtension = sourcePathTokens[-1]
else:
sourceFileBasename = sourceFilename
sourceFilenameExtension = ''
#click.echo(f"dir={sourceDirectory} base={sourceFileBasename} ext={sourceFilenameExtension}")
click.echo(f"\nProcessing file {sourcePath}")
se_result = se_match.search(sourceFilename)
s_result = s_match.search(sourceFilename)
e_result = e_match.search(sourceFilename)
#streamDescriptor = getStreamDescriptor(sourcePath)
#commandTokens = COMMAND_TOKENS + [sourcePath]
#for q in q_list:
#click.echo(f"\nRunning job q={q}")
#mappingVideoTokens = ['-map', 'v:0']
#mappingTokens = mappingVideoTokens.copy()
#audioTokens = []
#audioIndex = 0
#for audioStreamDescriptor in streamDescriptor:
#if audioStreamDescriptor['type'] == STREAM_TYPE_AUDIO:
#mappingTokens += ['-map', f"a:{audioIndex}"]
#audioTokens += generateAudioTokens(ctx.obj, audioIndex, audioStreamDescriptor['layout'])
#audioIndex += 1
#for s in range(len([d for d in streamDescriptor if d['type'] == STREAM_TYPE_SUBTITLE])):
#mappingTokens += ['-map', f"s:{s}"]
#if video_encoder == 'av1':
#commandSequence = commandTokens + mappingTokens + audioTokens + generateAV1Tokens(q, preset) + audioTokens
#if clear_metadata:
#commandSequence += generateClearTokens(streamDescriptor)
#if performCrop:
#commandSequence += generateCropTokens(cropStart, cropLength)
#commandSequence += generateOutputTokens(targetFilename, DEFAULT_FILE_SUFFIX, q)
#click.echo(f"Command: {' '.join(commandSequence)}")
#executeProcess(commandSequence)
#if video_encoder == 'vp9':
#commandSequence1 = commandTokens + mappingVideoTokens + generateVP9Pass1Tokens(q)
#if performCrop:
# commandSequence1 += generateCropTokens(cropStart, cropLength)
#commandSequence1 += NULL_TOKENS
#click.echo(f"Command 1: {' '.join(commandSequence1)}")
#if os.path.exists(TEMP_FILE_NAME):
# os.remove(TEMP_FILE_NAME)
#executeProcess(commandSequence1)
#commandSequence2 = commandTokens + mappingTokens
#if denoise:
# commandSequence2 += generateDenoiseTokens()
#commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens
#if clear_metadata:
# commandSequence2 += generateClearTokens(streamDescriptor)
#if performCrop:
# commandSequence2 += generateCropTokens(cropStart, cropLength)
#commandSequence2 += generateOutputTokens(targetFilename, DEFAULT_FILE_SUFFIX, q)
#click.echo(f"Command 2: {' '.join(commandSequence2)}")
#executeProcess(commandSequence2)
#app = ModesApp(ctx.obj)
#app.run()
#click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True)
click.echo('\nDONE\n')
endTime = time.perf_counter()
click.echo(f"Time elapsed {endTime - startTime}")
# click.echo(f"app result: {app.getContext()}")
if __name__ == '__main__':
ffx()

32
bin/check.py Normal file
View File

@@ -0,0 +1,32 @@
import os
from ffx.pattern_controller import PatternController
from ffx.model.show import Base
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, sessionmaker, Mapped, backref
filename = 'Boruto.Naruto.Next.Generations.S01E256.GerEngSub.AAC.1080p.WebDL.x264-Tanuki.mkv'
# Data 'input' variable
context = {}
# Initialize DB
homeDir = os.path.expanduser("~")
ffxVarDir = os.path.join(homeDir, '.local', 'var', 'ffx')
if not os.path.exists(ffxVarDir):
os.makedirs(ffxVarDir)
context['database_url'] = f"sqlite:///{os.path.join(ffxVarDir, 'ffx.db')}"
context['database_engine'] = create_engine(context['database_url'])
context['database_session'] = sessionmaker(bind=context['database_engine'])
Base.metadata.create_all(context['database_engine'])
pc = PatternController(context)
print(pc.matchFilename(filename))

1124
bin/ffx.py

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import os, re, click
class FileProperties():
FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
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)
sourcePathTokens = self.__sourceFilename.split('.')
if sourcePathTokens[-1] in FilenameController.FILE_EXTENSIONS:
self.__sourceFileBasename = '.'.join(sourcePathTokens[:-1])
self.__sourceFilenameExtension = sourcePathTokens[-1]
else:
self.__sourceFileBasename = self.__sourceFilename
self.__sourceFilenameExtension = ''
se_match = re.compile(FilenameController.SEASON_EPISODE_INDICATOR_MATCH)
e_match = re.compile(FilenameController.EPISODE_INDICATOR_MATCH)
se_result = se_match.search(self.__sourceFilename)
e_result = e_match.search(self.__sourceFilename)
self.__season = -1
self.__episode = -1
file_index = 0
if se_result is not None:
self.__season = int(se_result.group(1))
self.__episode = int(se_result.group(2))
elif e_result is not None:
self.__episode = int(e_result.group(1))
else:
file_index += 1
matchingFileSubtitleDescriptors = sorted([d for d in availableFileSubtitleDescriptors if d['season'] == season and d['episode'] == episode], key=lambda d: d['stream']) if availableFileSubtitleDescriptors else []
print(f"season={season} episode={episode} file={file_index}")
# Assemble target filename tokens
targetFilenameTokens = []
targetFilenameExtension = DEFAULT_FILE_EXTENSION
if label:
targetFilenameTokens = [label]
if season > -1 and episode > -1:
targetFilenameTokens += [f"S{season:0{season_digits}d}E{episode:0{episode_digits}d}"]
elif episode > -1:
targetFilenameTokens += [f"E{episode:0{episode_digits}d}"]
else:
targetFilenameTokens += [f"{file_index:0{index_digits}d}"]
else:
targetFilenameTokens = [sourceFileBasename]

0
bin/ffx/__init__.py Normal file
View File

View File

@@ -0,0 +1,16 @@
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label
class DashboardScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
context['dashboard'] = 'dashboard'
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Placeholder("Dashboard Screen")
yield Footer()

65
bin/ffx/ffx_app.py Normal file
View File

@@ -0,0 +1,65 @@
import os, time, sqlite3, sqlalchemy
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from ffx.model.show import Base, Show
from ffx.model.pattern import Pattern
from ffx.model.track import Track
from .shows_screen import ShowsScreen
from .warning_screen import WarningScreen
from .dashboard_screen import DashboardScreen
from .settings_screen import SettingsScreen
from .help_screen import HelpScreen
class FfxApp(App):
TITLE = "FFX"
BINDINGS = [
("q", "quit()", "Quit"),
("h", "switch_mode('help')", "Help"),
]
def __init__(self, context = {}):
super().__init__()
# Data 'input' variable
self.context = context
# Initialize DB
homeDir = os.path.expanduser("~")
ffxVarDir = os.path.join(homeDir, '.local', 'var', 'ffx')
if not os.path.exists(ffxVarDir):
os.makedirs(ffxVarDir)
self.context['database_url'] = f"sqlite:///{os.path.join(ffxVarDir, 'ffx.db')}"
self.context['database_engine'] = create_engine(self.context['database_url'])
self.context['database_session'] = sessionmaker(bind=self.context['database_engine'])
Base.metadata.create_all(self.context['database_engine'])
# isSyncronuous = False
# while not isSyncronuous:
# while True:
# try:
# with self.context['database_engine'].connect() as connection:
# connection.execute(sqlalchemy.text('PRAGMA foreign_keys=ON;'))
# #isSyncronuous = True
# break
# except sqlite3.OperationalError:
# time.sleep(0.1)
def on_mount(self) -> None:
self.push_screen(ShowsScreen())
def getContext(self):
"""Data 'output' method"""
return self.context

View File

@@ -0,0 +1,2 @@
class FfxController():
pass

View File

@@ -0,0 +1,66 @@
import os, re, click
class FileProperties():
FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
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)
sourcePathTokens = self.__sourceFilename.split('.')
if sourcePathTokens[-1] in FilenameController.FILE_EXTENSIONS:
self.__sourceFileBasename = '.'.join(sourcePathTokens[:-1])
self.__sourceFilenameExtension = sourcePathTokens[-1]
else:
self.__sourceFileBasename = self.__sourceFilename
self.__sourceFilenameExtension = ''
se_match = re.compile(FilenameController.SEASON_EPISODE_INDICATOR_MATCH)
e_match = re.compile(FilenameController.EPISODE_INDICATOR_MATCH)
se_result = se_match.search(self.__sourceFilename)
e_result = e_match.search(self.__sourceFilename)
self.__season = -1
self.__episode = -1
file_index = 0
if se_result is not None:
self.__season = int(se_result.group(1))
self.__episode = int(se_result.group(2))
elif e_result is not None:
self.__episode = int(e_result.group(1))
else:
file_index += 1
matchingFileSubtitleDescriptors = sorted([d for d in availableFileSubtitleDescriptors if d['season'] == season and d['episode'] == episode], key=lambda d: d['stream']) if availableFileSubtitleDescriptors else []
print(f"season={season} episode={episode} file={file_index}")
# Assemble target filename tokens
targetFilenameTokens = []
targetFilenameExtension = DEFAULT_FILE_EXTENSION
if label:
targetFilenameTokens = [label]
if season > -1 and episode > -1:
targetFilenameTokens += [f"S{season:0{season_digits}d}E{episode:0{episode_digits}d}"]
elif episode > -1:
targetFilenameTokens += [f"E{episode:0{episode_digits}d}"]
else:
targetFilenameTokens += [f"{file_index:0{index_digits}d}"]
else:
targetFilenameTokens = [sourceFileBasename]

12
bin/ffx/help_screen.py Normal file
View File

@@ -0,0 +1,12 @@
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label
class HelpScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Placeholder("Help Screen")
yield Footer()

113
bin/ffx/iso_language.py Normal file
View File

@@ -0,0 +1,113 @@
from enum import Enum
import difflib
class IsoLanguage(Enum):
AFRIKAANS = {"name": "Afrikaans", "iso639_1": "af", "iso639_2": "afr"}
ALBANIAN = {"name": "Albanian", "iso639_1": "sq", "iso639_2": "alb"}
ARABIC = {"name": "Arabic", "iso639_1": "ar", "iso639_2": "ara"}
ARMENIAN = {"name": "Armenian", "iso639_1": "hy", "iso639_2": "arm"}
AZERBAIJANI = {"name": "Azerbaijani", "iso639_1": "az", "iso639_2": "aze"}
BASQUE = {"name": "Basque", "iso639_1": "eu", "iso639_2": "baq"}
BELARUSIAN = {"name": "Belarusian", "iso639_1": "be", "iso639_2": "bel"}
BULGARIAN = {"name": "Bulgarian", "iso639_1": "bg", "iso639_2": "bul"}
CATALAN = {"name": "Catalan", "iso639_1": "ca", "iso639_2": "cat"}
CHINESE = {"name": "Chinese", "iso639_1": "zh", "iso639_2": "chi"}
CROATIAN = {"name": "Croatian", "iso639_1": "hr", "iso639_2": "hrv"}
CZECH = {"name": "Czech", "iso639_1": "cs", "iso639_2": "cze"}
DANISH = {"name": "Danish", "iso639_1": "da", "iso639_2": "dan"}
DUTCH = {"name": "Dutch", "iso639_1": "nl", "iso639_2": "dut"}
ENGLISH = {"name": "English", "iso639_1": "en", "iso639_2": "eng"}
ESTONIAN = {"name": "Estonian", "iso639_1": "et", "iso639_2": "est"}
FINNISH = {"name": "Finnish", "iso639_1": "fi", "iso639_2": "fin"}
FRENCH = {"name": "French", "iso639_1": "fr", "iso639_2": "fre"}
GEORGIAN = {"name": "Georgian", "iso639_1": "ka", "iso639_2": "geo"}
GERMAN = {"name": "German", "iso639_1": "de", "iso639_2": "ger"}
GREEK = {"name": "Greek", "iso639_1": "el", "iso639_2": "gre"}
HEBREW = {"name": "Hebrew", "iso639_1": "he", "iso639_2": "heb"}
HINDI = {"name": "Hindi", "iso639_1": "hi", "iso639_2": "hin"}
HUNGARIAN = {"name": "Hungarian", "iso639_1": "hu", "iso639_2": "hun"}
ICELANDIC = {"name": "Icelandic", "iso639_1": "is", "iso639_2": "ice"}
INDONESIAN = {"name": "Indonesian", "iso639_1": "id", "iso639_2": "ind"}
IRISH = {"name": "Irish", "iso639_1": "ga", "iso639_2": "gle"}
ITALIAN = {"name": "Italian", "iso639_1": "it", "iso639_2": "ita"}
JAPANESE = {"name": "Japanese", "iso639_1": "ja", "iso639_2": "jpn"}
KAZAKH = {"name": "Kazakh", "iso639_1": "kk", "iso639_2": "kaz"}
KOREAN = {"name": "Korean", "iso639_1": "ko", "iso639_2": "kor"}
LATIN = {"name": "Latin", "iso639_1": "la", "iso639_2": "lat"}
LATVIAN = {"name": "Latvian", "iso639_1": "lv", "iso639_2": "lav"}
LITHUANIAN = {"name": "Lithuanian", "iso639_1": "lt", "iso639_2": "lit"}
MACEDONIAN = {"name": "Macedonian", "iso639_1": "mk", "iso639_2": "mac"}
MALAY = {"name": "Malay", "iso639_1": "ms", "iso639_2": "may"}
MALTESE = {"name": "Maltese", "iso639_1": "mt", "iso639_2": "mlt"}
NORWEGIAN = {"name": "Norwegian", "iso639_1": "no", "iso639_2": "nor"}
PERSIAN = {"name": "Persian", "iso639_1": "fa", "iso639_2": "per"}
POLISH = {"name": "Polish", "iso639_1": "pl", "iso639_2": "pol"}
PORTUGUESE = {"name": "Portuguese", "iso639_1": "pt", "iso639_2": "por"}
ROMANIAN = {"name": "Romanian", "iso639_1": "ro", "iso639_2": "rum"}
RUSSIAN = {"name": "Russian", "iso639_1": "ru", "iso639_2": "rus"}
NORTHERN_SAMI = {"name": "Northern Sami", "iso639_1": "se", "iso639_2": "sme"}
SAMOAN = {"name": "Samoan", "iso639_1": "sm", "iso639_2": "smo"}
SANGO = {"name": "Sango", "iso639_1": "sg", "iso639_2": "sag"}
SANSKRIT = {"name": "Sanskrit", "iso639_1": "sa", "iso639_2": "san"}
SARDINIAN = {"name": "Sardinian", "iso639_1": "sc", "iso639_2": "srd"}
SERBIAN = {"name": "Serbian", "iso639_1": "sr", "iso639_2": "srp"}
SHONA = {"name": "Shona", "iso639_1": "sn", "iso639_2": "sna"}
SINDHI = {"name": "Sindhi", "iso639_1": "sd", "iso639_2": "snd"}
SINHALA = {"name": "Sinhala", "iso639_1": "si", "iso639_2": "sin"}
SLOVAK = {"name": "Slovak", "iso639_1": "sk", "iso639_2": "slk"}
SLOVENIAN = {"name": "Slovenian", "iso639_1": "sl", "iso639_2": "slv"}
SOMALI = {"name": "Somali", "iso639_1": "so", "iso639_2": "som"}
SOUTHERN_SOTHO = {"name": "Southern Sotho", "iso639_1": "st", "iso639_2": "sot"}
SPANISH = {"name": "Spanish", "iso639_1": "es", "iso639_2": "spa"}
SUNDANESE = {"name": "Sundanese", "iso639_1": "su", "iso639_2": "sun"}
SWAHILI = {"name": "Swahili", "iso639_1": "sw", "iso639_2": "swa"}
SWATI = {"name": "Swati", "iso639_1": "ss", "iso639_2": "ssw"}
SWEDISH = {"name": "Swedish", "iso639_1": "sv", "iso639_2": "swe"}
TAGALOG = {"name": "Tagalog", "iso639_1": "tl", "iso639_2": "tgl"}
TAMIL = {"name": "Tamil", "iso639_1": "ta", "iso639_2": "tam"}
THAI = {"name": "Thai", "iso639_1": "th", "iso639_2": "tha"}
TURKISH = {"name": "Turkish", "iso639_1": "tr", "iso639_2": "tur"}
UKRAINIAN = {"name": "Ukrainian", "iso639_1": "uk", "iso639_2": "ukr"}
URDU = {"name": "Urdu", "iso639_1": "ur", "iso639_2": "urd"}
VIETNAMESE = {"name": "Vietnamese", "iso639_1": "vi", "iso639_2": "vie"}
WELSH = {"name": "Welsh", "iso639_1": "cy", "iso639_2": "wel"}
@staticmethod
def find(label : str):
closestMatches = difflib.get_close_matches(label, [l.value["name"] for l in IsoLanguage], n=1)
if closestMatches:
foundLangs = [l for l in IsoLanguage if l.value['name'] == closestMatches[0]]
return foundLangs[0] if foundLangs else None
else:
return None
@staticmethod
def findThreeLetter(theeLetter : str):
foundLangs = [l for l in IsoLanguage if l.value['iso639_2'] == str(theeLetter)]
return foundLangs[0] if foundLangs else None
# def get(lang : str):
#
# selectedLangs = [l for l in IsoLanguage if l.value['iso639_2'] == lang]
#
# if selectedLangs:
# return selectedLangs[0]
# else:
# return None
def label(self):
return str(self.value['name'])
def twoLetter(self):
return str(self.value['iso639_1'])
def threeLetter(self):
return str(self.value['iso639_2'])

View File

@@ -0,0 +1,2 @@
class MediaDescriptor():
pass

View File

28
bin/ffx/model/pattern.py Normal file
View File

@@ -0,0 +1,28 @@
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, sessionmaker, Mapped, backref
from .show import Base
from .track import Track
class Pattern(Base):
__tablename__ = 'patterns'
# v1.x
id = Column(Integer, primary_key=True)
pattern = Column(String)
# v2.0
# id: Mapped[int] = mapped_column(Integer, primary_key=True)
# pattern: Mapped[str] = mapped_column(String, nullable=False)
# v1.x
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"))
show = relationship('Show', back_populates='patterns')
# v2.0
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
# show: Mapped["Show"] = relationship(back_populates="patterns")
tracks = relationship('Track', back_populates='pattern', cascade="all, delete")

41
bin/ffx/model/show.py Normal file
View File

@@ -0,0 +1,41 @@
# from typing import List
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
Base = declarative_base()
class Show(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__ = 'shows'
# v1.x
id = Column(Integer, primary_key=True)
name = Column(String)
year = Column(Integer)
# v2.0
# id: Mapped[int] = mapped_column(Integer, primary_key=True)
# name: Mapped[str] = mapped_column(String, nullable=False)
# year: Mapped[int] = mapped_column(Integer, nullable=False)
# v1.x
#patterns = relationship('Pattern', back_populates='show', cascade="all, delete", passive_deletes=True)
patterns = relationship('Pattern', back_populates='show', cascade="all, delete")
# patterns = relationship('Pattern', back_populates='show', cascade="all")
# v2.0
# patterns: Mapped[List["Pattern"]] = relationship(back_populates="show", cascade="all, delete")
index_season_digits = Column(Integer, default=2)
index_episode_digits = Column(Integer, default=2)
indicator_season_digits = Column(Integer, default=2)
indicator_episode_digits = Column(Integer, default=2)

30
bin/ffx/model/tag.py Normal file
View File

@@ -0,0 +1,30 @@
# 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
from ffx.track_type import TrackType
class Tag(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__ = 'tags'
# v1.x
id = Column(Integer, primary_key=True)
key = Column(String)
value = Column(String)
# v1.x
track_id = Column(Integer, ForeignKey('tracks.id', ondelete="CASCADE"))
track = relationship('Track', back_populates='tags')

49
bin/ffx/model/track.py Normal file
View File

@@ -0,0 +1,49 @@
# from typing import List
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
from .show import Base
from ffx.track_type import TrackType
from ffx.iso_language import IsoLanguage
from ffx.model.tag import Tag
class Track(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__ = 'tracks'
# v1.x
id = Column(Integer, primary_key=True, autoincrement = True)
# P=pattern_id+sub_index+track_type
track_type = Column(Integer) # TrackType
sub_index = Column(Integer)
# v1.x
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"))
pattern = relationship('Pattern', back_populates='tracks')
language = Column(String) # IsoLanguage threeLetter
title = Column(String)
tags = relationship('Tag', back_populates='track', cascade="all, delete")
disposition_flags = Column(Integer)
def getDescriptor(self):
pass

View File

@@ -0,0 +1,163 @@
import click, re
from ffx.model.pattern import Pattern
class PatternController():
def __init__(self, context):
self.context = context
self.Session = self.context['database_session'] # convenience
def addPattern(self, patternDescriptor):
try:
s = self.Session()
q = s.query(Pattern).filter(Pattern.show_id == int(patternDescriptor['show_id']), Pattern.pattern == str(patternDescriptor['pattern']))
if not q.count():
pattern = Pattern(show_id = int(patternDescriptor['show_id']),
pattern = str(patternDescriptor['pattern']))
s.add(pattern)
s.commit()
return patternDescriptor
else:
return {}
except Exception as ex:
raise click.ClickException(f"PatternController.addPattern(): {repr(ex)}")
finally:
s.close()
def updatePattern(self, patternId, patternDescriptor):
try:
s = self.Session()
q = s.query(Pattern).filter(Pattern.id == int(patternId))
if q.count():
pattern = q.first()
pattern.show_id = int(patternDescriptor['show_id'])
pattern.pattern = str(patternDescriptor['pattern'])
s.commit()
return True
else:
return False
except Exception as ex:
raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}")
finally:
s.close()
def findPattern(self, patternDescriptor):
try:
s = self.Session()
q = s.query(Pattern).filter(Pattern.show_id == int(patternDescriptor['show_id']), Pattern.pattern == str(patternDescriptor['pattern']))
if q.count():
pattern = q.first()
return int(pattern.id)
else:
return None
except Exception as ex:
raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}")
finally:
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:
s = self.Session()
q = s.query(Pattern).filter(Pattern.id == int(patternId))
if q.count():
pattern = q.first()
return self.getPatternDict(pattern)
except Exception as ex:
raise click.ClickException(f"PatternController.getPatternDescriptor(): {repr(ex)}")
finally:
s.close()
def deletePattern(self, patternId):
try:
s = self.Session()
q = s.query(Pattern).filter(Pattern.id == int(patternId))
if q.count():
#DAFUQ: https://stackoverflow.com/a/19245058
# q.delete()
pattern = q.first()
s.delete(pattern)
s.commit()
return True
return False
except Exception as ex:
raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}")
finally:
s.close()
def matchFilename(self, filename):
SEASON_PATTERN = '[sS]([0-9]+)'
EPISODE_PATTERN = '[eE]([0-9]+)'
result = {}
try:
s = self.Session()
q = s.query(Pattern)
for pattern in q.all():
match = re.search(pattern.pattern, filename)
if match:
result['pattern_id'] = pattern.id
result['show_id'] = pattern.show_id
result['indicator'] = match.group(1)
seasonMatch = re.search(SEASON_PATTERN, result['indicator'])
if seasonMatch:
result['season'] = int(seasonMatch.group(1))
episodeMatch = re.search(EPISODE_PATTERN, result['indicator'])
if episodeMatch:
result['episode'] = int(episodeMatch.group(1))
except Exception as ex:
raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}")
finally:
s.close()
return result

View File

@@ -0,0 +1,113 @@
import click
from textual import events
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label, ListView, ListItem, Static, DataTable, Button, Input
from textual.containers import Grid, Horizontal
from ffx.model.pattern import Pattern
from .show_controller import ShowController
from .pattern_controller import PatternController
# Screen[dict[int, str, int]]
class PatternDeleteScreen(Screen):
CSS = """
Grid {
grid-size: 2;
grid-rows: 2 auto;
grid-columns: 30 330;
height: 100%;
width: 100%;
padding: 1;
}
Input {
border: none;
}
Button {
border: none;
}
#toplabel {
height: 1;
}
.two {
column-span: 2;
}
.box {
height: 100%;
border: solid green;
}
"""
def __init__(self, patternId = None, showId = None):
super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database_session'] # convenience
self.__pc = PatternController(context = self.context)
self.__sc = ShowController(context = self.context)
self.pattern_id = patternId
self.pattern_obj = self.__pc.getPatternDescriptor(patternId) if patternId is not None else {}
self.show_obj = self.__sc.getShowDesciptor(showId) if showId is not None else {}
def on_mount(self):
if self.show_obj:
self.query_one("#showlabel", Static).update(f"{self.show_obj['id']} - {self.show_obj['name']} ({self.show_obj['year']})")
if self.pattern_obj:
self.query_one("#patternlabel", Static).update(str(self.pattern_obj['pattern']))
def compose(self):
yield Header()
with Grid():
yield Static("Are you sure to delete the following filename pattern?", id="toplabel", classes="two")
yield Static("", classes="two")
yield Static("Pattern")
yield Static("", id="patternlabel")
yield Static("", classes="two")
yield Static("from show")
yield Static("", id="showlabel")
yield Static("", classes="two")
yield Button("Delete", id="delete_button")
yield Button("Cancel", id="cancel_button")
yield Footer()
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "delete_button":
if self.pattern_id is None:
raise click.ClickException('PatternDeleteScreen.on_button_pressed(): pattern id is undefined')
if self.__pc.deletePattern(self.pattern_id):
self.dismiss(self.pattern_obj)
else:
#TODO: Meldung
self.app.pop_screen()
if event.button.id == "cancel_button":
self.app.pop_screen()

View File

@@ -0,0 +1,436 @@
import click, re
from textual import events
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label, ListView, ListItem, Static, DataTable, Button, Input
from textual.containers import Grid, Horizontal
from ffx.model.show import Show
from ffx.model.pattern import Pattern
from .pattern_controller import PatternController
from .show_controller import ShowController
from .track_controller import TrackController
from .track_details_screen import TrackDetailsScreen
from .track_delete_screen import TrackDeleteScreen
from ffx.track_type import TrackType
from ffx.track_disposition import TrackDisposition
from textual.widgets._data_table import CellDoesNotExist
# Screen[dict[int, str, int]]
class PatternDetailsScreen(Screen):
CSS = """
Grid {
grid-size: 5 12;
grid-rows: 2 2 2 2 2 6 2 2 6 2 2 2;
grid-columns: 25 25 25 25 25;
height: 100%;
width: 100%;
padding: 1;
}
Input {
border: none;
}
Button {
border: none;
}
DataTable {
min-height: 6;
}
#toplabel {
height: 1;
}
.three {
column-span: 3;
}
.four {
column-span: 4;
}
.five {
column-span: 5;
}
.box {
height: 100%;
border: solid green;
}
"""
def __init__(self, patternId = None, showId = None):
super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database_session'] # convenience
self.__pc = PatternController(context = self.context)
self.__sc = ShowController(context = self.context)
self.__tc = TrackController(context = self.context)
self.pattern_obj = self.__pc.getPatternDescriptor(patternId) if patternId is not None else {}
self.show_obj = self.__sc.getShowDesciptor(showId) if showId is not None else {}
def loadTracks(self, show_id):
try:
tracks = {}
tracks['audio'] = {}
tracks['subtitle'] = {}
s = self.Session()
q = s.query(Pattern).filter(Pattern.show_id == int(show_id))
return [{'id': int(p.id), 'pattern': p.pattern} for p in q.all()]
except Exception as ex:
click.ClickException(f"loadPatterns(): {repr(ex)}")
finally:
s.close()
def updateAudioTracks(self):
self.audioStreamsTable.clear()
trackIds = self.__tc.findAllTracks(self.pattern_obj['id'])
for audioTrackId in trackIds[TrackType.AUDIO.label()]:
ad = self.__tc.getTrackDescriptor(audioTrackId)
dispoList = ad['disposition_list']
row = (ad['sub_index'],
" ",
ad['language'].label(),
ad['title'],
'Yes' if TrackDisposition.DEFAULT in dispoList else 'No',
'Yes' if TrackDisposition.FORCED in dispoList else 'No')
self.audioStreamsTable.add_row(*map(str, row))
def updateSubtitleTracks(self):
self.subtitleStreamsTable.clear()
trackIds = self.__tc.findAllTracks(self.pattern_obj['id'])
for subtitleTrackId in trackIds[TrackType.SUBTITLE.label()]:
sd = self.__tc.getTrackDescriptor(subtitleTrackId)
dispoList = sd['disposition_list']
row = (sd['sub_index'],
" ",
sd['language'].label(),
sd['title'],
'Yes' if TrackDisposition.DEFAULT in dispoList else 'No',
'Yes' if TrackDisposition.FORCED in dispoList else 'No')
self.subtitleStreamsTable.add_row(*map(str, row))
def on_mount(self):
if self.pattern_obj:
self.query_one("#pattern_input", Input).value = str(self.pattern_obj['pattern'])
if self.show_obj:
self.query_one("#showlabel", Static).update(f"{self.show_obj['id']} - {self.show_obj['name']} ({self.show_obj['year']})")
if self.pattern_obj:
self.updateAudioTracks()
self.updateSubtitleTracks()
def compose(self):
self.audioStreamsTable = DataTable(classes="five")
# Define the columns with headers
self.column_key_audio_subid = self.audioStreamsTable.add_column("Subindex", width=20)
self.column_key_audio_layout = self.audioStreamsTable.add_column("Layout", width=20)
self.column_key_audio_language = self.audioStreamsTable.add_column("Language", width=20)
self.column_key_audio_title = self.audioStreamsTable.add_column("Title", width=30)
self.column_key_audio_default = self.audioStreamsTable.add_column("Default", width=10)
self.column_key_audio_forced = self.audioStreamsTable.add_column("Forced", width=10)
self.audioStreamsTable.cursor_type = 'row'
self.subtitleStreamsTable = DataTable(classes="five")
# Define the columns with headers
self.column_key_subtitle_subid = self.subtitleStreamsTable.add_column("Subindex", width=20)
self.column_key_subtitle_spacer = self.subtitleStreamsTable.add_column(" ", width=20)
self.column_key_subtitle_language = self.subtitleStreamsTable.add_column("Language", width=20)
self.column_key_subtitle_title = self.subtitleStreamsTable.add_column("Title", width=30)
self.column_key_subtitle_default = self.subtitleStreamsTable.add_column("Default", width=10)
self.column_key_subtitle_forced = self.subtitleStreamsTable.add_column("Forced", width=10)
self.subtitleStreamsTable.cursor_type = 'row'
yield Header()
with Grid():
# 1
yield Static("Edit filename pattern" if self.pattern_obj else "New filename pattern", id="toplabel")
yield Input(type="text", id="pattern_input", classes="four")
# 2
yield Static("from show")
yield Static("", id="showlabel", classes="three")
yield Button("Substitute pattern", id="patternbutton")
# 3
yield Static(" ", classes="five")
# 4
yield Static(" ", classes="five")
# 5
yield Static("Audio streams")
yield Static(" ")
if self.pattern_obj:
yield Button("Add", id="button_add_audio_stream")
yield Button("Edit", id="button_edit_audio_stream")
yield Button("Delete", id="button_delete_audio_stream")
else:
yield Static("")
yield Static("")
yield Static("")
# 6
yield self.audioStreamsTable
# 7
yield Static(" ", classes="five")
# 8
yield Static("Subtitle streams")
yield Static(" ")
if self.pattern_obj:
yield Button("Add", id="button_add_subtitle_stream")
yield Button("Edit", id="button_edit_subtitle_stream")
yield Button("Delete", id="button_delete_subtitle_stream")
else:
yield Static("")
yield Static("")
yield Static("")
# 9
yield self.subtitleStreamsTable
# 10
yield Static(" ", classes="five")
# 11
yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button")
yield Footer()
def getPatternFromInput(self):
return str(self.query_one("#pattern_input", Input).value)
def getSelectedAudioTrackId(self):
if not self.pattern_obj:
return None
try:
# Fetch the currently selected row when 'Enter' is pressed
#selected_row_index = self.table.cursor_row
row_key, col_key = self.audioStreamsTable.coordinate_to_cell_key(self.audioStreamsTable.cursor_coordinate)
if row_key is not None:
selected_track_data = self.audioStreamsTable.get_row(row_key)
subIndex = int(selected_track_data[0])
return self.__tc.findTrack(self.pattern_obj['id'], TrackType.AUDIO, subIndex)
else:
return None
except CellDoesNotExist:
return None
def getSelectedSubtitleTrackId(self):
if not self.pattern_obj:
return None
try:
# Fetch the currently selected row when 'Enter' is pressed
#selected_row_index = self.table.cursor_row
row_key, col_key = self.subtitleStreamsTable.coordinate_to_cell_key(self.subtitleStreamsTable.cursor_coordinate)
if row_key is not None:
selected_track_data = self.subtitleStreamsTable.get_row(row_key)
subIndex = int(selected_track_data[0])
return self.__tc.findTrack(self.pattern_obj['id'], TrackType.SUBTITLE, subIndex)
else:
return None
except CellDoesNotExist:
return None
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None:
# Check if the button pressed is the one we are interested in
if event.button.id == "save_button":
patternDescriptor = {}
patternDescriptor['show_id'] = self.show_obj['id']
patternDescriptor['pattern'] = self.getPatternFromInput()
if self.pattern_obj:
if self.__pc.updatePattern(self.pattern_obj['id'], patternDescriptor):
self.dismiss(patternDescriptor)
else:
#TODO: Meldung
self.app.pop_screen()
else:
if self.__pc.addPattern(patternDescriptor):
self.dismiss(patternDescriptor)
else:
#TODO: Meldung
self.app.pop_screen()
if event.button.id == "cancel_button":
self.app.pop_screen()
# Save pattern when just created before adding streams
if self.pattern_obj:
#self.pattern_obj
if event.button.id == "button_add_audio_stream":
self.app.push_screen(TrackDetailsScreen(trackType = TrackType.AUDIO, patternId = self.pattern_obj['id'], subIndex = len(self.audioStreamsTable.rows)), self.handle_add_track)
selectedAudioTrackId = self.getSelectedAudioTrackId()
if selectedAudioTrackId is not None:
if event.button.id == "button_edit_audio_stream":
self.app.push_screen(TrackDetailsScreen(trackId = selectedAudioTrackId), self.handle_edit_track)
if event.button.id == "button_delete_audio_stream":
self.app.push_screen(TrackDeleteScreen(trackId = selectedAudioTrackId), self.handle_delete_track)
if event.button.id == "button_add_subtitle_stream":
self.app.push_screen(TrackDetailsScreen(trackType = TrackType.SUBTITLE, patternId = self.pattern_obj['id'], subIndex = len(self.subtitleStreamsTable.rows)), self.handle_add_track)
selectedSubtitleTrackId = self.getSelectedSubtitleTrackId()
if selectedSubtitleTrackId is not None:
if event.button.id == "button_edit_subtitle_stream":
self.app.push_screen(TrackDetailsScreen(trackId = selectedSubtitleTrackId), self.handle_edit_track)
if event.button.id == "button_delete_subtitle_stream":
self.app.push_screen(TrackDeleteScreen(trackId = selectedSubtitleTrackId), self.handle_delete_track)
if event.button.id == "patternbutton":
INDICATOR_PATTERN = '([sS][0-9]+[eE][0-9]+)'
pattern = self.query_one("#pattern_input", Input).value
patternMatch = re.search(INDICATOR_PATTERN, pattern)
if patternMatch:
self.query_one("#pattern_input", Input).value = pattern.replace(patternMatch.group(1), INDICATOR_PATTERN)
def handle_add_track(self, trackDescriptor):
dispoList = trackDescriptor['disposition_list']
if trackDescriptor['type'] == TrackType.AUDIO:
row = (trackDescriptor['sub_index'],
" ",
trackDescriptor['language'].label(),
trackDescriptor['title'],
'Yes' if TrackDisposition.DEFAULT in dispoList else 'No',
'Yes' if TrackDisposition.FORCED in dispoList else 'No')
self.audioStreamsTable.add_row(*map(str, row))
if trackDescriptor['type'] == TrackType.SUBTITLE:
row = (trackDescriptor['sub_index'],
" ",
trackDescriptor['language'].label(),
trackDescriptor['title'],
'Yes' if TrackDisposition.DEFAULT in dispoList else 'No',
'Yes' if TrackDisposition.FORCED in dispoList else 'No')
self.subtitleStreamsTable.add_row(*map(str, row))
def handle_edit_track(self, trackDescriptor):
try:
if trackDescriptor['type'] == TrackType.AUDIO:
row_key, col_key = self.audioStreamsTable.coordinate_to_cell_key(self.audioStreamsTable.cursor_coordinate)
self.audioStreamsTable.update_cell(row_key, self.column_key_audio_language, trackDescriptor['language'].label())
self.audioStreamsTable.update_cell(row_key, self.column_key_audio_title, trackDescriptor['title'])
self.audioStreamsTable.update_cell(row_key, self.column_key_audio_default, 'Yes' if TrackDisposition.DEFAULT in trackDescriptor['disposition_list'] else 'No')
self.audioStreamsTable.update_cell(row_key, self.column_key_audio_forced, 'Yes' if TrackDisposition.FORCED in trackDescriptor['disposition_list'] else 'No')
if trackDescriptor['type'] == TrackType.SUBTITLE:
row_key, col_key = self.subtitleStreamsTable.coordinate_to_cell_key(self.subtitleStreamsTable.cursor_coordinate)
self.subtitleStreamsTable.update_cell(row_key, self.column_key_subtitle_language, trackDescriptor['language'].label())
self.subtitleStreamsTable.update_cell(row_key, self.column_key_subtitle_title, trackDescriptor['title'])
self.subtitleStreamsTable.update_cell(row_key, self.column_key_subtitle_default, 'Yes' if TrackDisposition.DEFAULT in trackDescriptor['disposition_list'] else 'No')
self.subtitleStreamsTable.update_cell(row_key, self.column_key_subtitle_forced, 'Yes' if TrackDisposition.FORCED in trackDescriptor['disposition_list'] else 'No')
except CellDoesNotExist:
pass
def handle_delete_track(self, trackDescriptor):
try:
if trackDescriptor['type'] == TrackType.AUDIO:
self.updateAudioTracks()
if trackDescriptor['type'] == TrackType.SUBTITLE:
self.updateSubtitleTracks()
except CellDoesNotExist:
pass

View File

@@ -0,0 +1,11 @@
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label
class SettingsScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Placeholder("Settings Screen")
yield Footer()

115
bin/ffx/show_controller.py Normal file
View File

@@ -0,0 +1,115 @@
import click
from ffx.model.show import Show
class ShowController():
def __init__(self, context):
self.context = context
self.Session = self.context['database_session'] # convenience
def getShowDesciptor(self, showId):
try:
s = self.Session()
q = s.query(Show).filter(Show.id == showId)
showDescriptor = {}
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)
return showDescriptor
except Exception as ex:
raise click.ClickException(f"ShowController.getShowDesciptor(): {repr(ex)}")
finally:
s.close()
def updateShow(self, showDescriptor):
try:
s = self.Session()
q = s.query(Show).filter(Show.id == showDescriptor['id'])
if not q.count():
show = Show(id = int(showDescriptor['id']),
name = str(showDescriptor['name']),
year = int(showDescriptor['year']),
index_season_digits = showDescriptor['index_season_digits'],
index_episode_digits = showDescriptor['index_episode_digits'],
indicator_season_digits = showDescriptor['indicator_season_digits'],
indicator_episode_digits = showDescriptor['indicator_episode_digits'])
s.add(show)
s.commit()
return True
else:
currentShow = q.first()
changed = False
if currentShow.name != str(showDescriptor['name']):
currentShow.name = str(showDescriptor['name'])
changed = True
if currentShow.year != int(showDescriptor['year']):
currentShow.year = int(showDescriptor['year'])
changed = True
if currentShow.index_season_digits != int(showDescriptor['index_season_digits']):
currentShow.index_season_digits = int(showDescriptor['index_season_digits'])
changed = True
if currentShow.index_episode_digits != int(showDescriptor['index_episode_digits']):
currentShow.index_episode_digits = int(showDescriptor['index_episode_digits'])
changed = True
if currentShow.indicator_season_digits != int(showDescriptor['indicator_season_digits']):
currentShow.indicator_season_digits = int(showDescriptor['indicator_season_digits'])
changed = True
if currentShow.indicator_episode_digits != int(showDescriptor['indicator_episode_digits']):
currentShow.indicator_episode_digits = int(showDescriptor['indicator_episode_digits'])
changed = True
if changed:
s.commit()
return changed
except Exception as ex:
raise click.ClickException(f"ShowController.updateShow(): {repr(ex)}")
finally:
s.close()
def deleteShow(self, show_id):
try:
s = self.Session()
q = s.query(Show).filter(Show.id == int(show_id))
if q.count():
#DAFUQ: https://stackoverflow.com/a/19245058
# q.delete()
show = q.first()
s.delete(show)
s.commit()
return True
return False
except Exception as ex:
raise click.ClickException(f"ShowController.deleteShow(): {repr(ex)}")
finally:
s.close()

View File

@@ -0,0 +1,101 @@
import click
from textual import events
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label, ListView, ListItem, Static, DataTable, Button, Input
from textual.containers import Grid, Horizontal
from ffx.model.show import Show
from .show_controller import ShowController
# Screen[dict[int, str, int]]
class ShowDeleteScreen(Screen):
CSS = """
Grid {
grid-size: 2;
grid-rows: 2 auto;
grid-columns: 30 auto;
height: 100%;
width: 100%;
padding: 1;
}
Input {
border: none;
}
Button {
border: none;
}
#toplabel {
height: 1;
}
.two {
column-span: 2;
}
.box {
height: 100%;
border: solid green;
}
"""
def __init__(self, showId = None):
super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database_session'] # convenience
self.__sc = ShowController(context = self.context)
self.show_obj = self.__sc.getShowDesciptor(showId) if showId is not None else {}
def on_mount(self):
if self.show_obj:
self.query_one("#showlabel", Static).update(f"{self.show_obj['id']} - {self.show_obj['name']} ({self.show_obj['year']})")
def compose(self):
yield Header()
with Grid():
yield Static("Are you sure to delete the following show?", id="toplabel", classes="two")
yield Static("", classes="two")
yield Static("", id="showlabel")
yield Static("")
yield Static("", classes="two")
yield Static("", classes="two")
yield Button("Delete", id="delete_button")
yield Button("Cancel", id="cancel_button")
yield Footer()
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "delete_button":
if self.__sc.deleteShow(self.show_obj['id']):
self.dismiss(self.show_obj['id'])
else:
#TODO: Meldung
self.app.pop_screen()
if event.button.id == "cancel_button":
self.app.pop_screen()

View File

@@ -0,0 +1,319 @@
import click
from textual import events
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label, ListView, ListItem, Static, DataTable, Button, Input
from textual.containers import Grid, Horizontal
from textual.widgets._data_table import CellDoesNotExist
from ffx.model.show import Show
from ffx.model.pattern import Pattern
from .pattern_details_screen import PatternDetailsScreen
from .pattern_delete_screen import PatternDeleteScreen
from .show_controller import ShowController
from .pattern_controller import PatternController
# Screen[dict[int, str, int]]
class ShowDetailsScreen(Screen):
CSS = """
Grid {
grid-size: 5 14;
grid-rows: 2 2 2 2 2 2 2 2 2 2 2 6 2 2;
grid-columns: 30 30 30 30 30;
height: 100%;
width: 100%;
padding: 1;
}
Input {
border: none;
}
Button {
border: none;
}
DataTable {
column-span: 2;
min-height: 5;
}
#toplabel {
height: 1;
}
.two {
column-span: 2;
}
.three {
column-span: 3;
}
.four {
column-span: 4;
}
.five {
column-span: 5;
}
.box {
height: 100%;
border: solid green;
}
"""
BINDINGS = [
("a", "add_pattern", "Add Pattern"),
("e", "edit_pattern", "Edit Pattern"),
("r", "remove_pattern", "Remove Pattern"),
]
def __init__(self, showId = None):
super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database_session'] # convenience
self.__sc = ShowController(context = self.context)
self.__pc = PatternController(context = self.context)
self.show_obj = self.__sc.getShowDesciptor(showId) if showId is not None else {}
def loadPatterns(self, show_id):
try:
s = self.Session()
q = s.query(Pattern).filter(Pattern.show_id == int(show_id))
return [{'id': int(p.id), 'pattern': p.pattern} for p in q.all()]
except Exception as ex:
click.ClickException(f"loadPatterns(): {repr(ex)}")
finally:
s.close()
def on_mount(self):
if self.show_obj:
self.query_one("#id_wdg", Static).update(str(self.show_obj['id']))
self.query_one("#name_input", Input).value = str(self.show_obj['name'])
self.query_one("#year_input", Input).value = str(self.show_obj['year'])
self.query_one("#index_season_digits_input", Input).value = str(self.show_obj['index_season_digits'])
self.query_one("#index_episode_digits_input", Input).value = str(self.show_obj['index_episode_digits'])
self.query_one("#indicator_season_digits_input", Input).value = str(self.show_obj['indicator_season_digits'])
self.query_one("#indicator_episode_digits_input", Input).value = str(self.show_obj['indicator_episode_digits'])
for pattern in self.loadPatterns(int(self.show_obj['id'])):
row = (pattern['pattern'],)
self.patternTable.add_row(*map(str, row))
else:
self.query_one("#index_season_digits_input", Input).value = "2"
self.query_one("#index_episode_digits_input", Input).value = "2"
self.query_one("#indicator_season_digits_input", Input).value = "2"
self.query_one("#indicator_episode_digits_input", Input).value = "2"
def getSelectedPatternDescriptor(self):
selectedPattern = {}
try:
# Fetch the currently selected row when 'Enter' is pressed
#selected_row_index = self.table.cursor_row
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
if row_key is not None:
selected_row_data = self.patternTable.get_row(row_key)
selectedPattern['show_id'] = self.show_obj['id']
selectedPattern['pattern'] = str(selected_row_data[0])
except CellDoesNotExist:
pass
return selectedPattern
def action_add_pattern(self):
if self.show_obj:
self.app.push_screen(PatternDetailsScreen(showId = self.show_obj['id']), self.handle_add_pattern) # <-
def handle_add_pattern(self, screenResult):
pattern = (screenResult['pattern'],)
self.patternTable.add_row(*map(str, pattern))
def action_edit_pattern(self):
selectedPatternDescriptor = self.getSelectedPatternDescriptor()
if selectedPatternDescriptor:
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
if selectedPatternId is None:
raise click.ClickException(f"ShowDetailsScreen.action_edit_pattern(): Pattern to remove has no id")
self.app.push_screen(PatternDetailsScreen(patternId = selectedPatternId, showId = self.show_obj['id']), self.handle_edit_pattern) # <-
def handle_edit_pattern(self, screenResult):
try:
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
self.patternTable.update_cell(row_key, self.column_key_pattern, screenResult['pattern'])
except CellDoesNotExist:
pass
def action_remove_pattern(self):
selectedPatternDescriptor = self.getSelectedPatternDescriptor()
if selectedPatternDescriptor:
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
if selectedPatternId is None:
raise click.ClickException(f"ShowDetailsScreen.action_remove_pattern(): Pattern to remove has no id")
self.app.push_screen(PatternDeleteScreen(patternId = selectedPatternId, showId = self.show_obj['id']), self.handle_remove_pattern)
def handle_remove_pattern(self, screenResult):
try:
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
self.patternTable.remove_row(row_key)
except CellDoesNotExist:
pass
def compose(self):
# Create the DataTable widget
self.patternTable = DataTable(classes="five")
# Define the columns with headers
self.column_key_pattern = self.patternTable.add_column("Pattern", width=150)
#self.column_key_name = self.patternTable.add_column("Name", width=50)
#self.column_key_year = self.patternTable.add_column("Year", width=10)
self.patternTable.cursor_type = 'row'
yield Header()
with Grid():
# 1
yield Static("Show" if self.show_obj else "New Show", id="toplabel", classes="five")
# 2
yield Static("ID")
if self.show_obj:
yield Static("", id="id_wdg", classes="four")
else:
yield Input(type="integer", id="id_wdg", classes="four")
# 3
yield Static("Name")
yield Input(type="text", id="name_input", classes="four")
# 4
yield Static("Year")
yield Input(type="integer", id="year_input", classes="four")
#5
yield Static(" ", classes="five")
#6
yield Static("Index Season Digits")
yield Input(type="integer", id="index_season_digits_input", classes="four")
#7
yield Static("Index Episode Digits")
yield Input(type="integer", id="index_episode_digits_input", classes="four")
#8
yield Static("Indicator Season Digits")
yield Input(type="integer", id="indicator_season_digits_input", classes="four")
#9
yield Static("Indicator Edisode Digits")
yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
# 10
yield Static(" ", classes="five")
# 11
yield Static("File patterns", classes="five")
# 12
yield self.patternTable
# 13
yield Static(" ", classes="five")
# 14
yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button")
yield Footer()
def getShowDescriptorFromInput(self):
showDescriptor = {}
if self.show_obj:
showDescriptor['id'] = int(self.show_obj['id'])
else:
showDescriptor['id'] = int(self.query_one("#id_wdg", Input).value)
showDescriptor['name'] = str(self.query_one("#name_input", Input).value)
showDescriptor['year'] = int(self.query_one("#year_input", Input).value)
showDescriptor['index_season_digits'] = int(self.query_one("#index_season_digits_input", Input).value)
showDescriptor['index_episode_digits'] = int(self.query_one("#index_episode_digits_input", Input).value)
showDescriptor['indicator_season_digits'] = int(self.query_one("#indicator_season_digits_input", Input).value)
showDescriptor['indicator_episode_digits'] = int(self.query_one("#indicator_episode_digits_input", Input).value)
return showDescriptor
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None:
# Check if the button pressed is the one we are interested in
if event.button.id == "save_button":
showDescriptor = self.getShowDescriptorFromInput()
if self.__sc.updateShow(showDescriptor):
self.dismiss(showDescriptor)
else:
#TODO: Meldung
self.app.pop_screen()
if event.button.id == "cancel_button":
self.app.pop_screen()

172
bin/ffx/shows_screen.py Normal file
View File

@@ -0,0 +1,172 @@
import click
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label, ListView, ListItem, Static, DataTable, Button
from textual.containers import Grid, Horizontal
from ffx.model.show import Show
from .show_details_screen import ShowDetailsScreen
from .show_delete_screen import ShowDeleteScreen
from .help_screen import HelpScreen
from textual.widgets._data_table import CellDoesNotExist
class ShowsScreen(Screen):
CSS = """
Grid {
grid-size: 1;
grid-rows: 2 auto;
height: 100%;
width: 100%;
padding: 1;
}
#top {
height: 1;
}
#two {
column-span: 2;
row-span: 2;
tint: magenta 40%;
}
.box {
height: 100%;
border: solid green;
}
"""
BINDINGS = [
("e", "edit_show", "Edit Show"),
("n", "new_show", "New Show"),
("d", "delete_show", "Delete Show"),
]
def __init__(self):
super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database_session'] # convenience
def getSelectedShowId(self):
try:
# Fetch the currently selected row when 'Enter' is pressed
#selected_row_index = self.table.cursor_row
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
if row_key is not None:
selected_row_data = self.table.get_row(row_key)
return selected_row_data[0]
except CellDoesNotExist:
return None
def action_new_show(self):
self.app.push_screen(ShowDetailsScreen(), self.handle_new_screen)
def handle_new_screen(self, screenResult):
show = (screenResult['id'], screenResult['name'], screenResult['year'])
self.table.add_row(*map(str, show))
def action_edit_show(self):
selectedShowId = self.getSelectedShowId()
if selectedShowId is not None:
self.app.push_screen(ShowDetailsScreen(showId = selectedShowId), self.handle_edit_screen)
def handle_edit_screen(self, screenResult):
try:
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
self.table.update_cell(row_key, self.column_key_name, screenResult['name'])
self.table.update_cell(row_key, self.column_key_year, screenResult['year'])
except CellDoesNotExist:
pass
def action_delete_show(self):
selectedShowId = self.getSelectedShowId()
if selectedShowId is not None:
self.app.push_screen(ShowDeleteScreen(showId = selectedShowId), self.handle_delete_show)
def handle_delete_show(self, screenResult):
try:
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
self.table.remove_row(row_key)
except CellDoesNotExist:
pass
def loadShows(self):
try:
s = self.Session()
q = s.query(Show)
return [(int(s.id), s.name, s.year) for s in q.all()]
except Exception as ex:
raise click.ClickException(f"ShowsScreen.loadShows(): {repr(ex)}")
finally:
s.close()
def on_mount(self) -> None:
for show in self.loadShows():
self.table.add_row(*map(str, show)) # Convert each element to a string before adding
def compose(self):
# Create the DataTable widget
self.table = DataTable()
# Define the columns with headers
self.column_key_id = self.table.add_column("ID", width=10)
self.column_key_name = self.table.add_column("Name", width=50)
self.column_key_year = self.table.add_column("Year", width=10)
self.table.cursor_type = 'row'
yield Header()
with Grid():
yield Static("Shows")
yield self.table
yield Footer()

View File

@@ -0,0 +1,98 @@
import os, click, requests
class TmdbController():
DEFAULT_LANGUAGE = 'de-DE'
def __init__(self):
try:
self.__tmdbApiKey = os.environ['TMDB_API_KEY']
except KeyError:
click.ClickException('TMDB api key is not available, please set environment variable TMDB_API_KEY')
self.tmdbLanguage = TmdbController.DEFAULT_LANGUAGE
def queryTmdb(self, showId, season, episode):
"""
First level keys in the response object:
air_date str 'YYY-MM-DD'
crew []
episode_number int
guest_stars []
name str
overview str
id int
production_code
runtime int
season_number int
still_path str '/filename.jpg'
vote_average float
vote_count int
"""
urlParams = f"?language={self.tmdbLanguage}&api_key={self.__tmdbApiKey}"
tmdbUrl = f"https://api.themoviedb.org/3/tv/{showId}/season/{season}/episode/{episode}{urlParams}"
return requests.get(tmdbUrl).json()
def getEpisodeFilename(self,
showName,
episodeName,
season,
episode,
extension,
indexSeasonDigits = 2,
indexEpisodeDigits = 2,
indicatorSeasonDigits = 2,
indicatorEpisodeDigits = 2):
"""
One Piece:
indexSeasonDigits = 0,
indexEpisodeDigits = 4,
indicatorSeasonDigits = 2,
indicatorEpisodeDigits = 4
Three-Body:
indexSeasonDigits = 0,
indexEpisodeDigits = 2,
indicatorSeasonDigits = 2,
indicatorEpisodeDigits = 2
Dragonball:
indexSeasonDigits = 0,
indexEpisodeDigits = 3,
indicatorSeasonDigits = 2,
indicatorEpisodeDigits = 3
Boruto:
indexSeasonDigits = 0,
indexEpisodeDigits = 4,
indicatorSeasonDigits = 2,
indicatorEpisodeDigits = 4
"""
filenameTokens = [str(showName), ' - ']
if indexSeasonDigits:
filenameTokens += ['{num:{fill}{width}}'.format(num=season, fill='0', width=indexSeasonDigits)]
if indexEpisodeDigits:
filenameTokens += ['{num:{fill}{width}}'.format(num=episode, fill='0', width=indexEpisodeDigits)]
if indexSeasonDigits or indexEpisodeDigits:
filenameTokens += [' ']
filenameTokens += [episodeName]
if indicatorSeasonDigits or indicatorEpisodeDigits:
filenameTokens += [' - ']
if indicatorSeasonDigits:
filenameTokens += ['S{num:{fill}{width}}'.format(num=season, fill='0', width=indicatorSeasonDigits)]
if indicatorEpisodeDigits:
filenameTokens += ['E{num:{fill}{width}}'.format(num=episode, fill='0', width=indicatorEpisodeDigits)]
filenameTokens += ['.', extension]
return ''.join(filenameTokens)

182
bin/ffx/track_controller.py Normal file
View File

@@ -0,0 +1,182 @@
import click
from ffx.model.track import Track
from .track_type import TrackType
from .track_disposition import TrackDisposition
from .iso_language import IsoLanguage
from .track_type import TrackType
class TrackController():
def __init__(self, context):
self.context = context
self.Session = self.context['database_session'] # convenience
def addTrack(self, trackDescriptor):
try:
s = self.Session()
track = Track(pattern_id = int(trackDescriptor['pattern_id']),
track_type = int(trackDescriptor['type'].value),
sub_index = int(trackDescriptor['sub_index']),
language = str(trackDescriptor['language'].threeLetter()),
title = str(trackDescriptor['title']),
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor['disposition_list'])))
s.add(track)
s.commit()
except Exception as ex:
raise click.ClickException(f"TrackController.addTrack(): {repr(ex)}")
finally:
s.close()
def updateTrack(self, trackId, trackDescriptor):
try:
s = self.Session()
q = s.query(Track).filter(Track.id == int(trackId))
if q.count():
track = q.first()
track.sub_index = int(trackDescriptor['sub_index'])
track.language = str(trackDescriptor['language'].threeLetter())
track.title = str(trackDescriptor['title'])
track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor['disposition_list']))
s.commit()
return True
else:
return False
except Exception as ex:
raise click.ClickException(f"TrackController.addTrack(): {repr(ex)}")
finally:
s.close()
def findAllTracks(self, patternId):
try:
s = self.Session()
trackDescriptors = {}
trackDescriptors[TrackType.AUDIO.label()] = []
trackDescriptors[TrackType.SUBTITLE.label()] = []
q_audio = s.query(Track).filter(Track.pattern_id == int(patternId), Track.track_type == TrackType.AUDIO.value)
for audioTrack in q_audio.all():
trackDescriptors[TrackType.AUDIO.label()].append(audioTrack.id)
q_subtitle = s.query(Track).filter(Track.pattern_id == int(patternId), Track.track_type == TrackType.SUBTITLE.value)
for subtitleTrack in q_subtitle.all():
trackDescriptors[TrackType.SUBTITLE.label()].append(subtitleTrack.id)
return trackDescriptors
except Exception as ex:
raise click.ClickException(f"TrackController.findAllTracks(): {repr(ex)}")
finally:
s.close()
def findTrack(self, patternId, trackType : TrackType, subIndex):
try:
s = self.Session()
q = s.query(Track).filter(Track.pattern_id == int(patternId), Track.track_type == trackType.value, Track.sub_index == int(subIndex))
if q.count():
track = q.first()
return int(track.id)
else:
return None
except Exception as ex:
raise click.ClickException(f"TrackController.findTrack(): {repr(ex)}")
finally:
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:
s = self.Session()
q = s.query(Track).filter(Track.id == int(trackId))
if q.count():
track = q.first()
return self.getTrackDict(track)
else:
return {}
except Exception as ex:
raise click.ClickException(f"TrackController.getTrackDescriptor(): {repr(ex)}")
finally:
s.close()
def deleteTrack(self, trackId):
try:
s = self.Session()
q = s.query(Track).filter(Track.id == int(trackId))
if q.count():
trackDescriptor = self.getTrackDict(q.first())
q_siblings = s.query(Track).filter(Track.pattern_id == int(trackDescriptor['pattern_id']), Track.track_type == trackDescriptor['type'].value).order_by(Track.sub_index)
subIndex = 0
for track in q_siblings.all():
if track.sub_index == trackDescriptor['sub_index']:
s.delete(track)
else:
track.sub_index = subIndex
subIndex += 1
s.commit()
return True
return False
except Exception as ex:
raise click.ClickException(f"TrackController.deleteTrack(): {repr(ex)}")
finally:
s.close()

View File

@@ -0,0 +1,138 @@
import click
from textual import events
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label, ListView, ListItem, Static, DataTable, Button, Input
from textual.containers import Grid, Horizontal
from ffx.model.pattern import Pattern
# from .show_controller import ShowController
# from .pattern_controller import PatternController
from .track_controller import TrackController
from .track_type import TrackType
# Screen[dict[int, str, int]]
class TrackDeleteScreen(Screen):
CSS = """
Grid {
grid-size: 4 9;
grid-rows: 2 2 2 2 2 2 2 2 2;
grid-columns: 30 30 30 30;
height: 100%;
width: 100%;
padding: 1;
}
Input {
border: none;
}
Button {
border: none;
}
#toplabel {
height: 1;
}
.two {
column-span: 2;
}
.three {
column-span: 3;
}
.four {
column-span: 4;
}
.box {
height: 100%;
border: solid green;
}
"""
def __init__(self, trackId = None):
super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database_session'] # convenience
if trackId is None:
raise click.ClickException('TrackDeleteScreen.init(): trackId is required to be set')
self.__tc = TrackController(context = self.context)
self.track_obj = self.__tc.getTrackDescriptor(trackId)
def on_mount(self):
self.query_one("#subindexlabel", Static).update(str(self.track_obj['sub_index']))
self.query_one("#patternlabel", Static).update(str(self.track_obj['pattern_id']))
self.query_one("#languagelabel", Static).update(str(self.track_obj['language'].label()))
self.query_one("#titlelabel", Static).update(str(str(self.track_obj['title'])))
def compose(self):
yield Header()
with Grid():
#1
yield Static(f"Are you sure to delete the following {self.track_obj['type'].label()} track?", id="toplabel", classes="four")
#2
yield Static("sub index")
yield Static(" ", id="subindexlabel", classes="three")
#3
yield Static("from pattern")
yield Static(" ", id="patternlabel", classes="three")
#4
yield Static(" ", classes="four")
#5
yield Static("Language")
yield Static(" ", id="languagelabel", classes="three")
#6
yield Static("Title")
yield Static(" ", id="titlelabel", classes="three")
#7
yield Static(" ", classes="four")
#8
yield Static(" ", classes="four")
#9
yield Button("Delete", id="delete_button")
yield Button("Cancel", id="cancel_button")
yield Footer()
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "delete_button":
trackId = self.__tc.findTrack(self.track_obj['pattern_id'], self.track_obj['type'], self.track_obj['sub_index'])
if trackId is not None:
if self.__tc.deleteTrack(trackId):
self.dismiss(self.track_obj)
else:
#TODO: Meldung
self.app.pop_screen()
if event.button.id == "cancel_button":
self.app.pop_screen()

View File

@@ -0,0 +1,56 @@
from language_data import LanguageData
from stream_type import StreamType
class StreamDescriptor():
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"

View File

@@ -0,0 +1,291 @@
import click, time
from textual import events
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label, ListView, ListItem, Static, DataTable, Button, Input, Checkbox, SelectionList, Select
from textual.containers import Grid, Horizontal
from ffx.model.show import Show
from ffx.model.pattern import Pattern
from .track_controller import TrackController
from .pattern_controller import PatternController
# from .show_controller import ShowController
from .track_type import TrackType
from .iso_language import IsoLanguage
from .track_disposition import TrackDisposition
# Screen[dict[int, str, int]]
class TrackDetailsScreen(Screen):
CSS = """
Grid {
grid-size: 5 20;
grid-rows: 2 2 2 2 2 3 2 2 2 2 2 6 2 2 6 2 2 2 2 6;
grid-columns: 25 25 25 25 225;
height: 100%;
width: 100%;
padding: 1;
}
Input {
border: none;
}
Button {
border: none;
}
SelectionList {
border: none;
min-height: 6;
}
Select {
border: none;
}
DataTable {
min-height: 6;
}
#toplabel {
height: 1;
}
.two {
column-span: 2;
}
.three {
column-span: 3;
}
.four {
column-span: 4;
}
.five {
column-span: 5;
}
.box {
height: 100%;
border: solid green;
}
"""
def __init__(self, trackId = None, patternId = None, trackType : TrackType = None, subIndex = None):
super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database_session'] # convenience
self.__tc = TrackController(context = self.context)
self.__pc = PatternController(context = self.context)
self.track_obj = self.__tc.getTrackDescriptor(trackId) if trackId is not None else {}
if self.track_obj:
self.trackType = self.track_obj['type']
self.subIndex = self.track_obj['sub_index']
self.pattern_obj = self.__pc.getPatternDescriptor(self.track_obj['pattern_id'])
self.track_obj['is_new'] = False
else:
self.trackType = trackType
self.subIndex = subIndex
self.pattern_obj = self.__pc.getPatternDescriptor(patternId) if patternId is not None else {}
self.track_obj['is_new'] = True
if self.trackType is None:
raise click.ClickException('Track type is required to be set')
if self.subIndex is None:
raise click.ClickException('Sub index for track is required to be set')
def on_mount(self):
if self.pattern_obj:
self.query_one("#patternlabel", Static).update(str(self.pattern_obj['pattern']))
if self.subIndex is not None:
self.query_one("#subindexlabel", Static).update(str(self.subIndex))
for d in TrackDisposition:
dispositionIsSet = (self.track_obj
and 'disposition_list' in self.track_obj.keys()
and d in self.track_obj['disposition_list'])
disposition = (d.label(), d.index(), dispositionIsSet)
self.query_one("#dispositions_selection_list", SelectionList).add_option(disposition)
if 'language' in self.track_obj.keys():
self.query_one("#language_select", Select).value = self.track_obj['language'].label()
if 'title' in self.track_obj.keys():
self.query_one("#title_input", Input).value = str(self.track_obj['title'])
def compose(self):
self.trackTagsTable = DataTable(classes="five")
# Define the columns with headers
self.column_key_track_tag_key = self.trackTagsTable.add_column("Key", width=10)
self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=30)
self.trackTagsTable.cursor_type = 'row'
languages = [l.label() for l in IsoLanguage]
yield Header()
with Grid():
# 1
yield Static(f"New {self.trackType.label()} stream" if self.track_obj['is_new'] else f"Edit {self.trackType.label()} stream", id="toplabel", classes="five")
# 2
yield Static("for pattern")
yield Static("", id="patternlabel", classes="four")
# 3
yield Static("sub index")
yield Static("", id="subindexlabel", classes="four")
# 4
yield Static(" ", classes="five")
# 5
yield Static(" ", classes="five")
# 6
yield Static("Language")
yield Select.from_values(languages, classes="four", id="language_select")
# 7
yield Static(" ", classes="five")
# 8
yield Static("Title")
yield Input(id="title_input", classes="four")
# 9
yield Static(" ", classes="five")
# 10
yield Static(" ", classes="five")
# 11
yield Static("Stream tags")
yield Static(" ", classes="two")
yield Button("Add", id="button_add_stream_tag")
yield Button("Delete", id="button_delete_stream_tag")
# 12
yield self.trackTagsTable
# 13
yield Static(" ", classes="five")
# 14
yield Static("Stream dispositions", classes="five")
# 15
yield SelectionList[int](
classes="five",
id = "dispositions_selection_list"
)
# 16
yield Static(" ", classes="five")
# 17
yield Static(" ", classes="five")
# 18
yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button")
# 19
yield Static(" ", classes="five")
# 20
yield Static(" ", classes="five", id="messagestatic")
yield Footer(id="footer")
def getTrackDescriptorFromInput(self):
trackDescriptor = {}
trackDescriptor['pattern_id'] = int(self.pattern_obj['id'])
trackDescriptor['type'] = TrackType(self.trackType)
trackDescriptor['sub_index'] = self.subIndex
trackDescriptor['language'] = IsoLanguage.find(str(self.query_one("#language_select", Select).value))
trackDescriptor['title'] = str(self.query_one("#title_input", Input).value)
disposition_flags = sum([2**f for f in self.query_one("#dispositions_selection_list", SelectionList).selected])
trackDescriptor['disposition_list'] = TrackDisposition.toList(disposition_flags)
return trackDescriptor
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None:
# Check if the button pressed is the one we are interested in
if event.button.id == "save_button":
trackDescriptor = self.getTrackDescriptorFromInput()
# Check for multiple default/forced disposition flags
trackIdList = self.__tc.findAllTracks(self.pattern_obj['id'])[self.trackType.label()]
descriptorList = [d for d in (self.__tc.getTrackDescriptor(t) for t in trackIdList)
if d['type'] == self.trackType
and d['sub_index'] != self.subIndex]
numDefaultTracks = [d for d in descriptorList if TrackDisposition.DEFAULT in d['disposition_list']]
numForcedTracks = [d for d in descriptorList if TrackDisposition.FORCED in d['disposition_list']]
doubleDefaultOrForced = ((TrackDisposition.DEFAULT in trackDescriptor['disposition_list'] and numDefaultTracks)
or (TrackDisposition.FORCED in trackDescriptor['disposition_list'] and numForcedTracks))
if doubleDefaultOrForced:
self.query_one("#messagestatic", Static).update("Cannot add another stream with disposition flag 'debug' or 'forced' set")
else:
self.query_one("#messagestatic", Static).update(" ")
if self.track_obj['is_new']:
self.__tc.addTrack(trackDescriptor)
self.dismiss(trackDescriptor)
else:
trackId = self.__tc.findTrack(self.pattern_obj['id'], self.trackType, self.subIndex)
if self.__tc.updateTrack(trackId, trackDescriptor):
self.dismiss(trackDescriptor)
else:
self.app.pop_screen()
if event.button.id == "cancel_button":
self.app.pop_screen()

View File

@@ -0,0 +1,49 @@
from enum import Enum
import difflib
class TrackDisposition(Enum):
DEFAULT = {"name": "default", "index": 0}
FORCED = {"name": "forced", "index": 1}
DUB = {"name": "dub", "index": 2}
ORIGINAL = {"name": "original", "index": 3}
COMMENT = {"name": "comment", "index": 4}
LYRICS = {"name": "lyrics", "index": 5}
KARAOKE = {"name": "karaoke", "index": 6}
HEARING_IMPAIRED = {"name": "hearing_impaired", "index": 7}
VISUAL_IMPAIRED = {"name": "visual_impaired", "index": 8}
CLEAN_EFFECTS = {"name": "clean_effects", "index": 9}
ATTACHED_PIC = {"name": "attached_pic", "index": 10}
TIMED_THUMBNAILS = {"name": "timed_thumbnails", "index": 11}
NON_DIEGETICS = {"name": "non_diegetic", "index": 12}
CAPTIONS = {"name": "captions", "index": 13}
DESCRIPTIONS = {"name": "descriptions", "index": 14}
METADATA = {"name": "metadata", "index": 15}
DEPENDENT = {"name": "dependent", "index": 16}
STILL_IMAGE = {"name": "still_image", "index": 17}
def label(self):
return str(self.value['name'])
def index(self):
return int(self.value['index'])
@staticmethod
def toFlags(dispositionList):
"""Flags stored in integer bits (2**index)"""
flags = 0
for d in dispositionList:
flags += 2 ** d.index()
return flags
@staticmethod
def toList(flags):
dispositionList = []
for d in TrackDisposition:
if flags & int(2 ** d.index()):
dispositionList += [d]
return dispositionList

19
bin/ffx/track_type.py Normal file
View File

@@ -0,0 +1,19 @@
from enum import Enum
class TrackType(Enum):
VIDEO = 1
AUDIO = 2
SUBTITLE = 3
def label(self):
"""Returns the stream type as string"""
labels = {
TrackType.VIDEO: "video",
TrackType.AUDIO: "audio",
TrackType.SUBTITLE: "subtitle"
}
return labels.get(self, "undefined")

11
bin/ffx/warning_screen.py Normal file
View File

@@ -0,0 +1,11 @@
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label
class WarningScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Label("Warning! This file is not compliant to the defined source schema!")
yield Footer()