Merge branch 'development' into morpheus

# Conflicts:
#	bazarr/get_subtitle.py
#	bazarr/main.py
#	views/movie.tpl
pull/543/head
Louis Vézina 5 years ago
commit 5b96df4c40

@ -17,7 +17,6 @@ defaults = {
'single_language': 'False', 'single_language': 'False',
'minimum_score': '90', 'minimum_score': '90',
'use_scenename': 'True', 'use_scenename': 'True',
'use_mediainfo': 'True',
'use_postprocessing': 'False', 'use_postprocessing': 'False',
'postprocessing_cmd': '', 'postprocessing_cmd': '',
'use_sonarr': 'False', 'use_sonarr': 'False',

@ -4,8 +4,8 @@ import os
import subprocess import subprocess
import locale import locale
from config import settings
from utils import get_binary from utils import get_binary
from pyprobe.pyprobe import VideoFileParser
class NotMKVAndNoFFprobe(Exception): class NotMKVAndNoFFprobe(Exception):
pass pass
@ -20,22 +20,20 @@ class EmbeddedSubsReader:
def list_languages(self, file): def list_languages(self, file):
subtitles_list = [] subtitles_list = []
if os.path.splitext(file)[1] == '.mkv': if self.ffprobe:
with open(file, 'rb') as f: parser = VideoFileParser(ffprobe=self.ffprobe, includeMissing=True, rawMode=False)
mkv = enzyme.MKV(f) data = parser.parseFfprobe(file)
for subtitle_track in mkv.subtitle_tracks:
subtitles_list.append([subtitle_track.language, subtitle_track.forced]) detected_languages = []
for detected_language in data['subtitles']:
subtitles_list.append([detected_language['language'], detected_language['forced']])
else: else:
if self.ffprobe: if os.path.splitext(file)[1] == '.mkv':
detected_languages = [] with open(file, 'rb') as f:
try: mkv = enzyme.MKV(f)
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") for subtitle_track in mkv.subtitle_tracks:
except subprocess.CalledProcessError as e: subtitles_list.append([subtitle_track.language, subtitle_track.forced])
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
return subtitles_list return subtitles_list

@ -19,7 +19,7 @@ from subzero.video import parse_video
from subliminal import region, score as subliminal_scores, \ from subliminal import region, score as subliminal_scores, \
list_subtitles, Episode, Movie list_subtitles, Episode, Movie
from subliminal_patch.core import SZAsyncProviderPool, download_best_subtitles, save_subtitles, download_subtitles, \ 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_patch.score import compute_score
from subliminal.refiners.tvdb import series_re from subliminal.refiners.tvdb import series_re
from get_languages import language_from_alpha3, alpha2_from_alpha3, alpha3_from_alpha2, language_from_alpha2 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_providers import get_providers, get_providers_auth, provider_throttle, provider_pool
from get_args import args from get_args import args
from queueconfig import notifications from queueconfig import notifications
from pymediainfo import MediaInfo from pyprobe.pyprobe import VideoFileParser
from database import TableShows, TableEpisodes, TableMovies, TableHistory, TableHistoryMovie from database import TableShows, TableEpisodes, TableMovies, TableHistory, TableHistoryMovie
from peewee import fn, JOIN 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 Construct `Video` instance
:param path: path to video :param path: path to video
:param title: series/movie title :param title: series/movie title
:param sceneName: sceneName :param sceneName: sceneName
:param use_scenename: use sceneName :param use_scenename: use sceneName
:param use_mediainfo: use media info to refine the video
:param providers: provider list for selective hashing :param providers: provider list for selective hashing
:param media_type: movie/series :param media_type: movie/series
:return: `Video` instance :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.used_scene_name = used_scene_name
video.original_name = original_name video.original_name = original_name
video.original_path = original_path video.original_path = original_path
refine_from_db(original_path, video)
if platform.system() != "Linux" and use_mediainfo: refine_from_db(original_path, video)
refine_from_mediainfo(original_path, video) refine_from_ffprobe(original_path, video)
logging.debug('BAZARR is using those video object properties: %s', vars(video)) logging.debug('BAZARR is using those video object properties: %s', vars(video))
return video return video
@ -143,7 +141,6 @@ def download_subtitle(path, language, hi, forced, providers, providers_auth, sce
language_set.add(lang_obj) language_set.add(lang_obj)
use_scenename = settings.general.getboolean('use_scenename') use_scenename = settings.general.getboolean('use_scenename')
use_mediainfo = settings.general.getboolean('use_mediainfo')
minimum_score = settings.general.minimum_score minimum_score = settings.general.minimum_score
minimum_score_movie = settings.general.minimum_score_movie minimum_score_movie = settings.general.minimum_score_movie
use_postprocessing = settings.general.getboolean('use_postprocessing') 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, post_download_hook=None,
language_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) media_type=media_type)
if video: if video:
min_score, max_score, scores = get_scores(video, media_type, min_score_movie_perc=int(minimum_score_movie), min_score, max_score, scores = get_scores(video, media_type, min_score_movie_perc=int(minimum_score_movie),
@ -283,6 +280,7 @@ def manual_search(path, language, hi, forced, providers, providers_auth, sceneNa
final_subtitles = [] final_subtitles = []
initial_hi = True if hi == "True" else False
if hi == "True": if hi == "True":
hi = "force HI" hi = "force HI"
else: else:
@ -311,13 +309,12 @@ def manual_search(path, language, hi, forced, providers, providers_auth, sceneNa
language_set.add(lang_obj) language_set.add(lang_obj)
use_scenename = settings.general.getboolean('use_scenename') use_scenename = settings.general.getboolean('use_scenename')
use_mediainfo = settings.general.getboolean('use_mediainfo')
minimum_score = settings.general.minimum_score minimum_score = settings.general.minimum_score
minimum_score_movie = settings.general.minimum_score_movie minimum_score_movie = settings.general.minimum_score_movie
use_postprocessing = settings.general.getboolean('use_postprocessing') use_postprocessing = settings.general.getboolean('use_postprocessing')
postprocessing_cmd = settings.general.postprocessing_cmd postprocessing_cmd = settings.general.postprocessing_cmd
if providers: 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) media_type=media_type)
else: else:
logging.info("BAZARR All providers are throttled") logging.info("BAZARR All providers are throttled")
@ -358,7 +355,10 @@ def manual_search(path, language, hi, forced, providers, providers_auth, sceneNa
logging.debug(u"BAZARR Skipping %s, because it doesn't match our series/episode", s) logging.debug(u"BAZARR Skipping %s, because it doesn't match our series/episode", s)
continue 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 not_matched = scores - matches
s.score = score 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")) subtitle = pickle.loads(codecs.decode(subtitle.encode(), "base64"))
use_scenename = settings.general.getboolean('use_scenename') use_scenename = settings.general.getboolean('use_scenename')
use_mediainfo = settings.general.getboolean('use_mediainfo')
use_postprocessing = settings.general.getboolean('use_postprocessing') use_postprocessing = settings.general.getboolean('use_postprocessing')
postprocessing_cmd = settings.general.postprocessing_cmd postprocessing_cmd = settings.general.postprocessing_cmd
single = settings.general.getboolean('single_language') 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) media_type=media_type)
if video: if video:
min_score, max_score, scores = get_scores(video, media_type) 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) 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): def series_download_subtitles(no):
episodes_details_clause = [ episodes_details_clause = [
(TableEpisodes.sonarr_series_id == no), (TableEpisodes.sonarr_series_id == no),
@ -962,31 +1006,42 @@ def refine_from_db(path, video):
return video return video
def refine_from_mediainfo(path, video): def refine_from_ffprobe(path, video):
if video.fps: exe = get_binary('ffprobe')
return
exe = get_binary('mediainfo')
if not exe: if not exe:
logging.debug('BAZARR MediaInfo library not found!') logging.debug('BAZARR FFprobe not found!')
return return
else: 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) parser = VideoFileParser(ffprobe=exe, includeMissing=True, rawMode=False)
data = parser.parseFfprobe(path)
video_track = next((t for t in media_info.tracks if t.track_type == 'Video'), None) logging.debug('FFprobe found: %s', data)
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 '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 not video.fps: if 'audios' not in data:
if video_track.frame_rate: logging.debug('BAZARR FFprobe was unable to find audio tracks in the file!')
video.fps = float(video_track.frame_rate) else:
elif video_track.framerate_num and video_track.framerate_den: if 'codec' in data['audios'][0]:
video.fps = round(float(video_track.framerate_num) / float(video_track.framerate_den), 3) if not video.audio_codec:
video.audio_codec = data['audios'][0]['codec'].upper()
def upgrade_subtitles(): def upgrade_subtitles():

@ -47,12 +47,13 @@ from get_episodes import *
from list_subtitles import store_subtitles, store_subtitles_movie, series_scan_subtitles, movies_scan_subtitles, \ from list_subtitles import store_subtitles, store_subtitles_movie, series_scan_subtitles, movies_scan_subtitles, \
list_missing_subtitles, list_missing_subtitles_movies list_missing_subtitles, list_missing_subtitles_movies
from get_subtitle import download_subtitle, series_download_subtitles, movies_download_subtitles, \ 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 utils import history_log, history_log_movie
from scheduler import * from scheduler import *
from notifier import send_notifications, send_notifications_movie 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 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.extensions import provider_registry as provider_manager
from subliminal_patch.core import SUBTITLE_EXTENSIONS
reload(sys) reload(sys)
sys.setdefaultencoding('utf8') sys.setdefaultencoding('utf8')
@ -1476,11 +1477,6 @@ def save_settings():
settings_general_scenename = 'False' settings_general_scenename = 'False'
else: else:
settings_general_scenename = 'True' 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') settings_general_embedded = request.forms.get('settings_general_embedded')
if settings_general_embedded is None: if settings_general_embedded is None:
settings_general_embedded = 'False' settings_general_embedded = 'False'
@ -1563,7 +1559,6 @@ def save_settings():
settings.general.single_language = text_type(settings_general_single_language) settings.general.single_language = text_type(settings_general_single_language)
settings.general.minimum_score = text_type(settings_general_minimum_score) settings.general.minimum_score = text_type(settings_general_minimum_score)
settings.general.use_scenename = text_type(settings_general_scenename) 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.use_postprocessing = text_type(settings_general_use_postprocessing)
settings.general.postprocessing_cmd = text_type(settings_general_postprocessing_cmd) settings.general.postprocessing_cmd = text_type(settings_general_postprocessing_cmd)
settings.general.use_sonarr = text_type(settings_general_use_sonarr) settings.general.use_sonarr = text_type(settings_general_use_sonarr)
@ -2147,6 +2142,51 @@ def manual_get_subtitle():
pass 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') @route(base_url + 'get_subtitle_movie', method='POST')
@custom_auth_basic(check_credentials) @custom_auth_basic(check_credentials)
def get_subtitle_movie(): def get_subtitle_movie():
@ -2239,6 +2279,50 @@ def manual_get_subtitle_movie():
pass 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(): def configured():
System.update({System.configured: 1}).execute() System.update({System.configured: 1}).execute()

@ -52,19 +52,13 @@ def get_binary(name):
binaries_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'bin')) binaries_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'bin'))
exe = None 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 return installed_exe
else: else:
if platform.system() == "Windows": # Windows if platform.system() == "Windows": # Windows
exe = os.path.abspath(os.path.join(binaries_dir, "Windows", "i386", name, "%s.exe" % name)) 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 elif platform.system() == "Darwin": # MacOSX
exe = os.path.abspath(os.path.join(binaries_dir, "MacOSX", "i386", name, name)) exe = os.path.abspath(os.path.join(binaries_dir, "MacOSX", "i386", name, name))

