19 Commits

Author SHA1 Message Date
Maveno
58b01f2be7 0.2.2 2024-11-12 21:19:26 +01:00
Maveno
1d4507782b #401 nice cpu limit 2024-11-12 20:55:30 +01:00
Maveno
8c7eee580d #401 tag filter in config file 2024-11-12 20:17:50 +01:00
Maveno
303fd4bc80 #387 Suffices 2024-11-12 19:40:35 +01:00
Maveno
5febb96916 #394, #406 2024-11-10 16:50:13 +01:00
Maveno
b16e76370b #393 CLI-Overrides 2024-11-10 14:33:59 +01:00
Maveno
feb5441251 #399 2024-11-08 19:22:40 +01:00
Maveno
ea182d4ddb Fixes track swap, codec in DataTables 2024-11-08 19:06:32 +01:00
Maveno
f853cf0f85 bugfixes, inc Swap Tracks 2024-11-08 18:25:51 +01:00
Maveno
b492be227a ff 2024-11-06 18:38:56 +01:00
Maveno
7fe5b66c0c ff 2024-11-06 17:46:24 +01:00
Maveno
07cc0cd95e ff 2024-11-06 17:26:25 +01:00
Maveno
de2d7c0593 tf add pattern 2024-11-06 17:23:54 +01:00
Maveno
826677cb03 nighl disable jellyfin mode 2024-11-05 21:07:40 +01:00
Maveno
95aeacf694 nightly interims tag filter 2024-11-05 20:52:01 +01:00
Maveno
a3bb16e850 Signature, Tags cleaning, Bugfixes, Refactoring 2024-11-04 16:44:08 +01:00
Maveno
0ed85fce4a Add asterisk to filename filter, Signature 2024-11-04 14:28:11 +01:00
Maveno
1a0a5f4482 Fix test pattern, Test-Limit 2024-11-04 12:54:22 +01:00
Maveno
06f6322d32 ff 2024-11-04 11:45:09 +01:00
41 changed files with 1293 additions and 668 deletions

4
.gitignore vendored
View File

@@ -1,4 +1,6 @@
__pycache__ __pycache__
junk/ junk/
.vscode/launch.json .vscode
.ipynb_checkpoints/ .ipynb_checkpoints/
ansible/inventory/hawaii.yml
ansible/inventory/peppermint.yml

View File

@@ -0,0 +1,8 @@
all:
hosts:
ffx:
ansible_host: <domain>
ansible_user: <system user>
ffxSystemUsername: <system user>
ffxHomeDirectory: <home directory>

135
ansible/setup_node.yml Normal file
View File

@@ -0,0 +1,135 @@
- name: Setup FFX node
hosts: all
vars:
ffxRepoUrl: https://gitea.maveno.de/Javanaut/ffx.git
tasks:
- name: Update system and install packages
become: true
ansible.builtin.apt:
name:
- python3-virtualenv
- ffmpeg
- git
- screen
update_cache: yes
- name: Create sync dir
become: true
file:
path: "{{ ffxHomeDirectory }}/.local/var/sync/ffx"
state: directory
owner: "{{ ffxSystemUsername }}"
group: "{{ ffxSystemUsername }}"
mode: 0755
- name: Ensure local etc directory
become: true
file:
path: "{{ ffxHomeDirectory }}/.local/etc"
state: directory
owner: "{{ ffxSystemUsername }}"
group: "{{ ffxSystemUsername }}"
mode: 0755
- name: Ensure local src directory
become: true
file:
path: "{{ ffxHomeDirectory }}/.local/src"
state: directory
owner: "{{ ffxSystemUsername }}"
group: "{{ ffxSystemUsername }}"
mode: 0755
- name: Ensure local share directory
become: true
file:
path: "{{ ffxHomeDirectory }}/.local/share"
state: directory
owner: "{{ ffxSystemUsername }}"
group: "{{ ffxSystemUsername }}"
mode: 0755
- name: Prepare ffx virtualenv
become: true
become_user: "{{ ffxSystemUsername }}"
ansible.builtin.pip:
name:
- click
- textual
- sqlalchemy
- requests
virtualenv: "{{ ffxHomeDirectory }}/.local/share/ffx.venv"
- name: Clone ffx repository
become: true
become_user: "{{ ffxSystemUsername }}"
ansible.builtin.git:
repo: "{{ ffxRepoUrl }}"
dest: "{{ ffxHomeDirectory }}/.local/src/ffx"
version: dev
- name: Add TMDB API token placeholer to .bashrc
become: true
become_user: "{{ ffxSystemUsername }}"
ansible.builtin.lineinfile:
path: "{{ ffxHomeDirectory }}/.bashrc"
insertbefore: BOF
line: >-
export TMDB_API_KEY="<TMDB API token>"
- name: Add ffx alias to .bashrc
become: true
become_user: "{{ ffxSystemUsername }}"
ansible.builtin.lineinfile:
path: "{{ ffxHomeDirectory }}/.bashrc"
insertbefore: BOF
line: >-
alias ffx="{{ ffxHomeDirectory }}/.local/share/ffx.venv/bin/python
{{ ffxHomeDirectory }}/.local/src/ffx/bin/ffx.py"
- name: Ensure local sync directory
become: true
file:
path: "{{ ffxHomeDirectory }}/.local/var/sync/ffx"
state: directory
owner: "{{ ffxSystemUsername }}"
group: "{{ ffxSystemUsername }}"
mode: 0755
- name: Create ffx config file
become: true
become_user: "{{ ffxSystemUsername }}"
vars:
ffxConfiguration:
databasePath: "{{ ffxHomeDirectory }}/.local/var/sync/ffx/ffx.db"
metadata:
signature:
RECODED_WITH: FFX
remove:
- VERSION-eng
- creation_time
- NAME
streams:
remove:
- BPS
- NUMBER_OF_FRAMES
- NUMBER_OF_BYTES
- _STATISTICS_WRITING_APP
- _STATISTICS_WRITING_DATE_UTC
- _STATISTICS_TAGS
- BPS-eng
- DURATION-eng
- NUMBER_OF_FRAMES-eng
- NUMBER_OF_BYTES-eng
- _STATISTICS_WRITING_APP-eng
- _STATISTICS_WRITING_DATE_UTC-eng
- _STATISTICS_TAGS-eng
ansible.builtin.copy:
content: "{{ ffxConfiguration | to_json }}"
dest: "{{ ffxHomeDirectory }}/.local/etc/ffx.json"
owner: "{{ ffxSystemUsername }}"
group: "{{ ffxSystemUsername }}"
mode: 0644

View File

