diff --git a/CHANGELOG.md b/CHANGELOG.md index 7919808b04..a17db14935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ #### New Features #### Improvements +- Bump Knowit + pymediainfo to version 0.4.0 and 5.1.0 ([10564](https://github.com/pymedusa/Medusa/pull/10564)) #### Fixes diff --git a/ext/knowit/__init__.py b/ext/knowit/__init__.py index b753f1ded5..eda7067795 100644 --- a/ext/knowit/__init__.py +++ b/ext/knowit/__init__.py @@ -1,13 +1,10 @@ -# -*- coding: utf-8 -*- """Know your media files better.""" -from __future__ import unicode_literals - __title__ = 'knowit' -__version__ = '0.3.0-dev' +__version__ = '0.4.0' __short_version__ = '.'.join(__version__.split('.')[:2]) __author__ = 'Rato AQ2' __license__ = 'MIT' -__copyright__ = 'Copyright 2016-2017, Rato AQ2' +__copyright__ = 'Copyright 2016-2021, Rato AQ2' __url__ = 'https://github.com/ratoaq2/knowit' #: Video extensions @@ -19,9 +16,4 @@ '.omf', '.ps', '.qt', '.ram', '.rm', '.rmvb', '.swf', '.ts', '.vfw', '.vid', '.video', '.viv', '.vivo', '.vob', '.vro', '.webm', '.wm', '.wmv', '.wmx', '.wrap', '.wvx', '.wx', '.x264', '.xvid') -try: - from collections import OrderedDict -except ImportError: # pragma: no cover - from ordereddict import OrderedDict - -from .api import KnowitException, know +from knowit.api import KnowitException, know diff --git a/ext/knowit/__main__.py b/ext/knowit/__main__.py index 3b55af8729..c301484211 100644 --- a/ext/knowit/__main__.py +++ b/ext/knowit/__main__.py @@ -1,25 +1,24 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import argparse import json import logging +import os import sys +import typing from argparse import ArgumentParser -from six import PY2 import yaml -from . import ( +from knowit import ( __url__, __version__, api, ) -from .provider import ProviderError -from .serializer import ( +from knowit.provider import ProviderError +from knowit.serializer import ( get_json_encoder, get_yaml_dumper, ) -from .utils import recurse_paths +from knowit.utils import recurse_paths logging.basicConfig(stream=sys.stdout, format='%(message)s') logging.getLogger('CONSOLE').setLevel(logging.INFO) @@ -29,45 +28,96 @@ logger = logging.getLogger('knowit') -def build_argument_parser(): - """Build the argument parser. - - :return: the argument parser - :rtype: ArgumentParser - """ +def build_argument_parser() -> ArgumentParser: + """Build the argument parser.""" opts = ArgumentParser() - opts.add_argument(dest='videopath', help='Path to the video to introspect', nargs='*') + opts.add_argument( + dest='videopath', + help='Path to the video to introspect', + nargs='*', + type=str, + ) provider_opts = opts.add_argument_group('Providers') - provider_opts.add_argument('-p', '--provider', dest='provider', - help='The provider to be used: mediainfo, ffmpeg or enzyme.') + provider_opts.add_argument( + '-p', + '--provider', + dest='provider', + help='The provider to be used: mediainfo, ffmpeg, mkvmerge or enzyme.', + type=str, + ) output_opts = opts.add_argument_group('Output') - output_opts.add_argument('--debug', action='store_true', dest='debug', - help='Print useful information for debugging knowit and for reporting bugs.') - output_opts.add_argument('--report', action='store_true', dest='report', - help='Parse media and report all non-detected values') - output_opts.add_argument('-y', '--yaml', action='store_true', dest='yaml', - help='Display output in yaml format') - output_opts.add_argument('-N', '--no-units', action='store_true', dest='no_units', - help='Display output without units') - output_opts.add_argument('-P', '--profile', dest='profile', - help='Display values according to specified profile: code, default, human, technical') + output_opts.add_argument( + '--debug', + action='store_true', + dest='debug', + help='Print information for debugging knowit and for reporting bugs.' + ) + output_opts.add_argument( + '--report', + action='store_true', + dest='report', + help='Parse media and report all non-detected values' + ) + output_opts.add_argument( + '-y', + '--yaml', + action='store_true', + dest='yaml', + help='Display output in yaml format' + ) + output_opts.add_argument( + '-N', + '--no-units', + action='store_true', + dest='no_units', + help='Display output without units' + ) + output_opts.add_argument( + '-P', + '--profile', + dest='profile', + help='Display values according to specified profile: code, default, human, technical', + type=str, + ) conf_opts = opts.add_argument_group('Configuration') - conf_opts.add_argument('--mediainfo', dest='mediainfo', - help='The location to search for MediaInfo binaries') - conf_opts.add_argument('--ffmpeg', dest='ffmpeg', - help='The location to search for FFmpeg (ffprobe) binaries') + conf_opts.add_argument( + '--mediainfo', + dest='mediainfo', + help='The location to search for MediaInfo binaries', + type=str, + ) + conf_opts.add_argument( + '--ffmpeg', + dest='ffmpeg', + help='The location to search for ffprobe (FFmpeg) binaries', + type=str, + ) + conf_opts.add_argument( + '--mkvmerge', + dest='mkvmerge', + help='The location to search for mkvmerge (MKVToolNix) binaries', + type=str, + ) information_opts = opts.add_argument_group('Information') - information_opts.add_argument('--version', dest='version', action='store_true', - help='Display knowit version.') + information_opts.add_argument( + '--version', + dest='version', + action='store_true', + help='Display knowit version.' + ) return opts -def knowit(video_path, options, context): +def knowit( + video_path: typing.Union[str, os.PathLike], + options: argparse.Namespace, + context: typing.MutableMapping, +) -> typing.Mapping: """Extract video metadata.""" context['path'] = video_path if not options.report: @@ -77,27 +127,49 @@ def knowit(video_path, options, context): info = api.know(video_path, context) if not options.report: console.info('Knowit %s found: ', __version__) - console.info(dump(info, options, context)) - + console.info(dumps(info, options, context)) return info -def dump(info, options, context): +def _as_yaml( + info: typing.Mapping[str, typing.Any], + context: typing.Mapping, +) -> str: + """Convert info to string using YAML format.""" + data = {info['path']: info} if 'path' in info else info + return yaml.dump( + data, + Dumper=get_yaml_dumper(context), + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) + + +def _as_json( + info: typing.Mapping[str, typing.Any], + context: typing.Mapping, +) -> str: + """Convert info to string using JSON format.""" + return json.dumps( + info, + cls=get_json_encoder(context), + indent=4, + ensure_ascii=False, + ) + + +def dumps( + info: typing.Mapping[str, typing.Any], + options: argparse.Namespace, + context: typing.Mapping, +) -> str: """Convert info to string using json or yaml format.""" - if options.yaml: - data = {info['path']: info} if 'path' in info else info - result = yaml.dump(data, Dumper=get_yaml_dumper(context), - default_flow_style=False, allow_unicode=True) - if PY2: - result = result.decode('utf-8') - - else: - result = json.dumps(info, cls=get_json_encoder(context), indent=4, ensure_ascii=False) - - return result + convert = _as_yaml if options.yaml else _as_json + return convert(info, context) -def main(args=None): +def main(args: typing.List[str] = None) -> None: """Execute main function for entry point.""" argument_parser = build_argument_parser() args = args or sys.argv[1:] @@ -111,40 +183,42 @@ def main(args=None): paths = recurse_paths(options.videopath) - if paths: - report = {} - for i, videopath in enumerate(paths): - try: - context = dict(vars(options)) - if options.report: - context['report'] = report - else: - del context['report'] - knowit(videopath, options, context) - except ProviderError: - logger.exception('Error when processing video') - except OSError: - logger.exception('OS error when processing video') - except UnicodeError: - logger.exception('Character encoding error when processing video') - except api.KnowitException as e: - logger.error(e) - if options.report and i % 20 == 19 and report: - console.info('Unknown values so far:') - console.info(dump(report, options, vars(options))) - - if options.report: - if report: - console.info('Knowit %s found unknown values:', __version__) - console.info(dump(report, options, vars(options))) - console.info('Please report them at %s', __url__) + if not paths: + if options.version: + console.info(api.debug_info()) + else: + argument_parser.print_help() + return + + report: typing.MutableMapping[str, str] = {} + for i, video_path in enumerate(paths): + try: + context = {k: v for k, v in vars(options).items() if v is not None} + if options.report: + context['report'] = report else: - console.info('Knowit %s knows everything. :-)', __version__) - - elif options.version: - console.info(api.debug_info()) - else: - argument_parser.print_help() + del context['report'] + knowit(video_path, options, context) + except ProviderError: + logger.exception('Error when processing video') + except OSError: + logger.exception('OS error when processing video') + except UnicodeError: + logger.exception('Character encoding error when processing video') + except api.KnowitException as e: + logger.error(e) + + if options.report and i % 20 == 19 and report: + console.info('Unknown values so far:') + console.info(dumps(report, options, vars(options))) + + if options.report: + if report: + console.info('Knowit %s found unknown values:', __version__) + console.info(dumps(report, options, vars(options))) + console.info('Please report them at %s', __url__) + else: + console.info('Knowit %s knows everything. :-)', __version__) if __name__ == '__main__': diff --git a/ext/knowit/api.py b/ext/knowit/api.py index 769ec1601f..4df7806054 100644 --- a/ext/knowit/api.py +++ b/ext/knowit/api.py @@ -1,55 +1,50 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import os import traceback +import typing -from . import OrderedDict, __version__ -from .config import Config +from knowit import __version__ +from knowit.config import Config +from knowit.provider import Provider from .providers import ( EnzymeProvider, FFmpegProvider, MediaInfoProvider, + MkvMergeProvider, ) -_provider_map = OrderedDict([ - ('mediainfo', MediaInfoProvider), - ('ffmpeg', FFmpegProvider), - ('enzyme', EnzymeProvider) -]) +_provider_map = { + 'mediainfo': MediaInfoProvider, + 'ffmpeg': FFmpegProvider, + 'mkvmerge': MkvMergeProvider, + 'enzyme': EnzymeProvider, +} provider_names = _provider_map.keys() -available_providers = OrderedDict([]) +available_providers: typing.Dict[str, Provider] = {} class KnowitException(Exception): - """Exception raised when knowit fails to perform media info extraction because of an internal error.""" + """Exception raised when knowit encounters an internal error.""" -def initialize(context=None): +def initialize(context: typing.Optional[typing.Mapping] = None) -> None: """Initialize knowit.""" if not available_providers: context = context or {} config = Config.build(context.get('config')) for name, provider_cls in _provider_map.items(): - available_providers[name] = provider_cls(config, context.get(name) or config.general.get(name)) - + general_config = getattr(config, 'general', {}) + mapping = context.get(name) or general_config.get(name) + available_providers[name] = provider_cls(config, mapping) -def know(video_path, context=None): - """Return a dict containing the video metadata. - :param video_path: - :type video_path: string - :param context: - :type context: dict - :return: - :rtype: dict - """ - try: - # handle path-like objects - video_path = video_path.__fspath__() - except AttributeError: - pass +def know( + video_path: typing.Union[str, os.PathLike], + context: typing.Optional[typing.MutableMapping] = None +) -> typing.Mapping: + """Return a mapping of video metadata.""" + video_path = os.fspath(video_path) try: context = context or {} @@ -70,9 +65,9 @@ def know(video_path, context=None): raise KnowitException(debug_info(context=context, exc_info=True)) -def dependencies(context=None): +def dependencies(context: typing.Mapping = None) -> typing.Mapping: """Return all dependencies detected by knowit.""" - deps = OrderedDict([]) + deps = {} try: initialize(context) for name, provider_cls in _provider_map.items(): @@ -86,15 +81,18 @@ def dependencies(context=None): return deps -def _centered(value): +def _centered(value: str) -> str: value = value[-52:] - return '| {msg:^53} |'.format(msg=value) + return f'| {value:^53} |' -def debug_info(context=None, exc_info=False): +def debug_info( + context: typing.Optional[typing.MutableMapping] = None, + exc_info: bool = False, +) -> str: lines = [ '+-------------------------------------------------------+', - _centered('KnowIt {0}'.format(__version__)), + _centered(f'KnowIt {__version__}'), '+-------------------------------------------------------+' ] @@ -114,7 +112,7 @@ def debug_info(context=None, exc_info=False): lines.append('+-------------------------------------------------------+') for k, v in context.items(): if v: - lines.append(_centered('{}: {}'.format(k, v))) + lines.append(_centered(f'{k}: {v}')) if debug_data: lines.append('+-------------------------------------------------------+') diff --git a/ext/knowit/config.py b/ext/knowit/config.py index 04e8713e23..8331399233 100644 --- a/ext/knowit/config.py +++ b/ext/knowit/config.py @@ -1,34 +1,38 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from collections import namedtuple +import os +import typing from logging import NullHandler, getLogger from pkg_resources import resource_stream -from six import text_type import yaml -from .serializer import get_yaml_loader +from knowit.serializer import get_yaml_loader logger = getLogger(__name__) logger.addHandler(NullHandler()) -_valid_aliases = ('code', 'default', 'human', 'technical') -_Value = namedtuple('_Value', _valid_aliases) + +class _Value(typing.NamedTuple): + code: str + default: str + human: str + technical: str + + +_valid_aliases = _Value._fields -class Config(object): +class Config: """Application config class.""" @classmethod - def build(cls, path=None): + def build(cls, path: typing.Optional[typing.Union[str, os.PathLike]] = None) -> 'Config': """Build config instance.""" loader = get_yaml_loader() with resource_stream('knowit', 'defaults.yml') as stream: cfgs = [yaml.load(stream, Loader=loader)] if path: - with open(path, 'r') as stream: + with open(path, 'rb') as stream: cfgs.append(yaml.load(stream, Loader=loader)) profiles_data = {} @@ -41,7 +45,7 @@ def build(cls, path=None): if 'knowledge' in cfg: knowledge_data.update(cfg['knowledge']) - data = {'general': {}} + data: typing.Dict[str, typing.MutableMapping] = {'general': {}} for class_name, data_map in knowledge_data.items(): data.setdefault(class_name, {}) for code, detection_values in data_map.items(): @@ -52,7 +56,7 @@ def build(cls, path=None): alias_map.setdefault('technical', alias_map['human']) value = _Value(**{k: v for k, v in alias_map.items() if k in _valid_aliases}) for detection_value in detection_values: - data[class_name][text_type(detection_value)] = value + data[class_name][str(detection_value)] = value config = Config() config.__dict__ = data diff --git a/ext/knowit/core.py b/ext/knowit/core.py index c567d2ccf4..9736d7ba26 100644 --- a/ext/knowit/core.py +++ b/ext/knowit/core.py @@ -1,36 +1,226 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import typing from logging import NullHandler, getLogger -from six import text_type - logger = getLogger(__name__) logger.addHandler(NullHandler()) +T = typing.TypeVar('T') + +_visible_chars_table = dict.fromkeys(range(32)) + + +def _is_unknown(value: typing.Any) -> bool: + return isinstance(value, str) and (not value or value.lower() == 'unknown') + -class Reportable(object): +class Reportable(typing.Generic[T]): """Reportable abstract class.""" - def __init__(self, name, description=None, reportable=True): - """Constructor.""" - self.name = name + def __init__( + self, + *args: str, + description: typing.Optional[str] = None, + reportable: bool = True, + ): + """Initialize the object.""" + self.names = args self._description = description self.reportable = reportable @property - def description(self): + def description(self) -> str: """Rule description.""" - return self._description or self.name + return self._description or '|'.join(self.names) - def report(self, value, context): + def report(self, value: typing.Union[str, T], context: typing.MutableMapping) -> None: """Report unknown value.""" if not value or not self.reportable: return - value = text_type(value) if 'report' in context: report_map = context['report'].setdefault(self.description, {}) if value not in report_map: report_map[value] = context['path'] logger.info('Invalid %s: %r', self.description, value) + + +class Property(Reportable[T]): + """Property class.""" + + def __init__( + self, + *args: str, + default: typing.Optional[T] = None, + private: bool = False, + description: typing.Optional[str] = None, + delimiter: str = ' / ', + **kwargs, + ): + """Init method.""" + super().__init__(*args, description=description, **kwargs) + self.default = default + self.private = private + # Used to detect duplicated values. e.g.: en / en or High@L4.0 / High@L4.0 or Progressive / Progressive + self.delimiter = delimiter + + def extract_value( + self, + track: typing.Mapping, + context: typing.MutableMapping, + ) -> typing.Optional[T]: + """Extract the property value from a given track.""" + for name in self.names: + names = name.split('.') + value = track.get(names[0], {}).get(names[1]) if len(names) == 2 else track.get(name) + if value is None: + if self.default is None: + continue + + value = self.default + + if isinstance(value, bytes): + value = value.decode() + + if isinstance(value, str): + value = value.translate(_visible_chars_table).strip() + if _is_unknown(value): + continue + value = self._deduplicate(value) + + result = self.handle(value, context) + if result is not None and not _is_unknown(result): + return result + + return None + + @classmethod + def _deduplicate(cls, value: str) -> str: + values = value.split(' / ') + if len(values) == 2 and values[0] == values[1]: + return values[0] + return value + + def handle(self, value: T, context: typing.MutableMapping) -> typing.Optional[T]: + """Return the value without any modification.""" + return value + + +class Configurable(Property[T]): + """Configurable property where values are in a config mapping.""" + + def __init__(self, config: typing.Mapping[str, typing.Mapping], *args: str, + config_key: typing.Optional[str] = None, **kwargs): + """Init method.""" + super().__init__(*args, **kwargs) + self.mapping = getattr(config, config_key or self.__class__.__name__) if config else {} + + @classmethod + def _extract_key(cls, value: str) -> typing.Union[str, bool]: + return value.upper() + + @classmethod + def _extract_fallback_key(cls, value: str, key: str) -> typing.Optional[T]: + return None + + def _lookup( + self, + key: str, + context: typing.MutableMapping, + ) -> typing.Union[T, None, bool]: + result = self.mapping.get(key) + if result is not None: + result = getattr(result, context.get('profile') or 'default') + return result if result != '__ignored__' else False + return None + + def handle(self, value, context): + """Return Variable or Constant.""" + key = self._extract_key(value) + if key is False: + return + + result = self._lookup(key, context) + if result is False: + return + + while not result and key: + key = self._extract_fallback_key(value, key) + result = self._lookup(key, context) + if result is False: + return + + if not result: + self.report(value, context) + + return result + + +class MultiValue(Property): + """Property with multiple values.""" + + def __init__(self, prop: typing.Optional[Property] = None, delimiter='/', single=False, + handler=None, name=None, **kwargs): + """Init method.""" + super().__init__(*(prop.names if prop else (name,)), **kwargs) + self.prop = prop + self.delimiter = delimiter + self.single = single + self.handler = handler + + def handle( + self, + value: str, + context: typing.MutableMapping, + ) -> typing.Union[T, typing.List[T]]: + """Handle properties with multiple values.""" + if self.handler: + call = self.handler + elif self.prop: + call = self.prop.handle + else: + raise NotImplementedError('No handler available') + + result = call(value, context) + if result is not None: + return result + + if isinstance(value, list): + if len(value) == 1: + values = self._split(value[0], self.delimiter) + else: + values = value + else: + values = self._split(value, self.delimiter) + + if values is None: + return call(values, context) + if len(values) > 1 and not self.single: + results = [call(item, context) if not _is_unknown(item) else None for item in values] + results = [r for r in results if r is not None] + if results: + return results + return call(values[0], context) + + @classmethod + def _split( + cls, + value: typing.Optional[T], + delimiter: str = '/', + ) -> typing.Optional[typing.List[str]]: + if value is None: + return None + + return [x.strip() for x in str(value).split(delimiter)] + + +class Rule(Reportable[T]): + """Rule abstract class.""" + + def __init__(self, name: str, override=False, **kwargs): + """Initialize the object.""" + super().__init__(name, **kwargs) + self.override = override + + def execute(self, props, pv_props, context: typing.Mapping): + """How to execute a rule.""" + raise NotImplementedError diff --git a/ext/knowit/defaults.yml b/ext/knowit/defaults.yml index 234f934268..af6b79c321 100644 --- a/ext/knowit/defaults.yml +++ b/ext/knowit/defaults.yml @@ -200,6 +200,12 @@ knowledge: HIGH: - HIGH + VideoHdrFormat: + DV: + - DOLBY VISION + HDR10: + - SMPTE ST 2086 + ScanType: PROGRESSIVE: - PROGRESSIVE @@ -232,6 +238,7 @@ knowledge: MA: - MA - DTS-HD MA + - XLL MAIN: - MAIN LC: @@ -282,6 +289,7 @@ knowledge: - DTS-HD AAC: - AAC + - AAC-2 FLAC: - FLAC PCM: @@ -503,6 +511,13 @@ profiles: HIGH: default: High + VideoHdrFormat: + DV: + default: Dolby Vision + HDR10: + default: HDR10 + technical: HDR10 - SMPTE ST 2086 + ScanType: PROGRESSIVE: default: Progressive diff --git a/ext/knowit/properties/__init__.py b/ext/knowit/properties/__init__.py index f871bc47f8..da0cfd9e72 100644 --- a/ext/knowit/properties/__init__.py +++ b/ext/knowit/properties/__init__.py @@ -1,27 +1,28 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .audio import ( +from knowit.properties.audio import ( AudioChannels, AudioCodec, AudioCompression, AudioProfile, BitRateMode, ) -from .basic import Basic -from .duration import Duration -from .language import Language -from .quantity import Quantity -from .subtitle import ( +from knowit.properties.general import ( + Basic, + Duration, + Language, + Quantity, + YesNo, +) +from knowit.properties.subtitle import ( SubtitleFormat, ) -from .video import ( +from knowit.properties.video import ( Ratio, ScanType, VideoCodec, + VideoDimensions, VideoEncoder, + VideoHdrFormat, VideoProfile, VideoProfileLevel, VideoProfileTier, ) -from .yesno import YesNo diff --git a/ext/knowit/properties/audio.py b/ext/knowit/properties/audio.py new file mode 100644 index 0000000000..8347420287 --- /dev/null +++ b/ext/knowit/properties/audio.py @@ -0,0 +1,55 @@ +import typing + +from knowit.core import Configurable, Property + + +class BitRateMode(Configurable[str]): + """Bit Rate mode property.""" + + +class AudioCompression(Configurable[str]): + """Audio Compression property.""" + + +class AudioProfile(Configurable[str]): + """Audio profile property.""" + + +class AudioChannels(Property[int]): + """Audio Channels property.""" + + ignored = { + 'object based', # Dolby Atmos + } + + def handle(self, value: typing.Union[int, str], context: typing.MutableMapping) -> typing.Optional[int]: + """Handle audio channels.""" + if isinstance(value, int): + return value + + if value.lower() not in self.ignored: + try: + return int(value) + except ValueError: + self.report(value, context) + return None + + +class AudioCodec(Configurable[str]): + """Audio codec property.""" + + @classmethod + def _extract_key(cls, value) -> str: + key = str(value).upper() + if key.startswith('A_'): + key = key[2:] + + # only the first part of the word. E.g.: 'AAC LC' => 'AAC' + return key.split(' ')[0] + + @classmethod + def _extract_fallback_key(cls, value, key) -> typing.Optional[str]: + if '/' in key: + return key.split('/')[0] + else: + return None diff --git a/ext/knowit/properties/audio/__init__.py b/ext/knowit/properties/audio/__init__.py deleted file mode 100644 index c7a1198f2c..0000000000 --- a/ext/knowit/properties/audio/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .bitratemode import BitRateMode -from .channels import AudioChannels -from .codec import AudioCodec -from .compression import AudioCompression -from .profile import AudioProfile diff --git a/ext/knowit/properties/audio/bitratemode.py b/ext/knowit/properties/audio/bitratemode.py deleted file mode 100644 index 82fb9e68fd..0000000000 --- a/ext/knowit/properties/audio/bitratemode.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...property import Configurable - - -class BitRateMode(Configurable): - """Bit Rate mode property.""" - - pass diff --git a/ext/knowit/properties/audio/channels.py b/ext/knowit/properties/audio/channels.py deleted file mode 100644 index 597a46bc55..0000000000 --- a/ext/knowit/properties/audio/channels.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from six import text_type - -from ...property import Property - - -class AudioChannels(Property): - """Audio Channels property.""" - - ignored = { - 'object based', # Dolby Atmos - } - - def handle(self, value, context): - """Handle audio channels.""" - if isinstance(value, int): - return value - - v = text_type(value).lower() - if v not in self.ignored: - try: - return int(v) - except ValueError: - self.report(value, context) diff --git a/ext/knowit/properties/audio/codec.py b/ext/knowit/properties/audio/codec.py deleted file mode 100644 index 9107de4e73..0000000000 --- a/ext/knowit/properties/audio/codec.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from six import text_type - -from ...property import Configurable - - -class AudioCodec(Configurable): - """Audio codec property.""" - - @classmethod - def _extract_key(cls, value): - key = text_type(value).upper() - if key.startswith('A_'): - key = key[2:] - - # only the first part of the word. E.g.: 'AAC LC' => 'AAC' - return key.split(' ')[0] - - @classmethod - def _extract_fallback_key(cls, value, key): - if '/' in key: - return key.split('/')[0] diff --git a/ext/knowit/properties/audio/compression.py b/ext/knowit/properties/audio/compression.py deleted file mode 100644 index 4842b80e91..0000000000 --- a/ext/knowit/properties/audio/compression.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...property import Configurable - - -class AudioCompression(Configurable): - """Audio Compression property.""" - - pass diff --git a/ext/knowit/properties/audio/profile.py b/ext/knowit/properties/audio/profile.py deleted file mode 100644 index 05a39c98ef..0000000000 --- a/ext/knowit/properties/audio/profile.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...property import Configurable - - -class AudioProfile(Configurable): - """Audio profile property.""" - - pass diff --git a/ext/knowit/properties/basic.py b/ext/knowit/properties/basic.py deleted file mode 100644 index 46176cdd47..0000000000 --- a/ext/knowit/properties/basic.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from six import text_type - -from ..property import Property - - -class Basic(Property): - """Basic property to handle int, float and other basic types.""" - - def __init__(self, name, data_type, allow_fallback=False, **kwargs): - """Init method.""" - super(Basic, self).__init__(name, **kwargs) - self.data_type = data_type - self.allow_fallback = allow_fallback - - def handle(self, value, context): - """Handle value.""" - if isinstance(value, self.data_type): - return value - - try: - return self.data_type(text_type(value)) - except ValueError: - if not self.allow_fallback: - self.report(value, context) diff --git a/ext/knowit/properties/duration.py b/ext/knowit/properties/duration.py deleted file mode 100644 index f902356c25..0000000000 --- a/ext/knowit/properties/duration.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import re -from datetime import timedelta - -from six import text_type - -from ..property import Property - - -class Duration(Property): - """Duration property.""" - - duration_re = re.compile(r'(?P\d{1,2}):' - r'(?P\d{1,2}):' - r'(?P\d{1,2})(?:\.' - r'(?P\d{3})' - r'(?P\d{3})?\d*)?') - - def handle(self, value, context): - """Return duration as timedelta.""" - if isinstance(value, timedelta): - return value - elif isinstance(value, int): - return timedelta(milliseconds=value) - try: - return timedelta(milliseconds=int(float(value))) - except ValueError: - pass - - try: - h, m, s, ms, mc = self.duration_re.match(text_type(value)).groups('0') - return timedelta(hours=int(h), minutes=int(m), seconds=int(s), milliseconds=int(ms), microseconds=int(mc)) - except ValueError: - pass - - self.report(value, context) diff --git a/ext/knowit/properties/general.py b/ext/knowit/properties/general.py new file mode 100644 index 0000000000..c522f87fc8 --- /dev/null +++ b/ext/knowit/properties/general.py @@ -0,0 +1,144 @@ +import re +import typing +from datetime import timedelta +from decimal import Decimal, InvalidOperation + +import babelfish + +from knowit.core import Configurable, Property +from knowit.utils import round_decimal + +T = typing.TypeVar('T') + + +class Basic(Property[T]): + """Basic property to handle int, Decimal and other basic types.""" + + def __init__(self, *args: str, data_type: typing.Type, + processor: typing.Optional[typing.Callable[[T], T]] = None, + allow_fallback: bool = False, **kwargs): + """Init method.""" + super().__init__(*args, **kwargs) + self.data_type = data_type + self.processor = processor or (lambda x: x) + self.allow_fallback = allow_fallback + + def handle(self, value, context: typing.MutableMapping): + """Handle value.""" + if isinstance(value, self.data_type): + return self.processor(value) + + try: + return self.processor(self.data_type(value)) + except ValueError: + if not self.allow_fallback: + self.report(value, context) + + +class Duration(Property[timedelta]): + """Duration property.""" + + duration_re = re.compile(r'(?P\d{1,2}):' + r'(?P\d{1,2}):' + r'(?P\d{1,2})(?:\.' + r'(?P\d{3})' + r'(?P\d{3})?\d*)?') + + def __init__(self, *args: str, resolution: typing.Union[int, Decimal] = 1, **kwargs): + """Initialize a Duration.""" + super().__init__(*args, **kwargs) + self.resolution = resolution + + def handle(self, value, context: typing.MutableMapping): + """Return duration as timedelta.""" + if isinstance(value, timedelta): + return value + elif isinstance(value, int): + return timedelta(milliseconds=int(value * self.resolution)) + try: + return timedelta( + milliseconds=int(Decimal(value) * self.resolution)) + except (ValueError, InvalidOperation): + pass + + match = self.duration_re.match(value) + if not match: + self.report(value, context) + return None + + params = { + key: int(value) + for key, value in match.groupdict().items() + if value + } + return timedelta(**params) + + +class Language(Property[babelfish.Language]): + """Language property.""" + + def handle(self, value, context: typing.MutableMapping): + """Handle languages.""" + try: + if len(value) == 3: + return babelfish.Language.fromalpha3b(value) + + return babelfish.Language.fromietf(value) + except (babelfish.Error, ValueError): + pass + + try: + return babelfish.Language.fromname(value) + except babelfish.Error: + pass + + self.report(value, context) + return babelfish.Language('und') + + +class Quantity(Property): + """Quantity is a property with unit.""" + + def __init__(self, *args: str, unit, data_type=int, **kwargs): + """Init method.""" + super().__init__(*args, **kwargs) + self.unit = unit + self.data_type = data_type + + def handle(self, value, context): + """Handle value with unit.""" + if not isinstance(value, self.data_type): + try: + value = self.data_type(value) + except ValueError: + self.report(value, context) + return + if isinstance(value, Decimal): + value = round_decimal(value, min_digits=1, max_digits=3) + + return value if context.get('no_units') else value * self.unit + + +class YesNo(Configurable[str]): + """Yes or No handler.""" + + yes_values = ('yes', 'true', '1') + + def __init__(self, *args: str, yes=True, no=False, hide_value=None, + config: typing.Optional[ + typing.Mapping[str, typing.Mapping]] = None, + config_key: typing.Optional[str] = None, + **kwargs): + """Init method.""" + super().__init__(config or {}, config_key=config_key, *args, **kwargs) + self.yes = yes + self.no = no + self.hide_value = hide_value + + def handle(self, value, context): + """Handle boolean values.""" + result = self.yes if str(value).lower() in self.yes_values else self.no + if result == self.hide_value: + return None + + return super().handle(result, context) if self.mapping else result diff --git a/ext/knowit/properties/language.py b/ext/knowit/properties/language.py deleted file mode 100644 index b203c816c9..0000000000 --- a/ext/knowit/properties/language.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import babelfish - -from ..property import Property - - -class Language(Property): - """Language property.""" - - def handle(self, value, context): - """Handle languages.""" - try: - if len(value) == 3: - return babelfish.Language.fromalpha3b(value) - - return babelfish.Language.fromietf(value) - except (babelfish.Error, ValueError): - pass - - try: - return babelfish.Language.fromname(value) - except babelfish.Error: - pass - - self.report(value, context) - return babelfish.Language('und') diff --git a/ext/knowit/properties/quantity.py b/ext/knowit/properties/quantity.py deleted file mode 100644 index 487dc275d2..0000000000 --- a/ext/knowit/properties/quantity.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from six import text_type - -from ..property import Property - - -class Quantity(Property): - """Quantity is a property with unit.""" - - def __init__(self, name, unit, data_type=int, **kwargs): - """Init method.""" - super(Quantity, self).__init__(name, **kwargs) - self.unit = unit - self.data_type = data_type - - def handle(self, value, context): - """Handle value with unit.""" - if not isinstance(value, self.data_type): - try: - value = self.data_type(text_type(value)) - except ValueError: - self.report(value, context) - return - - return value if context.get('no_units') else value * self.unit diff --git a/ext/knowit/properties/subtitle.py b/ext/knowit/properties/subtitle.py new file mode 100644 index 0000000000..67f2733f71 --- /dev/null +++ b/ext/knowit/properties/subtitle.py @@ -0,0 +1,14 @@ + +from knowit.core import Configurable + + +class SubtitleFormat(Configurable[str]): + """Subtitle Format property.""" + + @classmethod + def _extract_key(cls, value) -> str: + key = str(value).upper() + if key.startswith('S_'): + key = key[2:] + + return key.split('/')[-1] diff --git a/ext/knowit/properties/subtitle/__init__.py b/ext/knowit/properties/subtitle/__init__.py deleted file mode 100644 index b791152fb2..0000000000 --- a/ext/knowit/properties/subtitle/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .format import SubtitleFormat diff --git a/ext/knowit/properties/subtitle/format.py b/ext/knowit/properties/subtitle/format.py deleted file mode 100644 index 7d57348ca1..0000000000 --- a/ext/knowit/properties/subtitle/format.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from six import text_type - -from ...property import Configurable - - -class SubtitleFormat(Configurable): - """Subtitle Format property.""" - - @classmethod - def _extract_key(cls, value): - key = text_type(value) .upper() - if key.startswith('S_'): - key = key[2:] - - return key.split('/')[-1] diff --git a/ext/knowit/properties/video.py b/ext/knowit/properties/video.py new file mode 100644 index 0000000000..e1b293d01b --- /dev/null +++ b/ext/knowit/properties/video.py @@ -0,0 +1,120 @@ +import re +import typing +from decimal import Decimal + +from knowit.core import Configurable +from knowit.core import Property +from knowit.utils import round_decimal + + +class VideoCodec(Configurable[str]): + """Video Codec handler.""" + + @classmethod + def _extract_key(cls, value) -> str: + key = value.upper().split('/')[-1] + if key.startswith('V_'): + key = key[2:] + + return key.split(' ')[-1] + + +class VideoDimensions(Property[int]): + """Dimensions property.""" + + def __init__(self, *args: str, dimension='width' or 'height', **kwargs): + """Initialize the object.""" + super().__init__(*args, **kwargs) + self.dimension = dimension + + dimensions_re = re.compile(r'(?P\d+)x(?P\d+)') + + def handle(self, value, context) -> typing.Optional[int]: + """Handle ratio.""" + match = self.dimensions_re.match(value) + if match: + match_dict = match.groupdict() + try: + value = match_dict[self.dimension] + except KeyError: + pass + else: + return int(value) + + self.report(value, context) + return None + + +class VideoEncoder(Configurable): + """Video Encoder property.""" + + +class VideoHdrFormat(Configurable): + """Video HDR Format property.""" + + +class VideoProfile(Configurable[str]): + """Video Profile property.""" + + @classmethod + def _extract_key(cls, value) -> str: + return value.upper().split('@')[0] + + +class VideoProfileLevel(Configurable[str]): + """Video Profile Level property.""" + + @classmethod + def _extract_key(cls, value) -> typing.Union[str, bool]: + values = str(value).upper().split('@') + if len(values) > 1: + value = values[1] + return value + + # There's no level, so don't warn or report it + return False + + +class VideoProfileTier(Configurable[str]): + """Video Profile Tier property.""" + + @classmethod + def _extract_key(cls, value) -> typing.Union[str, bool]: + values = str(value).upper().split('@') + if len(values) > 2: + return values[2] + + # There's no tier, so don't warn or report it + return False + + +class Ratio(Property[Decimal]): + """Ratio property.""" + + def __init__(self, *args: str, unit=None, **kwargs): + """Initialize the object.""" + super().__init__(*args, **kwargs) + self.unit = unit + + ratio_re = re.compile(r'(?P\d+)[:/](?P\d+)') + + def handle(self, value, context) -> typing.Optional[Decimal]: + """Handle ratio.""" + match = self.ratio_re.match(value) + if match: + width, height = match.groups() + if (width, height) == ('0', '1'): # identity + return Decimal('1.0') + + result = round_decimal(Decimal(width) / Decimal(height), min_digits=1, max_digits=3) + if self.unit: + result *= self.unit + + return result + + self.report(value, context) + return None + + +class ScanType(Configurable[str]): + """Scan Type property.""" diff --git a/ext/knowit/properties/video/__init__.py b/ext/knowit/properties/video/__init__.py deleted file mode 100644 index e823b39d6b..0000000000 --- a/ext/knowit/properties/video/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .codec import VideoCodec -from .encoder import VideoEncoder -from .profile import VideoProfile -from .profile import VideoProfileLevel -from .profile import VideoProfileTier -from .ratio import Ratio -from .scantype import ScanType diff --git a/ext/knowit/properties/video/codec.py b/ext/knowit/properties/video/codec.py deleted file mode 100644 index d1a873cd53..0000000000 --- a/ext/knowit/properties/video/codec.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...property import Configurable - - -class VideoCodec(Configurable): - """Video Codec handler.""" - - @classmethod - def _extract_key(cls, value): - key = value.upper().split('/')[-1] - if key.startswith('V_'): - key = key[2:] - - return key.split(' ')[-1] diff --git a/ext/knowit/properties/video/encoder.py b/ext/knowit/properties/video/encoder.py deleted file mode 100644 index b2c925b69c..0000000000 --- a/ext/knowit/properties/video/encoder.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...property import Configurable - - -class VideoEncoder(Configurable): - """Video Encoder property.""" - - pass diff --git a/ext/knowit/properties/video/profile.py b/ext/knowit/properties/video/profile.py deleted file mode 100644 index 2459d40d00..0000000000 --- a/ext/knowit/properties/video/profile.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from six import text_type - -from ...property import Configurable - - -class VideoProfile(Configurable): - """Video Profile property.""" - - @classmethod - def _extract_key(cls, value): - return value.upper().split('@')[0] - - -class VideoProfileLevel(Configurable): - """Video Profile Level property.""" - - @classmethod - def _extract_key(cls, value): - values = text_type(value).upper().split('@') - if len(values) > 1: - value = values[1] - return value - - # There's no level, so don't warn or report it - return False - - -class VideoProfileTier(Configurable): - """Video Profile Tier property.""" - - @classmethod - def _extract_key(cls, value): - values = value.upper().split('@') - if len(values) > 2: - return values[2] - - # There's no tier, so don't warn or report it - return False diff --git a/ext/knowit/properties/video/ratio.py b/ext/knowit/properties/video/ratio.py deleted file mode 100644 index 149183bd2e..0000000000 --- a/ext/knowit/properties/video/ratio.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import re - -from six import text_type - -from ...property import Property - - -class Ratio(Property): - """Ratio property.""" - - def __init__(self, name, unit=None, **kwargs): - """Constructor.""" - super(Ratio, self).__init__(name, **kwargs) - self.unit = unit - - ratio_re = re.compile(r'(?P\d+)[:/](?P\d+)') - - def handle(self, value, context): - """Handle ratio.""" - match = self.ratio_re.match(text_type(value)) - if match: - width, height = match.groups() - if (width, height) == ('0', '1'): # identity - return 1. - - result = round(float(width) / float(height), 3) - if self.unit: - result *= self.unit - - return result - - self.report(value, context) diff --git a/ext/knowit/properties/video/scantype.py b/ext/knowit/properties/video/scantype.py deleted file mode 100644 index e744ff7ad2..0000000000 --- a/ext/knowit/properties/video/scantype.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...property import Configurable - - -class ScanType(Configurable): - """Scan Type property.""" - - pass diff --git a/ext/knowit/properties/yesno.py b/ext/knowit/properties/yesno.py deleted file mode 100644 index 28edce59b4..0000000000 --- a/ext/knowit/properties/yesno.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from six import text_type - -from ..property import Property - - -class YesNo(Property): - """Yes or No handler.""" - - mapping = ('yes', 'true', '1') - - def __init__(self, name, yes=True, no=False, hide_value=None, **kwargs): - """Init method.""" - super(YesNo, self).__init__(name, **kwargs) - self.yes = yes - self.no = no - self.hide_value = hide_value - - def handle(self, value, context): - """Handle boolean values.""" - v = text_type(value).lower() - result = self.yes if v in self.mapping else self.no - return result if result != self.hide_value else None diff --git a/ext/knowit/property.py b/ext/knowit/property.py deleted file mode 100644 index 475ea403b7..0000000000 --- a/ext/knowit/property.py +++ /dev/null @@ -1,137 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from logging import NullHandler, getLogger -from six import PY3, binary_type, string_types, text_type - -from .core import Reportable - -logger = getLogger(__name__) -logger.addHandler(NullHandler()) - -_visible_chars_table = dict.fromkeys(range(32)) - - -def _is_unknown(value): - return isinstance(value, text_type) and (not value or value.lower() == 'unknown') - - -class Property(Reportable): - """Property class.""" - - def __init__(self, name, default=None, private=False, description=None, delimiter=' / ', **kwargs): - """Init method.""" - super(Property, self).__init__(name, description, **kwargs) - self.default = default - self.private = private - # Used to detect duplicated values. e.g.: en / en or High@L4.0 / High@L4.0 or Progressive / Progressive - self.delimiter = delimiter - - def extract_value(self, track, context): - """Extract the property value from a given track.""" - names = self.name.split('.') - value = track.get(names[0], {}).get(names[1]) if len(names) == 2 else track.get(self.name) - if value is None: - if self.default is None: - return - - value = self.default - - if isinstance(value, string_types): - if isinstance(value, binary_type): - value = text_type(value) - else: - value = value.translate(_visible_chars_table).strip() - if _is_unknown(value): - return - value = self._deduplicate(value) - - result = self.handle(value, context) - if result is not None and not _is_unknown(result): - return result - - @classmethod - def _deduplicate(cls, value): - values = value.split(' / ') - if len(values) == 2 and values[0] == values[1]: - return values[0] - return value - - def handle(self, value, context): - """Return the value without any modification.""" - return value - - -class Configurable(Property): - """Configurable property where values are in a config mapping.""" - - def __init__(self, config, *args, **kwargs): - """Init method.""" - super(Configurable, self).__init__(*args, **kwargs) - self.mapping = getattr(config, self.__class__.__name__) - - @classmethod - def _extract_key(cls, value): - return text_type(value).upper() - - @classmethod - def _extract_fallback_key(cls, value, key): - pass - - def _lookup(self, key, context): - result = self.mapping.get(key) - if result is not None: - result = getattr(result, context.get('profile') or 'default') - return result if result != '__ignored__' else False - - def handle(self, value, context): - """Return Variable or Constant.""" - key = self._extract_key(value) - if key is False: - return - - result = self._lookup(key, context) - if result is False: - return - - while not result and key: - key = self._extract_fallback_key(value, key) - result = self._lookup(key, context) - if result is False: - return - - if not result: - self.report(value, context) - - return result - - -class MultiValue(Property): - """Property with multiple values.""" - - def __init__(self, prop=None, delimiter='/', single=False, handler=None, name=None, **kwargs): - """Init method.""" - super(MultiValue, self).__init__(prop.name if prop else name, **kwargs) - self.prop = prop - self.delimiter = delimiter - self.single = single - self.handler = handler - - def handle(self, value, context): - """Handle properties with multiple values.""" - values = (self._split(value[0], self.delimiter) - if len(value) == 1 else value) if isinstance(value, list) else self._split(value, self.delimiter) - call = self.handler or self.prop.handle - if len(values) > 1 and not self.single: - return [call(item, context) if not _is_unknown(item) else None for item in values] - - return call(values[0], context) - - @classmethod - def _split(cls, value, delimiter='/'): - if value is None: - return - - v = text_type(value) - result = map(text_type.strip, v.split(delimiter)) - return list(result) if PY3 else result diff --git a/ext/knowit/provider.py b/ext/knowit/provider.py old mode 100644 new mode 100755 index cb58c01808..f8c29f5f3b --- a/ext/knowit/provider.py +++ b/ext/knowit/provider.py @@ -1,27 +1,38 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals import os +import typing from logging import NullHandler, getLogger -from . import OrderedDict -from .properties import Quantity -from .units import units +import knowit.config +from knowit.core import Property, Rule +from knowit.properties import Quantity +from knowit.units import units logger = getLogger(__name__) logger.addHandler(NullHandler()) -size_property = Quantity('size', units.byte, description='media size') +size_property = Quantity('size', unit=units.byte, description='media size') +PropertyMap = typing.Mapping[str, Property] +PropertyConfig = typing.Mapping[str, PropertyMap] -class Provider(object): +RuleMap = typing.Mapping[str, Rule] +RuleConfig = typing.Mapping[str, RuleMap] + + +class Provider: """Base class for all providers.""" min_fps = 10 max_fps = 200 - def __init__(self, config, mapping, rules=None): + def __init__( + self, + config: knowit.config.Config, + mapping: PropertyConfig, + rules: typing.Optional[RuleConfig] = None, + ): """Init method.""" self.config = config self.mapping = mapping @@ -82,7 +93,7 @@ def _describe_track(self, track, track_type, context): :param track_type: :rtype: dict """ - props = OrderedDict() + props = {} pv_props = {} for name, prop in self.mapping[track_type].items(): if not prop: diff --git a/ext/knowit/providers/__init__.py b/ext/knowit/providers/__init__.py index 66a0075c55..34ea048bef 100644 --- a/ext/knowit/providers/__init__.py +++ b/ext/knowit/providers/__init__.py @@ -1,7 +1,6 @@ -# -*- coding: utf-8 -*- """Provider package.""" -from __future__ import unicode_literals -from .enzyme import EnzymeProvider -from .ffmpeg import FFmpegProvider -from .mediainfo import MediaInfoProvider +from knowit.providers.enzyme import EnzymeProvider +from knowit.providers.ffmpeg import FFmpegProvider +from knowit.providers.mediainfo import MediaInfoProvider +from knowit.providers.mkvmerge import MkvMergeProvider diff --git a/ext/knowit/providers/enzyme.py b/ext/knowit/providers/enzyme.py index dd9c29417f..5dd3d8cef4 100644 --- a/ext/knowit/providers/enzyme.py +++ b/ext/knowit/providers/enzyme.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals import json import logging @@ -7,8 +5,8 @@ from logging import NullHandler, getLogger import enzyme -from .. import OrderedDict -from ..properties import ( +from knowit.core import Property +from knowit.properties import ( AudioCodec, Basic, Duration, @@ -17,21 +15,20 @@ VideoCodec, YesNo, ) -from ..property import Property -from ..provider import ( +from knowit.provider import ( MalformedFileError, Provider, ) -from ..rules import ( +from knowit.rules import ( AudioChannelsRule, ClosedCaptionRule, HearingImpairedRule, LanguageRule, ResolutionRule, ) -from ..serializer import get_json_encoder -from ..units import units -from ..utils import todict +from knowit.serializer import get_json_encoder +from knowit.units import units +from knowit.utils import to_dict logger = getLogger(__name__) logger.addHandler(NullHandler()) @@ -42,61 +39,62 @@ class EnzymeProvider(Provider): def __init__(self, config, *args, **kwargs): """Init method.""" - super(EnzymeProvider, self).__init__(config, { - 'general': OrderedDict([ - ('title', Property('title', description='media title')), - ('duration', Duration('duration', description='media duration')), - ]), - 'video': OrderedDict([ - ('id', Basic('number', int, description='video track number')), - ('name', Property('name', description='video track name')), - ('language', Language('language', description='video language')), - ('width', Quantity('width', units.pixel)), - ('height', Quantity('height', units.pixel)), - ('scan_type', YesNo('interlaced', yes='Interlaced', no='Progressive', default='Progressive', - description='video scan type')), - ('resolution', None), # populated with ResolutionRule - # ('bit_depth', Property('bit_depth', Integer('video bit depth'))), - ('codec', VideoCodec(config, 'codec_id', description='video codec')), - ('forced', YesNo('forced', hide_value=False, description='video track forced')), - ('default', YesNo('default', hide_value=False, description='video track default')), - ('enabled', YesNo('enabled', hide_value=True, description='video track enabled')), - ]), - 'audio': OrderedDict([ - ('id', Basic('number', int, description='audio track number')), - ('name', Property('name', description='audio track name')), - ('language', Language('language', description='audio language')), - ('codec', AudioCodec(config, 'codec_id', description='audio codec')), - ('channels_count', Basic('channels', int, description='audio channels count')), - ('channels', None), # populated with AudioChannelsRule - ('forced', YesNo('forced', hide_value=False, description='audio track forced')), - ('default', YesNo('default', hide_value=False, description='audio track default')), - ('enabled', YesNo('enabled', hide_value=True, description='audio track enabled')), - ]), - 'subtitle': OrderedDict([ - ('id', Basic('number', int, description='subtitle track number')), - ('name', Property('name', description='subtitle track name')), - ('language', Language('language', description='subtitle language')), - ('hearing_impaired', None), # populated with HearingImpairedRule - ('closed_caption', None), # populated with ClosedCaptionRule - ('forced', YesNo('forced', hide_value=False, description='subtitle track forced')), - ('default', YesNo('default', hide_value=False, description='subtitle track default')), - ('enabled', YesNo('enabled', hide_value=True, description='subtitle track enabled')), - ]), + super().__init__(config, { + 'general': { + 'title': Property('title', description='media title'), + 'duration': Duration('duration', description='media duration'), + }, + 'video': { + 'id': Basic('number', data_type=int, description='video track number'), + 'name': Property('name', description='video track name'), + 'language': Language('language', description='video language'), + 'width': Quantity('width', unit=units.pixel), + 'height': Quantity('height', unit=units.pixel), + 'scan_type': YesNo('interlaced', yes='Interlaced', no='Progressive', default='Progressive', + config=config, config_key='ScanType', + description='video scan type'), + 'resolution': None, # populated with ResolutionRule + # 'bit_depth', Property('bit_depth', Integer('video bit depth')), + 'codec': VideoCodec(config, 'codec_id', description='video codec'), + 'forced': YesNo('forced', hide_value=False, description='video track forced'), + 'default': YesNo('default', hide_value=False, description='video track default'), + 'enabled': YesNo('enabled', hide_value=True, description='video track enabled'), + }, + 'audio': { + 'id': Basic('number', data_type=int, description='audio track number'), + 'name': Property('name', description='audio track name'), + 'language': Language('language', description='audio language'), + 'codec': AudioCodec(config, 'codec_id', description='audio codec'), + 'channels_count': Basic('channels', data_type=int, description='audio channels count'), + 'channels': None, # populated with AudioChannelsRule + 'forced': YesNo('forced', hide_value=False, description='audio track forced'), + 'default': YesNo('default', hide_value=False, description='audio track default'), + 'enabled': YesNo('enabled', hide_value=True, description='audio track enabled'), + }, + 'subtitle': { + 'id': Basic('number', data_type=int, description='subtitle track number'), + 'name': Property('name', description='subtitle track name'), + 'language': Language('language', description='subtitle language'), + 'hearing_impaired': None, # populated with HearingImpairedRule + 'closed_caption': None, # populated with ClosedCaptionRule + 'forced': YesNo('forced', hide_value=False, description='subtitle track forced'), + 'default': YesNo('default', hide_value=False, description='subtitle track default'), + 'enabled': YesNo('enabled', hide_value=True, description='subtitle track enabled'), + }, }, { - 'video': OrderedDict([ - ('language', LanguageRule('video language')), - ('resolution', ResolutionRule('video resolution')), - ]), - 'audio': OrderedDict([ - ('language', LanguageRule('audio language')), - ('channels', AudioChannelsRule('audio channels')), - ]), - 'subtitle': OrderedDict([ - ('language', LanguageRule('subtitle language')), - ('hearing_impaired', HearingImpairedRule('subtitle hearing impaired')), - ('closed_caption', ClosedCaptionRule('closed caption')), - ]) + 'video': { + 'language': LanguageRule('video language'), + 'resolution': ResolutionRule('video resolution'), + }, + 'audio': { + 'language': LanguageRule('audio language'), + 'channels': AudioChannelsRule('audio channels'), + }, + 'subtitle': { + 'language': LanguageRule('subtitle language'), + 'hearing_impaired': HearingImpairedRule('subtitle hearing impaired'), + 'closed_caption': ClosedCaptionRule('closed caption'), + } }) def accepts(self, video_path): @@ -107,7 +105,7 @@ def accepts(self, video_path): def extract_info(cls, video_path): """Extract info from the video.""" with open(video_path, 'rb') as f: - return todict(enzyme.MKV(f)) + return to_dict(enzyme.MKV(f)) def describe(self, video_path, context): """Return video metadata.""" diff --git a/ext/knowit/providers/ffmpeg.py b/ext/knowit/providers/ffmpeg.py index c849bc43d3..2474408ccb 100644 --- a/ext/knowit/providers/ffmpeg.py +++ b/ext/knowit/providers/ffmpeg.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals import json import logging @@ -7,13 +5,9 @@ from logging import NullHandler, getLogger from subprocess import check_output -from six import ensure_text - -from .. import ( - OrderedDict, - VIDEO_EXTENSIONS, -) -from ..properties import ( +from knowit import VIDEO_EXTENSIONS +from knowit.core import Property +from knowit.properties import ( AudioChannels, AudioCodec, AudioProfile, @@ -29,24 +23,20 @@ VideoProfileLevel, YesNo, ) -from ..property import ( - Property, -) -from ..provider import ( +from knowit.provider import ( MalformedFileError, Provider, ) -from ..rules import ( +from knowit.rules import ( AudioChannelsRule, - AudioCodecRule, ClosedCaptionRule, HearingImpairedRule, LanguageRule, ResolutionRule, ) -from ..serializer import get_json_encoder -from ..units import units -from ..utils import ( +from knowit.serializer import get_json_encoder +from knowit.units import units +from knowit.utils import ( define_candidate, detect_os, ) @@ -69,10 +59,10 @@ ''' -class FFmpegExecutor(object): +class FFmpegExecutor: """Executor that knows how to execute media info: using ctypes or cli.""" - version_re = re.compile(r'\bversion\s+(?P\d+(?:\.\d+)+)\b') + version_re = re.compile(r'\bversion\s+(?P[^\b\s]+)') locations = { 'unix': ('/usr/local/ffmpeg/lib', '/usr/local/ffmpeg/bin', '__PATH__'), 'windows': ('__PATH__', ), @@ -80,7 +70,7 @@ class FFmpegExecutor(object): } def __init__(self, location, version): - """Constructor.""" + """Initialize the object.""" self.location = location self.version = version @@ -96,7 +86,7 @@ def _execute(self, filename): def _get_version(cls, output): match = cls.version_re.search(output) if match: - version = tuple([int(v) for v in match.groupdict()['version'].split('.')]) + version = match.groupdict()['version'] return version @classmethod @@ -120,19 +110,19 @@ class FFmpegCliExecutor(FFmpegExecutor): } def _execute(self, filename): - return ensure_text(check_output([self.location, '-v', 'quiet', '-print_format', 'json', - '-show_format', '-show_streams', '-sexagesimal', filename])) + return check_output([self.location, '-v', 'quiet', '-print_format', 'json', + '-show_format', '-show_streams', '-sexagesimal', filename]).decode() @classmethod def create(cls, os_family=None, suggested_path=None): """Create the executor instance.""" for candidate in define_candidate(cls.locations, cls.names, os_family, suggested_path): try: - output = ensure_text(check_output([candidate, '-version'])) + output = check_output([candidate, '-version']).decode() version = cls._get_version(output) if version: - logger.debug('FFmpeg cli detected: %s v%s', candidate, '.'.join(map(str, version))) - return FFmpegCliExecutor(candidate, version) + logger.debug('FFmpeg cli detected: %s v%s', candidate, version) + return FFmpegCliExecutor(candidate, version.split('.')) except OSError: pass @@ -142,78 +132,76 @@ class FFmpegProvider(Provider): def __init__(self, config, suggested_path=None): """Init method.""" - super(FFmpegProvider, self).__init__(config, { - 'general': OrderedDict([ - ('title', Property('tags.title', description='media title')), - ('path', Property('filename', description='media path')), - ('duration', Duration('duration', description='media duration')), - ('size', Quantity('size', units.byte, description='media size')), - ('bit_rate', Quantity('bit_rate', units.bps, description='media bit rate')), - ]), - 'video': OrderedDict([ - ('id', Basic('index', int, allow_fallback=True, description='video track number')), - ('name', Property('tags.title', description='video track name')), - ('language', Language('tags.language', description='video language')), - ('duration', Duration('duration', description='video duration')), - ('width', Quantity('width', units.pixel)), - ('height', Quantity('height', units.pixel)), - ('scan_type', ScanType(config, 'field_order', default='Progressive', description='video scan type')), - ('aspect_ratio', Ratio('display_aspect_ratio', description='display aspect ratio')), - ('pixel_aspect_ratio', Ratio('sample_aspect_ratio', description='pixel aspect ratio')), - ('resolution', None), # populated with ResolutionRule - ('frame_rate', Ratio('r_frame_rate', unit=units.FPS, description='video frame rate')), + super().__init__(config, { + 'general': { + 'title': Property('tags.title', description='media title'), + 'path': Property('filename', description='media path'), + 'duration': Duration('duration', description='media duration'), + 'size': Quantity('size', unit=units.byte, description='media size'), + 'bit_rate': Quantity('bit_rate', unit=units.bps, description='media bit rate'), + }, + 'video': { + 'id': Basic('index', data_type=int, allow_fallback=True, description='video track number'), + 'name': Property('tags.title', description='video track name'), + 'language': Language('tags.language', description='video language'), + 'duration': Duration('duration', description='video duration'), + 'width': Quantity('width', unit=units.pixel), + 'height': Quantity('height', unit=units.pixel), + 'scan_type': ScanType(config, 'field_order', default='Progressive', description='video scan type'), + 'aspect_ratio': Ratio('display_aspect_ratio', description='display aspect ratio'), + 'pixel_aspect_ratio': Ratio('sample_aspect_ratio', description='pixel aspect ratio'), + 'resolution': None, # populated with ResolutionRule + 'frame_rate': Ratio('r_frame_rate', unit=units.FPS, description='video frame rate'), # frame_rate_mode - ('bit_rate', Quantity('bit_rate', units.bps, description='video bit rate')), - ('bit_depth', Quantity('bits_per_raw_sample', units.bit, description='video bit depth')), - ('codec', VideoCodec(config, 'codec_name', description='video codec')), - ('profile', VideoProfile(config, 'profile', description='video codec profile')), - ('profile_level', VideoProfileLevel(config, 'level', description='video codec profile level')), - # ('profile_tier', VideoProfileTier(config, 'codec_profile', description='video codec profile tier')), - ('forced', YesNo('disposition.forced', hide_value=False, description='video track forced')), - ('default', YesNo('disposition.default', hide_value=False, description='video track default')), - ]), - 'audio': OrderedDict([ - ('id', Basic('index', int, allow_fallback=True, description='audio track number')), - ('name', Property('tags.title', description='audio track name')), - ('language', Language('tags.language', description='audio language')), - ('duration', Duration('duration', description='audio duration')), - ('codec', AudioCodec(config, 'codec_name', description='audio codec')), - ('_codec', AudioCodec(config, 'profile', description='audio codec', private=True, reportable=False)), - ('profile', AudioProfile(config, 'profile', description='audio codec profile')), - ('channels_count', AudioChannels('channels', description='audio channels count')), - ('channels', None), # populated with AudioChannelsRule - ('bit_depth', Quantity('bits_per_raw_sample', units.bit, description='audio bit depth')), - ('bit_rate', Quantity('bit_rate', units.bps, description='audio bit rate')), - ('sampling_rate', Quantity('sample_rate', units.Hz, description='audio sampling rate')), - ('forced', YesNo('disposition.forced', hide_value=False, description='audio track forced')), - ('default', YesNo('disposition.default', hide_value=False, description='audio track default')), - ]), - 'subtitle': OrderedDict([ - ('id', Basic('index', int, allow_fallback=True, description='subtitle track number')), - ('name', Property('tags.title', description='subtitle track name')), - ('language', Language('tags.language', description='subtitle language')), - ('hearing_impaired', YesNo('disposition.hearing_impaired', - hide_value=False, description='subtitle hearing impaired')), - ('closed_caption', None), # populated with ClosedCaptionRule - ('format', SubtitleFormat(config, 'codec_name', description='subtitle format')), - ('forced', YesNo('disposition.forced', hide_value=False, description='subtitle track forced')), - ('default', YesNo('disposition.default', hide_value=False, description='subtitle track default')), - ]), + 'bit_rate': Quantity('bit_rate', unit=units.bps, description='video bit rate'), + 'bit_depth': Quantity('bits_per_raw_sample', unit=units.bit, description='video bit depth'), + 'codec': VideoCodec(config, 'codec_name', description='video codec'), + 'profile': VideoProfile(config, 'profile', description='video codec profile'), + 'profile_level': VideoProfileLevel(config, 'level', description='video codec profile level'), + # 'profile_tier': VideoProfileTier(config, 'codec_profile', description='video codec profile tier'), + 'forced': YesNo('disposition.forced', hide_value=False, description='video track forced'), + 'default': YesNo('disposition.default', hide_value=False, description='video track default'), + }, + 'audio': { + 'id': Basic('index', data_type=int, allow_fallback=True, description='audio track number'), + 'name': Property('tags.title', description='audio track name'), + 'language': Language('tags.language', description='audio language'), + 'duration': Duration('duration', description='audio duration'), + 'codec': AudioCodec(config, 'profile', 'codec_name', description='audio codec'), + 'profile': AudioProfile(config, 'profile', description='audio codec profile'), + 'channels_count': AudioChannels('channels', description='audio channels count'), + 'channels': None, # populated with AudioChannelsRule + 'bit_depth': Quantity('bits_per_raw_sample', unit=units.bit, description='audio bit depth'), + 'bit_rate': Quantity('bit_rate', unit=units.bps, description='audio bit rate'), + 'sampling_rate': Quantity('sample_rate', unit=units.Hz, description='audio sampling rate'), + 'forced': YesNo('disposition.forced', hide_value=False, description='audio track forced'), + 'default': YesNo('disposition.default', hide_value=False, description='audio track default'), + }, + 'subtitle': { + 'id': Basic('index', data_type=int, allow_fallback=True, description='subtitle track number'), + 'name': Property('tags.title', description='subtitle track name'), + 'language': Language('tags.language', description='subtitle language'), + 'hearing_impaired': YesNo('disposition.hearing_impaired', + hide_value=False, description='subtitle hearing impaired'), + 'closed_caption': None, # populated with ClosedCaptionRule + 'format': SubtitleFormat(config, 'codec_name', description='subtitle format'), + 'forced': YesNo('disposition.forced', hide_value=False, description='subtitle track forced'), + 'default': YesNo('disposition.default', hide_value=False, description='subtitle track default'), + }, }, { - 'video': OrderedDict([ - ('language', LanguageRule('video language')), - ('resolution', ResolutionRule('video resolution')), - ]), - 'audio': OrderedDict([ - ('language', LanguageRule('audio language')), - ('channels', AudioChannelsRule('audio channels')), - ('codec', AudioCodecRule('audio codec', override=True)), - ]), - 'subtitle': OrderedDict([ - ('language', LanguageRule('subtitle language')), - ('hearing_impaired', HearingImpairedRule('subtitle hearing impaired')), - ('closed_caption', ClosedCaptionRule('closed caption')) - ]) + 'video': { + 'language': LanguageRule('video language'), + 'resolution': ResolutionRule('video resolution'), + }, + 'audio': { + 'language': LanguageRule('audio language'), + 'channels': AudioChannelsRule('audio channels'), + }, + 'subtitle': { + 'language': LanguageRule('subtitle language'), + 'hearing_impaired': HearingImpairedRule('subtitle hearing impaired'), + 'closed_caption': ClosedCaptionRule('closed caption'), + }, }) self.executor = FFmpegExecutor.get_executor_instance(suggested_path) @@ -272,5 +260,6 @@ def version(self): """Return ffmpeg version information.""" if not self.executor: return {} + version = '.'.join(map(str, self.executor.version)) - return {self.executor.location: 'v{}'.format('.'.join(map(str, self.executor.version)))} + return {self.executor.location: f'v{version}'} diff --git a/ext/knowit/providers/mediainfo.py b/ext/knowit/providers/mediainfo.py index 519fe862a1..39fd403edd 100644 --- a/ext/knowit/providers/mediainfo.py +++ b/ext/knowit/providers/mediainfo.py @@ -1,22 +1,17 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +import json import re from ctypes import c_void_p, c_wchar_p +from decimal import Decimal from logging import DEBUG, NullHandler, getLogger from subprocess import CalledProcessError, check_output -from xml.dom import minidom -from xml.etree import ElementTree from pymediainfo import MediaInfo from pymediainfo import __version__ as pymediainfo_version -from six import ensure_text -from .. import ( - OrderedDict, - VIDEO_EXTENSIONS, -) -from ..properties import ( +from knowit import VIDEO_EXTENSIONS +from knowit.core import MultiValue, Property +from knowit.properties import ( AudioChannels, AudioCodec, AudioCompression, @@ -30,20 +25,16 @@ SubtitleFormat, VideoCodec, VideoEncoder, + VideoHdrFormat, VideoProfile, - VideoProfileLevel, VideoProfileTier, YesNo, ) -from ..property import ( - MultiValue, - Property, -) -from ..provider import ( +from knowit.provider import ( MalformedFileError, Provider, ) -from ..rules import ( +from knowit.rules import ( AtmosRule, AudioChannelsRule, ClosedCaptionRule, @@ -52,10 +43,10 @@ LanguageRule, ResolutionRule, ) -from ..units import units -from ..utils import ( +from knowit.units import units +from knowit.utils import ( define_candidate, - detect_os, + detect_os, round_decimal, ) logger = getLogger(__name__) @@ -79,7 +70,7 @@ ''' -class MediaInfoExecutor(object): +class MediaInfoExecutor: """Media info executable knows how to execute media info: using ctypes or cli.""" version_re = re.compile(r'\bv(?P\d+(?:\.\d+)+)\b') @@ -91,7 +82,7 @@ class MediaInfoExecutor(object): } def __init__(self, location, version): - """Constructor.""" + """Initialize the object.""" self.location = location self.version = version @@ -130,22 +121,21 @@ class MediaInfoCliExecutor(MediaInfoExecutor): } def _execute(self, filename): - output_type = 'OLDXML' if self.version >= (17, 10) else 'XML' - return MediaInfo(ensure_text(check_output([self.location, '--Output=' + output_type, '--Full', filename]))) + return json.loads(check_output([self.location, '--Output=JSON', '--Full', filename]).decode()) @classmethod def create(cls, os_family=None, suggested_path=None): """Create the executor instance.""" for candidate in define_candidate(cls.locations, cls.names, os_family, suggested_path): try: - output = ensure_text(check_output([candidate, '--version'])) + output = check_output([candidate, '--version']).decode() version = cls._get_version(output) if version: logger.debug('MediaInfo cli detected: %s', candidate) return MediaInfoCliExecutor(candidate, version) except CalledProcessError as e: # old mediainfo returns non-zero exit code for mediainfo --version - version = cls._get_version(ensure_text(e.output)) + version = cls._get_version(e.output.decode()) if version: logger.debug('MediaInfo cli detected: %s', candidate) return MediaInfoCliExecutor(candidate, version) @@ -164,14 +154,14 @@ class MediaInfoCTypesExecutor(MediaInfoExecutor): def _execute(self, filename): # Create a MediaInfo handle - return MediaInfo.parse(filename, library_file=self.location) + return json.loads(MediaInfo.parse(filename, library_file=self.location, output='JSON')) @classmethod def create(cls, os_family=None, suggested_path=None): """Create the executor instance.""" for candidate in define_candidate(cls.locations, cls.names, os_family, suggested_path): if MediaInfo.can_parse(candidate): - lib = MediaInfo._get_library(candidate) + lib, handle, lib_version_str, lib_version = MediaInfo._get_library(candidate) lib.MediaInfo_Option.argtypes = [c_void_p, c_wchar_p, c_wchar_p] lib.MediaInfo_Option.restype = c_wchar_p version = MediaInfoExecutor._get_version(lib.MediaInfo_Option(None, "Info_Version", "")) @@ -187,88 +177,97 @@ class MediaInfoProvider(Provider): def __init__(self, config, suggested_path): """Init method.""" - super(MediaInfoProvider, self).__init__(config, { - 'general': OrderedDict([ - ('title', Property('title', description='media title')), - ('path', Property('complete_name', description='media path')), - ('duration', Duration('duration', description='media duration')), - ('size', Quantity('file_size', units.byte, description='media size')), - ('bit_rate', Quantity('overall_bit_rate', units.bps, description='media bit rate')), - ]), - 'video': OrderedDict([ - ('id', Basic('track_id', int, allow_fallback=True, description='video track number')), - ('name', Property('name', description='video track name')), - ('language', Language('language', description='video language')), - ('duration', Duration('duration', description='video duration')), - ('size', Quantity('stream_size', units.byte, description='video stream size')), - ('width', Quantity('width', units.pixel)), - ('height', Quantity('height', units.pixel)), - ('scan_type', ScanType(config, 'scan_type', default='Progressive', description='video scan type')), - ('aspect_ratio', Basic('display_aspect_ratio', float, description='display aspect ratio')), - ('pixel_aspect_ratio', Basic('pixel_aspect_ratio', float, description='pixel aspect ratio')), - ('resolution', None), # populated with ResolutionRule - ('frame_rate', Quantity('frame_rate', units.FPS, float, description='video frame rate')), + super().__init__(config, { + 'general': { + 'title': Property('Title', description='media title'), + 'path': Property('CompleteName', description='media path'), + 'duration': Duration('Duration', resolution=1000, description='media duration'), + 'size': Quantity('FileSize', unit=units.byte, description='media size'), + 'bit_rate': Quantity('OverallBitRate', unit=units.bps, description='media bit rate'), + }, + 'video': { + 'id': Basic('ID', data_type=int, allow_fallback=True, description='video track number'), + 'name': Property('Title', description='video track name'), + 'language': Language('Language', description='video language'), + 'duration': Duration('Duration', resolution=1000, description='video duration'), + 'size': Quantity('StreamSize', unit=units.byte, description='video stream size'), + 'width': Quantity('Width', unit=units.pixel), + 'height': Quantity('Height', unit=units.pixel), + 'scan_type': ScanType(config, 'ScanType', default='Progressive', description='video scan type'), + 'aspect_ratio': Basic('DisplayAspectRatio', data_type=Decimal, + processor=lambda x: round_decimal(x, min_digits=1, max_digits=3), + description='display aspect ratio'), + 'pixel_aspect_ratio': Basic('PixelAspectRatio', data_type=Decimal, + processor=lambda x: round_decimal(x, min_digits=1, max_digits=3), + description='pixel aspect ratio'), + 'resolution': None, # populated with ResolutionRule + 'frame_rate': Quantity('FrameRate', unit=units.FPS, data_type=Decimal, description='video frame rate'), # frame_rate_mode - ('bit_rate', Quantity('bit_rate', units.bps, description='video bit rate')), - ('bit_depth', Quantity('bit_depth', units.bit, description='video bit depth')), - ('codec', VideoCodec(config, 'codec', description='video codec')), - ('profile', VideoProfile(config, 'codec_profile', description='video codec profile')), - ('profile_level', VideoProfileLevel(config, 'codec_profile', description='video codec profile level')), - ('profile_tier', VideoProfileTier(config, 'codec_profile', description='video codec profile tier')), - ('encoder', VideoEncoder(config, 'encoded_library_name', description='video encoder')), - ('media_type', Property('internet_media_type', description='video media type')), - ('forced', YesNo('forced', hide_value=False, description='video track forced')), - ('default', YesNo('default', hide_value=False, description='video track default')), - ]), - 'audio': OrderedDict([ - ('id', Basic('track_id', int, allow_fallback=True, description='audio track number')), - ('name', Property('title', description='audio track name')), - ('language', Language('language', description='audio language')), - ('duration', Duration('duration', description='audio duration')), - ('size', Quantity('stream_size', units.byte, description='audio stream size')), - ('codec', MultiValue(AudioCodec(config, 'codec', description='audio codec'))), - ('profile', MultiValue(AudioProfile(config, 'format_profile', description='audio codec profile'), - delimiter=' / ')), - ('channels_count', MultiValue(AudioChannels('channel_s', description='audio channels count'))), - ('channel_positions', MultiValue(name='other_channel_positions', handler=(lambda x, *args: x), - delimiter=' / ', private=True, description='audio channels position')), - ('channels', None), # populated with AudioChannelsRule - ('bit_depth', Quantity('bit_depth', units.bit, description='audio bit depth')), - ('bit_rate', MultiValue(Quantity('bit_rate', units.bps, description='audio bit rate'))), - ('bit_rate_mode', MultiValue(BitRateMode(config, 'bit_rate_mode', description='audio bit rate mode'))), - ('sampling_rate', MultiValue(Quantity('sampling_rate', units.Hz, description='audio sampling rate'))), - ('compression', MultiValue(AudioCompression(config, 'compression_mode', - description='audio compression'))), - ('forced', YesNo('forced', hide_value=False, description='audio track forced')), - ('default', YesNo('default', hide_value=False, description='audio track default')), - ]), - 'subtitle': OrderedDict([ - ('id', Basic('track_id', int, allow_fallback=True, description='subtitle track number')), - ('name', Property('title', description='subtitle track name')), - ('language', Language('language', description='subtitle language')), - ('hearing_impaired', None), # populated with HearingImpairedRule - ('_closed_caption', Property('captionservicename', private=True)), - ('closed_caption', None), # populated with ClosedCaptionRule - ('format', SubtitleFormat(config, 'codec_id', description='subtitle format')), - ('forced', YesNo('forced', hide_value=False, description='subtitle track forced')), - ('default', YesNo('default', hide_value=False, description='subtitle track default')), - ]), + 'bit_rate': Quantity('BitRate', unit=units.bps, description='video bit rate'), + 'bit_depth': Quantity('BitDepth', unit=units.bit, description='video bit depth'), + 'codec': VideoCodec(config, 'CodecID', description='video codec'), + 'profile': VideoProfile(config, 'Format_Profile', description='video codec profile'), + 'profile_level': Property('Format_Level', description='video codec profile level'), + 'profile_tier': VideoProfileTier(config, 'Format_Tier', description='video codec profile tier'), + 'encoder': VideoEncoder(config, 'Encoded_Library_Name', description='video encoder'), + 'hdr_format': MultiValue(VideoHdrFormat(config, 'HDR_Format', description='video hdr format'), + delimiter=' / '), + 'media_type': Property('InternetMediaType', description='video media type'), + 'forced': YesNo('Forced', hide_value=False, description='video track forced'), + 'default': YesNo('Default', hide_value=False, description='video track default'), + }, + 'audio': { + 'id': Basic('ID', data_type=int, allow_fallback=True, description='audio track number'), + 'name': Property('Title', description='audio track name'), + 'language': Language('Language', description='audio language'), + 'duration': Duration('Duration', resolution=1000, description='audio duration'), + 'size': Quantity('StreamSize', unit=units.byte, description='audio stream size'), + 'codec': MultiValue(AudioCodec(config, 'CodecID', description='audio codec')), + 'format_commercial': Property('Format_Commercial', private=True), + 'profile': MultiValue(AudioProfile(config, 'Format_Profile', 'Format_AdditionalFeatures', + description='audio codec profile'), + delimiter=' / '), + 'channels_count': MultiValue(AudioChannels('Channels_Original', 'Channels', + description='audio channels count')), + 'channel_positions': MultiValue(name='ChannelPositions_String2', handler=(lambda x, *args: x), + delimiter=' / ', private=True, description='audio channels position'), + 'channels': None, # populated with AudioChannelsRule + 'bit_depth': Quantity('BitDepth', unit=units.bit, description='audio bit depth'), + 'bit_rate': MultiValue(Quantity('BitRate', unit=units.bps, description='audio bit rate')), + 'bit_rate_mode': MultiValue(BitRateMode(config, 'BitRate_Mode', description='audio bit rate mode')), + 'sampling_rate': MultiValue(Quantity('SamplingRate', unit=units.Hz, description='audio sampling rate')), + 'compression': MultiValue(AudioCompression(config, 'Compression_Mode', + description='audio compression')), + 'forced': YesNo('Forced', hide_value=False, description='audio track forced'), + 'default': YesNo('Default', hide_value=False, description='audio track default'), + }, + 'subtitle': { + 'id': Basic('ID', data_type=int, allow_fallback=True, description='subtitle track number'), + 'name': Property('Title', description='subtitle track name'), + 'language': Language('Language', description='subtitle language'), + 'hearing_impaired': None, # populated with HearingImpairedRule + '_closed_caption': Property('ClosedCaptionsPresent', private=True), + 'closed_caption': None, # populated with ClosedCaptionRule + 'format': SubtitleFormat(config, 'CodecID', description='subtitle format'), + 'forced': YesNo('Forced', hide_value=False, description='subtitle track forced'), + 'default': YesNo('Default', hide_value=False, description='subtitle track default'), + }, }, { - 'video': OrderedDict([ - ('language', LanguageRule('video language')), - ('resolution', ResolutionRule('video resolution')), - ]), - 'audio': OrderedDict([ - ('language', LanguageRule('audio language')), - ('channels', AudioChannelsRule('audio channels')), - ('_atmosrule', AtmosRule('atmos rule')), - ('_dtshdrule', DtsHdRule('dts-hd rule')), - ]), - 'subtitle': OrderedDict([ - ('language', LanguageRule('subtitle language')), - ('hearing_impaired', HearingImpairedRule('subtitle hearing impaired')), - ('closed_caption', ClosedCaptionRule('closed caption')), - ]) + 'video': { + 'language': LanguageRule('video language'), + 'resolution': ResolutionRule('video resolution'), + }, + 'audio': { + 'language': LanguageRule('audio language'), + 'channels': AudioChannelsRule('audio channels'), + '_atmosrule': AtmosRule(config, 'atmos rule'), + '_dtshdrule': DtsHdRule(config, 'dts-hd rule'), + }, + 'subtitle': { + 'language': LanguageRule('subtitle language'), + 'hearing_impaired': HearingImpairedRule('subtitle hearing impaired'), + 'closed_caption': ClosedCaptionRule('closed caption'), + } }) self.executor = MediaInfoExecutor.get_executor_instance(suggested_path) @@ -282,12 +281,11 @@ def accepts(self, video_path): def describe(self, video_path, context): """Return video metadata.""" - media_info = self.executor.extract_info(video_path) + data = self.executor.extract_info(video_path) def debug_data(): """Debug data.""" - xml = ensure_text(ElementTree.tostring(media_info.xml_dom)).replace('\r', '').replace('\n', '') - return ensure_text(minidom.parseString(xml).toprettyxml(indent=' ', newl='\n', encoding='utf-8')) + return json.dumps(data, indent=4) context['debug_data'] = debug_data @@ -295,15 +293,15 @@ def debug_data(): logger.debug('Video %r scanned using mediainfo %r has raw data:\n%s', video_path, self.executor.location, debug_data()) - data = media_info.to_data() result = {} - if data.get('tracks'): + tracks = data.get('media', {}).get('track', []) + if tracks: general_tracks = [] video_tracks = [] audio_tracks = [] subtitle_tracks = [] - for track in data.get('tracks'): - track_type = track.get('track_type') + for track in tracks: + track_type = track.get('@type') if track_type == 'General': general_tracks.append(track) elif track_type == 'Video': @@ -328,8 +326,8 @@ def debug_data(): @property def version(self): """Return mediainfo version information.""" - versions = [('pymediainfo', pymediainfo_version)] + versions = {'pymediainfo': pymediainfo_version} if self.executor: - versions.append((self.executor.location, 'v{}'.format('.'.join(map(str, self.executor.version))))) - - return OrderedDict(versions) + executor_version = '.'.join(map(str, self.executor.version)) + versions[self.executor.location] = f'v{executor_version}' + return versions diff --git a/ext/knowit/providers/mkvmerge.py b/ext/knowit/providers/mkvmerge.py new file mode 100644 index 0000000000..e5aca15506 --- /dev/null +++ b/ext/knowit/providers/mkvmerge.py @@ -0,0 +1,248 @@ + +import json +import logging +import re +from decimal import Decimal +from logging import NullHandler, getLogger +from subprocess import check_output + +from knowit.core import Property +from knowit.properties import ( + AudioCodec, + Basic, + Duration, + Language, + Quantity, + VideoCodec, + VideoDimensions, + YesNo, +) +from knowit.provider import ( + MalformedFileError, + Provider, +) +from knowit.rules import ( + AudioChannelsRule, + ClosedCaptionRule, + HearingImpairedRule, + LanguageRule, + ResolutionRule, +) +from knowit.serializer import get_json_encoder +from knowit.units import units +from knowit.utils import define_candidate, detect_os + +logger = getLogger(__name__) +logger.addHandler(NullHandler()) + +WARN_MSG = r''' +========================================================================================= +mkvmerge not found on your system or could not be loaded. +Visit https://mkvtoolnix.download to download it. +If you still have problems, please check if the downloaded version matches your system. +To load mkvmerge from a specific location, please define the location as follow: + knowit --mkvmerge /usr/local/mkvmerge/bin + knowit --mkvmerge /usr/local/mkvmerge/bin/ffprobe + knowit --mkvmerge "C:\Program Files\mkvmerge" + knowit --mkvmerge C:\Software\mkvmerge.exe +========================================================================================= +''' + + +class MkvMergeExecutor: + """Executor that knows how to execute mkvmerge.""" + + version_re = re.compile(r'\bv(?P[^\b\s]+)') + locations = { + 'unix': ('/usr/local/mkvmerge/lib', '/usr/local/mkvmerge/bin', '__PATH__'), + 'windows': ('__PATH__', ), + 'macos': ('__PATH__', ), + } + + def __init__(self, location, version): + """Initialize the object.""" + self.location = location + self.version = version + + def extract_info(self, filename): + """Extract media info.""" + json_dump = self._execute(filename) + return json.loads(json_dump) + + def _execute(self, filename): + raise NotImplementedError + + @classmethod + def _get_version(cls, output): + match = cls.version_re.search(output) + if match: + version = match.groupdict()['version'] + return version + + @classmethod + def get_executor_instance(cls, suggested_path=None): + """Return executor instance.""" + os_family = detect_os() + logger.debug('Detected os: %s', os_family) + for exec_cls in (MkvMergeCliExecutor, ): + executor = exec_cls.create(os_family, suggested_path) + if executor: + return executor + + +class MkvMergeCliExecutor(MkvMergeExecutor): + """Executor that uses mkvmerge cli.""" + + names = { + 'unix': ('mkvmerge', ), + 'windows': ('mkvmerge.exe', ), + 'macos': ('mkvmerge', ), + } + + def _execute(self, filename): + return check_output([self.location, '-i', '-F', 'json', filename]).decode() + + @classmethod + def create(cls, os_family=None, suggested_path=None): + """Create the executor instance.""" + for candidate in define_candidate(cls.locations, cls.names, os_family, suggested_path): + try: + output = check_output([candidate, '--version']).decode() + version = cls._get_version(output) + if version: + logger.debug('MkvMerge cli detected: %s v%s', candidate, version) + return MkvMergeCliExecutor(candidate, version.split('.')) + except OSError: + pass + + +class MkvMergeProvider(Provider): + """MkvMerge Provider.""" + + def __init__(self, config, suggested_path=None, *args, **kwargs): + """Init method.""" + super().__init__(config, { + 'general': { + 'title': Property('title', description='media title'), + 'duration': Duration('duration', resolution=Decimal('0.000001'), description='media duration'), + }, + 'video': { + 'id': Basic('number', data_type=int, description='video track number'), + 'name': Property('name', description='video track name'), + 'language': Language('language_ietf', 'language', description='video language'), + 'width': VideoDimensions('display_dimensions', dimension='width'), + 'height': VideoDimensions('display_dimensions', dimension='height'), + 'scan_type': YesNo('interlaced', yes='Interlaced', no='Progressive', default='Progressive', + config=config, config_key='ScanType', + description='video scan type'), + 'resolution': None, # populated with ResolutionRule + # 'bit_depth', Property('bit_depth', Integer('video bit depth')), + 'codec': VideoCodec(config, 'codec_id', description='video codec'), + 'forced': YesNo('forced_track', hide_value=False, description='video track forced'), + 'default': YesNo('default_track', hide_value=False, description='video track default'), + 'enabled': YesNo('enabled_track', hide_value=True, description='video track enabled'), + }, + 'audio': { + 'id': Basic('number', data_type=int, description='audio track number'), + 'name': Property('name', description='audio track name'), + 'language': Language('language_ietf', 'language', description='audio language'), + 'codec': AudioCodec(config, 'codec_id', description='audio codec'), + 'channels_count': Basic('audio_channels', data_type=int, description='audio channels count'), + 'channels': None, # populated with AudioChannelsRule + 'sampling_rate': Quantity('audio_sampling_frequency', unit=units.Hz, description='audio sampling rate'), + 'forced': YesNo('forced_track', hide_value=False, description='audio track forced'), + 'default': YesNo('default_track', hide_value=False, description='audio track default'), + 'enabled': YesNo('enabled_track', hide_value=True, description='audio track enabled'), + }, + 'subtitle': { + 'id': Basic('number', data_type=int, description='subtitle track number'), + 'name': Property('name', description='subtitle track name'), + 'language': Language('language_ietf', 'language', description='subtitle language'), + 'hearing_impaired': None, # populated with HearingImpairedRule + 'closed_caption': None, # populated with ClosedCaptionRule + 'forced': YesNo('forced_track', hide_value=False, description='subtitle track forced'), + 'default': YesNo('default_track', hide_value=False, description='subtitle track default'), + 'enabled': YesNo('enabled_track', hide_value=True, description='subtitle track enabled'), + }, + }, { + 'video': { + 'language': LanguageRule('video language', override=True), + 'resolution': ResolutionRule('video resolution'), + }, + 'audio': { + 'language': LanguageRule('audio language', override=True), + 'channels': AudioChannelsRule('audio channels'), + }, + 'subtitle': { + 'language': LanguageRule('subtitle language', override=True), + 'hearing_impaired': HearingImpairedRule('subtitle hearing impaired'), + 'closed_caption': ClosedCaptionRule('closed caption'), + } + }) + self.executor = MkvMergeExecutor.get_executor_instance(suggested_path) + + def accepts(self, video_path): + """Accept Matroska videos when mkvmerge is available.""" + if self.executor is None: + logger.warning(WARN_MSG) + self.executor = False + + return self.executor and video_path.lower().endswith(('.mkv', '.mka', '.mks')) + + @classmethod + def extract_info(cls, video_path): + """Extract info from the video.""" + return json.loads(check_output(['mkvmerge', '-i', '-F', video_path]).decode()) + + def describe(self, video_path, context): + """Return video metadata.""" + data = self.executor.extract_info(video_path) + + def debug_data(): + """Debug data.""" + return json.dumps(data, cls=get_json_encoder(context), indent=4, ensure_ascii=False) + + context['debug_data'] = debug_data + + if logger.isEnabledFor(logging.DEBUG): + logger.debug('Video %r scanned using mkvmerge %r has raw data:\n%s', + video_path, self.executor.location, debug_data()) + + def merge_properties(target: dict): + """Merge properties sub properties into the target container.""" + return {**{k: v for k, v in target.items() if k != 'properties'}, **target.get('properties', {})} + + general_track = merge_properties(data.get('container', {})) + video_tracks = [] + audio_tracks = [] + subtitle_tracks = [] + for track in data.get('tracks'): + track_type = track.get('type') + merged = merge_properties(track) + if track_type == 'video': + video_tracks.append(merged) + elif track_type == 'audio': + audio_tracks.append(merged) + elif track_type == 'subtitles': + subtitle_tracks.append(merged) + + result = self._describe_tracks(video_path, general_track, video_tracks, audio_tracks, subtitle_tracks, context) + + if not result: + raise MalformedFileError + + result['provider'] = { + 'name': 'mkvmerge', + 'version': self.version + } + + return result + + @property + def version(self): + """Return mkvmerge version information.""" + if not self.executor: + return {} + version = '.'.join(map(str, self.executor.version)) + + return {self.executor.location: f'v{version}'} diff --git a/ext/knowit/rule.py b/ext/knowit/rule.py deleted file mode 100644 index 6d0764955b..0000000000 --- a/ext/knowit/rule.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .core import Reportable - - -class Rule(Reportable): - """Rule abstract class.""" - - def __init__(self, name, override=False, **kwargs): - """Constructor.""" - super(Rule, self).__init__(name, **kwargs) - self.override = override - - def execute(self, props, pv_props, context): - """How to execute a rule.""" - raise NotImplementedError diff --git a/ext/knowit/rules/__init__.py b/ext/knowit/rules/__init__.py index 533706258e..90a943a8f3 100644 --- a/ext/knowit/rules/__init__.py +++ b/ext/knowit/rules/__init__.py @@ -1,11 +1,8 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals -from .audio import AtmosRule -from .audio import AudioChannelsRule -from .audio import AudioCodecRule -from .audio import DtsHdRule -from .language import LanguageRule -from .subtitle import ClosedCaptionRule -from .subtitle import HearingImpairedRule -from .video import ResolutionRule +from knowit.rules.audio import AtmosRule +from knowit.rules.audio import AudioChannelsRule +from knowit.rules.audio import DtsHdRule +from knowit.rules.general import LanguageRule +from knowit.rules.subtitle import ClosedCaptionRule +from knowit.rules.subtitle import HearingImpairedRule +from knowit.rules.video import ResolutionRule diff --git a/ext/knowit/rules/audio.py b/ext/knowit/rules/audio.py new file mode 100644 index 0000000000..b35d364370 --- /dev/null +++ b/ext/knowit/rules/audio.py @@ -0,0 +1,104 @@ +import typing +from decimal import Decimal +from logging import NullHandler, getLogger + +from knowit.core import Rule + +logger = getLogger(__name__) +logger.addHandler(NullHandler()) + + +class AtmosRule(Rule): + """Atmos rule.""" + + def __init__(self, config: typing.Mapping[str, typing.Mapping], name: str, + **kwargs): + """Initialize an Atmos rule.""" + super().__init__(name, **kwargs) + self.audio_codecs = getattr(config, 'AudioCodec') + + def execute(self, props, pv_props, context): + """Execute the rule against properties.""" + profile = context.get('profile') or 'default' + format_commercial = pv_props.get('format_commercial') + if 'codec' in props and format_commercial and 'atmos' in format_commercial.lower(): + props['codec'] = [props['codec'], + getattr(self.audio_codecs['ATMOS'], profile)] + + +class AudioChannelsRule(Rule): + """Audio Channel rule.""" + + mapping = { + 1: '1.0', + 2: '2.0', + 6: '5.1', + 8: '7.1', + } + + def execute(self, props, pv_props, context): + """Execute the rule against properties.""" + count = props.get('channels_count') + if count is None: + return + + channels = self.mapping.get(count) if isinstance(count, int) else None + positions = pv_props.get('channel_positions') or [] + positions = positions if isinstance(positions, list) else [positions] + candidate = 0 + for position in positions: + if not position: + continue + + c = Decimal('0.0') + for i in position.split('/'): + try: + c += Decimal(i) + except ValueError: + logger.debug('Invalid %s: %s', self.description, i) + pass + + c_count = int(c) + int(round((c - int(c)) * 10)) + if c_count == count: + return str(c) + + candidate = max(candidate, c) + + if channels: + return channels + + if candidate: + return candidate + + self.report(positions, context) + + +class DtsHdRule(Rule): + """DTS-HD rule.""" + + def __init__(self, config: typing.Mapping[str, typing.Mapping], name: str, + **kwargs): + """Initialize a DTS-HD Rule.""" + super().__init__(name, **kwargs) + self.audio_codecs = getattr(config, 'AudioCodec') + self.audio_profiles = getattr(config, 'AudioProfile') + + @classmethod + def _redefine(cls, props, name, index): + actual = props.get(name) + if isinstance(actual, list): + value = actual[index] + if value is None: + del props[name] + else: + props[name] = value + + def execute(self, props, pv_props, context): + """Execute the rule against properties.""" + profile = context.get('profile') or 'default' + + if props.get('codec') == getattr(self.audio_codecs['DTS'], + profile) and props.get('profile') in ( + getattr(self.audio_profiles['MA'], profile), + getattr(self.audio_profiles['HRA'], profile)): + props['codec'] = getattr(self.audio_codecs['DTS-HD'], profile) diff --git a/ext/knowit/rules/audio/__init__.py b/ext/knowit/rules/audio/__init__.py deleted file mode 100644 index d8a9470473..0000000000 --- a/ext/knowit/rules/audio/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .atmos import AtmosRule -from .channels import AudioChannelsRule -from .codec import AudioCodecRule -from .dtshd import DtsHdRule diff --git a/ext/knowit/rules/audio/atmos.py b/ext/knowit/rules/audio/atmos.py deleted file mode 100644 index 3e429d866c..0000000000 --- a/ext/knowit/rules/audio/atmos.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...rule import Rule - - -class AtmosRule(Rule): - """Atmos rule.""" - - @classmethod - def _redefine(cls, props, name, index): - actual = props.get(name) - if isinstance(actual, list): - value = actual[index] - if value is None: - del props[name] - else: - props[name] = value - - def execute(self, props, pv_props, context): - """Execute the rule against properties.""" - codecs = props.get('codec') or [] - # TODO: handle this properly - if 'atmos' in {codec.lower() for codec in codecs if codec}: - index = None - for i, codec in enumerate(codecs): - if codec and 'atmos' in codec.lower(): - index = i - break - - if index is not None: - for name in ('channels_count', 'sampling_rate'): - self._redefine(props, name, index) diff --git a/ext/knowit/rules/audio/channels.py b/ext/knowit/rules/audio/channels.py deleted file mode 100644 index 50975d5b2f..0000000000 --- a/ext/knowit/rules/audio/channels.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from logging import NullHandler, getLogger -from six import text_type - -from ...rule import Rule - -logger = getLogger(__name__) -logger.addHandler(NullHandler()) - - -class AudioChannelsRule(Rule): - """Audio Channel rule.""" - - mapping = { - 1: '1.0', - 2: '2.0', - 6: '5.1', - 8: '7.1', - } - - def execute(self, props, pv_props, context): - """Execute the rule against properties.""" - count = props.get('channels_count') - if count is None: - return - - channels = self.mapping.get(count) if isinstance(count, int) else None - positions = pv_props.get('channel_positions') or [] - positions = positions if isinstance(positions, list) else [positions] - candidate = 0 - for position in positions: - if not position: - continue - - c = 0 - for i in position.split('/'): - try: - c += float(i) - except ValueError: - logger.debug('Invalid %s: %s', self.description, i) - pass - - c_count = int(c) + int(round((c - int(c)) * 10)) - if c_count == count: - return text_type(c) - - candidate = max(candidate, c) - - if channels: - return channels - - if candidate: - return text_type(candidate) - - self.report(positions, context) diff --git a/ext/knowit/rules/audio/codec.py b/ext/knowit/rules/audio/codec.py deleted file mode 100644 index 5690e220b3..0000000000 --- a/ext/knowit/rules/audio/codec.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...rule import Rule - - -class AudioCodecRule(Rule): - """Audio Codec rule.""" - - def execute(self, props, pv_props, context): - """Execute the rule against properties.""" - if '_codec' in pv_props: - return pv_props.get('_codec') diff --git a/ext/knowit/rules/audio/dtshd.py b/ext/knowit/rules/audio/dtshd.py deleted file mode 100644 index d44cdf138d..0000000000 --- a/ext/knowit/rules/audio/dtshd.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from ...rule import Rule - - -class DtsHdRule(Rule): - """DTS-HD rule.""" - - @classmethod - def _redefine(cls, props, name, index): - actual = props.get(name) - if isinstance(actual, list): - value = actual[index] - if value is None: - del props[name] - else: - props[name] = value - - def execute(self, props, pv_props, context): - """Execute the rule against properties.""" - if props.get('codec') == 'DTS-HD': - index = None - for i, profile in enumerate(props.get('profile', [])): - if profile and profile.upper() != 'CORE': - index = i - break - - if index is not None: - for name in ('profile', 'channels_count', 'bit_rate', - 'bit_rate_mode', 'sampling_rate', 'compression'): - self._redefine(props, name, index) diff --git a/ext/knowit/rules/language.py b/ext/knowit/rules/general.py similarity index 89% rename from ext/knowit/rules/language.py rename to ext/knowit/rules/general.py index 8a51ccf059..b492c03a52 100644 --- a/ext/knowit/rules/language.py +++ b/ext/knowit/rules/general.py @@ -1,12 +1,10 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals import re from logging import NullHandler, getLogger import babelfish -from ..rule import Rule +from knowit.core import Rule logger = getLogger(__name__) logger.addHandler(NullHandler()) diff --git a/ext/knowit/rules/subtitle/closedcaption.py b/ext/knowit/rules/subtitle.py similarity index 52% rename from ext/knowit/rules/subtitle/closedcaption.py rename to ext/knowit/rules/subtitle.py index 14be06fdd2..fa16fdbc12 100644 --- a/ext/knowit/rules/subtitle/closedcaption.py +++ b/ext/knowit/rules/subtitle.py @@ -1,9 +1,6 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import re -from ...rule import Rule +from knowit.core import Rule class ClosedCaptionRule(Rule): @@ -16,3 +13,15 @@ def execute(self, props, pv_props, context): for name in (pv_props.get('_closed_caption'), props.get('name')): if name and self.cc_re.search(name): return True + + +class HearingImpairedRule(Rule): + """Hearing Impaired rule.""" + + hi_re = re.compile(r'(\bsdh\b)', re.IGNORECASE) + + def execute(self, props, pv_props, context): + """Hearing Impaired.""" + name = props.get('name') + if name and self.hi_re.search(name): + return True diff --git a/ext/knowit/rules/subtitle/__init__.py b/ext/knowit/rules/subtitle/__init__.py deleted file mode 100644 index eff71d670e..0000000000 --- a/ext/knowit/rules/subtitle/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .closedcaption import ClosedCaptionRule -from .hearingimpaired import HearingImpairedRule diff --git a/ext/knowit/rules/subtitle/hearingimpaired.py b/ext/knowit/rules/subtitle/hearingimpaired.py deleted file mode 100644 index 54c4d56794..0000000000 --- a/ext/knowit/rules/subtitle/hearingimpaired.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import re - -from ...rule import Rule - - -class HearingImpairedRule(Rule): - """Hearing Impaired rule.""" - - hi_re = re.compile(r'(\bsdh\b)', re.IGNORECASE) - - def execute(self, props, pv_props, context): - """Hearing Impaired.""" - name = props.get('name') - if name and self.hi_re.search(name): - return True diff --git a/ext/knowit/rules/video/resolution.py b/ext/knowit/rules/video.py similarity index 83% rename from ext/knowit/rules/video/resolution.py rename to ext/knowit/rules/video.py index bcdd594edc..dabde0a55d 100644 --- a/ext/knowit/rules/video/resolution.py +++ b/ext/knowit/rules/video.py @@ -1,7 +1,6 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +from decimal import Decimal -from ...rule import Rule +from knowit.core import Rule class ResolutionRule(Rule): @@ -47,7 +46,7 @@ def execute(self, props, pv_props, context): except AttributeError: pass - dar = props.get('aspect_ratio', float(width) / height) + dar = props.get('aspect_ratio', Decimal(width) / height) par = props.get('pixel_aspect_ratio', 1) scan_type = props.get('scan_type', 'p')[0].lower() @@ -68,8 +67,7 @@ def execute(self, props, pv_props, context): selected_resolution = r if selected_resolution: - return '{0}{1}'.format(selected_resolution, scan_type) + return f'{selected_resolution}{scan_type}' - msg = '{width}x{height} - scan_type: {scan_type}, aspect_ratio: {dar}, pixel_aspect_ratio: {par}'.format( - width=width, height=height, scan_type=scan_type, dar=dar, par=par) + msg = f'{width}x{height} - scan_type: {scan_type}, aspect_ratio: {dar}, pixel_aspect_ratio: {par}' self.report(msg, context) diff --git a/ext/knowit/rules/video/__init__.py b/ext/knowit/rules/video/__init__.py deleted file mode 100644 index 77c0b406f9..0000000000 --- a/ext/knowit/rules/video/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from .resolution import ResolutionRule diff --git a/ext/knowit/serializer.py b/ext/knowit/serializer.py index a799df7680..4922dc7f2a 100644 --- a/ext/knowit/serializer.py +++ b/ext/knowit/serializer.py @@ -1,29 +1,35 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import datetime import json -from collections import OrderedDict +import re +import typing from datetime import timedelta +from decimal import Decimal import babelfish -from six import text_type import yaml +from yaml.composer import Composer +from yaml.constructor import SafeConstructor +from yaml.parser import Parser +from yaml.reader import Reader +from yaml.resolver import Resolver as DefaultResolver +from yaml.scanner import Scanner -from .units import units +from knowit.units import units +from knowit.utils import round_decimal -def format_property(context, o): +def format_property(profile: str, o): """Convert properties to string.""" if isinstance(o, timedelta): - return format_duration(o, context['profile']) + return format_duration(o, profile) if isinstance(o, babelfish.language.Language): - return format_language(o, context['profile']) + return format_language(o, profile) if hasattr(o, 'units'): - return format_quantity(o, context['profile']) + return format_quantity(o, profile) - return text_type(o) + return str(o) def get_json_encoder(context): @@ -32,7 +38,7 @@ class StringEncoder(json.JSONEncoder): """String json encoder.""" def default(self, o): - return format_property(context, o) + return format_property(context['profile'], o) return StringEncoder @@ -46,14 +52,8 @@ def default_representer(self, data): """Convert data to string.""" if isinstance(data, int): return self.represent_int(data) - if isinstance(data, float): - return self.represent_float(data) return self.represent_str(str(data)) - def ordered_dict_representer(self, data): - """Representer for OrderedDict.""" - return self.represent_mapping('tag:yaml.org,2002:map', data.items()) - def default_language_representer(self, data): """Convert language to string.""" return self.represent_str(format_language(data, context['profile'])) @@ -66,10 +66,10 @@ def default_duration_representer(self, data): """Convert quantity to string.""" return self.default_representer(format_duration(data, context['profile'])) - CustomDumper.add_representer(OrderedDict, CustomDumper.ordered_dict_representer) CustomDumper.add_representer(babelfish.Language, CustomDumper.default_language_representer) CustomDumper.add_representer(timedelta, CustomDumper.default_duration_representer) CustomDumper.add_representer(units.Quantity, CustomDumper.default_quantity_representer) + CustomDumper.add_representer(Decimal, CustomDumper.default_representer) return CustomDumper @@ -77,26 +77,65 @@ def default_duration_representer(self, data): def get_yaml_loader(constructors=None): """Return a yaml loader that handles sequences as python lists.""" constructors = constructors or {} - - class CustomLoader(yaml.Loader): + yaml_implicit_resolvers = dict(DefaultResolver.yaml_implicit_resolvers) + + class Resolver(DefaultResolver): + """Custom YAML Resolver.""" + + Resolver.yaml_implicit_resolvers.clear() + for ch, vs in yaml_implicit_resolvers.items(): + Resolver.yaml_implicit_resolvers.setdefault(ch, []).extend( + (tag, regexp) for tag, regexp in vs + if not tag.endswith('float') + ) + Resolver.add_implicit_resolver( # regex copied from yaml source + '!decimal', + re.compile(r'''^(?: + [-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)? + |\.[0-9_]+(?:[eE][-+][0-9]+)? + |[-+]?[0-9][0-9_]*(?::[0-9]?[0-9])+\.[0-9_]* + |[-+]?\.(?:inf|Inf|INF) + |\.(?:nan|NaN|NAN) + )$''', re.VERBOSE), + list('-+0123456789.') + ) + + class CustomLoader(Reader, Scanner, Parser, Composer, SafeConstructor, Resolver): """Custom YAML Loader.""" - pass + def __init__(self, stream): + Reader.__init__(self, stream) + Scanner.__init__(self) + Parser.__init__(self) + Composer.__init__(self) + SafeConstructor.__init__(self) + Resolver.__init__(self) - CustomLoader.add_constructor('tag:yaml.org,2002:seq', CustomLoader.construct_python_tuple) + CustomLoader.add_constructor('tag:yaml.org,2002:seq', yaml.Loader.construct_python_tuple) for tag, constructor in constructors.items(): CustomLoader.add_constructor(tag, constructor) + def decimal_constructor(loader, node): + value = loader.construct_scalar(node) + return Decimal(value) + + CustomLoader.add_constructor('!decimal', decimal_constructor) + return CustomLoader -def format_duration(duration, profile='default'): +def format_duration( + duration: datetime.timedelta, + profile='default', +) -> typing.Union[str, Decimal]: if profile == 'technical': return str(duration) seconds = duration.total_seconds() if profile == 'code': - return duration.total_seconds() + return round_decimal( + Decimal((duration.days * 86400 + duration.seconds) * 10 ** 6 + duration.microseconds) / 10**6, min_digits=1 + ) hours = int(seconds // 3600) seconds = seconds - (hours * 3600) @@ -104,23 +143,28 @@ def format_duration(duration, profile='default'): seconds = int(seconds - (minutes * 60)) if profile == 'human': if hours > 0: - return '{0} hours {1:02d} minutes {2:02d} seconds'.format(hours, minutes, seconds) + return f'{hours} hours {minutes:02d} minutes { seconds:02d} seconds' if minutes > 0: - return '{0} minutes {1:02d} seconds'.format(minutes, seconds) - - return '{0} seconds'.format(seconds) + return f'{minutes} minutes {seconds:02d} seconds' + return f'{seconds} seconds' - return '{0}:{1:02d}:{2:02d}'.format(hours, minutes, seconds) + return f'{hours}:{minutes:02d}:{seconds:02d}' -def format_language(language, profile='default'): +def format_language( + language: babelfish.language.Language, + profile: str = 'default', +) -> str: if profile in ('default', 'human'): return str(language.name) return str(language) -def format_quantity(quantity, profile='default'): +def format_quantity( + quantity, + profile='default', +) -> str: """Human friendly format.""" if profile == 'code': return quantity.magnitude @@ -140,16 +184,26 @@ def format_quantity(quantity, profile='default'): return str(quantity) -def _format_quantity(num, unit='B', binary=False, precision=2): - fmt_pattern = '{value:3.%sf} {prefix}{affix}{unit}' % precision - factor = 1024. if binary else 1000. - binary_affix = 'i' if binary else '' +def _format_quantity( + num, + unit: str = 'B', + binary: bool = False, + precision: int = 2, +) -> str: + if binary: + factor = 1024 + affix = 'i' + else: + factor = 1000 + affix = '' for prefix in ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z'): if abs(num) < factor: - return fmt_pattern.format(value=num, prefix=prefix, affix=binary_affix, unit=unit) + break num /= factor + else: + prefix = 'Y' - return fmt_pattern.format(value=num, prefix='Y', affix=binary_affix, unit=unit) + return f'{num:3.{precision}f} {prefix}{affix}{unit}' YAMLLoader = get_yaml_loader() diff --git a/ext/knowit/units.py b/ext/knowit/units.py index 2397a60bc3..73ec16a5ae 100644 --- a/ext/knowit/units.py +++ b/ext/knowit/units.py @@ -1,23 +1,32 @@ -# -*- coding: utf-8 -*- +import typing +try: + import pint +except ImportError: + pint = False -def _build_unit_registry(): - try: - from pint import UnitRegistry - registry = UnitRegistry() - registry.define('FPS = 1 * hertz') - except ImportError: - class NoUnitRegistry: +class NullRegistry: + """A NullRegistry that masquerades as a pint.UnitRegistry.""" + + def __init__(self): + """Initialize a null registry.""" - def __init__(self): - pass + def __getattr__(self, item: typing.Any) -> int: + """Return a Scalar 1 to simulate a unit.""" + return 1 - def __getattr__(self, item): - return 1 + def __bool__(self): + """Return False since a NullRegistry is not a pint.UnitRegistry.""" + return False - registry = NoUnitRegistry() + def define(self, *args, **kwargs): + """Pretend to add unit to the registry.""" + +def _build_unit_registry(): + registry = pint.UnitRegistry() if pint else NullRegistry() + registry.define('FPS = 1 * hertz') return registry diff --git a/ext/knowit/utils.py b/ext/knowit/utils.py index c65d54943d..2bb0ed74b5 100644 --- a/ext/knowit/utils.py +++ b/ext/knowit/utils.py @@ -1,95 +1,134 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import os import sys -from collections import OrderedDict +import typing +from decimal import Decimal -from six import PY2, string_types, text_type +from knowit import VIDEO_EXTENSIONS -from . import VIDEO_EXTENSIONS +if sys.version_info < (3, 8): + OS_FAMILY = str +else: + OS_FAMILY = typing.Literal['windows', 'macos', 'unix'] +OPTION_MAP = typing.Dict[str, typing.Tuple[str]] -def recurse_paths(paths): - """Return a file system encoded list of videofiles. - :param paths: - :type paths: string or list - :return: - :rtype: list - """ +def recurse_paths( + paths: typing.Union[str, typing.Iterable[str]] +) -> typing.List[str]: + """Return a list of video files.""" enc_paths = [] - if isinstance(paths, (string_types, text_type)): + if isinstance(paths, str): paths = [p.strip() for p in paths.split(',')] if ',' in paths else paths.split() - encoding = sys.getfilesystemencoding() for path in paths: if os.path.isfile(path): - enc_paths.append(path.decode(encoding) if PY2 else path) + enc_paths.append(path) if os.path.isdir(path): for root, directories, filenames in os.walk(path): for filename in filenames: if os.path.splitext(filename)[1] in VIDEO_EXTENSIONS: - if PY2 and os.name == 'nt': - fullpath = os.path.join(root, filename.decode(encoding)) - else: - fullpath = os.path.join(root, filename).decode(encoding) - enc_paths.append(fullpath) + full_path = os.path.join(root, filename) + enc_paths.append(full_path) # Lets remove any dupes since mediainfo is rather slow. - seen = set() - seen_add = seen.add - return [f for f in enc_paths if not (f in seen or seen_add(f))] + unique_paths = dict.fromkeys(enc_paths) + return list(unique_paths) -def todict(obj, classkey=None): +def to_dict( + obj: typing.Any, + classkey: typing.Optional[typing.Type] = None +) -> typing.Union[str, dict, list]: """Transform an object to dict.""" - if isinstance(obj, string_types): + if isinstance(obj, str): return obj elif isinstance(obj, dict): data = {} for (k, v) in obj.items(): - data[k] = todict(v, classkey) + data[k] = to_dict(v, classkey) return data elif hasattr(obj, '_ast'): - return todict(obj._ast()) + return to_dict(obj._ast()) elif hasattr(obj, '__iter__'): - return [todict(v, classkey) for v in obj] + return [to_dict(v, classkey) for v in obj] elif hasattr(obj, '__dict__'): - values = [(key, todict(value, classkey)) + values = [(key, to_dict(value, classkey)) for key, value in obj.__dict__.items() if not callable(value) and not key.startswith('_')] - data = OrderedDict([(k, v) for k, v in values if v is not None]) + data = {k: v for k, v in values if v is not None} if classkey is not None and hasattr(obj, '__class__'): data[classkey] = obj.__class__.__name__ return data return obj -def detect_os(): +def detect_os() -> OS_FAMILY: """Detect os family: windows, macos or unix.""" if os.name in ('nt', 'dos', 'os2', 'ce'): return 'windows' if sys.platform == 'darwin': return 'macos' - return 'unix' -def define_candidate(locations, names, os_family=None, suggested_path=None): - """Generate candidate list for the given parameters.""" +def define_candidate( + locations: OPTION_MAP, + names: OPTION_MAP, + os_family: typing.Optional[OS_FAMILY] = None, + suggested_path: typing.Optional[str] = None, +) -> typing.Generator[str, None, None]: + """Select family-specific options and generate possible candidates.""" os_family = os_family or detect_os() - for location in (suggested_path, ) + locations[os_family]: + family_names = names[os_family] + all_locations = (suggested_path, ) + locations[os_family] + yield from build_candidates(all_locations, family_names) + + +def build_candidates( + locations: typing.Iterable[typing.Optional[str]], + names: typing.Iterable[str], +) -> typing.Generator[str, None, None]: + """Build candidate names.""" + for location in locations: if not location: continue - if location == '__PATH__': - for name in names[os_family]: - yield name + yield from build_path_candidates(names) elif os.path.isfile(location): yield location elif os.path.isdir(location): - for name in names[os_family]: + for name in names: cmd = os.path.join(location, name) if os.path.isfile(cmd): yield cmd + + +def build_path_candidates( + names: typing.Iterable[str], + os_family: typing.Optional[OS_FAMILY] = None, +) -> typing.Generator[str, None, None]: + """Build candidate names on environment PATH.""" + os_family = os_family or detect_os() + if os_family != 'windows': + yield from names + else: + paths = os.environ['PATH'].split(';') + yield from ( + os.path.join(path, name) + for path in paths + for name in names + ) + + +def round_decimal(value: Decimal, min_digits=0, max_digits: typing.Optional[int] = None): + exponent = value.normalize().as_tuple().exponent + if exponent >= 0: + return round(value, min_digits) + + decimal_places = abs(exponent) + if decimal_places <= min_digits: + return round(value, min_digits) + if max_digits: + return round(value, min(max_digits, decimal_places)) + return value diff --git a/ext/pymediainfo/__init__.py b/ext/pymediainfo/__init__.py new file mode 100644 index 0000000000..9c186798b3 --- /dev/null +++ b/ext/pymediainfo/__init__.py @@ -0,0 +1,528 @@ +# vim: set fileencoding=utf-8 : +""" +This module is a wrapper around the MediaInfo library. +""" +import ctypes +import json +import os +import pathlib +import re +import sys +import warnings +import xml.etree.ElementTree as ET +from typing import Any, Dict, List, Optional, Tuple, Union + +try: + from importlib import metadata +except ImportError: + import importlib_metadata as metadata # type: ignore + +try: + __version__ = metadata.version("pymediainfo") +except metadata.PackageNotFoundError: + __version__ = "" + + +class Track: + """ + An object associated with a media file track. + + Each :class:`Track` attribute corresponds to attributes parsed from MediaInfo's output. + All attributes are lower case. Attributes that are present several times such as `Duration` + yield a second attribute starting with `other_` which is a list of all alternative + attribute values. + + When a non-existing attribute is accessed, `None` is returned. + + Example: + + >>> t = mi.tracks[0] + >>> t + + >>> t.duration + 3000 + >>> t.other_duration + ['3 s 0 ms', '3 s 0 ms', '3 s 0 ms', + '00:00:03.000', '00:00:03.000'] + >>> type(t.non_existing) + NoneType + + All available attributes can be obtained by calling :func:`to_data`. + """ + + def __eq__(self, other): # type: ignore + return self.__dict__ == other.__dict__ + + def __getattribute__(self, name): # type: ignore + try: + return object.__getattribute__(self, name) + except AttributeError: + pass + return None + + def __getstate__(self): # type: ignore + return self.__dict__ + + def __setstate__(self, state): # type: ignore + self.__dict__ = state + + def __init__(self, xml_dom_fragment: ET.Element): + self.track_type = xml_dom_fragment.attrib["type"] + repeated_attributes = [] + for elem in xml_dom_fragment: + node_name = elem.tag.lower().strip().strip("_") + if node_name == "id": + node_name = "track_id" + node_value = elem.text + if getattr(self, node_name) is None: + setattr(self, node_name, node_value) + else: + other_node_name = f"other_{node_name}" + repeated_attributes.append((node_name, other_node_name)) + if getattr(self, other_node_name) is None: + setattr(self, other_node_name, [node_value]) + else: + getattr(self, other_node_name).append(node_value) + + for primary_key, other_key in repeated_attributes: + try: + # Attempt to convert the main value to int + # Usually, if an attribute is repeated, one of its value + # is an int and others are human-readable formats + setattr(self, primary_key, int(getattr(self, primary_key))) + except ValueError: + # If it fails, try to find a secondary value + # that is an int and swap it with the main value + for other_value in getattr(self, other_key): + try: + current = getattr(self, primary_key) + # Set the main value to an int + setattr(self, primary_key, int(other_value)) + # Append its previous value to other values + getattr(self, other_key).append(current) + break + except ValueError: + pass + + def __repr__(self): # type: ignore + return "".format(self.track_id, self.track_type) + + def to_data(self) -> Dict[str, Any]: + """ + Returns a dict representation of the track attributes. + + Example: + + >>> sorted(track.to_data().keys())[:3] + ['codec', 'codec_extensions_usually_used', 'codec_url'] + >>> t.to_data()["file_size"] + 5988 + + + :rtype: dict + """ + return self.__dict__ + + +class MediaInfo: + """ + An object containing information about a media file. + + + :class:`MediaInfo` objects can be created by directly calling code from + libmediainfo (in this case, the library must be present on the system): + + >>> pymediainfo.MediaInfo.parse("/path/to/file.mp4") + + Alternatively, objects may be created from MediaInfo's XML output. + Such output can be obtained using the ``XML`` output format on versions older than v17.10 + and the ``OLDXML`` format on newer versions. + + Using such an XML file, we can create a :class:`MediaInfo` object: + + >>> with open("output.xml") as f: + ... mi = pymediainfo.MediaInfo(f.read()) + + :param str xml: XML output obtained from MediaInfo. + :param str encoding_errors: option to pass to :func:`str.encode`'s `errors` + parameter before parsing `xml`. + :raises xml.etree.ElementTree.ParseError: if passed invalid XML. + :var tracks: A list of :py:class:`Track` objects which the media file contains. + For instance: + + >>> mi = pymediainfo.MediaInfo.parse("/path/to/file.mp4") + >>> for t in mi.tracks: + ... print(t) + + + """ + + def __eq__(self, other): # type: ignore + return self.tracks == other.tracks + + def __init__(self, xml: str, encoding_errors: str = "strict"): + xml_dom = ET.fromstring(xml.encode("utf-8", encoding_errors)) + self.tracks = [] + # This is the case for libmediainfo < 18.03 + # https://github.com/sbraz/pymediainfo/issues/57 + # https://github.com/MediaArea/MediaInfoLib/commit/575a9a32e6960ea34adb3bc982c64edfa06e95eb + if xml_dom.tag == "File": + xpath = "track" + else: + xpath = "File/track" + for xml_track in xml_dom.iterfind(xpath): + self.tracks.append(Track(xml_track)) + + def _tracks(self, track_type: str) -> List[Track]: + return [track for track in self.tracks if track.track_type == track_type] + + @property + def general_tracks(self) -> List[Track]: + """ + :return: All :class:`Track`\\s of type ``General``. + :rtype: list of :class:`Track`\\s + """ + return self._tracks("General") + + @property + def video_tracks(self) -> List[Track]: + """ + :return: All :class:`Track`\\s of type ``Video``. + :rtype: list of :class:`Track`\\s + """ + return self._tracks("Video") + + @property + def audio_tracks(self) -> List[Track]: + """ + :return: All :class:`Track`\\s of type ``Audio``. + :rtype: list of :class:`Track`\\s + """ + return self._tracks("Audio") + + @property + def text_tracks(self) -> List[Track]: + """ + :return: All :class:`Track`\\s of type ``Text``. + :rtype: list of :class:`Track`\\s + """ + return self._tracks("Text") + + @property + def other_tracks(self) -> List[Track]: + """ + :return: All :class:`Track`\\s of type ``Other``. + :rtype: list of :class:`Track`\\s + """ + return self._tracks("Other") + + @property + def image_tracks(self) -> List[Track]: + """ + :return: All :class:`Track`\\s of type ``Image``. + :rtype: list of :class:`Track`\\s + """ + return self._tracks("Image") + + @property + def menu_tracks(self) -> List[Track]: + """ + :return: All :class:`Track`\\s of type ``Menu``. + :rtype: list of :class:`Track`\\s + """ + return self._tracks("Menu") + + @staticmethod + def _normalize_filename(filename: Any) -> Any: + if hasattr(os, "PathLike") and isinstance(filename, os.PathLike): + return os.fspath(filename) + if pathlib is not None and isinstance(filename, pathlib.PurePath): + return str(filename) + return filename + + @classmethod + def _define_library_prototypes(cls, lib: Any) -> Any: + lib.MediaInfo_Inform.restype = ctypes.c_wchar_p + lib.MediaInfo_New.argtypes = [] + lib.MediaInfo_New.restype = ctypes.c_void_p + lib.MediaInfo_Option.argtypes = [ + ctypes.c_void_p, + ctypes.c_wchar_p, + ctypes.c_wchar_p, + ] + lib.MediaInfo_Option.restype = ctypes.c_wchar_p + lib.MediaInfo_Inform.argtypes = [ctypes.c_void_p, ctypes.c_size_t] + lib.MediaInfo_Inform.restype = ctypes.c_wchar_p + lib.MediaInfo_Open.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p] + lib.MediaInfo_Open.restype = ctypes.c_size_t + lib.MediaInfo_Open_Buffer_Init.argtypes = [ + ctypes.c_void_p, + ctypes.c_uint64, + ctypes.c_uint64, + ] + lib.MediaInfo_Open_Buffer_Init.restype = ctypes.c_size_t + lib.MediaInfo_Open_Buffer_Continue.argtypes = [ + ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_size_t, + ] + lib.MediaInfo_Open_Buffer_Continue.restype = ctypes.c_size_t + lib.MediaInfo_Open_Buffer_Continue_GoTo_Get.argtypes = [ctypes.c_void_p] + lib.MediaInfo_Open_Buffer_Continue_GoTo_Get.restype = ctypes.c_uint64 + lib.MediaInfo_Open_Buffer_Finalize.argtypes = [ctypes.c_void_p] + lib.MediaInfo_Open_Buffer_Finalize.restype = ctypes.c_size_t + lib.MediaInfo_Delete.argtypes = [ctypes.c_void_p] + lib.MediaInfo_Delete.restype = None + lib.MediaInfo_Close.argtypes = [ctypes.c_void_p] + lib.MediaInfo_Close.restype = None + + @staticmethod + def _get_library_paths(os_is_nt: bool) -> Tuple[str]: + if os_is_nt: + library_paths = ("MediaInfo.dll",) + elif sys.platform == "darwin": + library_paths = ("libmediainfo.0.dylib", "libmediainfo.dylib") + else: + library_paths = ("libmediainfo.so.0",) + script_dir = os.path.dirname(__file__) + # Look for the library file in the script folder + for library in library_paths: + absolute_library_path = os.path.join(script_dir, library) + if os.path.isfile(absolute_library_path): + # If we find it, don't try any other filename + library_paths = (absolute_library_path,) + break + return library_paths + + @classmethod + def _get_library( + cls, + library_file: Optional[str] = None, + ) -> Tuple[Any, Any, str, Tuple[int, ...]]: + os_is_nt = os.name in ("nt", "dos", "os2", "ce") + if os_is_nt: + lib_type = ctypes.WinDLL # type: ignore + else: + lib_type = ctypes.CDLL + if library_file is None: + library_paths = cls._get_library_paths(os_is_nt) + else: + library_paths = (library_file,) + exceptions = [] + for library_path in library_paths: + try: + lib = lib_type(library_path) + cls._define_library_prototypes(lib) + # Without a handle, there might be problems when using concurrent threads + # https://github.com/sbraz/pymediainfo/issues/76#issuecomment-574759621 + handle = lib.MediaInfo_New() + version = lib.MediaInfo_Option(handle, "Info_Version", "") + match = re.search(r"^MediaInfoLib - v(\S+)", version) + if match: + lib_version_str = match.group(1) + lib_version = tuple(int(_) for _ in lib_version_str.split(".")) + else: + raise RuntimeError("Could not determine library version") + return (lib, handle, lib_version_str, lib_version) + except OSError as exc: + exceptions.append(str(exc)) + raise OSError( + "Failed to load library from {} - {}".format( + ", ".join(library_paths), ", ".join(exceptions) + ) + ) + + @classmethod + def can_parse(cls, library_file: Optional[str] = None) -> bool: + """ + Checks whether media files can be analyzed using libmediainfo. + + :param str library_file: path to the libmediainfo library, this should only be used if + the library cannot be auto-detected. + :rtype: bool + """ + try: + lib, handle = cls._get_library(library_file)[:2] + lib.MediaInfo_Close(handle) + lib.MediaInfo_Delete(handle) + return True + except Exception: # pylint: disable=broad-except + return False + + @classmethod + def parse( + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches, too-many-locals, too-many-arguments + cls, + filename: Any, + library_file: Optional[str] = None, + cover_data: bool = False, + encoding_errors: str = "strict", + parse_speed: float = 0.5, + full: bool = True, + legacy_stream_display: bool = False, + mediainfo_options: Optional[Dict[str, str]] = None, + output: Optional[str] = None, + ) -> Union[str, "MediaInfo"]: + """ + Analyze a media file using libmediainfo. + + .. note:: + Because of the way the underlying library works, this method should not + be called simultaneously from multiple threads *with different arguments*. + Doing so will cause inconsistencies or failures by changing + library options that are shared across threads. + + :param filename: path to the media file or file-like object which will be analyzed. + A URL can also be used if libmediainfo was compiled + with CURL support. + :param str library_file: path to the libmediainfo library, this should only be used if + the library cannot be auto-detected. + :param bool cover_data: whether to retrieve cover data as base64. + :param str encoding_errors: option to pass to :func:`str.encode`'s `errors` + parameter before parsing MediaInfo's XML output. + :param float parse_speed: passed to the library as `ParseSpeed`, + this option takes values between 0 and 1. + A higher value will yield more precise results in some cases + but will also increase parsing time. + :param bool full: display additional tags, including computer-readable values + for sizes and durations. + :param bool legacy_stream_display: display additional information about streams. + :param dict mediainfo_options: additional options that will be passed to the + `MediaInfo_Option` function, for example: ``{"Language": "raw"}``. + Do not use this parameter when running the method simultaneously from multiple threads, + it will trigger a reset of all options which will cause inconsistencies or failures. + :param str output: custom output format for MediaInfo, corresponds to the CLI's + ``--Output`` parameter. Setting this causes the method to + return a `str` instead of a :class:`MediaInfo` object. + + Useful values include: + * the empty `str` ``""`` (corresponds to the default + text output, obtained when running ``mediainfo`` with no + additional parameters) + + * ``"XML"`` + + * ``"JSON"`` + + * ``%``-delimited templates (see ``mediainfo --Info-Parameters``) + :type filename: str or pathlib.Path or os.PathLike or file-like object. + :rtype: str if `output` is set. + :rtype: :class:`MediaInfo` otherwise. + :raises FileNotFoundError: if passed a non-existent file. + :raises ValueError: if passed a file-like object opened in text mode. + :raises OSError: if the library file could not be loaded. + :raises RuntimeError: if parsing fails, this should not + happen unless libmediainfo itself fails. + + Examples: + >>> pymediainfo.MediaInfo.parse("tests/data/sample.mkv") + + + >>> import json + >>> mi = pymediainfo.MediaInfo.parse("tests/data/sample.mkv", + ... output="JSON") + >>> json.loads(mi)["media"]["track"][0] + {'@type': 'General', 'TextCount': '1', 'FileExtension': 'mkv', + 'FileSize': '5904', … } + + + """ + lib, handle, lib_version_str, lib_version = cls._get_library(library_file) + # The XML option was renamed starting with version 17.10 + if lib_version >= (17, 10): + xml_option = "OLDXML" + else: + xml_option = "XML" + # Cover_Data is not extracted by default since version 18.03 + # See https://github.com/MediaArea/MediaInfoLib/commit/d8fd88a1 + if lib_version >= (18, 3): + lib.MediaInfo_Option(handle, "Cover_Data", "base64" if cover_data else "") + lib.MediaInfo_Option(handle, "CharSet", "UTF-8") + lib.MediaInfo_Option(handle, "Inform", xml_option if output is None else output) + lib.MediaInfo_Option(handle, "Complete", "1" if full else "") + lib.MediaInfo_Option(handle, "ParseSpeed", str(parse_speed)) + lib.MediaInfo_Option(handle, "LegacyStreamDisplay", "1" if legacy_stream_display else "") + if mediainfo_options is not None: + if lib_version < (19, 9): + warnings.warn( + "This version of MediaInfo (v{}) does not support resetting all " + "options to their default values, passing it custom options is not recommended " + "and may result in unpredictable behavior, see " + "https://github.com/MediaArea/MediaInfoLib/issues/1128".format(lib_version_str), + RuntimeWarning, + ) + for option_name, option_value in mediainfo_options.items(): + lib.MediaInfo_Option(handle, option_name, option_value) + try: + filename.seek(0, 2) + file_size = filename.tell() + filename.seek(0) + except AttributeError: # filename is not a file-like object + file_size = None + + if file_size is not None: # We have a file-like object, use the buffer protocol: + # Some file-like objects do not have a mode + if "b" not in getattr(filename, "mode", "b"): + raise ValueError("File should be opened in binary mode") + lib.MediaInfo_Open_Buffer_Init(handle, file_size, 0) + while True: + buffer = filename.read(64 * 1024) + if buffer: + # https://github.com/MediaArea/MediaInfoLib/blob/v20.09/Source/MediaInfo/File__Analyze.h#L1429 + # 4th bit = finished + if lib.MediaInfo_Open_Buffer_Continue(handle, buffer, len(buffer)) & 0x08: + break + # Ask MediaInfo if we need to seek + seek = lib.MediaInfo_Open_Buffer_Continue_GoTo_Get(handle) + # https://github.com/MediaArea/MediaInfoLib/blob/v20.09/Source/MediaInfoDLL/MediaInfoJNI.cpp#L127 + if seek != ctypes.c_uint64(-1).value: + filename.seek(seek) + # Inform MediaInfo we have sought + lib.MediaInfo_Open_Buffer_Init(handle, file_size, filename.tell()) + else: + break + lib.MediaInfo_Open_Buffer_Finalize(handle) + else: # We have a filename, simply pass it: + filename = cls._normalize_filename(filename) + # If an error occured + if lib.MediaInfo_Open(handle, filename) == 0: + lib.MediaInfo_Close(handle) + lib.MediaInfo_Delete(handle) + # If filename doesn't look like a URL and doesn't exist + if "://" not in filename and not os.path.exists(filename): + raise FileNotFoundError(filename) + # We ran into another kind of error + raise RuntimeError( + "An error occured while opening {}" " with libmediainfo".format(filename) + ) + info: str = lib.MediaInfo_Inform(handle, 0) + # Reset all options to their defaults so that they aren't + # retained when the parse method is called several times + # https://github.com/MediaArea/MediaInfoLib/issues/1128 + # Do not call it when it is not required because it breaks threads + # https://github.com/sbraz/pymediainfo/issues/76#issuecomment-575245093 + if mediainfo_options is not None and lib_version >= (19, 9): + lib.MediaInfo_Option(handle, "Reset", "") + # Delete the handle + lib.MediaInfo_Close(handle) + lib.MediaInfo_Delete(handle) + if output is None: + return cls(info, encoding_errors) + return info + + def to_data(self) -> Dict[str, Any]: + """ + Returns a dict representation of the object's :py:class:`Tracks `. + + :rtype: dict + """ + return {"tracks": [_.to_data() for _ in self.tracks]} + + def to_json(self) -> str: + """ + Returns a JSON representation of the object's :py:class:`Tracks `. + + :rtype: str + """ + return json.dumps(self.to_data()) diff --git a/ext/readme.md b/ext/readme.md index eae18b1caf..a0a3d44fc3 100644 --- a/ext/readme.md +++ b/ext/readme.md @@ -28,7 +28,8 @@ ext | **`idna`** | [2.8](https://pypi.org/project/idna/2.8/) | `requests` | - ext | **`imdbpie`** | [5.6.4](https://pypi.org/project/imdbpie/5.6.4/) | **`medusa`** | - ext | `importlib-resources` | [5.4.0](https://pypi.org/project/importlib-resources/5.4.0/) | `guessit` | Module: `importlib_resources` ext | `jsonrpclib-pelix` | [0.4.2](https://pypi.org/project/jsonrpclib-pelix/0.4.2/) | **`medusa`** | Module: `jsonrpclib` / Used by guessit on python version < 3.9` -ext | **`knowit`** | [eea9ac1](https://github.com/ratoaq2/knowit/tree/eea9ac18e38c930230cf81b5dca4a9af9fb10d4e) | **`medusa`** | - +ext | **`knowit`** | [eea9ac1](https://github.com/ratoaq2/knowit/tree/0.4.0) | **`medusa`** | - +ext | **`pymediainfo`** | [5.1.0](https://pypi.org/project/pymediainfo/5.1.0/) | `knowit` | - ext | `Mako` | [1.1.4](https://pypi.org/project/Mako/1.1.4/) | **`medusa`** | Module: `mako` ext | `markdown2` | [2.4.2](https://pypi.org/project/markdown2/2.4.2/) | **`medusa`** | File: `markdown2.py` ext | `MarkupSafe` | [1.1.1](https://pypi.org/project/MarkupSafe/1.1.1/) | `Mako` | Module: `markupsafe` diff --git a/lib/pymediainfo/__init__.py b/lib/pymediainfo/__init__.py deleted file mode 100644 index fccd3b71cd..0000000000 --- a/lib/pymediainfo/__init__.py +++ /dev/null @@ -1,316 +0,0 @@ -# vim: set fileencoding=utf-8 : -import os -import re -import locale -import json -import sys -from pkg_resources import get_distribution, DistributionNotFound -import xml.etree.ElementTree as ET -from ctypes import * - -try: - import pathlib -except ImportError: - pathlib = None - -if sys.version_info < (3,): - import urlparse -else: - import urllib.parse as urlparse - -try: - __version__ = get_distribution("pymediainfo").version -except DistributionNotFound: - __version__ = '3.2.1' - pass - -class Track(object): - """ - An object associated with a media file track. - - Each :class:`Track` attribute corresponds to attributes parsed from MediaInfo's output. - All attributes are lower case. Attributes that are present several times such as Duration - yield a second attribute starting with `other_` which is a list of all alternative attribute values. - - When a non-existing attribute is accessed, `None` is returned. - - Example: - - >>> t = mi.tracks[0] - >>> t - - >>> t.duration - 3000 - >>> t.to_data()["other_duration"] - ['3 s 0 ms', '3 s 0 ms', '3 s 0 ms', - '00:00:03.000', '00:00:03.000'] - >>> type(t.non_existing) - NoneType - - All available attributes can be obtained by calling :func:`to_data`. - """ - def __getattribute__(self, name): - try: - return object.__getattribute__(self, name) - except: - pass - return None - def __init__(self, xml_dom_fragment): - self.xml_dom_fragment = xml_dom_fragment - self.track_type = xml_dom_fragment.attrib['type'] - for el in self.xml_dom_fragment: - node_name = el.tag.lower().strip().strip('_') - if node_name == 'id': - node_name = 'track_id' - node_value = el.text - other_node_name = "other_%s" % node_name - if getattr(self, node_name) is None: - setattr(self, node_name, node_value) - else: - if getattr(self, other_node_name) is None: - setattr(self, other_node_name, [node_value, ]) - else: - getattr(self, other_node_name).append(node_value) - - for o in [d for d in self.__dict__.keys() if d.startswith('other_')]: - try: - primary = o.replace('other_', '') - setattr(self, primary, int(getattr(self, primary))) - except: - for v in getattr(self, o): - try: - current = getattr(self, primary) - setattr(self, primary, int(v)) - getattr(self, o).append(current) - break - except: - pass - def __repr__(self): - return("".format(self.track_id, self.track_type)) - def to_data(self): - """ - Returns a dict representation of the track attributes. - - Example: - - >>> sorted(track.to_data().keys())[:3] - ['codec', 'codec_extensions_usually_used', 'codec_url'] - >>> t.to_data()["file_size"] - 5988 - - - :rtype: dict - """ - data = {} - for k, v in self.__dict__.items(): - if k != 'xml_dom_fragment': - data[k] = v - return data - - -class MediaInfo(object): - """ - An object containing information about a media file. - - - :class:`MediaInfo` objects can be created by directly calling code from - libmediainfo (in this case, the library must be present on the system): - - >>> pymediainfo.MediaInfo.parse("/path/to/file.mp4") - - Alternatively, objects may be created from MediaInfo's XML output. - Such output can be obtained using the ``XML`` output format on versions older than v17.10 - and the ``OLDXML`` format on newer versions. - - Using such an XML file, we can create a :class:`MediaInfo` object: - - >>> with open("output.xml") as f: - ... mi = pymediainfo.MediaInfo(f.read()) - - :param str xml: XML output obtained from MediaInfo. - :param str encoding_errors: option to pass to :func:`str.encode`'s `errors` - parameter before parsing `xml`. - :raises xml.etree.ElementTree.ParseError: if passed invalid XML (Python ≥ 2.7). - :raises xml.parsers.expat.ExpatError: if passed invalid XML (Python 2.6). - """ - def __init__(self, xml, encoding_errors="strict"): - self.xml_dom = MediaInfo._parse_xml_data_into_dom(xml, encoding_errors) - @staticmethod - def _parse_xml_data_into_dom(xml_data, encoding_errors="strict"): - return ET.fromstring(xml_data.encode("utf-8", encoding_errors)) - @staticmethod - def _get_library(library_file=None): - os_is_nt = os.name in ("nt", "dos", "os2", "ce") - if os_is_nt: - lib_type = WinDLL - else: - lib_type = CDLL - if library_file is None: - if os_is_nt: - library_names = ("MediaInfo.dll",) - elif sys.platform == "darwin": - library_names = ("libmediainfo.0.dylib", "libmediainfo.dylib") - else: - library_names = ("libmediainfo.so.0",) - script_dir = os.path.dirname(__file__) - # Look for the library file in the script folder - for library in library_names: - lib_path = os.path.join(script_dir, library) - if os.path.isfile(lib_path): - # If we find it, don't try any other filename - library_names = (lib_path,) - break - else: - library_names = (library_file,) - for i, library in enumerate(library_names, start=1): - try: - return lib_type(library) - except OSError: - # If we've tried all possible filenames - if i == len(library_names): - raise - @classmethod - def can_parse(cls, library_file=None): - """ - Checks whether media files can be analyzed using libmediainfo. - - :rtype: bool - """ - try: - cls._get_library(library_file) - return True - except: - return False - @classmethod - def parse(cls, filename, library_file=None, cover_data=False, - encoding_errors="strict", parse_speed=0.5): - """ - Analyze a media file using libmediainfo. - If libmediainfo is located in a non-standard location, the `library_file` parameter can be used: - - >>> pymediainfo.MediaInfo.parse("tests/data/sample.mkv", - ... library_file="/path/to/libmediainfo.dylib") - - :param filename: path to the media file which will be analyzed. - A URL can also be used if libmediainfo was compiled - with CURL support. - :param str library_file: path to the libmediainfo library, this should only be used if the library cannot be auto-detected. - :param bool cover_data: whether to retrieve cover data as base64. - :param str encoding_errors: option to pass to :func:`str.encode`'s `errors` - parameter before parsing MediaInfo's XML output. - :param float parse_speed: passed to the library as `ParseSpeed`, - this option takes values between 0 and 1. - A higher value will yield more precise results in some cases - but will also increase parsing time. - :type filename: str or pathlib.Path - :rtype: MediaInfo - :raises FileNotFoundError: if passed a non-existent file - (Python ≥ 3.3), does not work on Windows. - :raises IOError: if passed a non-existent file (Python < 3.3), - does not work on Windows. - :raises RuntimeError: if parsing fails, this should not - happen unless libmediainfo itself fails. - """ - lib = cls._get_library(library_file) - if pathlib is not None and isinstance(filename, pathlib.PurePath): - filename = str(filename) - url = False - else: - url = urlparse.urlparse(filename) - # Try to open the file (if it's not a URL) - # Doesn't work on Windows because paths are URLs - if not (url and url.scheme): - # Test whether the file is readable - with open(filename, "rb"): - pass - # Define arguments and return types - lib.MediaInfo_Inform.restype = c_wchar_p - lib.MediaInfo_New.argtypes = [] - lib.MediaInfo_New.restype = c_void_p - lib.MediaInfo_Option.argtypes = [c_void_p, c_wchar_p, c_wchar_p] - lib.MediaInfo_Option.restype = c_wchar_p - lib.MediaInfo_Inform.argtypes = [c_void_p, c_size_t] - lib.MediaInfo_Inform.restype = c_wchar_p - lib.MediaInfo_Open.argtypes = [c_void_p, c_wchar_p] - lib.MediaInfo_Open.restype = c_size_t - lib.MediaInfo_Delete.argtypes = [c_void_p] - lib.MediaInfo_Delete.restype = None - lib.MediaInfo_Close.argtypes = [c_void_p] - lib.MediaInfo_Close.restype = None - # Obtain the library version - lib_version = lib.MediaInfo_Option(None, "Info_Version", "") - lib_version = tuple(int(_) for _ in re.search("^MediaInfoLib - v(\\S+)", lib_version).group(1).split(".")) - # The XML option was renamed starting with version 17.10 - if lib_version >= (17, 10): - xml_option = "OLDXML" - else: - xml_option = "XML" - # Cover_Data is not extracted by default since version 18.03 - # See https://github.com/MediaArea/MediaInfoLib/commit/d8fd88a1c282d1c09388c55ee0b46029e7330690 - if cover_data and lib_version >= (18, 3): - lib.MediaInfo_Option(None, "Cover_Data", "base64") - # Create a MediaInfo handle - handle = lib.MediaInfo_New() - lib.MediaInfo_Option(handle, "CharSet", "UTF-8") - # Fix for https://github.com/sbraz/pymediainfo/issues/22 - # Python 2 does not change LC_CTYPE - # at startup: https://bugs.python.org/issue6203 - if (sys.version_info < (3,) and os.name == "posix" - and locale.getlocale() == (None, None)): - locale.setlocale(locale.LC_CTYPE, locale.getdefaultlocale()) - lib.MediaInfo_Option(None, "Inform", xml_option) - lib.MediaInfo_Option(None, "Complete", "1") - lib.MediaInfo_Option(None, "ParseSpeed", str(parse_speed)) - if lib.MediaInfo_Open(handle, filename) == 0: - raise RuntimeError("An error occured while opening {0}" - " with libmediainfo".format(filename)) - xml = lib.MediaInfo_Inform(handle, 0) - # Delete the handle - lib.MediaInfo_Close(handle) - lib.MediaInfo_Delete(handle) - return cls(xml, encoding_errors) - def _populate_tracks(self): - self._tracks = [] - iterator = "findall" if sys.version_info < (2, 7) else "iterfind" - # This is the case for libmediainfo < 18.03 - # https://github.com/sbraz/pymediainfo/issues/57 - # https://github.com/MediaArea/MediaInfoLib/commit/575a9a32e6960ea34adb3bc982c64edfa06e95eb - if self.xml_dom.tag == "File": - xpath = "track" - else: - xpath = "File/track" - for xml_track in getattr(self.xml_dom, iterator)(xpath): - self._tracks.append(Track(xml_track)) - @property - def tracks(self): - """ - A list of :py:class:`Track` objects which the media file contains. - - For instance: - - >>> mi = pymediainfo.MediaInfo.parse("/path/to/file.mp4") - >>> for t in mi.tracks: - ... print(t) - - - """ - if not hasattr(self, "_tracks"): - self._populate_tracks() - return self._tracks - def to_data(self): - """ - Returns a dict representation of the object's :py:class:`Tracks `. - - :rtype: dict - """ - data = {'tracks': []} - for track in self.tracks: - data['tracks'].append(track.to_data()) - return data - def to_json(self): - """ - Returns a JSON representation of the object's :py:class:`Tracks `. - - :rtype: str - """ - return json.dumps(self.to_data())