@ -0,0 +1,2 @@
from pyprobe import VideoFileParser

@ -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<str, dict<str, var>>: 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

@ -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<dict>): 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

@ -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<str, dict<str, var>>: 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<str, fdict> or dict<str, str>
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<str, fdict> or dict<str, str>
Args:
fOutput (fdict): Stream data from ffprobe
inputFile (str): Video file path
Returns:
dict<str, dict<str, str>>: 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<str, fdict> or dict<str, str>
Args:
streams_data (fdict): Stream data from ffprobe
Returns:
dict<str, dict<str, var>>: 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<str, fdict> or dict<str, str>
Args:
chapters_data (fdict): Stream data from ffprobe
Returns:
dict<str, dict<str, var>>: 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

@ -16,6 +16,7 @@ langdetect=1.0.7
peewee=3.9.6 peewee=3.9.6
py-pretty=1 py-pretty=1
pycountry=18.2.23 pycountry=18.2.23
pyprobe=0.1.2 <-- modified version: do not update!!!
pysrt=1.1.1 pysrt=1.1.1
pytz=2018.4 pytz=2018.4
rarfile=3.0 rarfile=3.0

@ -199,6 +199,7 @@
<th class="collapsing">Existing<br>subtitles</th> <th class="collapsing">Existing<br>subtitles</th>
<th class="collapsing">Missing<br>subtitles</th> <th class="collapsing">Missing<br>subtitles</th>
<th class="collapsing">Manual<br>search</th> <th class="collapsing">Manual<br>search</th>
<th class="collapsing">Manual<br>upload</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -291,6 +292,11 @@
<a data-episodePath="{{episode['path']}}" data-scenename="{{episode['scene_name']}}" data-language="{{subs_languages_list}}" data-hi="{{details.hearing_impaired}}" data-forced="{{details.forced}}" data-series_title="{{details.title}}" data-season="{{episode['season']}}" data-episode="{{episode['episode']}}" data-episode_title="{{episode['title']}}" data-sonarrSeriesId="{{episode['sonarr_series_id']}}" data-sonarrEpisodeId="{{episode['sonarr_episode_id']}}" class="manual_search ui tiny label"><i class="ui user icon" style="margin-right:0px" ></i></a> <a data-episodePath="{{episode['path']}}" data-scenename="{{episode['scene_name']}}" data-language="{{subs_languages_list}}" data-hi="{{details.hearing_impaired}}" data-forced="{{details.forced}}" data-series_title="{{details.title}}" data-season="{{episode['season']}}" data-episode="{{episode['episode']}}" data-episode_title="{{episode['title']}}" data-sonarrSeriesId="{{episode['sonarr_series_id']}}" data-sonarrEpisodeId="{{episode['sonarr_episode_id']}}" class="manual_search ui tiny label"><i class="ui user icon" style="margin-right:0px" ></i></a>
%end %end
</td> </td>
<td>
%if subs_languages is not None:
<a data-episodePath="{{episode[1]}}" data-scenename="{{episode[8]}}" data-language="{{subs_languages_list}}" data-hi="{{details[4]}}" data-series_title="{{details[0]}}" data-season="{{episode[2]}}" data-episode="{{episode[3]}}" data-episode_title="{{episode[0]}}" data-sonarrSeriesId="{{episode[5]}}" data-sonarrEpisodeId="{{episode[7]}}" class="manual_upload ui tiny label"><i class="ui cloud upload icon" style="margin-right:0px" ></i></a>
%end
</td>
</tr> </tr>
%end %end
</tbody> </tbody>
@ -397,6 +403,59 @@
</div> </div>
</div> </div>
<div class="upload_dialog ui small modal">
<i class="close icon"></i>
<div class="header">
<span id="series_title_span_u"></span> - <span id="season_u"></span>x<span id="episode_u"></span> - <span id="episode_title_u"></span>
</div>
<div class="content">
<form class="ui form" name="upload_form" id="upload_form" action="{{base_url}}manual_upload_subtitle" method="post" enctype="multipart/form-data">
<div class="ui grid">
<div class="middle aligned row">
<div class="right aligned three wide column">
<label>Language</label>
</div>
<div class="thirteen wide column">
<select class="ui search dropdown" id="language" name="language">
%for language in subs_languages_list:
<option value="{{language}}">{{language_from_alpha2(language)}}</option>
%end
</select>
</div>
</div>
<div class="middle aligned row">
<div class="right aligned three wide column">
<label>Forced</label>
</div>
<div class="thirteen wide column">
<div class="ui toggle checkbox">
<input name="forced" type="checkbox" value="1">
<label></label>
</div>
</div>
</div>
<div class="middle aligned row">
<div class="right aligned three wide column">
<label>File</label>
</div>
<div class="thirteen wide column">
<input type="file" name="upload">
</div>
</div>
</div>
<input type="hidden" id="upload_episodePath" name="episodePath" value="" />
<input type="hidden" id="upload_sceneName" name="sceneName" value="" />
<input type="hidden" id="upload_sonarrSeriesId" name="sonarrSeriesId" value="" />
<input type="hidden" id="upload_sonarrEpisodeId" name="sonarrEpisodeId" value="" />
<input type="hidden" id="upload_title" name="title" value="" />
</form>
</div>
<div class="actions">
<button class="ui cancel button" >Cancel</button>
<button type="submit" name="save" value="save" form="upload_form" class="ui blue approve button">Save</button>
</div>
</div>
% include('footer.tpl') % include('footer.tpl')
</body> </body>
</html> </html>
@ -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'); $('#loader').addClass('active');
}); });
$('.modal')
.modal({
autofocus: false
});
$('#config').on('click', function(){ $('#config').on('click', function(){
$('#series_form').attr('action', '{{base_url}}edit_series/{{no}}'); $('#series_form').attr('action', '{{base_url}}edit_series/{{no}}');
@ -499,7 +553,12 @@
$("#series_hearing-impaired_div").checkbox('uncheck'); $("#series_hearing-impaired_div").checkbox('uncheck');
} }
$('.config_dialog').modal('show'); $('.config_dialog')
.modal({
centered: false,
autofocus: false
})
.modal('show');
}); });
$('.manual_search').on('click', function(){ $('.manual_search').on('click', function(){
@ -604,7 +663,41 @@
$('.search_dialog') $('.search_dialog')
.modal({ .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'); .modal('show');
}); });

@ -74,6 +74,10 @@
<div class="ui inverted basic compact icon" data-tooltip="Subtitles file has been upgraded." data-inverted="" data-position="top left"> <div class="ui inverted basic compact icon" data-tooltip="Subtitles file has been upgraded." data-inverted="" data-position="top left">
<i class="ui recycle icon"></i> <i class="ui recycle icon"></i>
</div> </div>
%elif row[0] == 4:
<div class="ui inverted basic compact icon" data-tooltip="Subtitles file has been manually uploaded." data-inverted="" data-position="top left">
<i class="ui cloud upload icon"></i>
</div>
%end %end
</td> </td>
<td> <td>

@ -76,6 +76,10 @@
<div class="ui inverted basic compact icon" data-tooltip="Subtitles file has been upgraded." data-inverted="" data-position="top left"> <div class="ui inverted basic compact icon" data-tooltip="Subtitles file has been upgraded." data-inverted="" data-position="top left">
<i class="ui recycle icon"></i> <i class="ui recycle icon"></i>
</div> </div>
%elif row[0] == 4:
<div class="ui inverted basic compact icon" data-tooltip="Subtitles file has been manually uploaded." data-inverted="" data-position="top left">
<i class="ui cloud upload icon"></i>
</div>
%end %end
</td> </td>
<td> <td>

@ -125,6 +125,7 @@
%> %>
%if subs_languages is not None: %if subs_languages is not None:
<button class="manual_search ui button" data-tooltip="Manually search for subtitles" data-inverted="" data-moviePath="{{details.path}}" data-scenename="{{details.scene_name}}" data-language="{{subs_languages_list}}" data-hi="{{details.hearing_impaired}}" data-forced="{{details.forced}}" data-movie_title="{{details.title}}" data-radarrId="{{details.radarr_id}}"><i class="ui inverted large compact user icon"></i></button> <button class="manual_search ui button" data-tooltip="Manually search for subtitles" data-inverted="" data-moviePath="{{details.path}}" data-scenename="{{details.scene_name}}" data-language="{{subs_languages_list}}" data-hi="{{details.hearing_impaired}}" data-forced="{{details.forced}}" data-movie_title="{{details.title}}" data-radarrId="{{details.radarr_id}}"><i class="ui inverted large compact user icon"></i></button>
<button class="manual_upload ui button" data-tooltip="Manually upload subtitles" data-inverted="" data-moviePath="{{details.path}}" data-scenename="{{details.scene_name}}" data-language="{{subs_languages_list}}" data-hi="{{details.hearing_impaired}}" data-movie_title="{{details.forced}}" data-radarrId="{{details.title}}"><i class="ui inverted large compact cloud upload icon"></i></button>
%end %end
<button id="config" class="ui button" data-tooltip="Edit movie" data-inverted="" data-tmdbid="{{details.tmdb_id}}" data-title="{{details.title}}" data-poster="{{details.poster}}" data-audio="{{details.audio_language}}" data-languages="{{!subs_languages_list}}" data-hearing-impaired="{{details.hearing_impaired}}" data-forced="{{details.forced}}"><i class="ui inverted large compact configure icon"></i></button> <button id="config" class="ui button" data-tooltip="Edit movie" data-inverted="" data-tmdbid="{{details.tmdb_id}}" data-title="{{details.title}}" data-poster="{{details.poster}}" data-audio="{{details.audio_language}}" data-languages="{{!subs_languages_list}}" data-hearing-impaired="{{details.hearing_impaired}}" data-forced="{{details.forced}}"><i class="ui inverted large compact configure icon"></i></button>
</div> </div>
@ -354,6 +355,58 @@
</div> </div>
</div> </div>
<div class="upload_dialog ui small modal">
<i class="close icon"></i>
<div class="header">
<span id="movie_title_upload_span"></span>
</div>
<div class="scrolling content">
<form class="ui form" name="upload_form" id="upload_form" action="{{base_url}}manual_upload_subtitle_movie" method="post" enctype="multipart/form-data">
<div class="ui grid">
<div class="middle aligned row">
<div class="right aligned three wide column">
<label>Language</label>
</div>
<div class="thirteen wide column">
<select class="ui search dropdown" id="language" name="language">
%for language in subs_languages_list:
<option value="{{language}}">{{language_from_alpha2(language)}}</option>
%end
</select>
</div>
</div>
<div class="middle aligned row">
<div class="right aligned three wide column">
<label>Forced</label>
</div>
<div class="thirteen wide column">
<div class="ui toggle checkbox">
<input name="forced" type="checkbox" value="1">
<label></label>
</div>
</div>
</div>
<div class="middle aligned row">
<div class="right aligned three wide column">
<label>File</label>
</div>
<div class="thirteen wide column">
<input type="file" name="upload">
</div>
</div>
</div>
<input type="hidden" id="upload_moviePath" name="moviePath" value="" />
<input type="hidden" id="upload_sceneName" name="sceneName" value="" />
<input type="hidden" id="upload_radarrId" name="radarrId" value="" />
<input type="hidden" id="upload_title" name="title" value="" />
</form>
</div>
<div class="actions">
<button class="ui cancel button" >Cancel</button>
<button type="submit" name="save" value="save" form="upload_form" class="ui blue approve button">Save</button>
</div>
</div>
% include('footer.tpl') % include('footer.tpl')
</body> </body>
</html> </html>
@ -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'); $('#loader').addClass('active');
}); });
$('.modal')
.modal({
autofocus: false
});
$('#config').on('click', function(){ $('#config').on('click', function(){
$('#movie_form').attr('action', '{{base_url}}edit_movie/{{no}}'); $('#movie_form').attr('action', '{{base_url}}edit_movie/{{no}}');
@ -455,7 +503,12 @@
$("#movie_hearing-impaired_div").checkbox('uncheck'); $("#movie_hearing-impaired_div").checkbox('uncheck');
} }
$('.config_dialog').modal('show'); $('.config_dialog')
.modal({
centered: false,
autofocus: false
})
.modal('show');
}); });
$('.manual_search').on('click', function(){ $('.manual_search').on('click', function(){
@ -557,7 +610,33 @@
$('.search_dialog') $('.search_dialog')
.modal({ .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') .modal('show')
; ;

@ -20,33 +20,6 @@
</div> </div>
</div> </div>
<div class="middle aligned row">
<div class="right aligned four wide column">
<label>Use MediaInfo</label>
</div>
<div class="one wide column">
% import platform
<div id="settings_mediainfo" class="ui toggle checkbox{{' disabled' if platform.system() == 'Linux' else ''}}" data-mediainfo={{settings.general.getboolean('use_mediainfo')}}>
<input name="settings_general_mediainfo" type="checkbox">
<label></label>
</div>
</div>
<div class="collapsed column">
<div class="collapsed center aligned column">
<div class="ui basic icon" data-tooltip="Use MediaInfo to extract video and audio stream properties." data-inverted="">
<i class="help circle large icon"></i>
</div>
</div>
</div>
<div class="collapsed column">
<div class="collapsed center aligned column">
<div class="ui basic icon" data-tooltip="This settings is only available on Windows and MacOS." data-inverted="">
<i class="yellow warning sign icon"></i>
</div>
</div>
</div>
</div>
<div class="middle aligned row"> <div class="middle aligned row">
<div class="right aligned four wide column"> <div class="right aligned four wide column">
<label>Minimum score for episodes</label> <label>Minimum score for episodes</label>
@ -579,12 +552,6 @@
$("#settings_scenename").checkbox('uncheck'); $("#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") { if ($('#settings_upgrade_subs').data("upgrade") === "True") {
$("#settings_upgrade_subs").checkbox('check'); $("#settings_upgrade_subs").checkbox('check');
} else { } else {

Loading…
Cancel
Save