diff --git a/.gitignore b/.gitignore index 3a0d44172..e7310d676 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ cachefile.dbm .idea/* bazarr.pid /venv -/data \ No newline at end of file +/data + +# Allow +!*.dll \ No newline at end of file diff --git a/bazarr/config.py b/bazarr/config.py index 66515e5aa..0b823f5f0 100644 --- a/bazarr/config.py +++ b/bazarr/config.py @@ -17,6 +17,7 @@ defaults = { 'single_language': 'False', 'minimum_score': '90', 'use_scenename': 'True', + 'use_mediainfo': 'True', 'use_postprocessing': 'False', 'postprocessing_cmd': '', 'use_sonarr': 'False', diff --git a/bazarr/get_subtitle.py b/bazarr/get_subtitle.py index d0db3a752..632c51e1e 100644 --- a/bazarr/get_subtitle.py +++ b/bazarr/get_subtitle.py @@ -28,11 +28,12 @@ from config import settings from helper import path_replace, path_replace_movie, path_replace_reverse, \ path_replace_reverse_movie, pp_replace, get_target_folder, force_unicode from list_subtitles import store_subtitles, list_missing_subtitles, store_subtitles_movie, list_missing_subtitles_movies -from utils import history_log, history_log_movie +from utils import history_log, history_log_movie, get_binary from notifier import send_notifications, send_notifications_movie from get_providers import get_providers, get_providers_auth, provider_throttle, provider_pool from get_args import args from queueconfig import notifications +from pymediainfo import MediaInfo # configure the cache @@ -40,13 +41,14 @@ from queueconfig import notifications region.configure('dogpile.cache.memory') -def get_video(path, title, sceneName, use_scenename, providers=None, media_type="movie"): +def get_video(path, title, sceneName, use_scenename, use_mediainfo, providers=None, media_type="movie"): """ Construct `Video` instance :param path: path to video :param title: series/movie title :param sceneName: sceneName :param use_scenename: use sceneName + :param use_mediainfo: use media info to refine the video :param providers: provider list for selective hashing :param media_type: movie/series :return: `Video` instance @@ -69,6 +71,10 @@ def get_video(path, title, sceneName, use_scenename, providers=None, media_type= video.original_name = original_name video.original_path = original_path refine_from_db(original_path, video) + + if use_mediainfo: + refine_from_mediainfo(original_path, video) + logging.debug('BAZARR is using those video object properties: %s', vars(video)) return video @@ -144,6 +150,7 @@ def download_subtitle(path, language, hi, forced, providers, providers_auth, sce language_set.add(lang_obj) use_scenename = settings.general.getboolean('use_scenename') + use_mediainfo = settings.general.getboolean('use_mediainfo') minimum_score = settings.general.minimum_score minimum_score_movie = settings.general.minimum_score_movie use_postprocessing = settings.general.getboolean('use_postprocessing') @@ -160,7 +167,7 @@ def download_subtitle(path, language, hi, forced, providers, providers_auth, sce language_hook=None """ - video = get_video(path, title, sceneName, use_scenename, providers=providers, media_type=media_type) + video = get_video(path, title, sceneName, use_scenename, use_mediainfo, providers=providers, media_type=media_type) if video: min_score, max_score, scores = get_scores(video, media_type, min_score_movie_perc=int(minimum_score_movie), min_score_series_perc=int(minimum_score)) @@ -307,12 +314,13 @@ def manual_search(path, language, hi, forced, providers, providers_auth, sceneNa language_set.add(lang_obj) use_scenename = settings.general.getboolean('use_scenename') + use_mediainfo = settings.general.getboolean('use_mediainfo') minimum_score = settings.general.minimum_score minimum_score_movie = settings.general.minimum_score_movie use_postprocessing = settings.general.getboolean('use_postprocessing') postprocessing_cmd = settings.general.postprocessing_cmd - video = get_video(path, title, sceneName, use_scenename, providers=providers, media_type=media_type) + video = get_video(path, title, sceneName, use_scenename, use_mediainfo, providers=providers, media_type=media_type) if video: min_score, max_score, scores = get_scores(video, media_type, min_score_movie_perc=int(minimum_score_movie), min_score_series_perc=int(minimum_score)) @@ -376,10 +384,11 @@ def manual_download_subtitle(path, language, hi, forced, subtitle, provider, pro subtitle = pickle.loads(codecs.decode(subtitle.encode(), "base64")) use_scenename = settings.general.getboolean('use_scenename') + use_mediainfo = settings.general.getboolean('use_mediainfo') use_postprocessing = settings.general.getboolean('use_postprocessing') postprocessing_cmd = settings.general.postprocessing_cmd single = settings.general.getboolean('single_language') - video = get_video(path, title, sceneName, use_scenename, providers={provider}, media_type=media_type) + video = get_video(path, title, sceneName, use_scenename, use_mediainfo, providers={provider}, media_type=media_type) if video: min_score, max_score, scores = get_scores(video, media_type) try: @@ -825,6 +834,31 @@ def refine_from_db(path, video): return video +def refine_from_mediainfo(path, video): + if video.fps: + return + + exe = get_binary('mediainfo') + if not exe: + logging.debug('BAZARR MediaInfo library not found!') + return + + media_info = MediaInfo.parse(path, library_file=exe); + + video_track = next((t for t in media_info.tracks if t.track_type == 'Video'), None) + if not video_track: + logging.debug('BAZARR MediaInfo was unable to find video tracks in the file!') + return + + logging.debug('MediaInfo found: %s', video_track.to_data()) + + if not video.fps: + if video_track.frame_rate: + video.fps = float(video_track.frame_rate) + elif video_track.framerate_num and video_track.framerate_den: + video.fps = round(float(video_track.framerate_num) / float(video_track.framerate_den), 3) + + def upgrade_subtitles(): days_to_upgrade_subs = settings.general.days_to_upgrade_subs minimum_timestamp = ((datetime.now() - timedelta(days=int(days_to_upgrade_subs))) - diff --git a/bazarr/main.py b/bazarr/main.py index 4b73915ee..f112acf1e 100644 --- a/bazarr/main.py +++ b/bazarr/main.py @@ -1254,6 +1254,11 @@ def save_settings(): settings_general_scenename = 'False' else: settings_general_scenename = 'True' + settings_general_mediainfo = request.forms.get('settings_general_mediainfo') + if settings_general_mediainfo is None: + settings_general_mediainfo = 'False' + else: + settings_general_mediainfo = 'True' settings_general_embedded = request.forms.get('settings_general_embedded') if settings_general_embedded is None: settings_general_embedded = 'False' @@ -1336,6 +1341,7 @@ def save_settings(): settings.general.single_language = text_type(settings_general_single_language) settings.general.minimum_score = text_type(settings_general_minimum_score) settings.general.use_scenename = text_type(settings_general_scenename) + settings.general.use_mediainfo = text_type(settings_general_mediainfo) settings.general.use_postprocessing = text_type(settings_general_use_postprocessing) settings.general.postprocessing_cmd = text_type(settings_general_postprocessing_cmd) settings.general.use_sonarr = text_type(settings_general_use_sonarr) diff --git a/bazarr/utils.py b/bazarr/utils.py index f92726454..9fa4d016f 100644 --- a/bazarr/utils.py +++ b/bazarr/utils.py @@ -54,6 +54,8 @@ def get_binary(name): else: if platform.system() == "Windows": # Windows exe = os.path.abspath(os.path.join(binaries_dir, "Windows", "i386", name, "%s.exe" % name)) + if exe and not os.path.isfile(exe): + exe = os.path.abspath(os.path.join(binaries_dir, "Windows", "i386", name, "%s.dll" % name)) elif platform.system() == "Darwin": # MacOSX exe = os.path.abspath(os.path.join(binaries_dir, "MacOSX", "i386", name, name)) diff --git a/bin/Linux/aarch64/MediaInfo/mediainfo b/bin/Linux/aarch64/MediaInfo/mediainfo new file mode 100644 index 000000000..60308384d Binary files /dev/null and b/bin/Linux/aarch64/MediaInfo/mediainfo differ diff --git a/bin/Linux/armv7l/MediaInfo/mediainfo b/bin/Linux/armv7l/MediaInfo/mediainfo new file mode 100644 index 000000000..2cacc30a2 Binary files /dev/null and b/bin/Linux/armv7l/MediaInfo/mediainfo differ diff --git a/bin/Linux/i386/MediaInfo/mediainfo b/bin/Linux/i386/MediaInfo/mediainfo new file mode 100644 index 000000000..e72e00188 Binary files /dev/null and b/bin/Linux/i386/MediaInfo/mediainfo differ diff --git a/bin/Linux/x86_64/MediaInfo/mediainfo b/bin/Linux/x86_64/MediaInfo/mediainfo new file mode 100644 index 000000000..f10405957 Binary files /dev/null and b/bin/Linux/x86_64/MediaInfo/mediainfo differ diff --git a/bin/MacOSX/i386/MediaInfo/mediainfo b/bin/MacOSX/i386/MediaInfo/mediainfo new file mode 100644 index 000000000..004270a41 Binary files /dev/null and b/bin/MacOSX/i386/MediaInfo/mediainfo differ diff --git a/bin/Windows/i386/MediaInfo/mediainfo.dll b/bin/Windows/i386/MediaInfo/mediainfo.dll new file mode 100644 index 000000000..df50d652b Binary files /dev/null and b/bin/Windows/i386/MediaInfo/mediainfo.dll differ diff --git a/libs/pymediainfo/AUTHORS b/libs/pymediainfo/AUTHORS new file mode 100644 index 000000000..d3b460d4d --- /dev/null +++ b/libs/pymediainfo/AUTHORS @@ -0,0 +1,3 @@ +Patrick Altman (author) +cjlucas https://github.com/cjlucas +Louis Sautier (maintainer since 2016) diff --git a/libs/pymediainfo/LICENSE b/libs/pymediainfo/LICENSE new file mode 100644 index 000000000..1b517762e --- /dev/null +++ b/libs/pymediainfo/LICENSE @@ -0,0 +1,24 @@ +The MIT License + +Copyright (c) 2010-2014, Patrick Altman +Copyright (c) 2016, Louis Sautier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +http://www.opensource.org/licenses/mit-license.php diff --git a/libs/pymediainfo/README.rst b/libs/pymediainfo/README.rst new file mode 100644 index 000000000..bced11fba --- /dev/null +++ b/libs/pymediainfo/README.rst @@ -0,0 +1,27 @@ +pymediainfo +----------- + +.. image:: https://img.shields.io/pypi/v/pymediainfo.svg + :target: https://pypi.org/project/pymediainfo + +.. image:: https://img.shields.io/pypi/pyversions/pymediainfo.svg + :target: https://pypi.org/project/pymediainfo + +.. image:: https://repology.org/badge/tiny-repos/python:pymediainfo.svg + :target: https://repology.org/metapackage/python:pymediainfo + +.. image:: https://img.shields.io/pypi/implementation/pymediainfo.svg + :target: https://pypi.org/project/pymediainfo + +.. image:: https://api.travis-ci.org/sbraz/pymediainfo.svg?branch=master + :target: https://travis-ci.org/sbraz/pymediainfo + +.. image:: https://ci.appveyor.com/api/projects/status/g15a2daem1oub57n/branch/master?svg=true + :target: https://ci.appveyor.com/project/sbraz/pymediainfo + + +This small package is a wrapper around the MediaInfo library. + +It works on Linux, Mac OS X and Windows and is tested with Python 2.7, 3.4, 3.5, 3.6, 3.7, PyPy and PyPy3. + +See https://pymediainfo.readthedocs.io/ for more information. diff --git a/libs/pymediainfo/__init__.py b/libs/pymediainfo/__init__.py new file mode 100644 index 000000000..c3b9875ed --- /dev/null +++ b/libs/pymediainfo/__init__.py @@ -0,0 +1,320 @@ +# vim: set fileencoding=utf-8 : +import os +import re +import locale +import json +import ctypes +import sys +from pkg_resources import get_distribution, DistributionNotFound +import xml.etree.ElementTree as ET + +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: + 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 __eq__(self, other): + return self.__dict__ == other.__dict__ + def __getattribute__(self, name): + try: + return object.__getattribute__(self, name) + except: + pass + return None + def __getstate__(self): + return self.__dict__ + def __setstate__(self, state): + self.__dict__ = state + def __init__(self, xml_dom_fragment): + self.track_type = xml_dom_fragment.attrib['type'] + for el in 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. + :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): + return self.tracks == other.tracks + def __init__(self, xml, encoding_errors="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)) + @staticmethod + def _get_library(library_file=None): + os_is_nt = os.name in ("nt", "dos", "os2", "ce") + if os_is_nt: + lib_type = ctypes.WinDLL + else: + lib_type = ctypes.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: + lib = lib_type(library) + # Define arguments and return types + 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_Delete.argtypes = [ctypes.c_void_p] + lib.MediaInfo_Delete.restype = None + lib.MediaInfo_Close.argtypes = [ctypes.c_void_p] + lib.MediaInfo_Close.restype = None + return lib + 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, text=False, + full=True, legacy_stream_display=False): + """ + 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. + :param bool text: if ``True``, MediaInfo's text output will be returned instead + of a :class:`MediaInfo` object. + :param bool full: display additional tags, including computer-readable values + for sizes and durations. + :param bool legacy_stream_display: display additional information about streams. + :type filename: str or pathlib.Path + :rtype: str if `text` is ``True``. + :rtype: :class:`MediaInfo` otherwise. + :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 + # 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", "" if text else xml_option) + lib.MediaInfo_Option(None, "Complete", "1" if full else "") + lib.MediaInfo_Option(None, "ParseSpeed", str(parse_speed)) + lib.MediaInfo_Option(None, "LegacyStreamDisplay", "1" if legacy_stream_display else "") + if lib.MediaInfo_Open(handle, filename) == 0: + raise RuntimeError("An eror occured while opening {}" + " with libmediainfo".format(filename)) + output = lib.MediaInfo_Inform(handle, 0) + # Delete the handle + lib.MediaInfo_Close(handle) + lib.MediaInfo_Delete(handle) + if text: + return output + else: + return cls(output, encoding_errors) + 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()) diff --git a/views/settings_subtitles.tpl b/views/settings_subtitles.tpl index 7899e7458..f1c48f32d 100644 --- a/views/settings_subtitles.tpl +++ b/views/settings_subtitles.tpl @@ -20,6 +20,25 @@ +
+
+ +
+
+
+ + +
+
+ +
+
@@ -552,6 +571,12 @@ $("#settings_scenename").checkbox('uncheck'); } + if ($('#settings_mediainfo').data("mediainfo") === "True") { + $("#settings_mediainfo").checkbox('check'); + } else { + $("#settings_mediainfo").checkbox('uncheck'); + } + if ($('#settings_upgrade_subs').data("upgrade") === "True") { $("#settings_upgrade_subs").checkbox('check'); } else {