@@ -14,14 +14,21 @@ from ffx.database import databaseContext
from ffx.media_descriptor import MediaDescriptor from ffx.media_descriptor import MediaDescriptor
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
from ffx.show_descriptor import ShowDescriptor
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.video_encoder import VideoEncoder from ffx.video_encoder import VideoEncoder
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.nlmeans_controller import NlmeansController
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.helper import filterFilename
from ffx.constants import DEFAULT_QUALITY, DEFAULT_AV1_PRESET
from ffx.constants import DEFAULT_STEREO_BANDWIDTH, DEFAULT_AC3_BANDWIDTH, DEFAULT_DTS_BANDWIDTH, DEFAULT_7_1_BANDWIDTH
VERSION='0.2.0' VERSION='0.2.1'
# 0.1.1 # 0.1.1
# Bugfixes, TMBD identify shows # Bugfixes, TMBD identify shows
@@ -31,6 +38,9 @@ VERSION='0.2.0'
# Subtitle file imports # Subtitle file imports
# 0.2.0 # 0.2.0
# Tests, Config-File # Tests, Config-File
# 0.2.1
# Signature, Tags cleaning, Bugfixes, Refactoring
@click.group() @click.group()
@click.pass_context @click.pass_context
@@ -143,15 +153,18 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix') @click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
@click.option("-o", "--output-directory", type=str, default='') @click.option("-o", "--output-directory", type=str, default='')
@click.option("-s", "--subtitles-only", is_flag=True, default=False) @click.option("-s", "--subtitles-only", is_flag=True, default=False)
@click.option('--nice', type=int, default=99, help='Niceness of started processes')
@click.option('--cpu', type=int, default=0, help='Limit CPU for started processes to percent')
def unmux(ctx, def unmux(ctx,
paths, paths,
label, label,
output_directory, output_directory,
subtitles_only): subtitles_only,
nice,
cpu):
existingSourcePaths = [p for p in paths if os.path.isfile(p)] existingSourcePaths = [p for p in paths if os.path.isfile(p)]
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"\nUnmuxing {len(existingSourcePaths)} files")
click.echo(f"\nUnmuxing {len(existingSourcePaths)} files")
for sourcePath in existingSourcePaths: for sourcePath in existingSourcePaths:
@@ -169,12 +182,10 @@ def unmux(ctx,
targetIndicator = f"_S{season}E{episode}" if label and season != -1 and episode != -1 else '' targetIndicator = f"_S{season}E{episode}" if label and season != -1 and episode != -1 else ''
if label and not targetIndicator: if label and not targetIndicator:
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].warning(f"Skipping file {fp.getFilename()}: Label set but no indicator recognized")
click.echo(f"Skipping file {fp.getFilename()}: Label set but no indicator recognized")
continue continue
else: else:
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"\nUnmuxing file {fp.getFilename()}\n")
click.echo(f"\nUnmuxing file {fp.getFilename()}\n")
for trackDescriptor in sourceMediaDescriptor.getAllTrackDescriptors(): for trackDescriptor in sourceMediaDescriptor.getAllTrackDescriptors():
@@ -187,18 +198,14 @@ def unmux(ctx,
if unmuxSequence: if unmuxSequence:
if not ctx.obj['dry_run']: if not ctx.obj['dry_run']:
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"Executing unmuxing sequence: {' '.join(unmuxSequence)}")
click.echo(f"Executing unmuxing sequence: {' '.join(unmuxSequence)}") out, err, rc = executeProcess(unmuxSequence, niceness=nice, cpu_percent=cpu)
out, err, rc = executeProcess(unmuxSequence)
if rc: if rc:
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].error(f"Unmuxing of stream {trackDescriptor.getIndex()} failed with error ({rc}) {err}")
click.echo(f"Unmuxing of stream {trackDescriptor.getIndex()} failed with error ({rc}) {err}")
else: else:
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].warning(f"Skipping stream with unknown codec {trackDescriptor.getCodec()}")
click.echo(f"Skipping stream with unknown codec {trackDescriptor.getCodec()}")
except Exception as ex: except Exception as ex:
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].warning(f"Skipping File {sourcePath} ({ex})")
click.echo(f"Skipping File {sourcePath} ({ex})")
@ffx.command() @ffx.command()
@@ -262,44 +269,57 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix') @click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
@click.option('-v', '--video-encoder', type=str, default=FfxController.DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1) default: {FfxController.DEFAULT_VIDEO_ENCODER}") @click.option('-v', '--video-encoder', type=str, default=FfxController.DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1)", show_default=True)
@click.option('-q', '--quality', type=str, default=FfxController.DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder (default: {FfxController.DEFAULT_QUALITY})") @click.option('-q', '--quality', type=str, default=DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder", show_default=True)
@click.option('-p', '--preset', type=str, default=FfxController.DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder (default: {FfxController.DEFAULT_AV1_PRESET})") @click.option('-p', '--preset', type=str, default=DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder", show_default=True)
@click.option('-s', '--stereo-bitrate', type=int, default=FfxController.DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams (default: {FfxController.DEFAULT_STEREO_BANDWIDTH})") @click.option('-a', '--stereo-bitrate', type=int, default=DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams", show_default=True)
@click.option('--ac3', type=int, default=FfxController.DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams (default: {FfxController.DEFAULT_AC3_BANDWIDTH})") @click.option('--ac3', type=int, default=DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams", show_default=True)
@click.option('--dts', type=int, default=FfxController.DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams (default: {FfxController.DEFAULT_DTS_BANDWIDTH})") @click.option('--dts', type=int, default=DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams", show_default=True)
@click.option('--subtitle-directory', type=str, default='', help='Load subtitles from here') @click.option('--subtitle-directory', type=str, default='', help='Load subtitles from here')
@click.option('--subtitle-prefix', type=str, default='', help='Subtitle filename prefix') @click.option('--subtitle-prefix', type=str, default='', help='Subtitle filename prefix')
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
@click.option('--audio-language', type=str, multiple=True, help='Audio stream language(s)') @click.option('--default-video', type=int, default=-1, help='Index of default video stream')
@click.option('--audio-title', type=str, multiple=True, help='Audio stream title(s)') @click.option('--forced-video', type=int, default=-1, help='Index of forced video stream')
@click.option('--default-audio', type=int, default=-1, help='Index of default audio stream') @click.option('--default-audio', type=int, default=-1, help='Index of default audio stream')
@click.option('--forced-audio', type=int, default=-1, help='Index of forced audio stream') @click.option('--forced-audio', type=int, default=-1, help='Index of forced audio stream')
@click.option('--subtitle-language', type=str, multiple=True, help='Subtitle stream language(s)')
@click.option('--subtitle-title', type=str, multiple=True, help='Subtitle stream title(s)')
@click.option('--default-subtitle', type=int, default=-1, help='Index of default subtitle stream') @click.option('--default-subtitle', type=int, default=-1, help='Index of default subtitle stream')
@click.option('--forced-subtitle', type=int, default=-1, help='Index of forced subtitle stream') # (including default audio stream tag) @click.option('--forced-subtitle', type=int, default=-1, help='Index of forced subtitle stream')
@click.option('--rearrange-streams', type=str, default="", help='Rearrange output streams order. Use format comma separated integers')
@click.option("--crop", is_flag=False, flag_value="default", default="none") @click.option("--crop", is_flag=False, flag_value="default", default="none")
@click.option("--output-directory", type=str, default='') @click.option("--output-directory", type=str, default='')
@click.option("--denoise", is_flag=True, default=False) @click.option("--denoise", is_flag=False, flag_value="default", default="none")
@click.option("--denoise-use-hw", is_flag=True, default=False)
@click.option('--denoise-strength', type=str, default='', help='Denoising strength, more blurring vs more details.')
@click.option('--denoise-patch-size', type=str, default='', help='Subimage size to apply filtering on luminosity plane. Reduces broader noise patterns but costly.')
@click.option('--denoise-chroma-patch-size', type=str, default='', help='Subimage size to apply filtering on chroma planes.')
@click.option('--denoise-research-window', type=str, default='', help='Range to search for comparable patches on luminosity plane. Better filtering but costly.')
@click.option('--denoise-chroma-research-window', type=str, default='', help='Range to search for comparable patches on chroma planes.')
@click.option('--show', type=int, default=-1, help='Set TMDB show identifier')
@click.option('--season', type=int, default=-1, help='Set season of show')
@click.option('--episode', type=int, default=-1, help='Set episode of show')
@click.option("--no-tmdb", is_flag=True, default=False) @click.option("--no-tmdb", is_flag=True, default=False)
@click.option("--no-jellyfin", is_flag=True, default=False)
@click.option("--no-pattern", is_flag=True, default=False) @click.option("--no-pattern", is_flag=True, default=False)
@click.option("--dont-pass-dispositions", is_flag=True, default=False) @click.option("--dont-pass-dispositions", is_flag=True, default=False)
@click.option("--no-prompt", is_flag=True, default=False) @click.option("--no-prompt", is_flag=True, default=False)
@click.option("--no-signature", is_flag=True, default=False)
@click.option("--keep-mkvmerge-metadata", is_flag=True, default=False)
@click.option('--nice', type=int, default=99, help='Niceness of started processes')
@click.option('--cpu', type=int, default=0, help='Limit CPU for started processes to percent')
def convert(ctx, def convert(ctx,
paths, paths,
@@ -314,24 +334,43 @@ def convert(ctx,
subtitle_directory, subtitle_directory,
subtitle_prefix, subtitle_prefix,
audio_language, language,
audio_title, title,
default_video,
forced_video,
default_audio, default_audio,
forced_audio, forced_audio,
subtitle_language,
subtitle_title,
default_subtitle, default_subtitle,
forced_subtitle, forced_subtitle,
rearrange_streams,
crop, crop,
output_directory, output_directory,
denoise, denoise,
denoise_use_hw,
denoise_strength,
denoise_patch_size,
denoise_chroma_patch_size,
denoise_research_window,
denoise_chroma_research_window,
show,
season,
episode,
no_tmdb, no_tmdb,
no_jellyfin, # no_jellyfin,
no_pattern, no_pattern,
dont_pass_dispositions, dont_pass_dispositions,
no_prompt): no_prompt,
no_signature,
keep_mkvmerge_metadata,
nice,
cpu):
"""Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin """Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin
Files found under PATHS will be converted according to parameters. Files found under PATHS will be converted according to parameters.
@@ -345,33 +384,121 @@ def convert(ctx,
context['video_encoder'] = VideoEncoder.fromLabel(video_encoder) context['video_encoder'] = VideoEncoder.fromLabel(video_encoder)
context['use_jellyfin'] = not no_jellyfin targetFormat = FfxController.DEFAULT_FILE_FORMAT
targetExtension = FfxController.DEFAULT_FILE_EXTENSION
#TODO: #407 Without effect -> remove
context['use_jellyfin'] = False
context['use_tmdb'] = not no_tmdb context['use_tmdb'] = not no_tmdb
context['use_pattern'] = not no_pattern context['use_pattern'] = not no_pattern
context['no_prompt'] = no_prompt context['no_prompt'] = no_prompt
context['no_signature'] = no_signature
context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata
context['resource_limits'] = {}
context['resource_limits']['niceness'] = nice
context['resource_limits']['cpu_percent'] = cpu
context['denoiser'] = NlmeansController(parameters = denoise,
strength = denoise_strength,
patchSize = denoise_patch_size,
chromaPatchSize = denoise_chroma_patch_size,
researchWindow = denoise_research_window,
chromaResearchWindow = denoise_chroma_research_window,
useHardware = denoise_use_hw)
context['import_subtitles'] = (subtitle_directory and subtitle_prefix) context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
if context['import_subtitles']: if context['import_subtitles']:
context['subtitle_directory'] = subtitle_directory context['subtitle_directory'] = subtitle_directory
context['subtitle_prefix'] = subtitle_prefix context['subtitle_prefix'] = subtitle_prefix
# click.echo(f"\nVideo encoder: {video_encoder}")
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in FfxController.INPUT_FILE_EXTENSIONS]
# CLI Overrides
cliOverrides = {}
if language:
cliOverrides['languages'] = {}
for overLang in language:
olTokens = overLang.split(':')
if len(olTokens) == 2:
try:
cliOverrides['languages'][int(olTokens[0])] = olTokens[1]
except ValueError:
ctx.obj['logger'].warning(f"Ignoring non-integer language index {olTokens[0]}")
continue
if title:
cliOverrides['titles'] = {}
for overTitle in title:
otTokens = overTitle.split(':')
if len(otTokens) == 2:
try:
cliOverrides['titles'][int(otTokens[0])] = otTokens[1]
except ValueError:
ctx.obj['logger'].warning(f"Ignoring non-integer title index {otTokens[0]}")
continue
if default_video != -1:
cliOverrides['default_video'] = default_video
if forced_video != -1:
cliOverrides['forced_video'] = forced_video
if default_audio != -1:
cliOverrides['default_audio'] = default_audio
if forced_audio != -1:
cliOverrides['forced_audio'] = forced_audio
if default_subtitle != -1:
cliOverrides['default_subtitle'] = default_subtitle
if forced_subtitle != -1:
cliOverrides['forced_subtitle'] = forced_subtitle
if show != -1 or season != -1 or episode != -1:
if len(existingSourcePaths) > 1:
context['logger'].warning(f"Ignoring TMDB show, season, episode overrides, not supported for multiple source files")
else:
cliOverrides['tmdb'] = {}
if show != -1:
cliOverrides['tmdb']['show'] = show
if season != -1:
cliOverrides['tmdb']['season'] = season
if episode != -1:
cliOverrides['tmdb']['episode'] = episode
if cliOverrides:
context['overrides'] = cliOverrides
if rearrange_streams:
try:
cliOverrides['stream_order'] = [int(si) for si in rearrange_streams.split(",")]
except ValueError as ve:
errorMessage = "Non-integer in rearrange stream parameter"
ctx.obj['logger'].error(errorMessage)
raise click.Abort()
ctx.obj['logger'].debug(f"\nVideo encoder: {video_encoder}")
qualityTokens = quality.split(',') qualityTokens = quality.split(',')
q_list = [q for q in qualityTokens if q.isnumeric()] q_list = [q for q in qualityTokens if q.isnumeric()]
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"Qualities: {q_list}")
click.echo(f"Qualities: {q_list}")
context['bitrates'] = {} context['bitrates'] = {}
context['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k" context['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k"
context['bitrates']['ac3'] = str(ac3) if str(ac3).endswith('k') else f"{ac3}k" context['bitrates']['ac3'] = str(ac3) if str(ac3).endswith('k') else f"{ac3}k"
context['bitrates']['dts'] = str(dts) if str(dts).endswith('k') else f"{dts}k" context['bitrates']['dts'] = str(dts) if str(dts).endswith('k') else f"{dts}k"
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"Stereo bitrate: {context['bitrates']['stereo']}")
click.echo(f"Stereo bitrate: {context['bitrates']['stereo']}") ctx.obj['logger'].debug(f"AC3 bitrate: {context['bitrates']['ac3']}")
click.echo(f"AC3 bitrate: {context['bitrates']['ac3']}") ctx.obj['logger'].debug(f"DTS bitrate: {context['bitrates']['dts']}")
click.echo(f"DTS bitrate: {context['bitrates']['dts']}")
# Process crop parameters # Process crop parameters
@@ -381,15 +508,14 @@ def convert(ctx,
if cTokens and len(cTokens) == 2: if cTokens and len(cTokens) == 2:
context['crop_start'] = int(cTokens[0]) context['crop_start'] = int(cTokens[0])
context['crop_length'] = int(cTokens[1]) context['crop_length'] = int(cTokens[1])
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"Crop start={context['crop_start']} length={context['crop_length']}")
click.echo(f"Crop start={context['crop_start']} length={context['crop_length']}")
tc = TmdbController() if context['use_tmdb'] else None tc = TmdbController() if context['use_tmdb'] else None
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in FfxController.INPUT_FILE_EXTENSIONS]
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
jobIndex = 0 jobIndex = 0
for sourcePath in existingSourcePaths: for sourcePath in existingSourcePaths:
@@ -402,135 +528,174 @@ def convert(ctx,
sourceFileBasename = '.'.join(sourcePathTokens[:-1]) sourceFileBasename = '.'.join(sourcePathTokens[:-1])
sourceFilenameExtension = sourcePathTokens[-1] sourceFilenameExtension = sourcePathTokens[-1]
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].info(f"\nProcessing file {sourcePath}")
click.echo(f"\nProcessing file {sourcePath}")
targetSuffices = {}
mediaFileProperties = FileProperties(context, sourceFilename) mediaFileProperties = FileProperties(context, sourceFilename)
#HINT: -1 if not set
if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']:
showSeason = cliOverrides['tmdb']['season']
else:
showSeason = mediaFileProperties.getSeason()
if 'tmdb' in cliOverrides.keys() and 'episode' in cliOverrides['tmdb']:
showEpisode = cliOverrides['tmdb']['episode']
else:
showEpisode = mediaFileProperties.getEpisode()
ctx.obj['logger'].debug(f"Season={showSeason} Episode={showEpisode}")
sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor() sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor()
#HINT: This is None if the filename did not match anything in database #HINT: This is None if the filename did not match anything in database
currentPattern = mediaFileProperties.getPattern() if context['use_pattern'] else None currentPattern = mediaFileProperties.getPattern() if context['use_pattern'] else None
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"Pattern matching: {'No' if currentPattern is None else 'Yes'}")
click.echo(f"Pattern matching: {'No' if currentPattern is None else 'Yes'}")
# fileBasename = ''
# Setup FfxController accordingly depending on pattern matching is enabled and a pattern was matched
if currentPattern is None: if currentPattern is None:
# Case no pattern matching
# fileBasename = currentShowDescriptor.getFilenamePrefix()
checkUniqueDispositions(context, sourceMediaDescriptor) checkUniqueDispositions(context, sourceMediaDescriptor)
currentShowDescriptor = None
if context['import_subtitles']: if context['import_subtitles']:
sourceMediaDescriptor.importSubtitles(context['subtitle_directory'], sourceMediaDescriptor.importSubtitles(context['subtitle_directory'],
context['subtitle_prefix'], context['subtitle_prefix'],
mediaFileProperties.getSeason(), showSeason,
mediaFileProperties.getEpisode()) showEpisode)
if context['use_jellyfin']: if cliOverrides:
# Reorder subtracks in types with default the last, then make subindices flat again sourceMediaDescriptor.applyOverrides(cliOverrides)
sourceMediaDescriptor.applyJellyfinOrder()
fc = FfxController(context, sourceMediaDescriptor) fc = FfxController(context, sourceMediaDescriptor)
else: else:
# Case pattern matching
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj) targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
checkUniqueDispositions(context, targetMediaDescriptor) checkUniqueDispositions(context, targetMediaDescriptor)
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj) currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
if context['use_tmdb']:
click.echo(f"Querying TMDB for show_id={currentShowDescriptor.getId()} season={mediaFileProperties.getSeason()} episode{mediaFileProperties.getEpisode()}")
tmdbEpisodeResult = tc.queryEpisode(currentShowDescriptor.getId(), mediaFileProperties.getSeason(), mediaFileProperties.getEpisode())
click.echo(f"tmdbEpisodeResult={tmdbEpisodeResult}")
if tmdbEpisodeResult:
sourceFileBasename = TmdbController.getEpisodeFileBasename(currentShowDescriptor.getFilenamePrefix(),
tmdbEpisodeResult['name'],
mediaFileProperties.getSeason(),
mediaFileProperties.getEpisode(),
currentShowDescriptor.getIndexSeasonDigits(),
currentShowDescriptor.getIndexEpisodeDigits(),
currentShowDescriptor.getIndicatorSeasonDigits(),
currentShowDescriptor.getIndicatorEpisodeDigits())
else:
sourceFileBasename = currentShowDescriptor.getFilenamePrefix()
if context['import_subtitles']: if context['import_subtitles']:
targetMediaDescriptor.importSubtitles(context['subtitle_directory'], targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
context['subtitle_prefix'], context['subtitle_prefix'],
mediaFileProperties.getSeason(), showSeason,
mediaFileProperties.getEpisode()) showEpisode)
# raise click.ClickException(f"tmd subindices: {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]}") ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
# click.echo(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
if context['use_jellyfin']: if cliOverrides:
# Reorder subtracks in types with default the last, then make subindices flat again targetMediaDescriptor.applyOverrides(cliOverrides)
targetMediaDescriptor.applyJellyfinOrder()
# sourceMediaDescriptor.applyJellyfinOrder()
# click.echo(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}") ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
# raise click.Abort
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].debug(f"Input mapping tokens (2nd pass): {targetMediaDescriptor.getInputMappingTokens()}")
click.echo(f"Input mapping tokens (2nd pass): {targetMediaDescriptor.getInputMappingTokens()}")
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor) fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
if ctx.obj['verbosity'] > 0: indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDEX_SEASON_DIGITS
click.echo(f"Season={mediaFileProperties.getSeason()} Episode={mediaFileProperties.getEpisode()}") indexEpisodeDigits = currentShowDescriptor.getIndexEpisodeDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS
indicatorSeasonDigits = currentShowDescriptor.getIndicatorSeasonDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS
indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS
if ctx.obj['verbosity'] > 0:
click.echo(f"fileBasename={sourceFileBasename}") # Assemble target filename accordingly depending on TMDB lookup is enabled
#HINT: -1 if not set
showId = cliOverrides['tmdb']['show'] if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
if context['use_tmdb'] and showId != -1 and showSeason != -1 and showEpisode != -1:
ctx.obj['logger'].debug(f"Querying TMDB for show_id={showId} season={showSeason} episode{showEpisode}")
if currentPattern is None:
sName, showYear = tc.getShowNameAndYear(showId)
showName = filterFilename(sName)
showFilenamePrefix = f"{showName} ({str(showYear)})"
else:
showFilenamePrefix = currentShowDescriptor.getFilenamePrefix()
tmdbEpisodeResult = tc.queryEpisode(showId, showSeason, showEpisode)
ctx.obj['logger'].debug(f"tmdbEpisodeResult={tmdbEpisodeResult}")
if tmdbEpisodeResult:
filteredEpisodeName = filterFilename(tmdbEpisodeResult['name'])
sourceFileBasename = TmdbController.getEpisodeFileBasename(showFilenamePrefix,
filteredEpisodeName,
showSeason,
showEpisode,
indexSeasonDigits,
indexEpisodeDigits,
indicatorSeasonDigits,
indicatorEpisodeDigits)
if label:
if showSeason > -1 and showEpisode > -1:
targetSuffices['se'] = f"S{showSeason:0{indicatorSeasonDigits}d}E{showEpisode:0{indicatorEpisodeDigits}d}"
elif showEpisode > -1:
targetSuffices['se'] = f"E{showEpisode:0{indicatorEpisodeDigits}d}"
else:
if 'se' in targetSuffices.keys():
del targetSuffices['se']
ctx.obj['logger'].debug(f"fileBasename={sourceFileBasename}")
for q in q_list: for q in q_list:
if ctx.obj['verbosity'] > 0: if len(q_list) > 1:
click.echo(f"\nRunning job {jobIndex} file={sourcePath} q={q}") targetSuffices['q'] = f"q{q}"
ctx.obj['logger'].debug(f"\nRunning job {jobIndex} file={sourcePath} q={q}")
jobIndex += 1 jobIndex += 1
extra = ['ffx'] if sourceFilenameExtension == FfxController.DEFAULT_FILE_EXTENSION else [] ctx.obj['logger'].debug(f"label={label if label else 'Falsy'}")
ctx.obj['logger'].debug(f"sourceFileBasename={sourceFileBasename}")
click.echo(f"label={label if label else 'Falsy'}") # targetFileBasename = mediaFileProperties.assembleTargetFileBasename(label,
click.echo(f"sourceFileBasename={sourceFileBasename}") # q if len(q_list) > 1 else -1,
#
targetFileBasename = sourceFileBasename if context['use_tmdb'] and not label else label
targetFilename = (sourceFileBasename if context['use_tmdb']
else mediaFileProperties.assembleTargetFileBasename(label, targetFilenameTokens = [targetFileBasename]
q if len(q_list) > 1 else -1,
extraTokens = extra)) if 'se' in targetSuffices.keys():
targetFilenameTokens += [targetSuffices['se']]
if 'q' in targetSuffices.keys():
targetFilenameTokens += [targetSuffices['q']]
#TODO #387
# targetFilename = ((f"{sourceFileBasename}_q{q}" if len(q_list) > 1 else sourceFileBasename)
# if context['use_tmdb'] else targetFileBasename)
targetFilename = f"{'_'.join(targetFilenameTokens)}.{targetExtension}"
targetPath = os.path.join(output_directory if output_directory else sourceDirectory, targetFilename) targetPath = os.path.join(output_directory if output_directory else sourceDirectory, targetFilename)
# media_S01E02_S01E02 #TODO: target extension anpassen
click.echo(f"targetPath={targetPath}") ctx.obj['logger'].info(f"Creating file {targetFilename}")
fc.runJob(sourcePath, fc.runJob(sourcePath,
targetPath, targetPath,
targetFormat,
context['video_encoder'], context['video_encoder'],
q, q,
preset, preset)
denoise)
#TODO: click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True) #TODO: click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True)
endTime = time.perf_counter() endTime = time.perf_counter()
if ctx.obj['verbosity'] > 0: ctx.obj['logger'].info(f"\nDONE\nTime elapsed {endTime - startTime}")
click.echo(f"\nDONE\nTime elapsed {endTime - startTime}")
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -28,7 +28,6 @@ class AudioLayout(Enum):
return [a for a in AudioLayout if a.value['label'] == str(label)][0] return [a for a in AudioLayout if a.value['label'] == str(label)][0]
except: except:
raise click.ClickException('fromLabel failed')
return AudioLayout.LAYOUT_UNDEFINED return AudioLayout.LAYOUT_UNDEFINED
@staticmethod @staticmethod
@@ -36,7 +35,6 @@ class AudioLayout(Enum):
try: try:
return [a for a in AudioLayout if a.value['index'] == int(index)][0] return [a for a in AudioLayout if a.value['index'] == int(index)][0]
except: except:
raise click.ClickException('fromIndex failed')
return AudioLayout.LAYOUT_UNDEFINED return AudioLayout.LAYOUT_UNDEFINED
@staticmethod @staticmethod

