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',
'minimum_score': '90',
'use_scenename': 'True',
'use_mediainfo': 'True',
'use_postprocessing': 'False',
'postprocessing_cmd': '',
'use_sonarr': 'False',

@ -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()
embedded_subs_reader = EmbeddedSubsReader()

@ -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,

@ -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()

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

@ -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
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

@ -199,6 +199,7 @@
<th class="collapsing">Existing<br>subtitles</th>
<th class="collapsing">Missing<br>subtitles</th>
<th class="collapsing">Manual<br>search</th>
<th class="collapsing">Manual<br>upload</th>
</tr>
</thead>
<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>
%end
</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>
%end
</tbody>
@ -397,6 +403,59 @@
</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')
</body>
</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');
});
$('.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');
});

@ -74,6 +74,10 @@
<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>
</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
</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">
<i class="ui recycle icon"></i>
</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
</td>
<td>

@ -125,6 +125,7 @@
%>
%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_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
<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>
@ -354,6 +355,58 @@
</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')
</body>
</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');
});
$('.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')
;

@ -20,33 +20,6 @@
</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="right aligned four wide column">
<label>Minimum score for episodes</label>
@ -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 {

Loading…
Cancel
Save