Impl Logging / Reporting base
This commit is contained in:
120
bin/ffx.py
120
bin/ffx.py
@@ -1,12 +1,11 @@
|
||||
#! /usr/bin/python3
|
||||
|
||||
import os, sys, subprocess, json, click, time, re
|
||||
import os, click, time, logging
|
||||
|
||||
from ffx.file_properties import FileProperties
|
||||
|
||||
from ffx.ffx_app import FfxApp
|
||||
from ffx.ffx_controller import FfxController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.tmdb_controller import TmdbController
|
||||
|
||||
from ffx.database import databaseContext
|
||||
@@ -32,11 +31,47 @@ VERSION='0.1.3'
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
@click.option('--database-file', type=str, default='', help='Path to database file')
|
||||
def ffx(ctx, database_file):
|
||||
@click.option('-v', '--verbose', type=int, default=0, help='Set verbosity of output')
|
||||
@click.option("--dry-run", is_flag=True, default=False)
|
||||
def ffx(ctx, database_file, verbose, dry_run):
|
||||
"""FFX"""
|
||||
|
||||
ctx.obj = {}
|
||||
ctx.obj['database'] = databaseContext(databasePath=database_file)
|
||||
ctx.obj['dry_run'] = dry_run
|
||||
ctx.obj['verbosity'] = verbose
|
||||
|
||||
# Critical 50
|
||||
# Error 40
|
||||
# Warning 30
|
||||
# Info 20
|
||||
# Debug 10
|
||||
fileLogVerbosity = max(40 - verbose * 10, 10)
|
||||
consoleLogVerbosity = max(20 - verbose * 10, 10)
|
||||
|
||||
homeDir = os.path.expanduser("~")
|
||||
ffxLogDir = os.path.join(homeDir, '.local', 'var', 'log')
|
||||
if not os.path.exists(ffxLogDir):
|
||||
os.makedirs(ffxLogDir)
|
||||
ffxLogFilePath = os.path.join(ffxLogDir, 'ffx.log')
|
||||
|
||||
ctx.obj['logger'] = logging.getLogger('FFX')
|
||||
ctx.obj['logger'].setLevel(logging.DEBUG)
|
||||
|
||||
ffxFileHandler = logging.FileHandler(ffxLogFilePath)
|
||||
ffxFileHandler.setLevel(fileLogVerbosity)
|
||||
ffxConsoleHandler = logging.StreamHandler()
|
||||
ffxConsoleHandler.setLevel(consoleLogVerbosity)
|
||||
|
||||
fileFormatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
ffxFileHandler.setFormatter(fileFormatter)
|
||||
consoleFormatter = logging.Formatter(
|
||||
'%(message)s')
|
||||
ffxConsoleHandler.setFormatter(consoleFormatter)
|
||||
|
||||
ctx.obj['logger'].addHandler(ffxConsoleHandler)
|
||||
ctx.obj['logger'].addHandler(ffxFileHandler)
|
||||
|
||||
|
||||
# Define a subcommand
|
||||
@@ -52,8 +87,6 @@ def help():
|
||||
click.echo(f"Usage: ffx [input file] [output file] [vp9|av1] [q=[nn[,nn,...]]] [p=nn] [a=nnn[k]] [ac3=nnn[k]] [dts=nnn[k]] [crop]")
|
||||
|
||||
|
||||
|
||||
|
||||
@ffx.command()
|
||||
@click.pass_context
|
||||
@click.argument('filename', nargs=1)
|
||||
@@ -107,16 +140,15 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
|
||||
@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("-s", "--subtitles-only", is_flag=True, default=False)
|
||||
@click.option("--dry-run", is_flag=True, default=False)
|
||||
def unmux(ctx,
|
||||
paths,
|
||||
label,
|
||||
output_directory,
|
||||
subtitles_only,
|
||||
dry_run):
|
||||
subtitles_only):
|
||||
|
||||
existingSourcePaths = [p for p in paths if os.path.isfile(p)]
|
||||
click.echo(f"\nUnmuxing {len(existingSourcePaths)} files")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"\nUnmuxing {len(existingSourcePaths)} files")
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
@@ -134,10 +166,12 @@ def unmux(ctx,
|
||||
targetIndicator = f"_S{season}E{episode}" if label and season != -1 and episode != -1 else ''
|
||||
|
||||
if label and not targetIndicator:
|
||||
click.echo(f"Skipping file {fp.getFilename()}: Label set but no indicator recognized")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Skipping file {fp.getFilename()}: Label set but no indicator recognized")
|
||||
continue
|
||||
else:
|
||||
click.echo(f"\nUnmuxing file {fp.getFilename()}\n")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"\nUnmuxing file {fp.getFilename()}\n")
|
||||
|
||||
for trackDescriptor in sourceMediaDescriptor.getAllTrackDescriptors():
|
||||
|
||||
@@ -149,15 +183,19 @@ def unmux(ctx,
|
||||
unmuxSequence = getUnmuxSequence(trackDescriptor, sourcePath, targetPrefix, targetDirectory = output_directory)
|
||||
|
||||
if unmuxSequence:
|
||||
if not dry_run:
|
||||
click.echo(f"Executing unmuxing sequence: {' '.join(unmuxSequence)}")
|
||||
if not ctx.obj['dry_run']:
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Executing unmuxing sequence: {' '.join(unmuxSequence)}")
|
||||
out, err, rc = executeProcess(unmuxSequence)
|
||||
if rc:
|
||||
click.echo(f"Unmuxing of stream {trackDescriptor.getIndex()} failed with error ({rc}) {err}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Unmuxing of stream {trackDescriptor.getIndex()} failed with error ({rc}) {err}")
|
||||
else:
|
||||
click.echo(f"Skipping stream with unknown codec {trackDescriptor.getCodec()}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Skipping stream with unknown codec {trackDescriptor.getCodec()}")
|
||||
except Exception as ex:
|
||||
click.echo(f"Skipping File {sourcePath} ({ex})")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Skipping File {sourcePath} ({ex})")
|
||||
|
||||
|
||||
@ffx.command()
|
||||
@@ -215,9 +253,6 @@ def shows(ctx):
|
||||
@click.option("-j", "--no-jellyfin", is_flag=True, default=False)
|
||||
@click.option("-np", "--no-pattern", is_flag=True, default=False)
|
||||
|
||||
@click.option("--dry-run", is_flag=True, default=False)
|
||||
|
||||
|
||||
def convert(ctx,
|
||||
paths,
|
||||
label,
|
||||
@@ -246,8 +281,7 @@ def convert(ctx,
|
||||
denoise,
|
||||
no_tmdb,
|
||||
no_jellyfin,
|
||||
no_pattern,
|
||||
dry_run):
|
||||
no_pattern):
|
||||
"""Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin
|
||||
|
||||
Files found under PATHS will be converted according to parameters.
|
||||
@@ -259,8 +293,6 @@ def convert(ctx,
|
||||
|
||||
context = ctx.obj
|
||||
|
||||
context['dry_run'] = dry_run
|
||||
|
||||
context['video_encoder'] = VideoEncoder.fromLabel(video_encoder)
|
||||
|
||||
context['use_jellyfin'] = not no_jellyfin
|
||||
@@ -277,16 +309,18 @@ def convert(ctx,
|
||||
qualityTokens = quality.split(',')
|
||||
q_list = [q for q in qualityTokens if q.isnumeric()]
|
||||
|
||||
click.echo(f"Qualities: {q_list}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Qualities: {q_list}")
|
||||
|
||||
context['bitrates'] = {}
|
||||
context['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k"
|
||||
context['bitrates']['ac3'] = str(ac3_bitrate) if str(ac3_bitrate).endswith('k') else f"{ac3_bitrate}k"
|
||||
context['bitrates']['dts'] = str(dts_bitrate) if str(dts_bitrate).endswith('k') else f"{dts_bitrate}k"
|
||||
|
||||
click.echo(f"Stereo bitrate: {context['bitrates']['stereo']}")
|
||||
click.echo(f"AC3 bitrate: {context['bitrates']['ac3']}")
|
||||
click.echo(f"DTS bitrate: {context['bitrates']['dts']}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Stereo bitrate: {context['bitrates']['stereo']}")
|
||||
click.echo(f"AC3 bitrate: {context['bitrates']['ac3']}")
|
||||
click.echo(f"DTS bitrate: {context['bitrates']['dts']}")
|
||||
|
||||
|
||||
# Process crop parameters
|
||||
@@ -296,13 +330,15 @@ def convert(ctx,
|
||||
if cTokens and len(cTokens) == 2:
|
||||
context['crop_start'] = int(cTokens[0])
|
||||
context['crop_length'] = int(cTokens[1])
|
||||
click.echo(f"Crop start={context['crop_start']} length={context['crop_length']}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Crop start={context['crop_start']} length={context['crop_length']}")
|
||||
|
||||
|
||||
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]
|
||||
click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
|
||||
jobIndex = 0
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
@@ -315,7 +351,8 @@ def convert(ctx,
|
||||
sourceFileBasename = '.'.join(sourcePathTokens[:-1])
|
||||
sourceFilenameExtension = sourcePathTokens[-1]
|
||||
|
||||
click.echo(f"\nProcessing file {sourcePath}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"\nProcessing file {sourcePath}")
|
||||
|
||||
|
||||
mediaFileProperties = FileProperties(context, sourceFilename)
|
||||
@@ -324,7 +361,8 @@ def convert(ctx,
|
||||
#HINT: This is None if the filename did not match anything in database
|
||||
currentPattern = mediaFileProperties.getPattern() if context['use_pattern'] else None
|
||||
|
||||
click.echo(f"Pattern matching: {'No' if currentPattern is None else 'Yes'}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Pattern matching: {'No' if currentPattern is None else 'Yes'}")
|
||||
|
||||
# fileBasename = ''
|
||||
|
||||
@@ -385,9 +423,9 @@ def convert(ctx,
|
||||
|
||||
# Case pattern matching
|
||||
|
||||
targetMediaDescriptor = currentPattern.getMediaDescriptor()
|
||||
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
|
||||
|
||||
currentShowDescriptor = currentPattern.getShowDescriptor()
|
||||
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
|
||||
|
||||
|
||||
if context['use_tmdb']:
|
||||
@@ -422,7 +460,8 @@ def convert(ctx,
|
||||
# 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()]}")
|
||||
# raise click.Abort
|
||||
|
||||
click.echo(f"Input mapping tokens (2nd pass): {targetMediaDescriptor.getInputMappingTokens()}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Input mapping tokens (2nd pass): {targetMediaDescriptor.getInputMappingTokens()}")
|
||||
|
||||
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
|
||||
|
||||
@@ -435,14 +474,16 @@ def convert(ctx,
|
||||
# audioTokens = fc.generateAudioEncodingTokens()
|
||||
# click.echo(f"Audio Tokens: {audioTokens}")
|
||||
|
||||
click.echo(f"Season={mediaFileProperties.getSeason()} Episode={mediaFileProperties.getEpisode()}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"Season={mediaFileProperties.getSeason()} Episode={mediaFileProperties.getEpisode()}")
|
||||
|
||||
|
||||
click.echo(f"fileBasename={sourceFileBasename}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"fileBasename={sourceFileBasename}")
|
||||
|
||||
for q in q_list:
|
||||
|
||||
click.echo(f"\nRunning job {jobIndex} file={sourcePath} q={q}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"\nRunning job {jobIndex} file={sourcePath} q={q}")
|
||||
jobIndex += 1
|
||||
|
||||
extra = ['ffx'] if sourceFilenameExtension == FfxController.DEFAULT_FILE_EXTENSION else []
|
||||
@@ -464,7 +505,8 @@ def convert(ctx,
|
||||
#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()
|
||||
click.echo(f"\nDONE\nTime elapsed {endTime - startTime}")
|
||||
if ctx.obj['verbosity'] > 0:
|
||||
click.echo(f"\nDONE\nTime elapsed {endTime - startTime}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -404,7 +404,7 @@ class FfxController():
|
||||
FfxController.DEFAULT_FILE_FORMAT,
|
||||
FfxController.DEFAULT_FILE_EXTENSION)
|
||||
|
||||
click.echo(f"Command: {' '.join(commandSequence)}")
|
||||
# click.echo(f"Command: {' '.join(commandSequence)}")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
executeProcess(commandSequence)
|
||||
@@ -421,7 +421,7 @@ class FfxController():
|
||||
|
||||
commandSequence1 += FfxController.NULL_TOKENS
|
||||
|
||||
click.echo(f"Command 1: {' '.join(commandSequence1)}")
|
||||
# click.echo(f"Command 1: {' '.join(commandSequence1)}")
|
||||
|
||||
if os.path.exists(FfxController.TEMP_FILE_NAME):
|
||||
os.remove(FfxController.TEMP_FILE_NAME)
|
||||
@@ -449,7 +449,7 @@ class FfxController():
|
||||
FfxController.DEFAULT_FILE_FORMAT,
|
||||
FfxController.DEFAULT_FILE_EXTENSION)
|
||||
|
||||
click.echo(f"Command 2: {' '.join(commandSequence2)}")
|
||||
# click.echo(f"Command 2: {' '.join(commandSequence2)}")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
out, err, rc = executeProcess(commandSequence2)
|
||||
|
||||
@@ -23,6 +23,8 @@ class FileProperties():
|
||||
|
||||
self.context = context
|
||||
|
||||
self.__logger = context['logger']
|
||||
|
||||
# Separate basedir, basename and extension for current source file
|
||||
self.__sourcePath = sourcePath
|
||||
|
||||
@@ -167,7 +169,7 @@ class FileProperties():
|
||||
|
||||
|
||||
def getMediaDescriptor(self):
|
||||
return MediaDescriptor.fromFfprobe(self.getFormatData(), self.getStreamData())
|
||||
return MediaDescriptor.fromFfprobe(self.context, self.getFormatData(), self.getStreamData())
|
||||
|
||||
|
||||
def getShowId(self) -> int:
|
||||
@@ -233,6 +235,6 @@ class FileProperties():
|
||||
|
||||
targetFilename = '_'.join(targetFilenameTokens)
|
||||
|
||||
click.echo(f"Target filename: {targetFilename}")
|
||||
self.__logger.debug(f"assembleTargetFileBasename(): Target filename: {targetFilename}")
|
||||
|
||||
return targetFilename
|
||||
|
||||
@@ -32,7 +32,6 @@ def dictDiff(a : dict, b : dict):
|
||||
def dictCache(element: dict, cache: list = []):
|
||||
for index in range(len(cache)):
|
||||
diff = dictDiff(cache[index], element)
|
||||
click.echo(f"dictCache() element={element} index={index} cached={cache[index]} diff={diff}")
|
||||
if not diff:
|
||||
return index, cache
|
||||
cache.append(element)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import os
|
||||
import re
|
||||
import click
|
||||
import os, re, click, logging
|
||||
|
||||
from typing import List, Self
|
||||
|
||||
@@ -36,6 +34,17 @@ class MediaDescriptor:
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
if MediaDescriptor.CONTEXT_KEY in kwargs.keys():
|
||||
if type(kwargs[MediaDescriptor.CONTEXT_KEY]) is not dict:
|
||||
raise TypeError(
|
||||
f"MediaDescriptor.__init__(): Argument {MediaDescriptor.CONTEXT_KEY} is required to be of type dict"
|
||||
)
|
||||
self.__context = kwargs[MediaDescriptor.CONTEXT_KEY]
|
||||
self.__logger = self.__context['logger']
|
||||
else:
|
||||
self.__context = {}
|
||||
self.__logger = logging.getLogger('FFX').addHandler(logging.NullHandler())
|
||||
|
||||
if MediaDescriptor.TAGS_KEY in kwargs.keys():
|
||||
if type(kwargs[MediaDescriptor.TAGS_KEY]) is not dict:
|
||||
raise TypeError(
|
||||
@@ -149,10 +158,12 @@ class MediaDescriptor:
|
||||
|
||||
|
||||
@classmethod
|
||||
def fromFfprobe(cls, formatData, streamData):
|
||||
def fromFfprobe(cls, context, formatData, streamData):
|
||||
|
||||
kwargs = {}
|
||||
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||
|
||||
if MediaDescriptor.FFPROBE_TAGS_KEY in formatData.keys():
|
||||
kwargs[MediaDescriptor.TAGS_KEY] = formatData[
|
||||
MediaDescriptor.FFPROBE_TAGS_KEY
|
||||
@@ -242,17 +253,14 @@ class MediaDescriptor:
|
||||
def compare(self, vsMediaDescriptor: Self):
|
||||
|
||||
if not isinstance(vsMediaDescriptor, self.__class__):
|
||||
raise click.ClickException(
|
||||
f"MediaDescriptor.compare(): Argument is required to be of type {self.__class__}"
|
||||
)
|
||||
errorMessage = f"MediaDescriptor.compare(): Argument is required to be of type {self.__class__}"
|
||||
self.__logger.error(errorMessage)
|
||||
# raise click.ClickException(errorMessage)
|
||||
click.Abort()
|
||||
|
||||
vsTags = vsMediaDescriptor.getTags()
|
||||
tags = self.getTags()
|
||||
|
||||
# tags ist leer
|
||||
# click.echo(f"tags={tags} vsTags={vsTags}")
|
||||
# raise click.Abort
|
||||
|
||||
# HINT: Some tags differ per file, for example creation_time, so these are removed before diff
|
||||
for emt in MediaDescriptor.EXCLUDED_MEDIA_TAGS:
|
||||
if emt in tags.keys():
|
||||
@@ -342,7 +350,7 @@ class MediaDescriptor:
|
||||
|
||||
|
||||
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"""
|
||||
|
||||
# reorderedTrackDescriptors = self.getReorderedTrackDescriptors()
|
||||
inputMappingTokens = []
|
||||
@@ -405,22 +413,25 @@ class MediaDescriptor:
|
||||
|
||||
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
||||
|
||||
click.echo(f"Available subtitle files {subtitleFileDescriptors}\n")
|
||||
self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}")
|
||||
|
||||
return subtitleFileDescriptors
|
||||
|
||||
|
||||
def importSubtitles(self, searchDirectory, prefix, season: int = -1, episode: int = -1):
|
||||
|
||||
click.echo(f"Season: {season} Episode: {episode}")
|
||||
# click.echo(f"Season: {season} Episode: {episode}")
|
||||
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
|
||||
|
||||
availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix)
|
||||
|
||||
click.echo(f"availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
|
||||
# click.echo(f"availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
|
||||
self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
|
||||
|
||||
subtitleTracks = self.getSubtitleTracks()
|
||||
|
||||
click.echo(f"subtitleTracks: {[s.getIndex() for s in subtitleTracks]}")
|
||||
# click.echo(f"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")
|
||||
@@ -438,10 +449,13 @@ class MediaDescriptor:
|
||||
else []
|
||||
)
|
||||
|
||||
click.echo(f"matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
||||
# click.echo(f"matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
||||
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
||||
click.echo(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
||||
|
||||
for msfd in matchingSubtitleFileDescriptors:
|
||||
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
|
||||
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"]}")
|
||||
matchingSubtitleTrackDescriptor[0].setExternalSourceFilePath(msfd["path"])
|
||||
|
||||
@@ -157,7 +157,7 @@ class MediaDetailsScreen(Screen):
|
||||
self.__currentPattern = self.__mediaFileProperties.getPattern()
|
||||
|
||||
# keine tags vorhanden
|
||||
self.__targetMediaDescriptor = self.__currentPattern.getMediaDescriptor() if self.__currentPattern is not None else None
|
||||
self.__targetMediaDescriptor = self.__currentPattern.getMediaDescriptor(self.context) if self.__currentPattern is not None else None
|
||||
|
||||
# Enumerating differences between media descriptors
|
||||
# from file (=current) vs from stored in database (=target)
|
||||
@@ -452,7 +452,7 @@ class MediaDetailsScreen(Screen):
|
||||
selected_track_data = self.tracksTable.get_row(row_key)
|
||||
|
||||
kwargs = {}
|
||||
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self.context
|
||||
kwargs[TrackDescriptor.INDEX_KEY] = int(selected_track_data[0])
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(selected_track_data[1])
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(selected_track_data[2])
|
||||
|
||||
@@ -41,9 +41,9 @@ class Pattern(Base):
|
||||
def getShowId(self):
|
||||
return int(self.show_id)
|
||||
|
||||
def getShowDescriptor(self) -> ShowDescriptor:
|
||||
click.echo(f"self.show {self.show} id={self.show_id}")
|
||||
return self.show.getDescriptor()
|
||||
def getShowDescriptor(self, context) -> ShowDescriptor:
|
||||
# click.echo(f"self.show {self.show} id={self.show_id}")
|
||||
return self.show.getDescriptor(context)
|
||||
|
||||
def getId(self):
|
||||
return int(self.id)
|
||||
@@ -55,11 +55,13 @@ class Pattern(Base):
|
||||
return {str(t.key):str(t.value) for t in self.media_tags}
|
||||
|
||||
|
||||
def getMediaDescriptor(self):
|
||||
def getMediaDescriptor(self, context):
|
||||
|
||||
kwargs = {}
|
||||
kwargs[MediaDescriptor.TAGS_KEY] = self.getTags()
|
||||
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||
|
||||
kwargs[MediaDescriptor.TAGS_KEY] = self.getTags()
|
||||
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY] = []
|
||||
|
||||
# Set ordered subindices
|
||||
@@ -68,7 +70,7 @@ class Pattern(Base):
|
||||
trackType = track.getType()
|
||||
if not trackType in subIndexCounter.keys():
|
||||
subIndexCounter[trackType] = 0
|
||||
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(subIndex = subIndexCounter[trackType]))
|
||||
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType]))
|
||||
subIndexCounter[trackType] += 1
|
||||
|
||||
return MediaDescriptor(**kwargs)
|
||||
|
||||
@@ -44,10 +44,10 @@ class Show(Base):
|
||||
indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS)
|
||||
|
||||
|
||||
def getDescriptor(self):
|
||||
def getDescriptor(self, context):
|
||||
|
||||
kwargs = {}
|
||||
|
||||
kwargs[ShowDescriptor.CONTEXT_KEY] = context
|
||||
kwargs[ShowDescriptor.ID_KEY] = int(self.id)
|
||||
kwargs[ShowDescriptor.NAME_KEY] = str(self.name)
|
||||
kwargs[ShowDescriptor.YEAR_KEY] = int(self.year)
|
||||
|
||||
@@ -189,10 +189,12 @@ class Track(Base):
|
||||
return bool(self.disposition_flags & 2**disposition.index())
|
||||
|
||||
|
||||
def getDescriptor(self, subIndex : int = -1) -> TrackDescriptor:
|
||||
def getDescriptor(self, context, subIndex : int = -1) -> TrackDescriptor:
|
||||
|
||||
kwargs = {}
|
||||
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = context
|
||||
|
||||
kwargs[TrackDescriptor.ID_KEY] = self.getId()
|
||||
kwargs[TrackDescriptor.PATTERN_ID_KEY] = self.getPatternId()
|
||||
|
||||
|
||||
@@ -137,14 +137,14 @@ class PatternController():
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def getMediaDescriptor(self, patternId):
|
||||
def getMediaDescriptor(self, context, patternId):
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.id == int(patternId))
|
||||
|
||||
if q.count():
|
||||
return q.first().getMediaDescriptor()
|
||||
return q.first().getMediaDescriptor(context)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
for tr in tracks:
|
||||
|
||||
td : TrackDescriptor = tr.getDescriptor()
|
||||
td : TrackDescriptor = tr.getDescriptor(self.context)
|
||||
|
||||
trackType = td.getType()
|
||||
if not trackType in typeCounter.keys():
|
||||
@@ -292,7 +292,7 @@ class PatternDetailsScreen(Screen):
|
||||
trackIndex = int(selected_track_data[0])
|
||||
trackSubIndex = int(selected_track_data[2])
|
||||
|
||||
return self.__tc.getTrack(self.__pattern.getId(), trackIndex).getDescriptor(subIndex=trackSubIndex)
|
||||
return self.__tc.getTrack(self.__pattern.getId(), trackIndex).getDescriptor(self.context, subIndex=trackSubIndex)
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -20,7 +20,7 @@ class ShowController():
|
||||
|
||||
if q.count():
|
||||
show: Show = q.first()
|
||||
return show.getDescriptor()
|
||||
return show.getDescriptor(self.context)
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShowController.getShowDescriptor(): {repr(ex)}")
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import click
|
||||
|
||||
from typing import List, Self
|
||||
|
||||
from ffx.track_type import TrackType
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
|
||||
from ffx.helper import dictDiff, DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
||||
import logging
|
||||
|
||||
|
||||
class ShowDescriptor():
|
||||
"""This class represents the structural content of a media file including streams and metadata"""
|
||||
|
||||
# CONTEXT_KEY = 'context'
|
||||
CONTEXT_KEY = 'context'
|
||||
|
||||
ID_KEY = 'id'
|
||||
NAME_KEY = 'name'
|
||||
@@ -32,6 +23,17 @@ class ShowDescriptor():
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
if ShowDescriptor.CONTEXT_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.CONTEXT_KEY]) is not dict:
|
||||
raise TypeError(
|
||||
f"ShowDescriptor.__init__(): Argument {ShowDescriptor.CONTEXT_KEY} is required to be of type dict"
|
||||
)
|
||||
self.__context = kwargs[ShowDescriptor.CONTEXT_KEY]
|
||||
self.__logger = self.__context['logger']
|
||||
else:
|
||||
self.__context = {}
|
||||
self.__logger = logging.getLogger('FFX').addHandler(logging.NullHandler())
|
||||
|
||||
if ShowDescriptor.ID_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.ID_KEY]) is not int:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.ID_KEY} is required to be of type int")
|
||||
|
||||
@@ -169,7 +169,7 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
|
||||
if trackType == TrackType.VIDEO:
|
||||
|
||||
cacheIndex, generatorCache = dictCache({'type': TrackType.VIDEO}, generatorCache)
|
||||
click.echo(f"createMediaTestFile() cache index={cacheIndex} size={len(generatorCache)}")
|
||||
# click.echo(f"createMediaTestFile() cache index={cacheIndex} size={len(generatorCache)}")
|
||||
|
||||
if cacheIndex == -1:
|
||||
generatorTokens += ['-f',
|
||||
@@ -192,9 +192,9 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
|
||||
audioLayout = 'stereo'
|
||||
|
||||
cacheIndex, generatorCache = dictCache({'type': TrackType.AUDIO, 'layout': audioLayout}, generatorCache)
|
||||
click.echo(f"createMediaTestFile() cache index={cacheIndex} size={len(generatorCache)}")
|
||||
# click.echo(f"createMediaTestFile() cache index={cacheIndex} size={len(generatorCache)}")
|
||||
|
||||
click.echo(f"generartorCache index={cacheIndex} len={len(generatorCache)}")
|
||||
# click.echo(f"generartorCache index={cacheIndex} len={len(generatorCache)}")
|
||||
if cacheIndex == -1:
|
||||
generatorTokens += ['-f',
|
||||
'lavfi',
|
||||
@@ -214,7 +214,7 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
|
||||
if trackType == TrackType.SUBTITLE:
|
||||
|
||||
cacheIndex, generatorCache = dictCache({'type': TrackType.SUBTITLE}, generatorCache)
|
||||
click.echo(f"createMediaTestFile() cache index={cacheIndex} size={len(generatorCache)}")
|
||||
# click.echo(f"createMediaTestFile() cache index={cacheIndex} size={len(generatorCache)}")
|
||||
|
||||
if cacheIndex == -1:
|
||||
importTokens = ['-i', createVttFile(SHORT_SUBTITLE_SEQUENCE)]
|
||||
@@ -254,10 +254,10 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
|
||||
|
||||
commandTokens += [outputPath]
|
||||
|
||||
print(f"command sequence: {commandTokens}")
|
||||
# click.echo(f"command sequence: {commandTokens}")
|
||||
out, err, rc = executeProcess(commandTokens)
|
||||
if rc:
|
||||
print(f"Creating testfile failed with {rc}: {err}")
|
||||
#if rc:
|
||||
# click.echo(f"Creating testfile failed with {rc}: {err}")
|
||||
|
||||
return outputPath
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ class Scenario():
|
||||
os.path.dirname(__file__))),
|
||||
'ffx.py')
|
||||
|
||||
self._logger = context['logger']
|
||||
self._reportLogger = context['report_logger']
|
||||
|
||||
@staticmethod
|
||||
def list():
|
||||
basePath = os.path.dirname(__file__)
|
||||
|
||||
@@ -24,23 +24,27 @@ class Scenario1(Scenario):
|
||||
|
||||
def run(self):
|
||||
|
||||
click.echo(f"Running scenario 1")
|
||||
self._logger.info(f"Running {self.__class__.__name__}")
|
||||
|
||||
kwargs = {}
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self._context
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.VIDEO
|
||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = set([TrackDisposition.DEFAULT])
|
||||
trackDescriptor1 = TrackDescriptor(**kwargs)
|
||||
|
||||
kwargs = {}
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self._context
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.AUDIO
|
||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = set([TrackDisposition.DEFAULT])
|
||||
trackDescriptor2 = TrackDescriptor(**kwargs)
|
||||
|
||||
kwargs = {}
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self._context
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.AUDIO
|
||||
trackDescriptor3 = TrackDescriptor(**kwargs)
|
||||
|
||||
kwargs = {}
|
||||
kwargs[MediaDescriptor.CONTEXT_KEY] = self._context
|
||||
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY] = [trackDescriptor1,
|
||||
trackDescriptor2,
|
||||
trackDescriptor3]
|
||||
@@ -58,11 +62,14 @@ class Scenario1(Scenario):
|
||||
'convert',
|
||||
mediaFilePath]
|
||||
|
||||
click.echo(f"Scenarion 1 test sequence: {commandSequence}")
|
||||
self._logger.debug(f"Scenario1.run(): test sequence: {commandSequence}")
|
||||
|
||||
out, err, rc = executeProcess(commandSequence, directory = self._testDirectory)
|
||||
click.echo(f"process output: {out}")
|
||||
|
||||
if out:
|
||||
self._logger.debug(f"Scenario1.run(): process output: {out}")
|
||||
if rc:
|
||||
self._logger.error(f"Scenario1.run(): process resultet in error {rc}: {err}")
|
||||
|
||||
# Phase 4: Evaluate results
|
||||
|
||||
@@ -87,6 +94,9 @@ class Scenario1(Scenario):
|
||||
assert resultMediaTracks[2].getType() == TrackType.AUDIO, f"Stream #2 is not of type audio"
|
||||
assert resultMediaTracks[2].getDispositionFlag(TrackDisposition.DEFAULT), f"Stream #1 has not set default disposition"
|
||||
|
||||
self._reportLogger.info('Scenario 1 test passed')
|
||||
|
||||
except AssertionError as ae:
|
||||
|
||||
click.echo(f"Scenario 1 test failed ({ae})")
|
||||
# click.echo(f"Scenario 1 test failed ({ae})")
|
||||
self._reportLogger.error(f"Scenario 1 test failed ({ae})")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import click
|
||||
import logging
|
||||
|
||||
from .iso_language import IsoLanguage
|
||||
from .track_type import TrackType
|
||||
@@ -10,6 +10,8 @@ from .helper import dictDiff, setDiff
|
||||
|
||||
class TrackDescriptor:
|
||||
|
||||
CONTEXT_KEY = "context"
|
||||
|
||||
ID_KEY = "id"
|
||||
INDEX_KEY = "index"
|
||||
SOURCE_INDEX_KEY = "source_index"
|
||||
@@ -34,6 +36,17 @@ class TrackDescriptor:
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
if TrackDescriptor.CONTEXT_KEY in kwargs.keys():
|
||||
if type(kwargs[TrackDescriptor.CONTEXT_KEY]) is not dict:
|
||||
raise TypeError(
|
||||
f"TrackDescriptor.__init__(): Argument {TrackDescriptor.CONTEXT_KEY} is required to be of type dict"
|
||||
)
|
||||
self.__context = kwargs[TrackDescriptor.CONTEXT_KEY]
|
||||
self.__logger = self.__context['logger']
|
||||
else:
|
||||
self.__context = {}
|
||||
self.__logger = logging.getLogger('FFX').addHandler(logging.NullHandler())
|
||||
|
||||
if TrackDescriptor.ID_KEY in kwargs.keys():
|
||||
if type(kwargs[TrackDescriptor.ID_KEY]) is not int:
|
||||
raise TypeError(
|
||||
|
||||
@@ -268,6 +268,8 @@ class TrackDetailsScreen(Screen):
|
||||
|
||||
kwargs = {}
|
||||
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self.context
|
||||
|
||||
kwargs[TrackDescriptor.PATTERN_ID_KEY] = int(self.__pattern.getId())
|
||||
|
||||
kwargs[TrackDescriptor.INDEX_KEY] = self.__index
|
||||
|
||||
@@ -1,64 +1,73 @@
|
||||
#! /usr/bin/python3
|
||||
|
||||
import os, sys, subprocess, json, click, time, re, tempfile, math
|
||||
from datetime import datetime, timedelta
|
||||
import os, logging, click
|
||||
|
||||
from ffx.file_properties import FileProperties
|
||||
|
||||
from ffx.ffx_app import FfxApp
|
||||
from ffx.ffx_controller import FfxController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.tmdb_controller import TmdbController
|
||||
|
||||
from ffx.database import databaseContext
|
||||
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.track_type import TrackType
|
||||
from ffx.video_encoder import VideoEncoder
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
|
||||
from ffx.process import executeProcess
|
||||
|
||||
from ffx.test.helper import createMediaTestFile
|
||||
|
||||
from ffx.test.scenario import Scenario
|
||||
|
||||
VERSION='0.1.0'
|
||||
|
||||
# 0.1.1
|
||||
# Bugfixes, TMBD identify shows
|
||||
# 0.1.2
|
||||
# Bugfixes
|
||||
# 0.1.3
|
||||
# Subtitle file imports
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
def ffx(ctx):
|
||||
@click.option('-v', '--verbose', type=int, default=0, help='Set verbosity of output')
|
||||
@click.option("--dry-run", is_flag=True, default=False)
|
||||
def ffx(ctx, verbose, dry_run):
|
||||
"""FFX"""
|
||||
|
||||
ctx.obj = {}
|
||||
ctx.obj['database'] = databaseContext(databasePath=None)
|
||||
ctx.obj['dry_run'] = dry_run
|
||||
|
||||
ctx.obj['verbosity'] = verbose
|
||||
|
||||
# Define a subcommand
|
||||
@ffx.command()
|
||||
def version():
|
||||
click.echo(VERSION)
|
||||
# Critical 50
|
||||
# Error 40
|
||||
# Warning 30
|
||||
# Info 20
|
||||
# Debug 10
|
||||
fileLogVerbosity = max(40 - verbose * 10, 10)
|
||||
consoleLogVerbosity = max(20 - verbose * 10, 10)
|
||||
|
||||
homeDir = os.path.expanduser("~")
|
||||
ffxLogDir = os.path.join(homeDir, '.local', 'var', 'log')
|
||||
if not os.path.exists(ffxLogDir):
|
||||
os.makedirs(ffxLogDir)
|
||||
ffxLogFilePath = os.path.join(ffxLogDir, 'ffx.tests.log')
|
||||
|
||||
# Another subcommand
|
||||
@ffx.command()
|
||||
def help():
|
||||
click.echo(f"ffx tests {VERSION}\n")
|
||||
click.echo(f"Usage: ffx_test ...")
|
||||
ctx.obj['logger'] = logging.getLogger('FFX Tests')
|
||||
ctx.obj['logger'].setLevel(logging.DEBUG)
|
||||
|
||||
ctx.obj['report_logger'] = logging.getLogger('FFX Test Result')
|
||||
ctx.obj['report_logger'].setLevel(logging.INFO)
|
||||
|
||||
# @ffx.command()
|
||||
# def show():
|
||||
# for i in Scenario().list():
|
||||
# click.echo(i)
|
||||
ffxFileHandler = logging.FileHandler(ffxLogFilePath)
|
||||
ffxFileHandler.setLevel(fileLogVerbosity)
|
||||
ffxConsoleHandler = logging.StreamHandler()
|
||||
ffxConsoleHandler.setLevel(consoleLogVerbosity)
|
||||
|
||||
os.unlink('ffx_test_report.log')
|
||||
ffxTestReportFileHandler = logging.FileHandler('ffx_test_report.log')
|
||||
|
||||
fileFormatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
ffxFileHandler.setFormatter(fileFormatter)
|
||||
consoleFormatter = logging.Formatter(
|
||||
'%(message)s')
|
||||
ffxConsoleHandler.setFormatter(consoleFormatter)
|
||||
reportFormatter = logging.Formatter(
|
||||
'%(message)s')
|
||||
ffxTestReportFileHandler.setFormatter(reportFormatter)
|
||||
|
||||
ctx.obj['logger'].addHandler(ffxConsoleHandler)
|
||||
ctx.obj['logger'].addHandler(ffxFileHandler)
|
||||
|
||||
ctx.obj['report_logger'].addHandler(ffxConsoleHandler)
|
||||
ctx.obj['report_logger'].addHandler(ffxTestReportFileHandler)
|
||||
|
||||
|
||||
# Another subcommand
|
||||
@@ -67,11 +76,13 @@ def help():
|
||||
def run(ctx):
|
||||
"""Run ffx test sequences"""
|
||||
|
||||
for scenarioIdentifier in Scenario().list():
|
||||
ctx.obj['logger'].info('Starting FFX test runs')
|
||||
|
||||
for scenarioIdentifier in Scenario.list():
|
||||
|
||||
scenario = Scenario.getClassReference(scenarioIdentifier)(ctx.obj)
|
||||
|
||||
click.echo(f"Running scenario {scenarioIdentifier}")
|
||||
ctx.obj['logger'].info(f"Running scenario {scenarioIdentifier}")
|
||||
|
||||
scenario.run()
|
||||
|
||||
|
||||
52
bin/logg.py
Executable file
52
bin/logg.py
Executable file
@@ -0,0 +1,52 @@
|
||||
#! /usr/bin/python3
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('FFX')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
testLogger = logging.getLogger('FFX Test')
|
||||
testLogger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
# create file handler that logs debug and higher level messages
|
||||
ffxFileHandler = logging.FileHandler('ffx.log')
|
||||
ffxFileHandler.setLevel(logging.DEBUG)
|
||||
|
||||
# create file handler that logs debug and higher level messages
|
||||
ffxTestReportFileHandler = logging.FileHandler('ffx_test_report.log')
|
||||
ffxTestReportFileHandler.setLevel(logging.DEBUG)
|
||||
|
||||
# create console handler with a higher log level
|
||||
ffxConsoleHandler = logging.StreamHandler()
|
||||
#ffxConsoleHandler.setLevel(logging.ERROR)
|
||||
|
||||
# create formatter and add it to the handlers
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
ffxConsoleHandler.setFormatter(formatter)
|
||||
ffxFileHandler.setFormatter(formatter)
|
||||
|
||||
|
||||
# add the handlers to logger
|
||||
testLogger.addHandler(ffxConsoleHandler)
|
||||
|
||||
|
||||
logger.addHandler(ffxConsoleHandler)
|
||||
logger.addHandler(ffxFileHandler)
|
||||
|
||||
|
||||
|
||||
logger.debug('debug message')
|
||||
logger.info('info message')
|
||||
logger.warning('warn message')
|
||||
logger.error('error message')
|
||||
logger.critical('critical message')
|
||||
|
||||
|
||||
testLogger.info('TEST: info message')
|
||||
|
||||
|
||||
|
||||
click / consoleLogger
|
||||
|
||||
Reference in New Issue
Block a user