View File

@@ -46,3 +46,7 @@ class ConfigurationController():
def getDatabaseFilePath(self): def getDatabaseFilePath(self):
return self.__databaseFilePath return self.__databaseFilePath
def getData(self):
return self.__configurationData

10
bin/ffx/constants.py Normal file
View File

@@ -0,0 +1,10 @@
DEFAULT_QUALITY = 32
DEFAULT_AV1_PRESET = 5
DEFAULT_STEREO_BANDWIDTH = "112"
DEFAULT_AC3_BANDWIDTH = "256"
DEFAULT_DTS_BANDWIDTH = "320"
DEFAULT_7_1_BANDWIDTH = "384"
DEFAULT_CROP_START = 60
DEFAULT_CROP_LENGTH = 180

View File

@@ -10,6 +10,10 @@ from ffx.video_encoder import VideoEncoder
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.constants import DEFAULT_QUALITY, DEFAULT_AV1_PRESET
from ffx.constants import DEFAULT_CROP_START, DEFAULT_CROP_LENGTH
class FfxController(): class FfxController():
COMMAND_TOKENS = ['ffmpeg', '-y'] COMMAND_TOKENS = ['ffmpeg', '-y']
@@ -19,30 +23,14 @@ class FfxController():
DEFAULT_VIDEO_ENCODER = VideoEncoder.VP9.label() DEFAULT_VIDEO_ENCODER = VideoEncoder.VP9.label()
DEFAULT_QUALITY = 23
DEFAULT_AV1_PRESET = 5
DEFAULT_FILE_FORMAT = 'webm' DEFAULT_FILE_FORMAT = 'webm'
DEFAULT_FILE_EXTENSION = 'webm' DEFAULT_FILE_EXTENSION = 'webm'
DEFAULT_STEREO_BANDWIDTH = "128"
DEFAULT_AC3_BANDWIDTH = "256"
DEFAULT_DTS_BANDWIDTH = "320"
DEFAULT_CROP_START = 60
DEFAULT_CROP_LENGTH = 180
MKVMERGE_METADATA_KEYS = ['BPS',
'NUMBER_OF_FRAMES',
'NUMBER_OF_BYTES',
'_STATISTICS_WRITING_APP',
'_STATISTICS_WRITING_DATE_UTC',
'_STATISTICS_TAGS']
INPUT_FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm'] INPUT_FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
CHANNEL_MAP_5_1 = 'FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1' CHANNEL_MAP_5_1 = 'FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1'
SIGNATURE_TAGS = {'RECODED_WITH': 'FFX'}
def __init__(self, def __init__(self,
context : dict, context : dict,
@@ -53,6 +41,12 @@ class FfxController():
self.__sourceMediaDescriptor = sourceMediaDescriptor self.__sourceMediaDescriptor = sourceMediaDescriptor
self.__targetMediaDescriptor = targetMediaDescriptor self.__targetMediaDescriptor = targetMediaDescriptor
self.__configurationData = self.__context['config'].getData()
# Convenience
self.__niceness = self.__context['resource_limits']['niceness'] if 'resource_limits' in self.__context.keys() and 'niceness' in self.__context['resource_limits'].keys() else 99
self.__cpuPercent = self.__context['resource_limits']['cpu_percent'] if 'resource_limits' in self.__context.keys() and 'cpu_percent' in self.__context['resource_limits'].keys() else 0
self.__logger = context['logger'] self.__logger = context['logger']
@@ -97,20 +91,18 @@ class FfxController():
cropStart = int(self.__context['crop_start']) cropStart = int(self.__context['crop_start'])
cropLength = int(self.__context['crop_length']) cropLength = int(self.__context['crop_length'])
else: else:
cropStart = FfxController.DEFAULT_CROP_START cropStart = DEFAULT_CROP_START
cropLength = FfxController.DEFAULT_CROP_LENGTH cropLength = DEFAULT_CROP_LENGTH
return ['-ss', str(cropStart), '-t', str(cropLength)] return ['-ss', str(cropStart), '-t', str(cropLength)]
def generateDenoiseTokens(self, spatial=5, patch=7, research=7, hw=False): def generateOutputTokens(self, filePathBase, format = '', ext = ''):
filterName = 'nlmeans_opencl' if hw else 'nlmeans' outputFilePath = f"{filePathBase}{'.'+str(ext) if ext else ''}"
return ['-vf', f"{filterName}=s={spatial}:p={patch}:r={research}"] if format:
return ['-f', format, outputFilePath]
else:
def generateOutputTokens(self, filepath, format, ext): return [outputFilePath]
outputFilePath = f"{filepath}.{ext}"
return ['-f', format, outputFilePath]
def generateAudioEncodingTokens(self): def generateAudioEncodingTokens(self):
@@ -118,23 +110,12 @@ class FfxController():
audioTokens = [] audioTokens = []
#sourceAudioTrackDescriptors = [smd for smd in self.__sourceMediaDescriptor.getAllTrackDescriptors() if smd.getType() == TrackType.AUDIO]
# targetAudioTrackDescriptors = [rtd for rtd in self.__targetMediaDescriptor.getReorderedTrackDescriptors() if rtd.getType() == TrackType.AUDIO]
targetAudioTrackDescriptors = [td for td in self.__targetMediaDescriptor.getAllTrackDescriptors() if td.getType() == TrackType.AUDIO] targetAudioTrackDescriptors = [td for td in self.__targetMediaDescriptor.getAllTrackDescriptors() if td.getType() == TrackType.AUDIO]
trackSubIndex = 0 trackSubIndex = 0
for trackDescriptor in targetAudioTrackDescriptors: for trackDescriptor in targetAudioTrackDescriptors:
# Calculate source sub index
#changedTargetTrackDescriptor : TrackDescriptor = targetAudioTrackDescriptors[trackDescriptor.getIndex()]
#changedTargetTrackSourceIndex = changedTargetTrackDescriptor.getSourceIndex()
#sourceSubIndex = sourceAudioTrackDescriptors[changedTargetTrackSourceIndex].getSubIndex()
trackAudioLayout = trackDescriptor.getAudioLayout() trackAudioLayout = trackDescriptor.getAudioLayout()
#TODO: Sollte nicht die sub index unverändert bleiben wenn jellyfin reordering angewendet wurde?
# siehe auch: MediaDescriptor.getInputMappingTokens()
#trackSubIndex = trackDescriptor.getSubIndex()
if trackAudioLayout == AudioLayout.LAYOUT_6_1: if trackAudioLayout == AudioLayout.LAYOUT_6_1:
audioTokens += [f"-c:a:{trackSubIndex}", audioTokens += [f"-c:a:{trackSubIndex}",
@@ -176,10 +157,6 @@ class FfxController():
sourceTrackDescriptors = ([] if self.__sourceMediaDescriptor is None sourceTrackDescriptors = ([] if self.__sourceMediaDescriptor is None
else self.__sourceMediaDescriptor.getAllTrackDescriptors()) else self.__sourceMediaDescriptor.getAllTrackDescriptors())
# if not self.__sourceMediaDescriptor is None:
# sourceTrackDescriptors = self.__sourceMediaDescriptor.getAllTrackDescriptors()
# else:
# sourceTrackDescriptors = []
dispositionTokens = [] dispositionTokens = []
@@ -214,17 +191,48 @@ class FfxController():
metadataTokens = [] metadataTokens = []
for tagKey, tagValue in self.__targetMediaDescriptor.getTags().items(): metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {}
signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {}
removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else []
removeTrackKeys = metadataConfiguration['streams']['remove'] if 'streams' in metadataConfiguration.keys() and 'remove' in metadataConfiguration['streams'].keys() else []
mediaTags = {k:v for k,v in self.__targetMediaDescriptor.getTags().items() if not k in removeGlobalKeys}
if (not 'no_signature' in self.__context.keys()
or not self.__context['no_signature']):
outputMediaTags = mediaTags | signatureTags
else:
outputMediaTags = mediaTags
for tagKey, tagValue in outputMediaTags.items():
metadataTokens += [f"-metadata:g", metadataTokens += [f"-metadata:g",
f"{tagKey}={tagValue}"] f"{tagKey}={tagValue}"]
for removeKey in removeGlobalKeys:
metadataTokens += [f"-metadata:g",
f"{removeKey}="]
removeMkvmergeMetadata = (not 'keep_mkvmerge_metadata' in self.__context.keys()
or not self.__context['keep_mkvmerge_metadata'])
#HINT: With current ffmpeg version track metadata tags are not passed to the outfile #HINT: With current ffmpeg version track metadata tags are not passed to the outfile
for td in self.__targetMediaDescriptor.getAllTrackDescriptors(): for td in self.__targetMediaDescriptor.getAllTrackDescriptors():
typeIndicator = td.getType().indicator()
subIndex = td.getSubIndex()
for tagKey, tagValue in td.getTags().items(): for tagKey, tagValue in td.getTags().items():
metadataTokens += [f"-metadata:s:{td.getType().indicator()}:{td.getSubIndex()}", if not tagKey in removeTrackKeys:
f"{tagKey}={tagValue}"] metadataTokens += [f"-metadata:s:{typeIndicator}:{subIndex}",
f"{tagKey}={tagValue}"]
for removeKey in removeTrackKeys:
metadataTokens += [f"-metadata:s:{typeIndicator}:{subIndex}",
f"{removeKey}="]
return metadataTokens return metadataTokens
@@ -232,12 +240,11 @@ class FfxController():
def runJob(self, def runJob(self,
sourcePath, sourcePath,
targetPath, targetPath,
targetFormat: str = '',
videoEncoder: VideoEncoder = VideoEncoder.VP9, videoEncoder: VideoEncoder = VideoEncoder.VP9,
quality: int = DEFAULT_QUALITY, quality: int = DEFAULT_QUALITY,
preset: int = DEFAULT_AV1_PRESET, preset: int = DEFAULT_AV1_PRESET):
denoise: bool = False):
# self.__targetMediaDescriptor order OK
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath] commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath]
@@ -248,11 +255,9 @@ class FfxController():
+ self.__targetMediaDescriptor.getInputMappingTokens() + self.__targetMediaDescriptor.getInputMappingTokens()
+ self.generateDispositionTokens()) + self.generateDispositionTokens())
if not self.__sourceMediaDescriptor is None: # Optional tokens
commandSequence += self.generateMetadataTokens() commandSequence += self.generateMetadataTokens()
commandSequence += self.__context['denoiser'].generateDenoiseTokens()
if denoise:
commandSequence += self.generateDenoiseTokens()
commandSequence += (self.generateAudioEncodingTokens() commandSequence += (self.generateAudioEncodingTokens()
+ self.generateAV1Tokens(int(quality), int(preset)) + self.generateAV1Tokens(int(quality), int(preset))
@@ -262,44 +267,49 @@ class FfxController():
commandSequence += FfxController.generateCropTokens() commandSequence += FfxController.generateCropTokens()
commandSequence += self.generateOutputTokens(targetPath, commandSequence += self.generateOutputTokens(targetPath,
FfxController.DEFAULT_FILE_FORMAT, targetFormat)
FfxController.DEFAULT_FILE_EXTENSION)
self.__logger.debug(f"FfxController.runJon() commandSequence:{' '.join(commandSequence)}") self.__logger.debug(f"FfxController.runJob() commandSequence:{' '.join(commandSequence)}")
if not self.__context['dry_run']: if not self.__context['dry_run']:
executeProcess(commandSequence) executeProcess(commandSequence, niceness=self.__niceness, cpu_percent=self.__cpuPercent)
if videoEncoder == VideoEncoder.VP9: if videoEncoder == VideoEncoder.VP9:
commandSequence1 = (commandTokens commandSequence1 = (commandTokens
+ self.__targetMediaDescriptor.getInputMappingTokens(only_video=True) + self.__targetMediaDescriptor.getInputMappingTokens(only_video=True))
+ self.generateVP9Pass1Tokens(int(quality)))
# Optional tokens
#NOTE: Filters and so needs to run on the first pass as well, as here
# the required bitrate for the second run is determined and recorded
# TODO: Results seems to be slightly better with first pass omitted,
# Confirm or find better filter settings for 2-pass
# commandSequence1 += self.__context['denoiser'].generateDenoiseTokens()
commandSequence1 += self.generateVP9Pass1Tokens(int(quality))
if self.__context['perform_crop']: if self.__context['perform_crop']:
commandSequence1 += self.generateCropTokens() commandSequence1 += self.generateCropTokens()
commandSequence1 += FfxController.NULL_TOKENS commandSequence1 += FfxController.NULL_TOKENS
self.__logger.debug(f"FfxController.runJon() commandSequence1:{' '.join(commandSequence1)}") self.__logger.debug(f"FfxController.runJob() commandSequence1:{' '.join(commandSequence1)}")
if os.path.exists(FfxController.TEMP_FILE_NAME): if os.path.exists(FfxController.TEMP_FILE_NAME):
os.remove(FfxController.TEMP_FILE_NAME) os.remove(FfxController.TEMP_FILE_NAME)
if not self.__context['dry_run']: if not self.__context['dry_run']:
executeProcess(commandSequence1) executeProcess(commandSequence1, niceness=self.__niceness, cpu_percent=self.__cpuPercent)
commandSequence2 = (commandTokens commandSequence2 = (commandTokens
+ self.__targetMediaDescriptor.getImportFileTokens() + self.__targetMediaDescriptor.getImportFileTokens()
+ self.__targetMediaDescriptor.getInputMappingTokens() + self.__targetMediaDescriptor.getInputMappingTokens()
+ self.generateDispositionTokens()) + self.generateDispositionTokens())
if not self.__sourceMediaDescriptor is None: # Optional tokens
commandSequence2 += self.generateMetadataTokens() commandSequence2 += self.generateMetadataTokens()
commandSequence2 += self.__context['denoiser'].generateDenoiseTokens()
if denoise:
commandSequence2 += self.generateDenoiseTokens()
commandSequence2 += self.generateVP9Pass2Tokens(int(quality)) + self.generateAudioEncodingTokens() commandSequence2 += self.generateVP9Pass2Tokens(int(quality)) + self.generateAudioEncodingTokens()
@@ -307,20 +317,19 @@ class FfxController():
commandSequence2 += self.generateCropTokens() commandSequence2 += self.generateCropTokens()
commandSequence2 += self.generateOutputTokens(targetPath, commandSequence2 += self.generateOutputTokens(targetPath,
FfxController.DEFAULT_FILE_FORMAT, targetFormat)
FfxController.DEFAULT_FILE_EXTENSION)
self.__logger.debug(f"FfxController.runJon() commandSequence2:{' '.join(commandSequence2)}") self.__logger.debug(f"FfxController.runJob() commandSequence2:{' '.join(commandSequence2)}")
if not self.__context['dry_run']: if not self.__context['dry_run']:
out, err, rc = executeProcess(commandSequence2) out, err, rc = executeProcess(commandSequence2, niceness=self.__niceness, cpu_percent=self.__cpuPercent)
if rc: if rc:
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}") raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
def createEmptyFile(self, def createEmptyFile(self,
path: str = 'output.mp4', path: str = 'empty.mkv',
sizeX: int = 1280, sizeX: int = 1280,
sizeY: int = 720, sizeY: int = 720,
rate: int = 25, rate: int = 25,
@@ -340,4 +349,4 @@ class FfxController():
str(length), str(length),
path] path]
out, err, rc = executeProcess(commandTokens) out, err, rc = executeProcess(commandTokens, niceness=self.__niceness, cpu_percent=self.__cpuPercent)

View File

@@ -14,7 +14,8 @@ class FileProperties():
FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm'] FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
SEASON_EPISODE_INDICATOR_MATCH = '[sS]([0-9]+)[eE]([0-9]+)' SE_INDICATOR_PATTERN = '([sS][0-9]+[eE][0-9]+)'
SEASON_EPISODE_INDICATOR_MATCH = '[sS]([0-9]+)[eE]([0-9]+)'
EPISODE_INDICATOR_MATCH = '[eE]([0-9]+)' EPISODE_INDICATOR_MATCH = '[eE]([0-9]+)'
DEFAULT_INDEX_DIGITS = 3 DEFAULT_INDEX_DIGITS = 3
@@ -40,22 +41,23 @@ class FileProperties():
self.__sourceFileBasename = self.__sourceFilename self.__sourceFileBasename = self.__sourceFilename
self.__sourceFilenameExtension = '' self.__sourceFilenameExtension = ''
self.__pc = PatternController(context) self.__pc = PatternController(context)
# db pattern boruto_[sS]([0-9]+)[eE]([0-9]+).mkv
# Checking if database contains matching pattern # Checking if database contains matching pattern
matchResult = self.__pc.matchFilename(self.__sourceFilename) matchResult = self.__pc.matchFilename(self.__sourceFilename)
self.__logger.debug(f"FileProperties.__init__(): Match result {matchResult}") self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}")
self.__pattern: Pattern = matchResult['pattern'] if matchResult else None self.__pattern: Pattern = matchResult['pattern'] if matchResult else None
if matchResult: if matchResult:
databaseMatchedGroups = matchResult['match'].groups() databaseMatchedGroups = matchResult['match'].groups()
self.__season = databaseMatchedGroups[0] self.__logger.debug(f"FileProperties.__init__(): Matched groups: {databaseMatchedGroups}")
self.__episode = databaseMatchedGroups[1]
seIndicator = databaseMatchedGroups[0]
se_match = re.search(FileProperties.SEASON_EPISODE_INDICATOR_MATCH, seIndicator)
e_match = re.search(FileProperties.EPISODE_INDICATOR_MATCH, seIndicator)
else: else:
self.__logger.debug(f"FileProperties.__init__(): Checking file name for indicator {self.__sourceFilename}") self.__logger.debug(f"FileProperties.__init__(): Checking file name for indicator {self.__sourceFilename}")
@@ -63,15 +65,15 @@ class FileProperties():
se_match = re.search(FileProperties.SEASON_EPISODE_INDICATOR_MATCH, self.__sourceFilename) se_match = re.search(FileProperties.SEASON_EPISODE_INDICATOR_MATCH, self.__sourceFilename)
e_match = re.search(FileProperties.EPISODE_INDICATOR_MATCH, self.__sourceFilename) e_match = re.search(FileProperties.EPISODE_INDICATOR_MATCH, self.__sourceFilename)
if se_match is not None: if se_match is not None:
self.__season = int(se_match.group(1)) self.__season = int(se_match.group(1))
self.__episode = int(se_match.group(2)) self.__episode = int(se_match.group(2))
elif e_match is not None: elif e_match is not None:
self.__season = -1 self.__season = -1
self.__episode = int(e_match.group(1)) self.__episode = int(e_match.group(1))
else: else:
self.__season = -1 self.__season = -1
self.__episode = -1 self.__episode = -1
def getFormatData(self): def getFormatData(self):
@@ -101,22 +103,14 @@ class FileProperties():
"-of", "json", "-of", "json",
self.__sourcePath]) self.__sourcePath])
if 'Invalid data found when processing input' in ffprobeError: if 'Invalid data found when processing input' in ffprobeError:
raise Exception(f"File {self.__sourcePath} does not contain valid stream data") raise Exception(f"File {self.__sourcePath} does not contain valid stream data")
if returnCode != 0: if returnCode != 0:
raise Exception(f"ffprobe returned with error {returnCode}") raise Exception(f"ffprobe returned with error {returnCode}")
return json.loads(ffprobeOutput)['format'] return json.loads(ffprobeOutput)['format']
#[{'index': 0, 'codec_name': 'vp9', 'codec_long_name': 'Google VP9', 'profile': 'Profile 0', 'codec_type': 'video', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'width': 1920, 'height': 1080, 'coded_width': 1920, 'coded_height': 1080, 'closed_captions': 0, 'film_grain': 0, 'has_b_frames': 0, 'sample_aspect_ratio': '1:1', 'display_aspect_ratio': '16:9', 'pix_fmt': 'yuv420p', 'level': -99, 'color_range': 'tv', 'chroma_location': 'left', 'field_order': 'progressive', 'refs': 1, 'r_frame_rate': '24000/1001', 'avg_frame_rate': '24000/1001', 'time_base': '1/1000', 'start_pts': 0, 'start_time': '0.000000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'BPS': '7974017', 'NUMBER_OF_FRAMES': '34382', 'NUMBER_OF_BYTES': '1429358655', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libvpx-vp9', 'DURATION': '00:23:54.016000000'}}]
#[{'index': 1, 'codec_name': 'opus', 'codec_long_name': 'Opus (Opus Interactive Audio Codec)', 'codec_type': 'audio', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'sample_fmt': 'fltp', 'sample_rate': '48000', 'channels': 2, 'channel_layout': 'stereo', 'bits_per_sample': 0, 'initial_padding': 312, 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'extradata_size': 19, 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'jpn', 'title': 'Japanisch', 'BPS': '128000', 'NUMBER_OF_FRAMES': '61763', 'NUMBER_OF_BYTES': '22946145', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libopus', 'DURATION': '00:23:54.141000000'}}]
#[{'index': 2, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'ger', 'title': 'Deutsch [Full]', 'BPS': '118', 'NUMBER_OF_FRAMES': '300', 'NUMBER_OF_BYTES': '21128', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:54.010000000'}}, {'index': 3, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 0, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'eng', 'title': 'Englisch [Full]', 'BPS': '101', 'NUMBER_OF_FRAMES': '276', 'NUMBER_OF_BYTES': '16980', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:53.230000000'}}]
def getStreamData(self): def getStreamData(self):
"""Returns ffprobe stream data as array with elements according to the following example """Returns ffprobe stream data as array with elements according to the following example
@@ -192,10 +186,10 @@ class FileProperties():
return self.__pattern return self.__pattern
def getSeason(self): def getSeason(self) -> int:
return int(self.__season) return int(self.__season)
def getEpisode(self): def getEpisode(self) -> int:
return int(self.__episode) return int(self.__episode)
@@ -204,54 +198,3 @@ class FileProperties():
def getFileBasename(self): def getFileBasename(self):
return self.__sourceFileBasename return self.__sourceFileBasename
def assembleTargetFileBasename(self,
label: str = "",
quality: int = -1,
fileIndex: int = -1,
indexDigits: int = DEFAULT_INDEX_DIGITS,
extraTokens: list = []):
if 'show_descriptor' in self.context.keys():
season_digits = self.context['show_descriptor'][ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
episode_digits = self.context['show_descriptor'][ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
else:
season_digits = ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS
episode_digits = ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS
targetFilenameTokens = []
# targetFilenameExtension = FfxController.DEFAULT_FILE_EXTENSION if extension is None else str(extension)
click.echo(f"assembleTargetFileBasename(): label={label} is {'truthy' if label else 'falsy'}")
if label:
targetFilenameTokens = [label]
if fileIndex > -1:
targetFilenameTokens += [f"{fileIndex:0{indexDigits}d}"]
elif self.__season > -1 and self.__episode > -1:
targetFilenameTokens += [f"S{self.__season:0{season_digits}d}E{self.__episode:0{episode_digits}d}"]
elif self.__episode > -1:
targetFilenameTokens += [f"E{self.__episode:0{episode_digits}d}"]
else:
targetFilenameTokens = [self.__sourceFileBasename]
if quality != -1:
targetFilenameTokens += [f"q{quality}"]
# In case source and target filenames are the same add an extension to distinct output from input
#if not label and self.__sourceFilenameExtension == targetFilenameExtension:
# targetFilenameTokens += ['ffx']
targetFilenameTokens += extraTokens
targetFilename = '_'.join(targetFilenameTokens)
#self.__logger.debug(f"assembleTargetFileBasename(): Target filename: {targetFilename}")
click.echo(f"assembleTargetFileBasename(): Target filename: {targetFilename}")
return targetFilename

View File

@@ -53,9 +53,26 @@ def setDiff(a : set, b : set) -> set:
return diffResult return diffResult
def permutateList(inputList: list, permutation: list):
# 0,1,2: ABC
# 0,2,1: ACB
# 1,2,0: BCA
pass
def filterFilename(fileName: str) -> str: def filterFilename(fileName: str) -> str:
"""This filter replaces charactes from TMDB responses with characters """This filter replaces charactes from TMDB responses with characters
less problemating when using in filenames or removes them""" less problemating when using in filenames or removes them"""
# This appears in TMDB episode names
fileName = str(fileName).replace(' (*)', '')
fileName = str(fileName).replace('(*)', '')
fileName = str(fileName).replace(':', ';') fileName = str(fileName).replace(':', ';')
return fileName fileName = str(fileName).replace('*', '')
fileName = str(fileName).replace("'", '')
return fileName.strip()

View File

@@ -3,6 +3,8 @@ import os, re, click, logging
from typing import List, Self from typing import List, Self
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.iso_language import IsoLanguage
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
@@ -70,15 +72,40 @@ class MediaDescriptor:
else: else:
self.__trackDescriptors = [] self.__trackDescriptors = []
# if MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY in kwargs.keys(): #TODO: to be removed
# if type(kwargs[MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY]) is not bool: self.__jellyfinOrder = False
# raise TypeError(
# f"MediaDescriptor.__init__(): Argument {MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY} is required to be of type bool" def setTrackLanguage(self, language: str, index: int, trackType: TrackType = None):
# )
# self.__jellyfinOrder = kwargs[MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY] trackLanguage = IsoLanguage.findThreeLetter(language)
# else: if trackLanguage == IsoLanguage.UNDEFINED:
# self.__jellyfinOrder = False self.__logger.warning('MediaDescriptor.setTrackLanguage(): Parameter language does not contain a registered '
self.__jellyfinOrder = self.__context['use_jellyfin'] if 'use_jellyfin' in self.__context.keys() else False + f"ISO 639 3-letter language code, skipping to set language for"
+ str('' if trackType is None else trackType.label()) + f"track {index}")
trackList = self.getTrackDescriptors(trackType=trackType)
if index < 0 or index > len(trackList) - 1:
self.__logger.warning(f"MediaDescriptor.setTrackLanguage(): Parameter index ({index}) is "
+ f"out of range of {'' if trackType is None else trackType.label()}track list")
td: TrackDescriptor = trackList[index]
td.setLanguage(trackLanguage)
return
def setTrackTitle(self, title: str, index: int, trackType: TrackType = None):
trackList = self.getTrackDescriptors(trackType=trackType)
if index < 0 or index > len(trackList) - 1:
self.__logger.error(f"MediaDescriptor.setTrackTitle(): Parameter index ({index}) is "
+ f"out of range of {'' if trackType is None else trackType.label()}track list")
raise click.Abort()
td: TrackDescriptor = trackList[index]
td.setTitle(title)
def setDefaultSubTrack(self, trackType: TrackType, subIndex: int): def setDefaultSubTrack(self, trackType: TrackType, subIndex: int):
@@ -123,6 +150,47 @@ class MediaDescriptor:
raise ValueError('Multiple streams originating from the same source stream') raise ValueError('Multiple streams originating from the same source stream')
def applyOverrides(self, overrides: dict):
if 'languages' in overrides.keys():
for trackIndex in overrides['languages'].keys():
self.setTrackLanguage(overrides['languages'][trackIndex], trackIndex)
if 'titles' in overrides.keys():
for trackIndex in overrides['titles'].keys():
self.setTrackTitle(overrides['titles'][trackIndex], trackIndex)
if 'forced_video' in overrides.keys():
sti = int(overrides['forced_video'])
self.setForcedSubTrack(TrackType.VIDEO, sti)
self.setDefaultSubTrack(TrackType.VIDEO, sti)
elif 'default_video' in overrides.keys():
sti = int(overrides['default_video'])
self.setDefaultSubTrack(TrackType.VIDEO, sti)
if 'forced_audio' in overrides.keys():
sti = int(overrides['forced_audio'])
self.setForcedSubTrack(TrackType.AUDIO, sti)
self.setDefaultSubTrack(TrackType.AUDIO, sti)
elif 'default_audio' in overrides.keys():
sti = int(overrides['default_audio'])
self.setDefaultSubTrack(TrackType.AUDIO, sti)
if 'forced_subtitle' in overrides.keys():
sti = int(overrides['forced_subtitle'])
self.setForcedSubTrack(TrackType.SUBTITLE, sti)
self.setDefaultSubTrack(TrackType.SUBTITLE, sti)
elif 'default_subtitle' in overrides.keys():
sti = int(overrides['default_subtitle'])
self.setDefaultSubTrack(TrackType.SUBTITLE, sti)
if 'stream_order' in overrides.keys():
self.rearrangeTrackDescriptors(overrides['stream_order'])
def applySourceIndices(self, sourceMediaDescriptor: Self): def applySourceIndices(self, sourceMediaDescriptor: Self):
sourceTrackDescriptors = sourceMediaDescriptor.getAllTrackDescriptors() sourceTrackDescriptors = sourceMediaDescriptor.getAllTrackDescriptors()
@@ -131,51 +199,16 @@ class MediaDescriptor:
raise ValueError('MediaDescriptor.applySourceIndices (): Number of track descriptors does not match') raise ValueError('MediaDescriptor.applySourceIndices (): Number of track descriptors does not match')
for trackIndex in range(numTrackDescriptors): for trackIndex in range(numTrackDescriptors):
# click.echo(f"{trackIndex} -> {sourceTrackDescriptors[trackIndex].getSourceIndex()}")
self.__trackDescriptors[trackIndex].setSourceIndex(sourceTrackDescriptors[trackIndex].getSourceIndex()) self.__trackDescriptors[trackIndex].setSourceIndex(sourceTrackDescriptors[trackIndex].getSourceIndex())
def applyJellyfinOrder(self): def rearrangeTrackDescriptors(self, newOrder: List[int]):
"""Reorder subtracks in types with default the last, then make subindices flat again""" if len(newOrder) != len(self.__trackDescriptors):
raise ValueError('Length of list with reordered indices does not match number of track descriptors')
# videoTracks = self.sortSubIndices(self.getVideoTracks()) reorderedTrackDescriptors = {}
# audioTracks = self.sortSubIndices(self.getAudioTracks()) for oldIndex in newOrder:
# subtitleTracks = self.sortSubIndices(self.getSubtitleTracks()) reorderedTrackDescriptors.append(self.__trackDescriptors[oldIndex])
self.__trackDescriptors = reorderedTrackDescriptors
self.checkConfiguration()
# from self.__trackDescriptors
videoTracks = self.getVideoTracks()
audioTracks = self.getAudioTracks()
subtitleTracks = self.getSubtitleTracks()
defaultVideoTracks = [v for v in videoTracks if v.getDispositionFlag(TrackDisposition.DEFAULT)]
defaultAudioTracks = [a for a in audioTracks if a.getDispositionFlag(TrackDisposition.DEFAULT)]
defaultSubtitleTracks = [s for s in subtitleTracks if s.getDispositionFlag(TrackDisposition.DEFAULT)]
if defaultVideoTracks:
videoTracks.append(videoTracks.pop(videoTracks.index(defaultVideoTracks[0])))
#self.sortSubIndices(videoTracks)
numVideoTracks = len(videoTracks)
for vIndex in range(numVideoTracks):
videoTracks[vIndex].setDispositionFlag(TrackDisposition.DEFAULT,
vIndex == numVideoTracks - 1)
if defaultAudioTracks:
audioTracks.append(audioTracks.pop(audioTracks.index(defaultAudioTracks[0])))
#self.sortSubIndices(audioTracks)
numAudioTracks = len(audioTracks)
for aIndex in range(numAudioTracks):
audioTracks[aIndex].setDispositionFlag(TrackDisposition.DEFAULT,
aIndex == numAudioTracks - 1)
if defaultSubtitleTracks:
subtitleTracks.append(subtitleTracks.pop(subtitleTracks.index(defaultSubtitleTracks[0])))
#self.sortSubIndices(subtitleTracks)
numSubtitleTracks = len(subtitleTracks)
for sIndex in range(numSubtitleTracks):
subtitleTracks[sIndex].setDispositionFlag(TrackDisposition.DEFAULT,
sIndex == numSubtitleTracks - 1)
self.__trackDescriptors = videoTracks + audioTracks + subtitleTracks
#self.sortIndices(self.__trackDescriptors)
self.reindexSubIndices() self.reindexSubIndices()
self.reindexIndices() self.reindexIndices()
@@ -254,18 +287,30 @@ class MediaDescriptor:
tdList[trackIndex].setIndex(trackIndex) tdList[trackIndex].setIndex(trackIndex)
def getAllTrackDescriptors(self) -> List[TrackDescriptor]: def getAllTrackDescriptors(self):
"""Returns all track descriptors sorted by type: video, audio then subtitles"""
return self.getVideoTracks() + self.getAudioTracks() + self.getSubtitleTracks() return self.getVideoTracks() + self.getAudioTracks() + self.getSubtitleTracks()
def getTrackDescriptors(self,
trackType: TrackType = None) -> List[TrackDescriptor]:
if trackType is None:
return self.__trackDescriptors
descriptorList = []
for td in self.__trackDescriptors:
if td.getType() == trackType:
descriptorList.append(td)
return descriptorList
def getVideoTracks(self) -> List[TrackDescriptor]: def getVideoTracks(self) -> List[TrackDescriptor]:
return [ return [v for v in self.__trackDescriptors if v.getType() == TrackType.VIDEO]
v for v in self.__trackDescriptors if v.getType() == TrackType.VIDEO
]
def getAudioTracks(self) -> List[TrackDescriptor]: def getAudioTracks(self) -> List[TrackDescriptor]:
return [ return [a for a in self.__trackDescriptors if a.getType() == TrackType.AUDIO]
a for a in self.__trackDescriptors if a.getType() == TrackType.AUDIO
]
def getSubtitleTracks(self) -> List[TrackDescriptor]: def getSubtitleTracks(self) -> List[TrackDescriptor]:
return [ return [
@@ -278,10 +323,8 @@ class MediaDescriptor:
def compare(self, vsMediaDescriptor: Self): def compare(self, vsMediaDescriptor: Self):
if not isinstance(vsMediaDescriptor, self.__class__): if not isinstance(vsMediaDescriptor, self.__class__):
errorMessage = f"MediaDescriptor.compare(): Argument is required to be of type {self.__class__}" self.__logger.error(f"MediaDescriptor.compare(): Argument is required to be of type {self.__class__}")
self.__logger.error(errorMessage) raise click.Abort()
# raise click.ClickException(errorMessage)
click.Abort()
vsTags = vsMediaDescriptor.getTags() vsTags = vsMediaDescriptor.getTags()
tags = self.getTags() tags = self.getTags()
@@ -357,10 +400,8 @@ class MediaDescriptor:
def getImportFileTokens(self, use_sub_index: bool = True): def getImportFileTokens(self, use_sub_index: bool = True):
# reorderedTrackDescriptors = self.getReorderedTrackDescriptors()
importFileTokens = [] importFileTokens = []
#for rtd in reorderedTrackDescriptors:
for td in self.__trackDescriptors: for td in self.__trackDescriptors:
importedFilePath = td.getExternalSourceFilePath() importedFilePath = td.getExternalSourceFilePath()
@@ -377,14 +418,6 @@ class MediaDescriptor:
def getInputMappingTokens(self, use_sub_index: bool = True, only_video: bool = False): def getInputMappingTokens(self, use_sub_index: bool = True, only_video: bool = False):
"""Tracks must be reordered for source index order""" """Tracks must be reordered for source index order"""
# sourceTrackDescriptorSubIndices = [self.__trackDescriptors[std.getSourceIndex()].getSubIndex()
# for std in self.__trackDescriptors]
# self.reindexSubIndices(trackDescriptors = sourceOrderTrackDescriptors)
# self.reindexIndices(trackDescriptors = sourceOrderTrackDescriptors)
# click.echo(sourceTrackDescriptorIndices)
inputMappingTokens = [] inputMappingTokens = []
filePointer = 1 filePointer = 1
@@ -467,17 +500,12 @@ class MediaDescriptor:
availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix) availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix)
# click.echo(f"availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}") self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
subtitleTracks = self.getSubtitleTracks() subtitleTracks = self.getSubtitleTracks()
# click.echo(f"subtitleTracks: {[s.getIndex() for s in subtitleTracks]}")
self.__logger.debug(f"importSubtitles(): subtitleTracks: {[s.getIndex() for s in subtitleTracks]}") self.__logger.debug(f"importSubtitles(): subtitleTracks: {[s.getIndex() for s in subtitleTracks]}")
# if len(availableFileSubtitleDescriptors) != len(subtitleTracks):
# raise click.ClickException(f"MediaDescriptor.importSubtitles(): Number if subtitle files not matching number of subtitle tracks")
matchingSubtitleFileDescriptors = ( matchingSubtitleFileDescriptors = (
sorted( sorted(
[ [
@@ -491,15 +519,13 @@ class MediaDescriptor:
else [] else []
) )
# click.echo(f"matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}") self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
# click.echo(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
for msfd in matchingSubtitleFileDescriptors: for msfd in matchingSubtitleFileDescriptors:
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]] matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
if matchingSubtitleTrackDescriptor: if matchingSubtitleTrackDescriptor:
# click.echo(f"Found matching subtitle file {msfd["path"]}\n") # click.echo(f"Found matching subtitle file {msfd["path"]}\n")
self.__logger.debug(f"importSubtitles(): Found matching subtitle file {msfd["path"]}") self.__logger.debug(f"importSubtitles(): Found matching subtitle file {msfd['path']}")
matchingSubtitleTrackDescriptor[0].setExternalSourceFilePath(msfd["path"]) matchingSubtitleTrackDescriptor[0].setExternalSourceFilePath(msfd["path"])

View File

@@ -9,6 +9,8 @@ from textual.containers import Grid
from ffx.model.show import Show from ffx.model.show import Show
from ffx.model.pattern import Pattern from ffx.model.pattern import Pattern
from ffx.audio_layout import AudioLayout
from .pattern_controller import PatternController from .pattern_controller import PatternController
from .show_controller import ShowController from .show_controller import ShowController
from .track_controller import TrackController from .track_controller import TrackController
@@ -42,7 +44,7 @@ class MediaDetailsScreen(Screen):
Grid { Grid {
grid-size: 5 8; grid-size: 5 8;
grid-rows: 8 2 2 2 8 2 2 8; grid-rows: 8 2 2 2 8 2 2 8;
grid-columns: 25 25 100 10 75; grid-columns: 25 25 120 10 75;
height: 100%; height: 100%;
width: 100%; width: 100%;
padding: 1; padding: 1;
@@ -299,11 +301,13 @@ class MediaDetailsScreen(Screen):
typeCounter[trackType] = 0 typeCounter[trackType] = 0
dispoSet = td.getDispositionSet() dispoSet = td.getDispositionSet()
audioLayout = td.getAudioLayout()
row = (td.getIndex(), row = (td.getIndex(),
trackType.label(), trackType.label(),
typeCounter[trackType], typeCounter[trackType],
td.getAudioLayout().label() if trackType == TrackType.AUDIO else ' ', td.getCodec(),
audioLayout.label() if trackType == TrackType.AUDIO
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
td.getLanguage().label(), td.getLanguage().label(),
td.getTitle(), td.getTitle(),
'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No', 'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No',
@@ -341,7 +345,8 @@ class MediaDetailsScreen(Screen):
# Define the columns with headers # Define the columns with headers
self.column_key_track_index = self.tracksTable.add_column("Index", width=5) self.column_key_track_index = self.tracksTable.add_column("Index", width=5)
self.column_key_track_type = self.tracksTable.add_column("Type", width=10) self.column_key_track_type = self.tracksTable.add_column("Type", width=10)
self.column_key_track_sub_index = self.tracksTable.add_column("Subindex", width=5) self.column_key_track_sub_index = self.tracksTable.add_column("SubIndex", width=8)
self.column_key_track_codec = self.tracksTable.add_column("Codec", width=10)
self.column_key_track_layout = self.tracksTable.add_column("Layout", width=10) self.column_key_track_layout = self.tracksTable.add_column("Layout", width=10)
self.column_key_track_language = self.tracksTable.add_column("Language", width=15) self.column_key_track_language = self.tracksTable.add_column("Language", width=15)
self.column_key_track_title = self.tracksTable.add_column("Title", width=48) self.column_key_track_title = self.tracksTable.add_column("Title", width=48)
@@ -419,14 +424,12 @@ class MediaDetailsScreen(Screen):
if event.button.id == "pattern_button": if event.button.id == "pattern_button":
INDICATOR_PATTERN = '([sS][0-9]+[eE][0-9]+)'
pattern = self.query_one("#pattern_input", Input).value pattern = self.query_one("#pattern_input", Input).value
patternMatch = re.search(INDICATOR_PATTERN, pattern) patternMatch = re.search(FileProperties.SE_INDICATOR_PATTERN, pattern)
if patternMatch: if patternMatch:
self.query_one("#pattern_input", Input).value = pattern.replace(patternMatch.group(1), INDICATOR_PATTERN) self.query_one("#pattern_input", Input).value = pattern.replace(patternMatch.group(1), FileProperties.SE_INDICATOR_PATTERN)
if event.button.id == "select_default_button": if event.button.id == "select_default_button":
@@ -456,6 +459,8 @@ class MediaDetailsScreen(Screen):
kwargs[TrackDescriptor.INDEX_KEY] = int(selected_track_data[0]) kwargs[TrackDescriptor.INDEX_KEY] = int(selected_track_data[0])
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(selected_track_data[1]) kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(selected_track_data[1])
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(selected_track_data[2]) kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(selected_track_data[2])
kwargs[TrackDescriptor.CODEC_NAME_KEY] = int(selected_track_data[3])
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(selected_track_data[4])
return TrackDescriptor(**kwargs) return TrackDescriptor(**kwargs)
else: else:
@@ -623,10 +628,8 @@ class MediaDetailsScreen(Screen):
def action_edit_pattern(self): def action_edit_pattern(self):
patternDescriptor = {} patternDescriptor = self.getPatternDescriptorFromInput()
patternDescriptor['show_id'] = self.getSelectedShowDescriptor().getId()
patternDescriptor['pattern'] = self.getPatternFromInput()
if patternDescriptor['pattern']: if patternDescriptor['pattern']:

View File

@@ -151,9 +151,9 @@ class Track(Base):
def getType(self): def getType(self):
return TrackType.fromIndex(self.track_type) return TrackType.fromIndex(self.track_type)
def getCodec(self): def getCodec(self):
return str(self.codec_name) return self.codec_name
def getIndex(self): def getIndex(self):
return int(self.index) if self.index is not None else -1 return int(self.index) if self.index is not None else -1
@@ -189,11 +189,12 @@ class Track(Base):
return bool(self.disposition_flags & 2**disposition.index()) return bool(self.disposition_flags & 2**disposition.index())
def getDescriptor(self, context, subIndex : int = -1) -> TrackDescriptor: def getDescriptor(self, context = None, subIndex : int = -1) -> TrackDescriptor:
kwargs = {} kwargs = {}
kwargs[TrackDescriptor.CONTEXT_KEY] = context if not context is None:
kwargs[TrackDescriptor.CONTEXT_KEY] = context
kwargs[TrackDescriptor.ID_KEY] = self.getId() kwargs[TrackDescriptor.ID_KEY] = self.getId()
kwargs[TrackDescriptor.PATTERN_ID_KEY] = self.getPatternId() kwargs[TrackDescriptor.PATTERN_ID_KEY] = self.getPatternId()

View File

@@ -0,0 +1,142 @@
class NlmeansController():
"""
s: double
Denoising strength (from 1 to 30) (default 1)
Trade-off between noise removal and detail retention. Comparable to gaussian sigma.
p: int patch size (from 0 to 99) (default 7)
Catches larger areas reducing broader noise patterns, but costly
pc: int patch size for chroma planes (from 0 to 99) (default 0)
r: int research window (from 0 to 99) (default 15)
Range to search for comparable patches.
Better filtering but costly
rc: int research window for chroma planes (from 0 to 99) (default 0)
Good values to denoise film grain that was subobtimally encoded:
strength: float = 2.8
patchSize: int = 12
chromaPatchSize: int = 8
researchWindow: int = 22
chromaResearchWindow: int= 16
"""
DEFAULT_STRENGTH: float = 2.8
DEFAULT_PATCH_SIZE: int = 13
DEFAULT_CHROMA_PATCH_SIZE: int = 9
DEFAULT_RESEARCH_WINDOW: int = 23
DEFAULT_CHROMA_RESEARCH_WINDOW: int= 17
def __init__(self,
parameters: str = "none",
strength: str = "",
patchSize: str = "",
chromaPatchSize: str = "",
researchWindow: str = "",
chromaResearchWindow: str = "",
useHardware: bool = False):
self.__isActive = (parameters != "none"
or strength
or patchSize
or chromaPatchSize
or researchWindow
or chromaResearchWindow)
self.__useHardware = useHardware
parameterTokens = parameters.split(',')
self.__strengthList = []
if strength:
strengthTokens = strength.split(',')
for st in strengthTokens:
try:
strengthValue = float(st)
except:
raise ValueError('NlmeansController: Strength value has to be of type float')
if strengthValue < 1.0 or strengthValue > 30.0:
raise ValueError('NlmeansController: Strength value has to be between 1.0 and 30.0')
self.__strengthList.append(strengthValue)
else:
self.__strengthList = [NlmeansController.DEFAULT_STRENGTH]
self.__patchSizeList = []
if patchSize:
patchSizeTokens = patchSize.split(',')
for pst in patchSizeTokens:
try:
patchSizeValue = int(pst)
except:
raise ValueError('NlmeansController: Patch size value has to be of type int')
if patchSizeValue < 0 or patchSizeValue > 99:
raise ValueError('NlmeansController: Patch size value has to be between 0 and 99')
if patchSizeValue % 2 == 0:
raise ValueError('NlmeansController: Patch size value has to an odd number')
self.__patchSizeList.append(patchSizeValue)
else:
self.__patchSizeList = [NlmeansController.DEFAULT_PATCH_SIZE]
self.__chromaPatchSizeList = []
if chromaPatchSize:
chromaPatchSizeTokens = chromaPatchSize.split(',')
for cpst in chromaPatchSizeTokens:
try:
chromaPatchSizeValue = int(pst)
except:
raise ValueError('NlmeansController: Chroma patch size value has to be of type int')
if chromaPatchSizeValue < 0 or chromaPatchSizeValue > 99:
raise ValueError('NlmeansController: Chroma patch value has to be between 0 and 99')
if chromaPatchSizeValue % 2 == 0:
raise ValueError('NlmeansController: Chroma patch value has to an odd number')
self.__chromaPatchSizeList.append(chromaPatchSizeValue)
else:
self.__chromaPatchSizeList = [NlmeansController.DEFAULT_CHROMA_PATCH_SIZE]
self.__researchWindowList = []
if researchWindow:
researchWindowTokens = researchWindow.split(',')
for rwt in researchWindowTokens:
try:
researchWindowValue = int(rwt)
except:
raise ValueError('NlmeansController: Research window value has to be of type int')
if researchWindowValue < 0 or researchWindowValue > 99:
raise ValueError('NlmeansController: Research window value has to be between 0 and 99')
if researchWindowValue % 2 == 0:
raise ValueError('NlmeansController: Research window value has to an odd number')
self.__researchWindowList.append(researchWindowValue)
else:
self.__researchWindowList = [NlmeansController.DEFAULT_RESEARCH_WINDOW]
self.__chromaResearchWindowList = []
if chromaResearchWindow:
chromaResearchWindowTokens = chromaResearchWindow.split(',')
for crwt in chromaResearchWindowTokens:
try:
chromaResearchWindowValue = int(crwt)
except:
raise ValueError('NlmeansController: Chroma research window value has to be of type int')
if chromaResearchWindowValue < 0 or chromaResearchWindowValue > 99:
raise ValueError('NlmeansController: Chroma research window value has to be between 0 and 99')
if chromaResearchWindowValue % 2 == 0:
raise ValueError('NlmeansController: Chroma research window value has to an odd number')
self.__chromaResearchWindowList.append(chromaResearchWindowValue)
else:
self.__chromaResearchWindowList = [NlmeansController.DEFAULT_CHROMA_RESEARCH_WINDOW]
def isActive(self):
return self.__isActive
def generateDenoiseTokens(self):
filterName = 'nlmeans_opencl' if self.__useHardware else 'nlmeans'
return ['-vf', f"{filterName}=s={self.__strengthList[0]}"
+ f":p={self.__patchSizeList[0]}"
+ f":pc={self.__chromaPatchSizeList[0]}"
+ f":r={self.__researchWindowList[0]}"
+ f":rc={self.__chromaResearchWindowList[0]}"] if self.__isActive else []

View File

@@ -16,7 +16,8 @@ class PatternController():
try: try:
s = self.Session() s = self.Session()
q = s.query(Pattern).filter(Pattern.show_id == int(patternDescriptor['show_id'])) q = s.query(Pattern).filter(Pattern.show_id == int(patternDescriptor['show_id']),
Pattern.pattern == str(patternDescriptor['pattern']))
if not q.count(): if not q.count():
pattern = Pattern(show_id = int(patternDescriptor['show_id']), pattern = Pattern(show_id = int(patternDescriptor['show_id']),
@@ -138,18 +139,18 @@ class PatternController():
finally: finally:
s.close() s.close()
def getMediaDescriptor(self, context, patternId): # def getMediaDescriptor(self, context, patternId):
#
try: # try:
s = self.Session() # s = self.Session()
q = s.query(Pattern).filter(Pattern.id == int(patternId)) # q = s.query(Pattern).filter(Pattern.id == int(patternId))
#
if q.count(): # if q.count():
return q.first().getMediaDescriptor(context) # return q.first().getMediaDescriptor(context)
else: # else:
return None # return None
#
except Exception as ex: # except Exception as ex:
raise click.ClickException(f"PatternController.getPatternDescriptor(): {repr(ex)}") # raise click.ClickException(f"PatternController.getMediaDescriptor(): {repr(ex)}")
finally: # finally:
s.close() # s.close()

View File

@@ -7,6 +7,8 @@ from textual.containers import Grid
from .show_controller import ShowController from .show_controller import ShowController
from .pattern_controller import PatternController from .pattern_controller import PatternController
from ffx.model.pattern import Pattern
# Screen[dict[int, str, int]] # Screen[dict[int, str, int]]
class PatternDeleteScreen(Screen): class PatternDeleteScreen(Screen):
@@ -51,16 +53,16 @@ class PatternDeleteScreen(Screen):
self.__pc = PatternController(context = self.context) self.__pc = PatternController(context = self.context)
self.__sc = ShowController(context = self.context) self.__sc = ShowController(context = self.context)
self.pattern_id = patternId self.__patternId = patternId
self.pattern_obj = self.__pc.getPatternDescriptor(patternId) if patternId is not None else {} self.__pattern: Pattern = self.__pc.getPattern(patternId) if patternId is not None else {}
self.show_obj = self.__sc.getShowDescriptor(showId) if showId is not None else {} self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else {}
def on_mount(self): def on_mount(self):
if self.show_obj: if self.__showDescriptor:
self.query_one("#showlabel", Static).update(f"{self.show_obj['id']} - {self.show_obj['name']} ({self.show_obj['year']})") self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
if self.pattern_obj: if not self.__pattern is None:
self.query_one("#patternlabel", Static).update(str(self.pattern_obj['pattern'])) self.query_one("#patternlabel", Static).update(str(self.__pattern.pattern))
def compose(self): def compose(self):
@@ -94,11 +96,11 @@ class PatternDeleteScreen(Screen):
if event.button.id == "delete_button": if event.button.id == "delete_button":
if self.pattern_id is None: if self.__patternId is None:
raise click.ClickException('PatternDeleteScreen.on_button_pressed(): pattern id is undefined') raise click.ClickException('PatternDeleteScreen.on_button_pressed(): pattern id is undefined')
if self.__pc.deletePattern(self.pattern_id): if self.__pc.deletePattern(self.__patternId):
self.dismiss(self.pattern_obj) self.dismiss(self.__pattern)
else: else:
#TODO: Meldung #TODO: Meldung

View File

@@ -1,4 +1,5 @@
import click, re import click, re
from typing import List
from textual import events from textual import events
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
@@ -28,6 +29,10 @@ from ffx.track_descriptor import TrackDescriptor
from textual.widgets._data_table import CellDoesNotExist from textual.widgets._data_table import CellDoesNotExist
from ffx.file_properties import FileProperties
from ffx.iso_language import IsoLanguage
from ffx.audio_layout import AudioLayout
# Screen[dict[int, str, int]] # Screen[dict[int, str, int]]
class PatternDetailsScreen(Screen): class PatternDetailsScreen(Screen):
@@ -35,9 +40,9 @@ class PatternDetailsScreen(Screen):
CSS = """ CSS = """
Grid { Grid {
grid-size: 5 13; grid-size: 7 13;
grid-rows: 2 2 2 2 2 8 2 2 8 2 2 2 2; grid-rows: 2 2 2 2 2 8 2 2 8 2 2 2 2;
grid-columns: 25 25 25 25 25; grid-columns: 25 25 25 25 25 25 25;
height: 100%; height: 100%;
width: 100%; width: 100%;
padding: 1; padding: 1;
@@ -68,6 +73,12 @@ class PatternDetailsScreen(Screen):
.five { .five {
column-span: 5; column-span: 5;
} }
.six {
column-span: 6;
}
.seven {
column-span: 7;
}
.box { .box {
height: 100%; height: 100%;
@@ -124,6 +135,7 @@ class PatternDetailsScreen(Screen):
typeCounter = {} typeCounter = {}
tr: Track
for tr in tracks: for tr in tracks:
td : TrackDescriptor = tr.getDescriptor(self.context) td : TrackDescriptor = tr.getDescriptor(self.context)
@@ -134,19 +146,61 @@ class PatternDetailsScreen(Screen):
dispoSet = td.getDispositionSet() dispoSet = td.getDispositionSet()
trackLanguage = td.getLanguage()
audioLayout = td.getAudioLayout()
row = (td.getIndex(), row = (td.getIndex(),
trackType.label(), trackType.label(),
typeCounter[trackType], typeCounter[trackType],
td.getAudioLayout().label() if trackType == TrackType.AUDIO else ' ', td.getCodec(),
td.getLanguage().label(), audioLayout.label() if trackType == TrackType.AUDIO
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ',
td.getTitle(), td.getTitle(),
'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No', 'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No',
'Yes' if TrackDisposition.FORCED in dispoSet else 'No') 'Yes' if TrackDisposition.FORCED in dispoSet else 'No',
td.getSourceIndex())
self.tracksTable.add_row(*map(str, row)) self.tracksTable.add_row(*map(str, row))
typeCounter[trackType] += 1 typeCounter[trackType] += 1
def swapTracks(self, trackIndex1: int, trackIndex2: int):
ti1 = int(trackIndex1)
ti2 = int(trackIndex2)
siblingDescriptors: List[TrackDescriptor] = self.__tc.findSiblingDescriptors(self.__pattern.getId())
numSiblings = len(siblingDescriptors)
if ti1 < 0 or ti1 >= numSiblings:
raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex1 ({ti1}) is out of range ({numSiblings})")
if ti2 < 0 or ti2 >= numSiblings:
raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex2 ({ti2}) is out of range ({numSiblings})")
sibling1 = siblingDescriptors[trackIndex1]
sibling2 = siblingDescriptors[trackIndex2]
# raise click.ClickException(f"siblings id1={sibling1.getId()} id2={sibling2.getId()}")
subIndex2 = sibling2.getSubIndex()
sibling2.setIndex(sibling1.getIndex())
sibling2.setSubIndex(sibling1.getSubIndex())
sibling1.setIndex(trackIndex2)
sibling1.setSubIndex(subIndex2)
if not self.__tc.updateTrack(sibling1.getId(), sibling1):
raise click.ClickException('Update sibling1 failed')
if not self.__tc.updateTrack(sibling2.getId(), sibling2):
raise click.ClickException('Update sibling2 failed')
self.updateTracks()
def updateTags(self): def updateTags(self):
self.tagsTable.clear() self.tagsTable.clear()
@@ -179,7 +233,7 @@ class PatternDetailsScreen(Screen):
def compose(self): def compose(self):
self.tagsTable = DataTable(classes="five") self.tagsTable = DataTable(classes="seven")
# Define the columns with headers # Define the columns with headers
self.column_key_tag_key = self.tagsTable.add_column("Key", width=10) self.column_key_tag_key = self.tagsTable.add_column("Key", width=10)
@@ -188,16 +242,18 @@ class PatternDetailsScreen(Screen):
self.tagsTable.cursor_type = 'row' self.tagsTable.cursor_type = 'row'
self.tracksTable = DataTable(id="tracks_table", classes="five") self.tracksTable = DataTable(id="tracks_table", classes="seven")
self.column_key_track_index = self.tracksTable.add_column("Index", width=5) self.column_key_track_index = self.tracksTable.add_column("Index", width=5)
self.column_key_track_type = self.tracksTable.add_column("Type", width=10) self.column_key_track_type = self.tracksTable.add_column("Type", width=10)
self.column_key_track_sub_index = self.tracksTable.add_column("Subindex", width=5) self.column_key_track_sub_index = self.tracksTable.add_column("SubIndex", width=8)
self.column_key_track_codec = self.tracksTable.add_column("Codec", width=10)
self.column_key_track_audio_layout = self.tracksTable.add_column("Layout", width=10) self.column_key_track_audio_layout = self.tracksTable.add_column("Layout", width=10)
self.column_key_track_language = self.tracksTable.add_column("Language", width=15) self.column_key_track_language = self.tracksTable.add_column("Language", width=15)
self.column_key_track_title = self.tracksTable.add_column("Title", width=48) self.column_key_track_title = self.tracksTable.add_column("Title", width=48)
self.column_key_track_default = self.tracksTable.add_column("Default", width=8) self.column_key_track_default = self.tracksTable.add_column("Default", width=8)
self.column_key_track_forced = self.tracksTable.add_column("Forced", width=8) self.column_key_track_forced = self.tracksTable.add_column("Forced", width=8)
self.column_key_track_source_index = self.tracksTable.add_column("SrcIndex", width=8)
self.tracksTable.cursor_type = 'row' self.tracksTable.cursor_type = 'row'
@@ -208,21 +264,21 @@ class PatternDetailsScreen(Screen):
# 1 # 1
yield Static("Edit filename pattern" if self.__pattern is not None else "New filename pattern", id="toplabel") yield Static("Edit filename pattern" if self.__pattern is not None else "New filename pattern", id="toplabel")
yield Input(type="text", id="pattern_input", classes="four") yield Input(type="text", id="pattern_input", classes="six")
# 2 # 2
yield Static("from show") yield Static("from show")
yield Static("", id="showlabel", classes="three") yield Static("", id="showlabel", classes="five")
yield Button("Substitute pattern", id="pattern_button") yield Button("Substitute pattern", id="pattern_button")
# 3 # 3
yield Static(" ", classes="five") yield Static(" ", classes="seven")
# 4 # 4
yield Static(" ", classes="five") yield Static(" ", classes="seven")
# 5 # 5
yield Static("Media Tags") yield Static("Media Tags")
yield Static(" ")
if self.__pattern is not None: if self.__pattern is not None:
yield Button("Add", id="button_add_tag") yield Button("Add", id="button_add_tag")
@@ -232,15 +288,20 @@ class PatternDetailsScreen(Screen):
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ")
# 6 # 6
yield self.tagsTable yield self.tagsTable
# 7 # 7
yield Static(" ", classes="five") yield Static(" ", classes="seven")
# 8 # 8
yield Static("Streams") yield Static("Streams")
yield Static(" ")
if self.__pattern is not None: if self.__pattern is not None:
yield Button("Add", id="button_add_track") yield Button("Add", id="button_add_track")
@@ -250,22 +311,27 @@ class PatternDetailsScreen(Screen):
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
yield Static(" ")
yield Button("Up", id="button_track_up")
yield Button("Down", id="button_track_down")
# 9 # 9
yield self.tracksTable yield self.tracksTable
# 10 # 10
yield Static(" ", classes="five") yield Static(" ", classes="seven")
# 11 # 11
yield Static(" ", classes="five") yield Static(" ", classes="seven")
# 12 # 12
yield Button("Save", id="save_button") yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button") yield Button("Cancel", id="cancel_button")
yield Static(" ", classes="three") yield Static(" ", classes="five")
# 13 # 13
yield Static(" ", classes="five") yield Static(" ", classes="seven")
yield Footer() yield Footer()
@@ -387,14 +453,33 @@ class PatternDetailsScreen(Screen):
if event.button.id == "pattern_button": if event.button.id == "pattern_button":
INDICATOR_PATTERN = '([sS][0-9]+[eE][0-9]+)'
pattern = self.query_one("#pattern_input", Input).value pattern = self.query_one("#pattern_input", Input).value
patternMatch = re.search(INDICATOR_PATTERN, pattern) patternMatch = re.search(FileProperties.SE_INDICATOR_PATTERN, pattern)
if patternMatch: if patternMatch:
self.query_one("#pattern_input", Input).value = pattern.replace(patternMatch.group(1), INDICATOR_PATTERN) self.query_one("#pattern_input", Input).value = pattern.replace(patternMatch.group(1),
FileProperties.SE_INDICATOR_PATTERN)
if event.button.id == "button_track_up":
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
selectedTrackIndex = selectedTrackDescriptor.getIndex()
if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count:
correspondingTrackIndex = selectedTrackIndex - 1
self.swapTracks(selectedTrackIndex, correspondingTrackIndex)
if event.button.id == "button_track_down":
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
selectedTrackIndex = selectedTrackDescriptor.getIndex()
if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1):
correspondingTrackIndex = selectedTrackIndex + 1
self.swapTracks(selectedTrackIndex, correspondingTrackIndex)
def handle_add_track(self, trackDescriptor : TrackDescriptor): def handle_add_track(self, trackDescriptor : TrackDescriptor):

View File

@@ -1,9 +1,29 @@
import subprocess import subprocess, click
from typing import List from typing import List
def executeProcess(commandSequence: List[str], directory: str = None): def executeProcess(commandSequence: List[str], directory: str = None, niceness: int = 99, cpu_percent: int = 0):
# process = subprocess.Popen([t.encode('utf-8') for t in commandSequence], stdout=subprocess.PIPE, stderr=subprocess.PIPE) """
process = subprocess.Popen(commandSequence, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', cwd = directory) niceness -20 bis +19
cpu_percent: 1 bis 99
"""
nice = int(niceness)
cpu = int(cpu_percent)
click.echo(f"nice {nice} cpu {cpu}")
niceSequence = []
if nice >= -20 and nice <= 19:
niceSequence += ['nice', '-n', str(nice)]
if cpu >= 1 and cpu <= 99:
niceSequence += ['cpulimit', '-l', str(cpu), '--']
niceCommand = niceSequence + commandSequence
click.echo(f"executeProcess(): {' '.join(niceCommand)}")
process = subprocess.Popen(niceCommand, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', cwd = directory)
output, error = process.communicate() output, error = process.communicate()
# return output.decode('utf-8'), error.decode('utf-8'), process.returncode
return output, error, process.returncode return output, error, process.returncode

View File

@@ -208,7 +208,7 @@ class ShowDetailsScreen(Screen):
self.app.push_screen(PatternDeleteScreen(patternId = selectedPatternId, showId = self.__showDescriptor.getId()), self.handle_remove_pattern) self.app.push_screen(PatternDeleteScreen(patternId = selectedPatternId, showId = self.__showDescriptor.getId()), self.handle_remove_pattern)
def handle_remove_pattern(self, screenResult): def handle_remove_pattern(self, pattern):
try: try:
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate) row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
@@ -354,8 +354,7 @@ class ShowDetailsScreen(Screen):
showDescriptor = self.getShowDescriptorFromInput() showDescriptor = self.getShowDescriptorFromInput()
if not showDescriptor is None: if not showDescriptor is None:
showResult = self.__tc.queryShow(showDescriptor.getId()) showName, showYear = self.__tc.getShowNameAndYear(showDescriptor.getId())
firstAirDate = datetime.strptime(showResult['first_air_date'], '%Y-%m-%d')
self.query_one("#name_input", Input).value = filterFilename(showResult['name']) self.query_one("#name_input", Input).value = filterFilename(showName)
self.query_one("#year_input", Input).value = str(firstAirDate.year) self.query_one("#year_input", Input).value = str(showYear)

View File

@@ -1,33 +0,0 @@
import os, sys, importlib, glob, inspect, itertools
class JellyfinCombinator():
IDENTIFIER = 'jellyfin'
def __init__(self, context = None):
self._context = context
self._logger = context['logger']
self._reportLogger = context['report_logger']
def getIdentifier(self):
return JellyfinCombinator.IDENTIFIER
@staticmethod
def list():
basePath = os.path.dirname(__file__)
return [os.path.basename(p)[20:-3]
for p
in glob.glob(f"{ basePath }/jellyfin_combinator_*.py", recursive = True)
if p != __file__]
@staticmethod
def getClassReference(identifier):
importlib.import_module(f"ffx.test.jellyfin_combinator_{ identifier }")
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.jellyfin_combinator_{ identifier }"]):
#HINT: Excluding MediaCombinator as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'JellyfinCombinator' and name.startswith('JellyfinCombinator'):
return obj
@staticmethod
def getAllClassReferences():
return [JellyfinCombinator.getClassReference(i) for i in JellyfinCombinator.list()]

View File

@@ -1,34 +0,0 @@
import os, sys, importlib, glob, inspect, itertools
from ffx.track_type import TrackType
from ffx.track_descriptor import TrackDescriptor
from ffx.media_descriptor import MediaDescriptor
from .jellyfin_combinator import JellyfinCombinator
class JellyfinCombinator0(JellyfinCombinator):
VARIANT = 'J0'
# def __init__(self, SubCombinators: dict = {}, context = None):
def __init__(self, context = None):
self._context = context
self._logger = context['logger']
self._reportLogger = context['report_logger']
# self._SubCombinators = SubCombinators
def getVariant(self):
return JellyfinCombinator0.VARIANT
def getPayload(self):
return False
def assertFunc(self, testObj = {}):
pass
def shouldFail(self):
return False

View File

@@ -1,34 +0,0 @@
import os, sys, importlib, glob, inspect, itertools
from ffx.track_type import TrackType
from ffx.track_descriptor import TrackDescriptor
from ffx.media_descriptor import MediaDescriptor
from .jellyfin_combinator import JellyfinCombinator
class JellyfinCombinator1(JellyfinCombinator):
VARIANT = 'J1'
# def __init__(self, SubCombinators: dict = {}, context = None):
def __init__(self, context = None):
self._context = context
self._logger = context['logger']
self._reportLogger = context['report_logger']
# self._SubCombinators = SubCombinations
def getVariant(self):
return JellyfinCombinator1.VARIANT
def getPayload(self):
return True
def assertFunc(self, testObj = {}):
pass
def shouldFail(self):
return False

View File

@@ -6,7 +6,6 @@ from ffx.track_descriptor import TrackDescriptor
from ffx.media_descriptor import MediaDescriptor from ffx.media_descriptor import MediaDescriptor
from .media_combinator import MediaCombinator from .media_combinator import MediaCombinator
from .jellyfin_combinator import JellyfinCombinator
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator

View File

@@ -6,7 +6,6 @@ from ffx.track_descriptor import TrackDescriptor
from ffx.media_descriptor import MediaDescriptor from ffx.media_descriptor import MediaDescriptor
from .media_combinator import MediaCombinator from .media_combinator import MediaCombinator
from .jellyfin_combinator import JellyfinCombinator
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator

View File

@@ -9,7 +9,7 @@ from .media_combinator import MediaCombinator
from .disposition_combinator_2 import DispositionCombinator2 from .disposition_combinator_2 import DispositionCombinator2
from .track_tag_combinator_2 import TrackTagCombinator2 from .track_tag_combinator_2 import TrackTagCombinator2
from .jellyfin_combinator import JellyfinCombinator from .permutation_combinator_2 import PermutationCombinator2
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator

View File

@@ -9,7 +9,7 @@ from .media_combinator import MediaCombinator
from .disposition_combinator_3 import DispositionCombinator3 from .disposition_combinator_3 import DispositionCombinator3
from .track_tag_combinator_3 import TrackTagCombinator3 from .track_tag_combinator_3 import TrackTagCombinator3
from .jellyfin_combinator import JellyfinCombinator from .permutation_combinator_3 import PermutationCombinator3
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator

View File

@@ -9,7 +9,7 @@ from .media_combinator import MediaCombinator
from .disposition_combinator_2 import DispositionCombinator2 from .disposition_combinator_2 import DispositionCombinator2
from .track_tag_combinator_2 import TrackTagCombinator2 from .track_tag_combinator_2 import TrackTagCombinator2
from .jellyfin_combinator import JellyfinCombinator from .permutation_combinator_2 import PermutationCombinator2
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator

View File

@@ -9,7 +9,7 @@ from .media_combinator import MediaCombinator
from .disposition_combinator_2 import DispositionCombinator2 from .disposition_combinator_2 import DispositionCombinator2
from .track_tag_combinator_2 import TrackTagCombinator2 from .track_tag_combinator_2 import TrackTagCombinator2
from .jellyfin_combinator import JellyfinCombinator from .permutation_combinator_2 import PermutationCombinator2
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator

View File

@@ -9,7 +9,7 @@ from .media_combinator import MediaCombinator
from .disposition_combinator_2 import DispositionCombinator2 from .disposition_combinator_2 import DispositionCombinator2
from .track_tag_combinator_2 import TrackTagCombinator2 from .track_tag_combinator_2 import TrackTagCombinator2
from .jellyfin_combinator import JellyfinCombinator from .permutation_combinator_2 import PermutationCombinator2
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator

View File

@@ -11,7 +11,8 @@ from .disposition_combinator_2 import DispositionCombinator2
from .disposition_combinator_3 import DispositionCombinator3 from .disposition_combinator_3 import DispositionCombinator3
from .track_tag_combinator_2 import TrackTagCombinator2 from .track_tag_combinator_2 import TrackTagCombinator2
from .track_tag_combinator_3 import TrackTagCombinator3 from .track_tag_combinator_3 import TrackTagCombinator3
from .jellyfin_combinator import JellyfinCombinator from .permutation_combinator_2 import PermutationCombinator2
from .permutation_combinator_3 import PermutationCombinator3
from .media_tag_combinator import MediaTagCombinator from .media_tag_combinator import MediaTagCombinator
class MediaCombinator7(MediaCombinator): class MediaCombinator7(MediaCombinator):
@@ -30,6 +31,8 @@ class MediaCombinator7(MediaCombinator):
def getPayload(self, def getPayload(self,
audioPermutation,
subtitlePermutation,
audioDispositionTuple = (set(), set()), audioDispositionTuple = (set(), set()),
audioTagTuple = ({}, {}), audioTagTuple = ({}, {}),
subtitleDispositionTuple = (set(), set(), set()), subtitleDispositionTuple = (set(), set(), set()),
@@ -116,100 +119,107 @@ class MediaCombinator7(MediaCombinator):
def getYield(self): def getYield(self):
pc2 = PermutationCombinator2(self._context)
pc3 = PermutationCombinator3(self._context)
for MTC in MediaTagCombinator.getAllClassReferences(): for MTC in MediaTagCombinator.getAllClassReferences():
for DC2_A in DispositionCombinator2.getAllClassReferences(): for DC2_A in DispositionCombinator2.getAllClassReferences():
for TC2_A in TrackTagCombinator2.getAllClassReferences(): for TC2_A in TrackTagCombinator2.getAllClassReferences():
for DC3_S in DispositionCombinator3.getAllClassReferences(): for DC3_S in DispositionCombinator3.getAllClassReferences():
for TC3_S in TrackTagCombinator3.getAllClassReferences(): for TC3_S in TrackTagCombinator3.getAllClassReferences():
for J in JellyfinCombinator.getAllClassReferences(): for p2y in pc2.getYield():
for p3y in pc3.getYield():
j = J(self._context)
self._context['use_jellyfin'] = j.getPayload()
dc2a = DC2_A(self._context)
tc2a = TC2_A(self._context)
dc3s = DC3_S(self._context)
tc3s = TC3_S(self._context)
mtc = MTC(self._context) dc2a = DC2_A(self._context)
tc2a = TC2_A(self._context)
dc3s = DC3_S(self._context)
tc3s = TC3_S(self._context)
yObj = {} mtc = MTC(self._context)
yObj['identifier'] = self.getIdentifier() yObj = {}
yObj['variants'] = [self.getVariant(),
f"A:{dc2a.getVariant()}",
f"A:{tc2a.getVariant()}",
f"S:{dc3s.getVariant()}",
f"S:{tc3s.getVariant()}",
mtc.getVariant(),
j.getVariant()]
yObj['payload'] = self.getPayload(dc2a.getPayload(), yObj['identifier'] = self.getIdentifier()
tc2a.getPayload(), yObj['variants'] = [self.getVariant(),
dc3s.getPayload(), f"A:{p2y['variant']}",
tc3s.getPayload()) f"S:{p3y['variant']}",
f"A:{dc2a.getVariant()}",
f"A:{tc2a.getVariant()}",
f"S:{dc3s.getVariant()}",
f"S:{tc3s.getVariant()}",
mtc.getVariant()]
yObj['assertSelectors'] = ['M', 'AD', 'AT', 'SD', 'ST', 'MT', 'J'] yObj['payload'] = self.getPayload(p2y['permutation'],
p3y['permutation'],
dc2a.getPayload(),
tc2a.getPayload(),
dc3s.getPayload(),
tc3s.getPayload())
yObj['assertFuncs'] = [self.assertFunc, yObj['assertSelectors'] = ['M', 'AP', 'SP', 'AD', 'AT', 'SD', 'ST', 'MT']
dc2a.createAssertFunc(),
tc2a.createAssertFunc(),
dc3s.createAssertFunc(),
tc3s.createAssertFunc(),
mtc.createAssertFunc(),
j.assertFunc]
yObj['shouldFail'] = (self.shouldFail() yObj['assertFuncs'] = [self.assertFunc,
| dc2a.shouldFail() p2y.createAssertFunc(),
| tc2a.shouldFail() p3y.createAssertFunc(),
| dc3s.shouldFail() dc2a.createAssertFunc(),
| tc3s.shouldFail() tc2a.createAssertFunc(),
| mtc.shouldFail() dc3s.createAssertFunc(),
| j.shouldFail()) tc3s.createAssertFunc(),
yieldObj = {'target': yObj} mtc.createAssertFunc()]
if self.__createPresets: yObj['shouldFail'] = (self.shouldFail()
| p2y.shouldFail()
| p3y.shouldFail()
| dc2a.shouldFail()
| tc2a.shouldFail()
| dc3s.shouldFail()
| tc3s.shouldFail()
| mtc.shouldFail())
yieldObj = {'target': yObj}
dc2a_p = DC2_A(self._context, createPresets = True) if self.__createPresets:
tc2a_p = TC2_A(self._context, createPresets = True)
dc3s_p = DC3_S(self._context, createPresets = True)
tc3s_p = TC3_S(self._context, createPresets = True)
mtc_p = MTC(self._context, createPresets = True) dc2a_p = DC2_A(self._context, createPresets = True)
tc2a_p = TC2_A(self._context, createPresets = True)
dc3s_p = DC3_S(self._context, createPresets = True)
tc3s_p = TC3_S(self._context, createPresets = True)
yObj_p = {} mtc_p = MTC(self._context, createPresets = True)
yObj_p['identifier'] = self.getIdentifier() yObj_p = {}
yObj_p['variants'] = [self.getVariant(),
f"A:{dc2a_p.getVariant()}",
f"A:{tc2a_p.getVariant()}",
f"S:{dc3s_p.getVariant()}",
f"S:{tc3s_p.getVariant()}",
mtc_p.getVariant(),
j.getVariant()]
yObj_p['payload'] = self.getPayload(dc2a_p.getPayload(), yObj_p['identifier'] = self.getIdentifier()
tc2a_p.getPayload(), yObj_p['variants'] = [self.getVariant(),
dc3s_p.getPayload(), f"A:{dc2a_p.getVariant()}",
tc3s_p.getPayload()) f"A:{tc2a_p.getVariant()}",
f"S:{dc3s_p.getVariant()}",
f"S:{tc3s_p.getVariant()}",
mtc_p.getVariant(),
j.getVariant()]
yObj_p['assertSelectors'] = ['M', 'AD', 'AT', 'SD', 'ST', 'MT', 'J'] yObj_p['payload'] = self.getPayload(dc2a_p.getPayload(),
tc2a_p.getPayload(),
dc3s_p.getPayload(),
tc3s_p.getPayload())
yObj_p['assertFuncs'] = [self.assertFunc, yObj_p['assertSelectors'] = ['M', 'AD', 'AT', 'SD', 'ST', 'MT', 'J']
dc2a_p.createAssertFunc(),
tc2a_p.createAssertFunc(),
dc3s_p.createAssertFunc(),
tc3s_p.createAssertFunc(),
mtc_p.createAssertFunc(),
j.assertFunc]
yObj_p['shouldFail'] = (self.shouldFail() yObj_p['assertFuncs'] = [self.assertFunc,
| dc2a_p.shouldFail() dc2a_p.createAssertFunc(),
| tc2a_p.shouldFail() tc2a_p.createAssertFunc(),
| dc3s_p.shouldFail() dc3s_p.createAssertFunc(),
| tc3s_p.shouldFail() tc3s_p.createAssertFunc(),
| mtc_p.shouldFail() mtc_p.createAssertFunc(),
| j.shouldFail()) j.assertFunc]
yieldObj['preset'] = yObj_p
yield yieldObj yObj_p['shouldFail'] = (self.shouldFail()
| dc2a_p.shouldFail()
| tc2a_p.shouldFail()
| dc3s_p.shouldFail()
| tc3s_p.shouldFail()
| mtc_p.shouldFail()
| j.shouldFail())
yieldObj['preset'] = yObj_p
yield yieldObj

View File

@@ -0,0 +1,36 @@
class PermutationCombinator2():
IDENTIFIER = 'permutation2'
PERMUTATION_LIST = [
[0,1],
[1,0]
]
def __init__(self, context = None):
self._context = context
self._logger = context['logger']
self._reportLogger = context['report_logger']
def getIdentifier(self):
return PermutationCombinator2.IDENTIFIER
def getPayload(self, permutationIndex):
return {
'variant': f"P{permutationIndex}",
'permutation': PermutationCombinator2.PERMUTATION_LIST[permutationIndex]
}
def createAssertFunc(self):
def f(testObj = {}):
pass
return f
def shouldFail(self):
return False
def getYield(self):
for permutationIndex in range(len(PermutationCombinator2.PERMUTATION_LIST)):
yield self.getPayload(permutationIndex)

View File

@@ -0,0 +1,37 @@
class PermutationCombinator3():
IDENTIFIER = 'permutation3'
PERMUTATION_LIST = [
[0,1,2],
[0,2,1],
[1,2,0]
]
def __init__(self, context = None):
self._context = context
self._logger = context['logger']
self._reportLogger = context['report_logger']
def getIdentifier(self):
return PermutationCombinator3.IDENTIFIER
def getPayload(self, permutationIndex):
return {
'variant': f"P{permutationIndex}",
'permutation': PermutationCombinator3.PERMUTATION_LIST[permutationIndex]
}
def createAssertFunc(self):
def f(testObj = {}):
pass
return f
def shouldFail(self):
return False
def getYield(self):
for permutationIndex in range(len(PermutationCombinator3.PERMUTATION_LIST)):
yield self.getPayload(permutationIndex)

View File

@@ -103,6 +103,10 @@ class Scenario():
self._testDbFilePath = os.path.join(self._testDirectory, 'test.db') self._testDbFilePath = os.path.join(self._testDirectory, 'test.db')
self.createEmptyTestDatabase() self.createEmptyTestDatabase()
# Convenience
self._niceness = self._context['resource_limits']['niceness'] if 'resource_limits' in self._context.keys() and 'niceness' in self._context['resource_limits'].keys() else 99
self._cpuPercent = self._context['resource_limits']['cpu_percent'] if 'resource_limits' in self._context.keys() and 'cpu_percent' in self._context['resource_limits'].keys() else 99
def createEmptyTestDatabase(self): def createEmptyTestDatabase(self):

View File

@@ -65,9 +65,12 @@ class Scenario1(Scenario):
expectedFilename = f"{expectedBasename}.{Scenario1.EXPECTED_FILE_EXTENSION}" expectedFilename = f"{expectedBasename}.{Scenario1.EXPECTED_FILE_EXTENSION}"
if self._context['test_variant'] and variantIdentifier != self._context['test_variant']: if self._context['test_variant'] and not variantIdentifier.startswith(self._context['test_variant']):
return return
if ((self._context['test_passed_counter'] + self._context['test_failed_counter'])
>= self._context['test_limit']):
return
self._logger.debug(f"Running Job: {variantLabel}") self._logger.debug(f"Running Job: {variantLabel}")
@@ -93,20 +96,21 @@ class Scenario1(Scenario):
commandSequence = [sys.executable, commandSequence = [sys.executable,
self._ffxExecutablePath] self._ffxExecutablePath]
# if self._context['verbosity']: if self._context['verbosity']:
# commandSequence += ['--verbose', commandSequence += ['--verbose',
# str(self._context['verbosity'])] str(self._context['verbosity'])]
commandSequence += ['convert', commandSequence += ['convert',
mediaFilePath, mediaFilePath,
'--no-prompt'] '--no-prompt',
'--no-signature']
if variantFilenameLabel: if variantFilenameLabel:
commandSequence += ['--label', variantFilenameLabel] commandSequence += ['--label', variantFilenameLabel]
if not testContext['use_jellyfin']: # if not testContext['use_jellyfin']:
commandSequence += ['--no-jellyfin'] # commandSequence += ['--no-jellyfin']
commandSequence += ['--no-pattern'] commandSequence += ['--no-pattern']
commandSequence += ['--no-tmdb'] commandSequence += ['--no-tmdb']
@@ -114,9 +118,9 @@ class Scenario1(Scenario):
self._logger.debug(f"{variantLabel}: Test sequence: {commandSequence}") self._logger.debug(f"{variantLabel}: Test sequence: {commandSequence}")
out, err, rc = executeProcess(commandSequence, directory = self._testDirectory) out, err, rc = executeProcess(commandSequence, directory = self._testDirectory, niceness=self._niceness, cpu_percent=self._cpuPercent)
if out: if out and self._context['verbosity'] >= 9:
self._logger.debug(f"{variantLabel}: Process output: {out}") self._logger.debug(f"{variantLabel}: Process output: {out}")
if rc: if rc:
self._logger.debug(f"{variantLabel}: Process returned ERROR {rc} ({err})") self._logger.debug(f"{variantLabel}: Process returned ERROR {rc} ({err})")

View File

@@ -64,10 +64,13 @@ class Scenario2(Scenario):
jellyfinSelectorIndex = -1 jellyfinSelectorIndex = -1
#if self._context['test_variant'] and variantIdentifier != self._context['test_variant']:
if self._context['test_variant'] and not variantIdentifier.startswith(self._context['test_variant']): if self._context['test_variant'] and not variantIdentifier.startswith(self._context['test_variant']):
return return
if ((self._context['test_passed_counter'] + self._context['test_failed_counter'])
>= self._context['test_limit']):
return
self._logger.debug(f"Running Job: {variantLabel}") self._logger.debug(f"Running Job: {variantLabel}")
@@ -83,20 +86,26 @@ class Scenario2(Scenario):
# Phase 2: Run ffx # Phase 2: Run ffx
commandSequence = [sys.executable, commandSequence = [sys.executable,
self._ffxExecutablePath, self._ffxExecutablePath]
'convert',
mediaFilePath, if self._context['verbosity']:
'--no-prompt'] commandSequence += ['--verbose',
str(self._context['verbosity'])]
if not testContext['use_jellyfin']: commandSequence += ['convert',
commandSequence += ['--no-jellyfin'] mediaFilePath,
'--no-prompt',
'--no-signature']
# if not testContext['use_jellyfin']:
# commandSequence += ['--no-jellyfin']
self._logger.debug(f"{variantLabel}: Test sequence: {commandSequence}") self._logger.debug(f"{variantLabel}: Test sequence: {commandSequence}")
out, err, rc = executeProcess(commandSequence, directory = self._testDirectory) out, err, rc = executeProcess(commandSequence, directory = self._testDirectory, niceness=self._niceness, cpu_percent=self._cpuPercent)
if out: if out and self._context['verbosity'] >= 9:
self._logger.debug(f"{variantLabel}: Process output: {out}") self._logger.debug(f"{variantLabel}: Process output: {out}")
if rc: if rc:
self._logger.debug(f"{variantLabel}: Process returned ERROR {rc} ({err})") self._logger.debug(f"{variantLabel}: Process returned ERROR {rc} ({err})")
@@ -127,9 +136,9 @@ class Scenario2(Scenario):
resultFileProperties = FileProperties(testContext, resultFile) resultFileProperties = FileProperties(testContext, resultFile)
resultMediaDescriptor = resultFileProperties.getMediaDescriptor() resultMediaDescriptor = resultFileProperties.getMediaDescriptor()
if testContext['use_jellyfin']: # if testContext['use_jellyfin']:
sourceMediaDescriptor.applyJellyfinOrder() # sourceMediaDescriptor.applyJellyfinOrder()
resultMediaDescriptor.applySourceIndices(sourceMediaDescriptor) # resultMediaDescriptor.applySourceIndices(sourceMediaDescriptor)
resultMediaTracks = resultMediaDescriptor.getAllTrackDescriptors() resultMediaTracks = resultMediaDescriptor.getAllTrackDescriptors()

View File

@@ -33,7 +33,7 @@ class Scenario4(Scenario):
TEST_FILE_LABEL = 'rotsh' TEST_FILE_LABEL = 'rotsh'
TEST_FILE_EXTENSION = 'mkv' TEST_FILE_EXTENSION = 'mkv'
TEST_PATTERN = f"{TEST_FILE_LABEL}_{FileProperties.SEASON_EPISODE_INDICATOR_MATCH}.{TEST_FILE_EXTENSION}" TEST_PATTERN = f"{TEST_FILE_LABEL}_{FileProperties.SE_INDICATOR_PATTERN}.{TEST_FILE_EXTENSION}"
EXPECTED_FILE_EXTENSION = 'webm' EXPECTED_FILE_EXTENSION = 'webm'
@@ -118,17 +118,22 @@ class Scenario4(Scenario):
jellyfinSelectorIndex = -1 jellyfinSelectorIndex = -1
if self._context['test_variant'] and variantIdentifier != self._context['test_variant']: if self._context['test_variant'] and not variantIdentifier.startswith(self._context['test_variant']):
return return
if ((self._context['test_passed_counter'] + self._context['test_failed_counter'])
>= self._context['test_limit']):
return
self._logger.debug(f"Running Job: {variantLabel}")
for l in presetMediaDescriptor.getConfiguration(label = 'presetMediaDescriptor'): for l in presetMediaDescriptor.getConfiguration(label = 'presetMediaDescriptor'):
self._logger.debug(l) self._logger.debug(l)
for l in sourceMediaDescriptor.getConfiguration(label = 'sourceMediaDescriptor'): for l in sourceMediaDescriptor.getConfiguration(label = 'sourceMediaDescriptor'):
self._logger.debug(l) self._logger.debug(l)
self._logger.debug(f"Running Job: {variantLabel}")
# Phase 1: Setup source files # Phase 1: Setup source files
@@ -164,23 +169,28 @@ class Scenario4(Scenario):
# Phase 3: Run ffx # Phase 3: Run ffx
commandSequence = [sys.executable, commandSequence = [sys.executable,
self._ffxExecutablePath, self._ffxExecutablePath]
'--database-file',
if self._context['verbosity']:
commandSequence += ['--verbose',
str(self._context['verbosity'])]
commandSequence += ['--database-file',
self._testDbFilePath, self._testDbFilePath,
'convert'] 'convert']
commandSequence += [tfo['filename'] for tfo in testFileList] commandSequence += [tfo['filename'] for tfo in testFileList]
commandSequence += ['--no-prompt'] commandSequence += ['--no-prompt', '--no-signature']
if not testContext['use_jellyfin']: # if not testContext['use_jellyfin']:
commandSequence += ['--no-jellyfin'] # commandSequence += ['--no-jellyfin']
self._logger.debug(f"{variantLabel}: Test sequence: {commandSequence}") self._logger.debug(f"{variantLabel}: Test sequence: {commandSequence}")
out, err, rc = executeProcess(commandSequence, directory = self._testDirectory) out, err, rc = executeProcess(commandSequence, directory = self._testDirectory, niceness=self._niceness, cpu_percent=self._cpuPercent)
# if out: if out and self._context['verbosity'] >= 9:
# self._logger.debug(f"{variantLabel}: Process output: {out}") self._logger.debug(f"{variantLabel}: Process output: {out}")
if rc: if rc:
self._logger.debug(f"{variantLabel}: Process returned ERROR {rc} ({err})") self._logger.debug(f"{variantLabel}: Process returned ERROR {rc} ({err})")
@@ -227,8 +237,8 @@ class Scenario4(Scenario):
for l in rmd.getConfiguration(label = 'resultMediaDescriptor'): for l in rmd.getConfiguration(label = 'resultMediaDescriptor'):
self._logger.debug(l) self._logger.debug(l)
if testContext['use_jellyfin']: # if testContext['use_jellyfin']:
sourceMediaDescriptor.applyJellyfinOrder() # sourceMediaDescriptor.applyJellyfinOrder()
# num tracks differ # num tracks differ
rmd.applySourceIndices(sourceMediaDescriptor) rmd.applySourceIndices(sourceMediaDescriptor)
@@ -258,14 +268,13 @@ class Scenario4(Scenario):
self._context['test_passed_counter'] += 1 self._context['test_passed_counter'] += 1
self._reportLogger.info(f"{variantLabel}: Test passed") self._reportLogger.info(f"\n{variantLabel}: Test passed\n")
except AssertionError as ae: except AssertionError as ae:
self._context['test_failed_counter'] += 1 self._context['test_failed_counter'] += 1
self._reportLogger.error(f"{variantLabel}: Test FAILED ({ae})") self._reportLogger.error(f"\n{variantLabel}: Test FAILED ({ae})\n")
# exit()
def run(self): def run(self):

View File

@@ -1,4 +1,6 @@
import os, click, requests, json, time, logging import os, click, requests, json, time, logging
from datetime import datetime
class TMDB_REQUEST_EXCEPTION(Exception): class TMDB_REQUEST_EXCEPTION(Exception):
def __init__(self, statusCode, statusMessage): def __init__(self, statusCode, statusMessage):
@@ -95,6 +97,14 @@ class TmdbController():
return self.getTmdbRequest(tmdbUrl) return self.getTmdbRequest(tmdbUrl)
def getShowNameAndYear(self, showId: int):
showResult = self.queryShow(int(showId))
firstAirDate = datetime.strptime(showResult['first_air_date'], '%Y-%m-%d')
return str(showResult['name']), int(firstAirDate.year)
def queryEpisode(self, showId, season, episode): def queryEpisode(self, showId, season, episode):
""" """
First level keys in the response object: First level keys in the response object:

View File

@@ -66,6 +66,8 @@ class TrackController():
track : Track = q.first() track : Track = q.first()
track.index = int(trackDescriptor.getIndex())
track.track_type = int(trackDescriptor.getType().index()) track.track_type = int(trackDescriptor.getType().index())
track.codec_name = str(trackDescriptor.getCodec()) track.codec_name = str(trackDescriptor.getCodec())
track.audio_layout = int(trackDescriptor.getAudioLayout().index()) track.audio_layout = int(trackDescriptor.getAudioLayout().index())
@@ -103,13 +105,34 @@ class TrackController():
s = self.Session() s = self.Session()
q = s.query(Track).filter(Track.pattern_id == int(patternId)) q = s.query(Track).filter(Track.pattern_id == int(patternId))
return [a for a in q.all()] return sorted([t for t in q.all()], key=lambda d: d.getIndex())
except Exception as ex: except Exception as ex:
raise click.ClickException(f"TrackController.findTracks(): {repr(ex)}") raise click.ClickException(f"TrackController.findTracks(): {repr(ex)}")
finally: finally:
s.close() s.close()
def findSiblingDescriptors(self, patternId):
"""Finds all stored tracks related to a pattern, packs them in descriptors
and also setting sub indices and returns list of descriptors"""
siblingTracks = self.findTracks(patternId)
siblingDescriptors = []
subIndexCounter = {}
st: Track
for st in siblingTracks:
trackType = st.getType()
if not trackType in subIndexCounter.keys():
subIndexCounter[trackType] = 0
siblingDescriptors.append(st.getDescriptor(subIndex=subIndexCounter[trackType]))
subIndexCounter[trackType] += 1
return siblingDescriptors
#TODO: mit optionalem Parameter lösen ^ #TODO: mit optionalem Parameter lösen ^
def findVideoTracks(self, patternId): def findVideoTracks(self, patternId):
@@ -233,8 +256,8 @@ class TrackController():
s.close() s.close()
def setDefaultSubTrack(self, trackType, subIndex): # def setDefaultSubTrack(self, trackType, subIndex):
pass # pass
#
def setForcedSubTrack(self, trackType, subIndex): # def setForcedSubTrack(self, trackType, subIndex):
pass # pass

View File

@@ -1,4 +1,5 @@
import logging import logging
from typing import Self
from .iso_language import IsoLanguage from .iso_language import IsoLanguage
from .track_type import TrackType from .track_type import TrackType
@@ -273,7 +274,7 @@ class TrackDescriptor:
return self.__trackType return self.__trackType
def getCodec(self): def getCodec(self):
return self.__codecName return str(self.__codecName)
def getLanguage(self): def getLanguage(self):
if "language" in self.__trackTags.keys(): if "language" in self.__trackTags.keys():
@@ -281,12 +282,21 @@ class TrackDescriptor:
else: else:
return IsoLanguage.UNDEFINED return IsoLanguage.UNDEFINED
def setLanguage(self, language: IsoLanguage):
if not type(language) is IsoLanguage:
raise TypeError('language has to be of type IsoLanguage')
self.__trackTags["language"] = language
def getTitle(self): def getTitle(self):
if "title" in self.__trackTags.keys(): if "title" in self.__trackTags.keys():
return str(self.__trackTags["title"]) return str(self.__trackTags["title"])
else: else:
return "" return ""
def setTitle(self, title: str):
self.__trackTags["title"] = str(title)
def getAudioLayout(self): def getAudioLayout(self):
return self.__audioLayout return self.__audioLayout
@@ -305,7 +315,7 @@ class TrackDescriptor:
else: else:
self.__dispositionSet.discard(disposition) self.__dispositionSet.discard(disposition)
def compare(self, vsTrackDescriptor): def compare(self, vsTrackDescriptor: Self):
compareResult = {} compareResult = {}

View File

@@ -99,6 +99,7 @@ class TrackDetailsScreen(Screen):
self.__isNew = trackDescriptor is None self.__isNew = trackDescriptor is None
if self.__isNew: if self.__isNew:
self.__trackType = trackType self.__trackType = trackType
self.__codec = ''
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
self.__index = index self.__index = index
self.__subIndex = subIndex self.__subIndex = subIndex
@@ -106,6 +107,7 @@ class TrackDetailsScreen(Screen):
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else {} self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else {}
else: else:
self.__trackType = trackDescriptor.getType() self.__trackType = trackDescriptor.getType()
self.__codec = trackDescriptor.getCodec()
self.__audioLayout = trackDescriptor.getAudioLayout() self.__audioLayout = trackDescriptor.getAudioLayout()
self.__index = trackDescriptor.getIndex() self.__index = trackDescriptor.getIndex()
self.__subIndex = trackDescriptor.getSubIndex() self.__subIndex = trackDescriptor.getSubIndex()
@@ -276,6 +278,8 @@ class TrackDetailsScreen(Screen):
kwargs[TrackDescriptor.SUB_INDEX_KEY] = self.__subIndex #! kwargs[TrackDescriptor.SUB_INDEX_KEY] = self.__subIndex #!
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(self.query_one("#type_select", Select).value) kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(self.query_one("#type_select", Select).value)
kwargs[TrackDescriptor.CODEC_NAME_KEY] = self.__codec
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(self.query_one("#audio_layout_select", Select).value) kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(self.query_one("#audio_layout_select", Select).value)
trackTags = {} trackTags = {}

View File

@@ -75,8 +75,9 @@ def ffx(ctx, verbose, dry_run):
@ffx.command() @ffx.command()
@click.pass_context @click.pass_context
@click.option('--scenario', type=str, default='', help='Only run tests from this scenario') @click.option('--scenario', type=str, default='', help='Only run tests from this scenario')
@click.option('--variant', type=str, default='', help='Only run this test variant') @click.option('--variant', type=str, default='', help='Only run variants beginning like this')
def run(ctx, scenario, variant): @click.option('--limit', type=int, default=0, help='Only run this number of tests')
def run(ctx, scenario, variant, limit):
"""Run ffx test sequences""" """Run ffx test sequences"""
ctx.obj['logger'].info('Starting FFX test runs') ctx.obj['logger'].info('Starting FFX test runs')
@@ -84,6 +85,7 @@ def run(ctx, scenario, variant):
ctx.obj['test_failed_counter'] = 0 ctx.obj['test_failed_counter'] = 0
ctx.obj['test_variant'] = variant ctx.obj['test_variant'] = variant
ctx.obj['test_limit'] = limit
for si in Scenario.list(): for si in Scenario.list():