diff --git a/bazarr/config.py b/bazarr/config.py index 4817cb65f..eaf765dae 100644 --- a/bazarr/config.py +++ b/bazarr/config.py @@ -17,7 +17,6 @@ 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/embedded_subs_reader.py b/bazarr/embedded_subs_reader.py index 835f577f5..df9625d4c 100644 --- a/bazarr/embedded_subs_reader.py +++ b/bazarr/embedded_subs_reader.py @@ -4,8 +4,8 @@ import os import subprocess import locale -from config import settings from utils import get_binary +from pyprobe.pyprobe import VideoFileParser class NotMKVAndNoFFprobe(Exception): pass @@ -20,24 +20,22 @@ class EmbeddedSubsReader: def list_languages(self, file): subtitles_list = [] - if os.path.splitext(file)[1] == '.mkv': - with open(file, 'rb') as f: - mkv = enzyme.MKV(f) - for subtitle_track in mkv.subtitle_tracks: - subtitles_list.append([subtitle_track.language, subtitle_track.forced]) + if self.ffprobe: + parser = VideoFileParser(ffprobe=self.ffprobe, includeMissing=True, rawMode=False) + data = parser.parseFfprobe(file) + + detected_languages = [] + + for detected_language in data['subtitles']: + subtitles_list.append([detected_language['language'], detected_language['forced']]) else: - if self.ffprobe: - detected_languages = [] - try: - detected_languages = subprocess.check_output([self.ffprobe, "-loglevel", "error", "-select_streams", "s", "-show_entries", "stream_tags=language", "-of", "csv=p=0", file.encode(locale.getpreferredencoding())], universal_newlines=True, stderr=subprocess.STDOUT).strip().split("\n") - except subprocess.CalledProcessError as e: - raise FFprobeError(e.output) - else: - for detected_language in detected_languages: - subtitles_list.append([detected_language, False]) - # I can't get the forced flag from ffprobe so I always assume it isn't forced + if os.path.splitext(file)[1] == '.mkv': + with open(file, 'rb') as f: + mkv = enzyme.MKV(f) + for subtitle_track in mkv.subtitle_tracks: + subtitles_list.append([subtitle_track.language, subtitle_track.forced]) return subtitles_list -embedded_subs_reader = EmbeddedSubsReader() \ No newline at end of file +embedded_subs_reader = EmbeddedSubsReader() diff --git a/bazarr/get_subtitle.py b/bazarr/get_subtitle.py index f72314cdc..c4bf7baf5 100644 --- a/bazarr/get_subtitle.py +++ b/bazarr/get_subtitle.py @@ -19,7 +19,7 @@ from subzero.video import parse_video from subliminal import region, score as subliminal_scores, \ list_subtitles, Episode, Movie from subliminal_patch.core import SZAsyncProviderPool, download_best_subtitles, save_subtitles, download_subtitles, \ - list_all_subtitles + list_all_subtitles, get_subtitle_path from subliminal_patch.score import compute_score from subliminal.refiners.tvdb import series_re from get_languages import language_from_alpha3, alpha2_from_alpha3, alpha3_from_alpha2, language_from_alpha2 @@ -32,19 +32,18 @@ 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 +from pyprobe.pyprobe import VideoFileParser from database import TableShows, TableEpisodes, TableMovies, TableHistory, TableHistoryMovie from peewee import fn, JOIN -def get_video(path, title, sceneName, use_scenename, use_mediainfo, providers=None, media_type="movie"): +def get_video(path, title, sceneName, use_scenename, 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 @@ -66,10 +65,9 @@ def get_video(path, title, sceneName, use_scenename, use_mediainfo, providers=No video.used_scene_name = used_scene_name video.original_name = original_name video.original_path = original_path + refine_from_db(original_path, video) - - if platform.system() != "Linux" and use_mediainfo: - refine_from_mediainfo(original_path, video) + refine_from_ffprobe(original_path, video) logging.debug('BAZARR is using those video object properties: %s', vars(video)) return video @@ -143,7 +141,6 @@ 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') @@ -159,7 +156,7 @@ def download_subtitle(path, language, hi, forced, providers, providers_auth, sce post_download_hook=None, language_hook=None """ - video = get_video(force_unicode(path), title, sceneName, use_scenename, use_mediainfo, providers=providers, + video = get_video(force_unicode(path), title, sceneName, use_scenename, 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), @@ -282,7 +279,8 @@ def manual_search(path, language, hi, forced, providers, providers_auth, sceneNa logging.debug('BAZARR Manually searching subtitles for this file: ' + path) final_subtitles = [] - + + initial_hi = True if hi == "True" else False if hi == "True": hi = "force HI" else: @@ -311,13 +309,12 @@ 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 if providers: - video = get_video(force_unicode(path), title, sceneName, use_scenename, use_mediainfo, providers=providers, + video = get_video(force_unicode(path), title, sceneName, use_scenename, providers=providers, media_type=media_type) else: logging.info("BAZARR All providers are throttled") @@ -357,8 +354,11 @@ def manual_search(path, language, hi, forced, providers, providers_auth, sceneNa if can_verify_series and not {"series", "season", "episode"}.issubset(matches): logging.debug(u"BAZARR Skipping %s, because it doesn't match our series/episode", s) continue - - score = compute_score(matches, s, video, hearing_impaired=hi) + + if s.hearing_impaired == initial_hi: + matches.add('hearing_impaired') + + score = compute_score(matches, s, video, hearing_impaired=initial_hi) not_matched = scores - matches s.score = score @@ -389,11 +389,10 @@ 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(force_unicode(path), title, sceneName, use_scenename, use_mediainfo, providers={provider}, + video = get_video(force_unicode(path), title, sceneName, use_scenename, providers={provider}, media_type=media_type) if video: min_score, max_score, scores = get_scores(video, media_type) @@ -494,6 +493,51 @@ def manual_download_subtitle(path, language, hi, forced, subtitle, provider, pro logging.debug('BAZARR Ended manually downloading subtitles for file: ' + path) +def manual_upload_subtitle(path, language, forced, title, scene_name, media_type, subtitle): + logging.debug('BAZARR Manually uploading subtitles for this file: ' + path) + + single = settings.general.getboolean('single_language') + + chmod = int(settings.general.chmod, 8) if not sys.platform.startswith( + 'win') and settings.general.getboolean('chmod_enabled') else None + + _, ext = os.path.splitext(subtitle.filename) + + language = alpha3_from_alpha2(language) + + if language == 'pob': + lang_obj = Language('por', 'BR') + else: + lang_obj = Language(language) + + if forced: + lang_obj = Language.rebuild(lang_obj, forced=True) + + subtitle_path = get_subtitle_path(video_path=force_unicode(path), + language=None if single else lang_obj, + extension=ext, + forced_tag=forced) + + subtitle_path = force_unicode(subtitle_path) + + if os.path.exists(subtitle_path): + os.remove(subtitle_path) + + subtitle.save(subtitle_path) + + if chmod: + os.chmod(subtitle_path, chmod) + + message = language_from_alpha3(language) + (" forced" if forced else "") + " subtitles manually uploaded." + + if media_type == 'series': + reversed_path = path_replace_reverse(path) + else: + reversed_path = path_replace_reverse_movie(path) + + return message, reversed_path + + def series_download_subtitles(no): episodes_details_clause = [ (TableEpisodes.sonarr_series_id == no), @@ -962,31 +1006,42 @@ def refine_from_db(path, video): return video -def refine_from_mediainfo(path, video): - if video.fps: - return - - exe = get_binary('mediainfo') +def refine_from_ffprobe(path, video): + exe = get_binary('ffprobe') if not exe: - logging.debug('BAZARR MediaInfo library not found!') + logging.debug('BAZARR FFprobe not found!') return else: - logging.debug('BAZARR MediaInfo library used is %s', exe) + logging.debug('BAZARR FFprobe used is %s', exe) - 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) + parser = VideoFileParser(ffprobe=exe, includeMissing=True, rawMode=False) + data = parser.parseFfprobe(path) + + logging.debug('FFprobe found: %s', data) + + if 'videos' not in data: + logging.debug('BAZARR FFprobe was unable to find video tracks in the file!') + else: + if 'resolution' in data['videos'][0]: + if not video.resolution: + if data['videos'][0]['resolution'][0] >= 3200: + video.resolution = "2160p" + elif data['videos'][0]['resolution'][0] >= 1800: + video.resolution = "1080p" + elif data['videos'][0]['resolution'][0] >= 1200: + video.resolution = "720p" + elif data['videos'][0]['resolution'][0] >= 0: + video.resolution = "480p" + if 'codec' in data['videos'][0]: + if not video.video_codec: + video.video_codec = data['videos'][0]['codec'] + + if 'audios' not in data: + logging.debug('BAZARR FFprobe was unable to find audio tracks in the file!') + else: + if 'codec' in data['audios'][0]: + if not video.audio_codec: + video.audio_codec = data['audios'][0]['codec'].upper() def upgrade_subtitles(): @@ -998,7 +1053,7 @@ def upgrade_subtitles(): query_actions = [1, 2, 3] else: query_actions = [1, 3] - + if settings.general.getboolean('use_sonarr'): upgradable_episodes = TableHistory.select( TableHistory.video_path, @@ -1039,7 +1094,7 @@ def upgrade_subtitles(): for episode in upgradable_episodes_not_perfect: if os.path.exists(path_replace(episode['video_path'])) and int(episode['score']) < 357: episodes_to_upgrade.append(episode) - + if settings.general.getboolean('use_radarr'): upgradable_movies = TableHistoryMovie.select( TableHistoryMovie.video_path, @@ -1055,7 +1110,7 @@ def upgrade_subtitles(): ).join_from( TableHistoryMovie, TableMovies, JOIN.LEFT_OUTER ).where( - (TableHistoryMovie.action.in_(query_actions)) & + (TableHistoryMovie.action.in_(query_actions)) & (TableHistoryMovie.score.is_null(False)) ).group_by( TableHistoryMovie.video_path, diff --git a/bazarr/main.py b/bazarr/main.py index cfa14f71c..03d4f6616 100644 --- a/bazarr/main.py +++ b/bazarr/main.py @@ -47,12 +47,13 @@ from get_episodes import * from list_subtitles import store_subtitles, store_subtitles_movie, series_scan_subtitles, movies_scan_subtitles, \ list_missing_subtitles, list_missing_subtitles_movies from get_subtitle import download_subtitle, series_download_subtitles, movies_download_subtitles, \ - manual_search, manual_download_subtitle + manual_search, manual_download_subtitle, manual_upload_subtitle from utils import history_log, history_log_movie from scheduler import * from notifier import send_notifications, send_notifications_movie from config import settings, url_sonarr, url_radarr, url_radarr_short, url_sonarr_short, base_url from subliminal_patch.extensions import provider_registry as provider_manager +from subliminal_patch.core import SUBTITLE_EXTENSIONS reload(sys) sys.setdefaultencoding('utf8') @@ -232,7 +233,7 @@ def wizard(): @custom_auth_basic(check_credentials) def save_wizard(): authorize() - + settings_general_ip = request.forms.get('settings_general_ip') settings_general_port = request.forms.get('settings_general_port') settings_general_baseurl = request.forms.get('settings_general_baseurl') @@ -441,7 +442,7 @@ def save_wizard(): with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle: settings.write(handle) - + configured() redirect(base_url) @@ -747,7 +748,7 @@ def edit_serieseditor(): lang = request.forms.getall('languages') hi = request.forms.get('hearing_impaired') forced = request.forms.get('forced') - + for serie in series: if str(lang) != "[]" and str(lang) != "['']": if str(lang) == "['None']": @@ -936,7 +937,7 @@ def edit_movieseditor(): lang = request.forms.getall('languages') hi = request.forms.get('hearing_impaired') forced = request.forms.get('forced') - + for movie in movies: if str(lang) != "[]" and str(lang) != "['']": if str(lang) == "['None']": @@ -1012,7 +1013,7 @@ def edit_movie(no): ).where( TableMovies.radarr_id % no ).execute() - + list_missing_subtitles_movies(no) redirect(ref) @@ -1185,7 +1186,7 @@ def historyseries(): fn.MAX(TableHistory.timestamp).alias('timestamp'), TableHistory.score ).where( - (TableHistory.action.in_(query_actions)) & + (TableHistory.action.in_(query_actions)) & (TableHistory.score.is_null(False)) ).group_by( TableHistory.video_path, @@ -1276,7 +1277,7 @@ def historymovies(): fn.MAX(TableHistoryMovie.timestamp).alias('timestamp'), TableHistoryMovie.score ).where( - (TableHistoryMovie.action.in_(query_actions)) & + (TableHistoryMovie.action.in_(query_actions)) & (TableHistoryMovie.score.is_null(False)) ).group_by( TableHistoryMovie.video_path, @@ -1292,7 +1293,7 @@ def historymovies(): else: if int(upgradable_movie['score']) < 120: upgradable_movies_not_perfect.append(tuple(upgradable_movie.values())) - + return template('historymovies', bazarr_version=bazarr_version, rows=data, row_count=row_count, page=page, max_page=max_page, stats=stats, base_url=base_url, page_size=page_size, current_port=settings.general.port, upgradable_movies=upgradable_movies_not_perfect) @@ -1429,7 +1430,7 @@ def _settings(): def save_settings(): authorize() ref = request.environ['HTTP_REFERER'] - + settings_general_ip = request.forms.get('settings_general_ip') settings_general_port = request.forms.get('settings_general_port') settings_general_baseurl = request.forms.get('settings_general_baseurl') @@ -1476,11 +1477,6 @@ 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' @@ -1563,7 +1559,6 @@ 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) @@ -2147,6 +2142,51 @@ def manual_get_subtitle(): pass +@route(base_url + 'manual_upload_subtitle', method='POST') +@custom_auth_basic(check_credentials) +def perform_manual_upload_subtitle(): + authorize() + ref = request.environ['HTTP_REFERER'] + + episodePath = request.forms.get('episodePath') + sceneName = request.forms.get('sceneName') + language = request.forms.get('language') + forced = True if request.forms.get('forced') == '1' else False + upload = request.files.get('upload') + sonarrSeriesId = request.forms.get('sonarrSeriesId') + sonarrEpisodeId = request.forms.get('sonarrEpisodeId') + title = request.forms.get('title') + + _, ext = os.path.splitext(upload.filename) + + if ext not in SUBTITLE_EXTENSIONS: + raise ValueError('A subtitle of an invalid format was uploaded.') + + try: + result = manual_upload_subtitle(path=episodePath, + language=language, + forced=forced, + title=title, + scene_name=sceneName, + media_type='series', + subtitle=upload) + + if result is not None: + message = result[0] + path = result[1] + language_code = language + ":forced" if forced else language + provider = "manual" + score = 360 + history_log(4, sonarrSeriesId, sonarrEpisodeId, message, path, language_code, provider, score) + send_notifications(sonarrSeriesId, sonarrEpisodeId, message) + store_subtitles(unicode(episodePath)) + list_missing_subtitles(sonarrSeriesId) + + redirect(ref) + except OSError: + pass + + @route(base_url + 'get_subtitle_movie', method='POST') @custom_auth_basic(check_credentials) def get_subtitle_movie(): @@ -2239,6 +2279,50 @@ def manual_get_subtitle_movie(): pass +@route(base_url + 'manual_upload_subtitle_movie', method='POST') +@custom_auth_basic(check_credentials) +def perform_manual_upload_subtitle_movie(): + authorize() + ref = request.environ['HTTP_REFERER'] + + moviePath = request.forms.get('moviePath') + sceneName = request.forms.get('sceneName') + language = request.forms.get('language') + forced = True if request.forms.get('forced') == '1' else False + upload = request.files.get('upload') + radarrId = request.forms.get('radarrId') + title = request.forms.get('title') + + _, ext = os.path.splitext(upload.filename) + + if ext not in SUBTITLE_EXTENSIONS: + raise ValueError('A subtitle of an invalid format was uploaded.') + + try: + result = manual_upload_subtitle(path=moviePath, + language=language, + forced=forced, + title=title, + scene_name=sceneName, + media_type='series', + subtitle=upload) + + if result is not None: + message = result[0] + path = result[1] + language_code = language + ":forced" if forced else language + provider = "manual" + score = 120 + history_log_movie(4, radarrId, message, path, language_code, provider, score) + send_notifications_movie(radarrId, message) + store_subtitles_movie(unicode(moviePath)) + list_missing_subtitles_movies(radarrId) + + redirect(ref) + except OSError: + pass + + def configured(): System.update({System.configured: 1}).execute() diff --git a/bazarr/utils.py b/bazarr/utils.py index 5362d13a0..dee9c11b2 100644 --- a/bazarr/utils.py +++ b/bazarr/utils.py @@ -52,19 +52,13 @@ def get_binary(name): binaries_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'bin')) exe = None - if name != 'mediainfo': - installed_exe = which(name) + installed_exe = which(name) - if name != 'mediainfo' and installed_exe and os.path.isfile(installed_exe): + if installed_exe and os.path.isfile(installed_exe): return installed_exe 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): - if sys.maxsize > 2**32: # is 64bits Python - exe = os.path.abspath(os.path.join(binaries_dir, "Windows", "x86_64", name, "%s.dll" % name)) - else: # is 32bits Python - 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/MacOSX/i386/MediaInfo/mediainfo b/bin/MacOSX/i386/MediaInfo/mediainfo deleted file mode 100644 index 004270a41..000000000 Binary files a/bin/MacOSX/i386/MediaInfo/mediainfo and /dev/null differ diff --git a/bin/Windows/i386/MediaInfo/mediainfo.dll b/bin/Windows/i386/MediaInfo/mediainfo.dll deleted file mode 100644 index df50d652b..000000000 Binary files a/bin/Windows/i386/MediaInfo/mediainfo.dll and /dev/null differ diff --git a/bin/Windows/x86_64/MediaInfo/mediainfo.dll b/bin/Windows/x86_64/MediaInfo/mediainfo.dll deleted file mode 100644 index c2d9b1e38..000000000 Binary files a/bin/Windows/x86_64/MediaInfo/mediainfo.dll and /dev/null differ diff --git a/libs/pyprobe/__init__.py b/libs/pyprobe/__init__.py new file mode 100644 index 000000000..b8a1da01a --- /dev/null +++ b/libs/pyprobe/__init__.py @@ -0,0 +1,2 @@ + +from pyprobe import VideoFileParser diff --git a/libs/pyprobe/baseparser.py b/libs/pyprobe/baseparser.py new file mode 100644 index 000000000..83240cdc4 --- /dev/null +++ b/libs/pyprobe/baseparser.py @@ -0,0 +1,41 @@ + +class BaseParser: + @classmethod + def parse(cls, data, rawMode, includeMissing): + """Core of the parser classes + + Collects all methods prefixed with "value_" and builds a dict of + their return values. Parser classes will inherit from this class. + All methods that begin with "value_" in a parser class will be given + the same `data` argument and are expected to pull their corresponding + value from the collection. + + These methods return a tuple - their raw value and formatted value. + The raw value is a string or tuple of string and the formatted value + be of type string, int, float, or tuple. + + If no data is found in a method, the raw value is expected to be None, + and for the formatted value, strings will be "null", ints will be 0, + floats will be 0.0. + + Args: + data (dict): Raw video data + rawMode (bool): Returns raw values instead of formatted values + includeMissing (bool): If value is missing, return "empty" value + + Returns: + dict>: Parsed data from class methods, may not have every value. + + """ + parsers = [getattr(cls, p) for p in dir(cls) if p.startswith("value_")] + info = {} + for parser in parsers: + parsed_raw, parsed_formatted = parser(data) + if parsed_raw == None and not includeMissing: + continue + name = parser.__name__[6:] + if rawMode: + info[name] = parsed_raw + else: + info[name] = parsed_formatted + return info diff --git a/libs/pyprobe/ffprobeparsers.py b/libs/pyprobe/ffprobeparsers.py new file mode 100644 index 000000000..36a395c53 --- /dev/null +++ b/libs/pyprobe/ffprobeparsers.py @@ -0,0 +1,215 @@ +from os import path + +from baseparser import BaseParser + + +class StreamParser(BaseParser): + @staticmethod + def value_codec(data): + """Returns a string""" + info = data.get("codec_name", None) + return info, (info or "null") + + @staticmethod + def value_format(data): + """Returns a string""" + info = data.get("format_name", None) + return info, (info or "null") + + @staticmethod + def value_bit_rate(data): + """Returns an int""" + info = data.get("bit_rate", None) + try: + return info, int(float(info)) + except (ValueError, TypeError): + return info, 0 + + +class VideoStreamParser(BaseParser): + @staticmethod + def value_codec(data): + return StreamParser.value_codec(data) + + @staticmethod + def value_format(data): + return StreamParser.value_format(data) + + @staticmethod + def value_bit_rate(data): + return StreamParser.value_bit_rate(data) + + @staticmethod + def value_resolution(data): + """Returns a tuple (width, height)""" + width = data.get("width", None) + height = data.get("height", None) + if width is None and height is None: + return None, (0, 0) + try: + return (width, height), (int(float(width)), int(float(height))) + except (ValueError, TypeError): + return (width, height), (0, 0) + + @staticmethod + def average_framerate(data): + """Returns an int""" + frames = data.get("nb_frames", None) + duration = data.get("duration", None) + try: + return float(frames) / float(duration) + except (ValueError, TypeError, ZeroDivisionError): + return 0.0 + + @classmethod + def value_framerate(cls, data): + """Returns a float""" + input_str = data.get("avg_frame_rate", None) + try: + num, den = input_str.split("/") + return input_str, round(float(num) / float(den), 3) + except (ValueError, ZeroDivisionError, AttributeError): + info = cls.average_framerate(data) + return input_str, info + + @staticmethod + def value_aspect_ratio(data): + """Returns a string""" + info = data.get("display_aspect_ratio", None) + return info, (info or "null") + + @staticmethod + def value_pixel_format(data): + """Returns a string""" + info = data.get("pix_fmt", None) + return info, (info or "null") + + +class AudioStreamParser(StreamParser): + @staticmethod + def value_sample_rate(data): + """Returns an int - audio sample rate in Hz""" + info = data.get("sample_rate", None) + try: + return info, int(float(info)) + except (ValueError, TypeError): + return info, 0 + + @staticmethod + def value_channel_count(data): + """Returns an int""" + info = data.get("channels", None) + try: + return info, int(float(info)) + except (ValueError, TypeError): + return info, 0 + + @staticmethod + def value_channel_layout(data): + """Returns a string""" + info = data.get("channel_layout", None) + return info, (info or "null") + + +class SubtitleStreamParser(BaseParser): + @staticmethod + def value_codec(data): + return StreamParser.value_codec(data) + + @staticmethod + def value_language(data): + """Returns a string """ + tags = data.get("tags", None) + if tags: + info = tags.get("language", None) + return info, (info or "null") + return None, "null" + + @staticmethod + def value_forced(data): + """Returns a bool """ + disposition = data.get("disposition", None) + if disposition: + info = disposition.get("forced", None) + return bool(info), (bool(info) or False) + return None, "null" + + +class ChapterParser(BaseParser): + @staticmethod + def value_start(data): + """Returns an int""" + info = data.get("start_time", None) + try: + return info, float(data.get("start_time")) + except (ValueError, TypeError): + return info, 0 + + @classmethod + def value_end(cls, data): + """Returns a float""" + info = data.get("end_time", None) + try: + return info, float(info) + except (ValueError, TypeError): + return info, 0 + + @staticmethod + def value_title(data): + """Returns a string""" + info = data.get("tags", {}).get("title", None) + return info, (info or "null") + + @staticmethod + def fillEmptyTitles(chapters): + """Add text in place of empty titles + If a chapter doesn't have a title, this will add a basic + string in the form "Chapter `index+1`" + + Args: + chapters(list): The list of parsed chapters + + """ + index = 0 + for chapter in chapters: + if not chapter["title"]: + chapter["title"] = "Chapter " + str(index) + index += 1 + + +class RootParser(BaseParser): + @staticmethod + def value_duration(data): + """Returns an int""" + info = data.get("duration", None) + try: + return info, float(info) + except (ValueError, TypeError): + return info, 0.0 + + @staticmethod + def value_size(data): + """Returns an int""" + info = data.get("size", None) + if info is None: + file_path = data.get("filename", "") + if path.isfile(file_path): + info = str(path.getsize(file_path)) + try: + return info, int(float(info)) + except (ValueError, TypeError): + return info, 0 + + @classmethod + def value_bit_rate(cls, data): + """Returns an int""" + info = data.get("bit_rate", None) + if info is None: + _, size = cls.value_size(data) + _, duration = cls.value_duration(data) + if size and duration: + info = size / (duration / 60 * 0.0075) / 1000 + try: + return info, int(float(info)) + except (ValueError, TypeError): + return info, 0 diff --git a/libs/pyprobe/pyprobe.py b/libs/pyprobe/pyprobe.py new file mode 100644 index 000000000..bb63fb0e6 --- /dev/null +++ b/libs/pyprobe/pyprobe.py @@ -0,0 +1,213 @@ +import json +import subprocess +from os import path +from sys import getfilesystemencoding + +import ffprobeparsers + + +class VideoFileParser: + def __init__( + self, + ffprobe="ffprobe", + includeMissing=True, + rawMode=False, + ): + self._ffprobe = ffprobe + self._includeMissing = includeMissing + self._rawMode = rawMode + + ######################################## + # Main Method + + def parseFfprobe(self, inputFile): + """Takes an input file and returns the parsed data using ffprobe. + + Args: + inputFile (str): Video file path + + Returns: + dict>: Parsed video info + + Raises: + FileNotFoundError: The input video file or input executable was not found + IOError: Execution failed + + """ + if not path.isfile(inputFile): + raise FileNotFoundError(inputFile + " not found") + self._checkExecutable(self._ffprobe) + fdict = self._executeFfprobe(inputFile) + return self._parseFfprobe(fdict, inputFile) + + ######################################## + # ffprobe Parsing + + def _executeFfprobe(self, inputFile): + """Executes ffprobe program on input file to get raw info + + fdict = dict or dict + + Args: + inputFile (str): Video file path + + Returns: + fdict: Parsed data + + """ + commandArgs = [ + "-v", + "quiet", + "-hide_banner", + "-show_error", + "-show_format", + "-show_streams", + "-show_programs", + "-show_chapters", + "-show_private_data", + "-print_format", + "json", + ] + outputJson = self._executeParser(self._ffprobe, commandArgs, inputFile) + + try: + data = json.loads(outputJson) + except json.JSONDecodeError: + raise IOError("Could not decode ffprobe output for file " + inputFile) + return data + + def _parseFfprobe(self, fOutput, inputFile): + """Parse all data from fOutput to organized format + + fdict = dict or dict + + Args: + fOutput (fdict): Stream data from ffprobe + inputFile (str): Video file path + + Returns: + dict>: Parsed video data + + """ + videoInfo = {} + videoInfo["path"] = path.abspath(inputFile) + videoInfo.update( + ffprobeparsers.RootParser.parse( + fOutput["format"], self._rawMode, self._includeMissing + ) + ) + videoInfo.update(self._parseFfprobeStreams(fOutput)) + videoInfo.update(self._parseFfprobeChapters(fOutput)) + if not self._rawMode: + ffprobeparsers.ChapterParser.fillEmptyTitles(videoInfo["chapters"]) + return videoInfo + + def _parseFfprobeStreams(self, fOutput): + """Parses video, audio, and subtitle streams + + fdict = dict or dict + + Args: + streams_data (fdict): Stream data from ffprobe + + Returns: + dict>: Parsed streams - video, audio, and subtitle + + """ + parsedInfo = {"videos": [], "audios": [], "subtitles": []} + for stream in fOutput["streams"]: + streamType = stream["codec_type"] + data = None + if streamType == "video": + data = ffprobeparsers.VideoStreamParser.parse( + stream, self._rawMode, self._includeMissing + ) + parsedInfo["videos"].append(data) + elif streamType == "audio": + data = ffprobeparsers.AudioStreamParser.parse( + stream, self._rawMode, self._includeMissing + ) + parsedInfo["audios"].append(data) + elif streamType == "subtitle": + data = ffprobeparsers.SubtitleStreamParser.parse( + stream, self._rawMode, self._includeMissing + ) + parsedInfo["subtitles"].append(data) + return parsedInfo + + def _parseFfprobeChapters(self, fOutput): + """Parses chapters + + fdict = dict or dict + + Args: + chapters_data (fdict): Stream data from ffprobe + + Returns: + dict>: Parsed chapters + + """ + parsedInfo = {"chapters": []} + if fOutput["chapters"] is None: + return parsedInfo + for chapter in fOutput["chapters"]: + parsedInfo["chapters"].append( + ffprobeparsers.ChapterParser.parse( + chapter, self._rawMode, self._includeMissing + ) + ) + return parsedInfo + + ######################################## + # Misc Methods + + @staticmethod + def _executeParser(parser, commandArgs, inputFile): + """Executes parser on the input file + + Args: + parser (str): Executable location or command + commandArgs (list of strings): Extra command arguments + inputFile (str): the input file location + + Raises: + IOError: ffprobe execution failed + + """ + command = [parser] + commandArgs + [inputFile.encode(getfilesystemencoding())] + try: + completedProcess = subprocess.check_output( + command, stderr=subprocess.STDOUT + ) + except subprocess.CalledProcessError as e: + raise IOError( + "Error occurred during execution - " + e.output + ) + return completedProcess + + @staticmethod + def _checkExecutable(executable): + """Checks if target is executable + + Args: + executable (str): Executable location, can be file or command + + Raises: + FileNotFoundError: Executable was not found + + """ + try: + subprocess.check_output( + [executable, "--help"], + stderr=subprocess.STDOUT + ) + except OSError: + raise FileNotFoundError(executable + " not found") + + +class FileNotFoundError(Exception): + pass + + +class IOError(Exception): + pass diff --git a/libs/version.txt b/libs/version.txt index f8e9f4bcc..11de25b40 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -16,6 +16,7 @@ langdetect=1.0.7 peewee=3.9.6 py-pretty=1 pycountry=18.2.23 +pyprobe=0.1.2 <-- modified version: do not update!!! pysrt=1.1.1 pytz=2018.4 rarfile=3.0 diff --git a/views/episodes.tpl b/views/episodes.tpl index 975cf038d..9de53fc26 100644 --- a/views/episodes.tpl +++ b/views/episodes.tpl @@ -199,6 +199,7 @@ Existing
subtitles Missing
subtitles Manual
search + Manual
upload @@ -291,6 +292,11 @@ %end + + %if subs_languages is not None: + + %end + %end @@ -397,6 +403,59 @@ + + % include('footer.tpl') @@ -469,15 +528,10 @@ }); }); - $('a:not(.manual_search), .menu .item, button:not(#config, .cancel, #search_missing_subtitles)').on('click', function(){ + $('a:not(.manual_search, .manual_upload), .menu .item, button:not(#config, .cancel, #search_missing_subtitles)').on('click', function(){ $('#loader').addClass('active'); }); - $('.modal') - .modal({ - autofocus: false - }); - $('#config').on('click', function(){ $('#series_form').attr('action', '{{base_url}}edit_series/{{no}}'); @@ -499,7 +553,12 @@ $("#series_hearing-impaired_div").checkbox('uncheck'); } - $('.config_dialog').modal('show'); + $('.config_dialog') + .modal({ + centered: false, + autofocus: false + }) + .modal('show'); }); $('.manual_search').on('click', function(){ @@ -604,7 +663,41 @@ $('.search_dialog') .modal({ - centered: false + centered: false, + autofocus: false + }) + .modal('show'); + }); + + $('.manual_upload').on('click', function(){ + $("#series_title_span_u").html($(this).data("series_title")); + $("#season_u").html($(this).data("season")); + $("#episode_u").html($(this).data("episode")); + $("#episode_title_u").html($(this).data("episode_title")); + + episodePath = $(this).attr("data-episodePath"); + sceneName = $(this).attr("data-sceneName"); + language = $(this).attr("data-language"); + hi = $(this).attr("data-hi"); + sonarrSeriesId = $(this).attr("data-sonarrSeriesId"); + sonarrEpisodeId = $(this).attr("data-sonarrEpisodeId"); + var languages = Array.from({{!subs_languages_list}}); + var is_pb = languages.includes('pb'); + var is_pt = languages.includes('pt'); + var title = "{{!details[0].replace("'", "\'")}}"; + + $('#language').dropdown(); + + $('#upload_episodePath').val(episodePath); + $('#upload_sceneName').val(sceneName); + $('#upload_sonarrSeriesId').val(sonarrSeriesId); + $('#upload_sonarrEpisodeId').val(sonarrEpisodeId); + $('#upload_title').val(title); + + $('.upload_dialog') + .modal({ + centered: false, + autofocus: false }) .modal('show'); }); diff --git a/views/historymovies.tpl b/views/historymovies.tpl index 8828b64b4..aa6aa9b1f 100644 --- a/views/historymovies.tpl +++ b/views/historymovies.tpl @@ -74,6 +74,10 @@
+ %elif row[0] == 4: +
+ +
%end diff --git a/views/historyseries.tpl b/views/historyseries.tpl index 99a7a7125..e5c87d84c 100644 --- a/views/historyseries.tpl +++ b/views/historyseries.tpl @@ -76,6 +76,10 @@
+ %elif row[0] == 4: +
+ +
%end diff --git a/views/movie.tpl b/views/movie.tpl index 04f188a3a..2b5fbbd4f 100644 --- a/views/movie.tpl +++ b/views/movie.tpl @@ -125,6 +125,7 @@ %> %if subs_languages is not None: + %end @@ -354,6 +355,58 @@ + + % include('footer.tpl') @@ -425,15 +478,10 @@ }); }); - $('a, .menu .item, button:not(#config, .cancel, .manual_search, #search_missing_subtitles_movie)').on('click', function(){ + $('a, .menu .item, button:not(#config, .cancel, .manual_search, .manual_upload, #search_missing_subtitles_movie)').on('click', function(){ $('#loader').addClass('active'); }); - $('.modal') - .modal({ - autofocus: false - }); - $('#config').on('click', function(){ $('#movie_form').attr('action', '{{base_url}}edit_movie/{{no}}'); @@ -455,7 +503,12 @@ $("#movie_hearing-impaired_div").checkbox('uncheck'); } - $('.config_dialog').modal('show'); + $('.config_dialog') + .modal({ + centered: false, + autofocus: false + }) + .modal('show'); }); $('.manual_search').on('click', function(){ @@ -557,7 +610,33 @@ $('.search_dialog') .modal({ - centered: false + centered: false, + autofocus: false + }) + .modal('show') + ; + }); + + $('.manual_upload').on('click', function() { + $("#movie_title_upload_span").html($(this).data("movie_title")); + + moviePath = $(this).attr("data-moviePath"); + sceneName = $(this).attr("data-sceneName"); + language = $(this).attr("data-language"); + radarrId = $(this).attr("data-radarrId"); + var title = "{{!details[0].replace("'", "\'")}}"; + + $('#language').dropdown(); + + $('#upload_moviePath').val(moviePath); + $('#upload_sceneName').val(sceneName); + $('#upload_radarrId').val(radarrId); + $('#upload_title').val(title); + + $('.upload_dialog') + .modal({ + centered: false, + autofocus: false }) .modal('show') ; diff --git a/views/settings_subtitles.tpl b/views/settings_subtitles.tpl index 808ab3052..04075501b 100644 --- a/views/settings_subtitles.tpl +++ b/views/settings_subtitles.tpl @@ -20,33 +20,6 @@ -
-
- -
-
- % import platform -
- - -
-
- - -
-
@@ -579,12 +552,6 @@ $("#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 {