You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
504 lines
19 KiB
504 lines
19 KiB
# coding=utf-8
|
|
|
|
import os
|
|
import datetime
|
|
import pytz
|
|
import logging
|
|
import subliminal_patch
|
|
import pretty
|
|
import time
|
|
import socket
|
|
import requests
|
|
import traceback
|
|
import re
|
|
|
|
from requests import ConnectionError
|
|
from subzero.language import Language
|
|
from subliminal_patch.exceptions import TooManyRequests, APIThrottled, ParseResponseError, IPAddressBlocked, \
|
|
MustGetBlacklisted, SearchLimitReached
|
|
from subliminal.providers.opensubtitles import DownloadLimitReached, PaymentRequired, Unauthorized
|
|
from subliminal.exceptions import DownloadLimitExceeded, ServiceUnavailable, AuthenticationError, ConfigurationError
|
|
from subliminal import region as subliminal_cache_region
|
|
from subliminal_patch.extensions import provider_registry
|
|
|
|
from app.get_args import args
|
|
from app.config import settings
|
|
from languages.get_languages import CustomLanguage
|
|
from app.event_handler import event_stream
|
|
from utilities.binaries import get_binary
|
|
from radarr.blacklist import blacklist_log_movie
|
|
from sonarr.blacklist import blacklist_log
|
|
from utilities.analytics import event_tracker
|
|
|
|
|
|
_TRACEBACK_RE = re.compile(r'File "(.*?providers[\\/].*?)", line (\d+)')
|
|
|
|
|
|
def time_until_midnight(timezone):
|
|
# type: (datetime.datetime) -> datetime.timedelta
|
|
"""
|
|
Get timedelta until midnight.
|
|
"""
|
|
now_in_tz = datetime.datetime.now(tz=timezone)
|
|
midnight = now_in_tz.replace(hour=0, minute=0, second=0, microsecond=0) + \
|
|
datetime.timedelta(days=1)
|
|
return midnight - now_in_tz
|
|
|
|
|
|
# Titulky resets its download limits at the start of a new day from its perspective - the Europe/Prague timezone
|
|
# Needs to convert to offset-naive dt
|
|
def titulky_limit_reset_timedelta():
|
|
return time_until_midnight(timezone=pytz.timezone('Europe/Prague'))
|
|
|
|
|
|
# LegendasDivx reset its searches limit at approximately midnight, Lisbon time, every day. We wait 1 more hours just
|
|
# to be sure.
|
|
def legendasdivx_limit_reset_timedelta():
|
|
return time_until_midnight(timezone=pytz.timezone('Europe/Lisbon')) + datetime.timedelta(minutes=60)
|
|
|
|
|
|
VALID_THROTTLE_EXCEPTIONS = (TooManyRequests, DownloadLimitExceeded, ServiceUnavailable, APIThrottled,
|
|
ParseResponseError, IPAddressBlocked)
|
|
VALID_COUNT_EXCEPTIONS = ('TooManyRequests', 'ServiceUnavailable', 'APIThrottled', requests.exceptions.Timeout,
|
|
requests.exceptions.ConnectTimeout, requests.exceptions.ReadTimeout, socket.timeout)
|
|
|
|
|
|
def provider_throttle_map():
|
|
return {
|
|
"default": {
|
|
TooManyRequests: (datetime.timedelta(hours=1), "1 hour"),
|
|
DownloadLimitExceeded: (datetime.timedelta(hours=3), "3 hours"),
|
|
ServiceUnavailable: (datetime.timedelta(minutes=20), "20 minutes"),
|
|
APIThrottled: (datetime.timedelta(minutes=10), "10 minutes"),
|
|
ParseResponseError: (datetime.timedelta(hours=6), "6 hours"),
|
|
requests.exceptions.Timeout: (datetime.timedelta(hours=1), "1 hour"),
|
|
socket.timeout: (datetime.timedelta(hours=1), "1 hour"),
|
|
requests.exceptions.ConnectTimeout: (datetime.timedelta(hours=1), "1 hour"),
|
|
requests.exceptions.ReadTimeout: (datetime.timedelta(hours=1), "1 hour"),
|
|
ConfigurationError: (datetime.timedelta(hours=12), "12 hours"),
|
|
PermissionError: (datetime.timedelta(hours=12), "12 hours"),
|
|
requests.exceptions.ProxyError: (datetime.timedelta(hours=1), "1 hour"),
|
|
AuthenticationError: (datetime.timedelta(hours=12), "12 hours"),
|
|
},
|
|
"opensubtitles": {
|
|
TooManyRequests: (datetime.timedelta(hours=3), "3 hours"),
|
|
DownloadLimitExceeded: (datetime.timedelta(hours=6), "6 hours"),
|
|
DownloadLimitReached: (datetime.timedelta(hours=6), "6 hours"),
|
|
PaymentRequired: (datetime.timedelta(hours=12), "12 hours"),
|
|
Unauthorized: (datetime.timedelta(hours=12), "12 hours"),
|
|
APIThrottled: (datetime.timedelta(seconds=15), "15 seconds"),
|
|
ServiceUnavailable: (datetime.timedelta(hours=1), "1 hour"),
|
|
},
|
|
"opensubtitlescom": {
|
|
TooManyRequests: (datetime.timedelta(minutes=1), "1 minute"),
|
|
DownloadLimitExceeded: (datetime.timedelta(hours=24), "24 hours"),
|
|
},
|
|
"addic7ed": {
|
|
DownloadLimitExceeded: (datetime.timedelta(hours=3), "3 hours"),
|
|
TooManyRequests: (datetime.timedelta(minutes=5), "5 minutes"),
|
|
IPAddressBlocked: (datetime.timedelta(hours=1), "1 hours"),
|
|
},
|
|
"titlovi": {
|
|
TooManyRequests: (datetime.timedelta(minutes=5), "5 minutes"),
|
|
},
|
|
"titulky": {
|
|
DownloadLimitExceeded: (
|
|
titulky_limit_reset_timedelta(),
|
|
f"{titulky_limit_reset_timedelta().seconds // 3600 + 1} hours")
|
|
},
|
|
"legendasdivx": {
|
|
TooManyRequests: (datetime.timedelta(hours=3), "3 hours"),
|
|
DownloadLimitExceeded: (
|
|
legendasdivx_limit_reset_timedelta(),
|
|
f"{legendasdivx_limit_reset_timedelta().seconds // 3600 + 1} hours"),
|
|
IPAddressBlocked: (
|
|
legendasdivx_limit_reset_timedelta(),
|
|
f"{legendasdivx_limit_reset_timedelta().seconds // 3600 + 1} hours"),
|
|
SearchLimitReached: (
|
|
legendasdivx_limit_reset_timedelta(),
|
|
f"{legendasdivx_limit_reset_timedelta().seconds // 3600 + 1} hours"),
|
|
},
|
|
"whisperai": {
|
|
ConnectionError: (datetime.timedelta(hours=24), "24 hours"),
|
|
},
|
|
}
|
|
|
|
|
|
PROVIDERS_FORCED_OFF = ["addic7ed", "tvsubtitles", "legendasdivx", "napiprojekt", "shooter",
|
|
"hosszupuska", "supersubtitles", "titlovi", "assrt", "subscene"]
|
|
|
|
throttle_count = {}
|
|
|
|
|
|
def provider_pool():
|
|
if settings.general.multithreading:
|
|
return subliminal_patch.core.SZAsyncProviderPool
|
|
return subliminal_patch.core.SZProviderPool
|
|
|
|
|
|
def _lang_from_str(content: str):
|
|
" Formats: es-MX en@hi es-MX@forced "
|
|
extra_info = content.split("@")
|
|
if len(extra_info) > 1:
|
|
kwargs = {extra_info[-1]: True}
|
|
else:
|
|
kwargs = {}
|
|
|
|
content = extra_info[0]
|
|
|
|
try:
|
|
code, country = content.split("-")
|
|
except ValueError:
|
|
lang = CustomLanguage.from_value(content)
|
|
if lang is not None:
|
|
lang = lang.subzero_language()
|
|
return lang.rebuild(lang, **kwargs)
|
|
|
|
code, country = content, None
|
|
|
|
return subliminal_patch.core.Language(code, country, **kwargs)
|
|
|
|
|
|
def get_language_equals(settings_=None):
|
|
settings_ = settings_ or settings
|
|
|
|
equals = settings_.general.language_equals
|
|
if not equals:
|
|
return []
|
|
|
|
items = []
|
|
for equal in equals:
|
|
try:
|
|
from_, to_ = equal.split(":")
|
|
from_, to_ = _lang_from_str(from_), _lang_from_str(to_)
|
|
except Exception as error:
|
|
logging.info("Invalid equal value: '%s' [%s]", equal, error)
|
|
else:
|
|
items.append((from_, to_))
|
|
|
|
return items
|
|
|
|
|
|
def get_providers():
|
|
providers_list = []
|
|
existing_providers = provider_registry.names()
|
|
providers = [x for x in settings.general.enabled_providers if x in existing_providers]
|
|
for provider in providers:
|
|
reason, until, throttle_desc = tp.get(provider, (None, None, None))
|
|
providers_list.append(provider)
|
|
|
|
if reason:
|
|
now = datetime.datetime.now()
|
|
if now < until:
|
|
logging.debug("Not using %s until %s, because of: %s", provider,
|
|
until.strftime("%y/%m/%d %H:%M"), reason)
|
|
providers_list.remove(provider)
|
|
else:
|
|
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
|
|
del tp[provider]
|
|
set_throttled_providers(str(tp))
|
|
# if forced only is enabled: # fixme: Prepared for forced only implementation to remove providers with don't support forced only subtitles
|
|
# for provider in providers_list:
|
|
# if provider in PROVIDERS_FORCED_OFF:
|
|
# providers_list.remove(provider)
|
|
|
|
if not providers_list:
|
|
providers_list = None
|
|
|
|
return providers_list
|
|
|
|
|
|
def get_enabled_providers():
|
|
# return enabled provider including those who can be throttled
|
|
if isinstance(settings.general.enabled_providers, list):
|
|
return settings.general.enabled_providers
|
|
else:
|
|
return []
|
|
|
|
|
|
_FFPROBE_BINARY = get_binary("ffprobe")
|
|
_FFMPEG_BINARY = get_binary("ffmpeg")
|
|
|
|
|
|
def get_providers_auth():
|
|
return {
|
|
'addic7ed': {
|
|
'username': settings.addic7ed.username,
|
|
'password': settings.addic7ed.password,
|
|
'cookies': settings.addic7ed.cookies,
|
|
'user_agent': settings.addic7ed.user_agent,
|
|
'is_vip': settings.addic7ed.vip,
|
|
},
|
|
'opensubtitles': {
|
|
'username': settings.opensubtitles.username,
|
|
'password': settings.opensubtitles.password,
|
|
'use_tag_search': settings.opensubtitles.use_tag_search,
|
|
'only_foreign': False, # fixme
|
|
'also_foreign': False, # fixme
|
|
'is_vip': settings.opensubtitles.vip,
|
|
'use_ssl': settings.opensubtitles.ssl,
|
|
'timeout': int(settings.opensubtitles.timeout) or 15,
|
|
'skip_wrong_fps': settings.opensubtitles.skip_wrong_fps,
|
|
},
|
|
'opensubtitlescom': {'username': settings.opensubtitlescom.username,
|
|
'password': settings.opensubtitlescom.password,
|
|
'use_hash': settings.opensubtitlescom.use_hash,
|
|
'include_ai_translated': settings.opensubtitlescom.include_ai_translated,
|
|
'api_key': 's38zmzVlW7IlYruWi7mHwDYl2SfMQoC1'
|
|
},
|
|
'podnapisi': {
|
|
'only_foreign': False, # fixme
|
|
'also_foreign': False, # fixme
|
|
'verify_ssl': settings.podnapisi.verify_ssl
|
|
},
|
|
'subscene': {
|
|
'username': settings.subscene.username,
|
|
'password': settings.subscene.password,
|
|
'only_foreign': False, # fixme
|
|
},
|
|
'legendasdivx': {
|
|
'username': settings.legendasdivx.username,
|
|
'password': settings.legendasdivx.password,
|
|
'skip_wrong_fps': settings.legendasdivx.skip_wrong_fps,
|
|
},
|
|
'xsubs': {
|
|
'username': settings.xsubs.username,
|
|
'password': settings.xsubs.password,
|
|
},
|
|
'assrt': {
|
|
'token': settings.assrt.token,
|
|
},
|
|
'napisy24': {
|
|
'username': settings.napisy24.username,
|
|
'password': settings.napisy24.password,
|
|
},
|
|
'betaseries': {'token': settings.betaseries.token},
|
|
'titulky': {
|
|
'username': settings.titulky.username,
|
|
'password': settings.titulky.password,
|
|
'approved_only': settings.titulky.approved_only,
|
|
},
|
|
'titlovi': {
|
|
'username': settings.titlovi.username,
|
|
'password': settings.titlovi.password,
|
|
},
|
|
'ktuvit': {
|
|
'email': settings.ktuvit.email,
|
|
'hashed_password': settings.ktuvit.hashed_password,
|
|
},
|
|
'embeddedsubtitles': {
|
|
'included_codecs': settings.embeddedsubtitles.included_codecs,
|
|
'hi_fallback': settings.embeddedsubtitles.hi_fallback,
|
|
'cache_dir': os.path.join(args.config_dir, "cache"),
|
|
'ffprobe_path': _FFPROBE_BINARY,
|
|
'ffmpeg_path': _FFMPEG_BINARY,
|
|
'timeout': settings.embeddedsubtitles.timeout,
|
|
'unknown_as_english': settings.embeddedsubtitles.unknown_as_english,
|
|
},
|
|
'karagarga': {
|
|
'username': settings.karagarga.username,
|
|
'password': settings.karagarga.password,
|
|
'f_username': settings.karagarga.f_username,
|
|
'f_password': settings.karagarga.f_password,
|
|
},
|
|
'hdbits': {
|
|
'username': settings.hdbits.username,
|
|
'passkey': settings.hdbits.passkey,
|
|
},
|
|
'subf2m': {
|
|
'verify_ssl': settings.subf2m.verify_ssl,
|
|
'user_agent': settings.subf2m.user_agent,
|
|
},
|
|
'whisperai': {
|
|
'endpoint': settings.whisperai.endpoint,
|
|
'timeout': settings.whisperai.timeout,
|
|
'ffmpeg_path': _FFMPEG_BINARY,
|
|
'loglevel': settings.whisperai.loglevel,
|
|
}
|
|
}
|
|
|
|
|
|
def _handle_mgb(name, exception, ids, language):
|
|
if language.forced:
|
|
language_str = f'{language.basename}:forced'
|
|
elif language.hi:
|
|
language_str = f'{language.basename}:hi'
|
|
else:
|
|
language_str = language.basename
|
|
|
|
if ids:
|
|
if exception.media_type == "series":
|
|
if 'sonarrSeriesId' in ids and 'sonarrEpsiodeId' in ids:
|
|
blacklist_log(ids['sonarrSeriesId'], ids['sonarrEpisodeId'], name, exception.id, language_str)
|
|
else:
|
|
blacklist_log_movie(ids['radarrId'], name, exception.id, language_str)
|
|
|
|
|
|
def provider_throttle(name, exception, ids=None, language=None):
|
|
if isinstance(exception, MustGetBlacklisted) and isinstance(ids, dict) and isinstance(language, Language):
|
|
return _handle_mgb(name, exception, ids, language)
|
|
|
|
cls = getattr(exception, "__class__")
|
|
cls_name = getattr(cls, "__name__")
|
|
if cls not in VALID_THROTTLE_EXCEPTIONS:
|
|
for valid_cls in VALID_THROTTLE_EXCEPTIONS:
|
|
if isinstance(cls, valid_cls):
|
|
cls = valid_cls
|
|
|
|
throttle_data = provider_throttle_map().get(name, provider_throttle_map()["default"]).get(cls, None) or \
|
|
provider_throttle_map()["default"].get(cls, None)
|
|
|
|
if throttle_data:
|
|
throttle_delta, throttle_description = throttle_data
|
|
else:
|
|
throttle_delta, throttle_description = datetime.timedelta(minutes=10), "10 minutes"
|
|
|
|
throttle_until = datetime.datetime.now() + throttle_delta
|
|
|
|
if cls_name not in VALID_COUNT_EXCEPTIONS or throttled_count(name):
|
|
if cls_name == 'ValueError' and isinstance(exception.args, tuple) and len(exception.args) and exception.args[0].startswith('unsupported pickle protocol'):
|
|
for fn in subliminal_cache_region.backend.all_filenames:
|
|
try:
|
|
os.remove(fn)
|
|
except (IOError, OSError):
|
|
logging.debug("Couldn't remove cache file: %s", os.path.basename(fn))
|
|
else:
|
|
tp[name] = (cls_name, throttle_until, throttle_description)
|
|
set_throttled_providers(str(tp))
|
|
|
|
trac_info = _get_traceback_info(exception)
|
|
|
|
logging.info("Throttling %s for %s, until %s, because of: %s. Exception info: %r", name,
|
|
throttle_description, throttle_until.strftime("%y/%m/%d %H:%M"), cls_name, trac_info)
|
|
event_tracker.track_throttling(provider=name, exception_name=cls_name, exception_info=trac_info)
|
|
|
|
update_throttled_provider()
|
|
|
|
|
|
def _get_traceback_info(exc: Exception):
|
|
traceback_str = " ".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
|
|
clean_msg = str(exc).replace("\n", " ").strip()
|
|
|
|
line_info = _TRACEBACK_RE.findall(traceback_str)
|
|
|
|
# Value info max chars len is 100
|
|
|
|
if not line_info:
|
|
return clean_msg[:100]
|
|
|
|
line_info = line_info[-1]
|
|
file_, line = line_info
|
|
|
|
extra = f"' ~ {os.path.basename(file_)}@{line}"[:90]
|
|
message = f"'{clean_msg}"[:100 - len(extra)]
|
|
|
|
return message + extra
|
|
|
|
|
|
def throttled_count(name):
|
|
global throttle_count
|
|
if name in list(throttle_count.keys()):
|
|
if 'count' in list(throttle_count[name].keys()):
|
|
for key, value in throttle_count[name].items():
|
|
if key == 'count':
|
|
value += 1
|
|
throttle_count[name]['count'] = value
|
|
else:
|
|
throttle_count[name] = {"count": 1, "time": (datetime.datetime.now() + datetime.timedelta(seconds=120))}
|
|
|
|
else:
|
|
throttle_count[name] = {"count": 1, "time": (datetime.datetime.now() + datetime.timedelta(seconds=120))}
|
|
|
|
if throttle_count[name]['count'] >= 5:
|
|
return True
|
|
if throttle_count[name]['time'] <= datetime.datetime.now():
|
|
throttle_count[name] = {"count": 1, "time": (datetime.datetime.now() + datetime.timedelta(seconds=120))}
|
|
logging.info("Provider %s throttle count %s of 5, waiting 5sec and trying again", name,
|
|
throttle_count[name]['count'])
|
|
time.sleep(5)
|
|
return False
|
|
|
|
|
|
def update_throttled_provider():
|
|
existing_providers = provider_registry.names()
|
|
providers_list = [x for x in settings.general.enabled_providers if x in existing_providers]
|
|
|
|
for provider in list(tp):
|
|
if provider not in providers_list:
|
|
del tp[provider]
|
|
set_throttled_providers(str(tp))
|
|
|
|
reason, until, throttle_desc = tp.get(provider, (None, None, None))
|
|
|
|
if reason:
|
|
now = datetime.datetime.now()
|
|
if now < until:
|
|
pass
|
|
else:
|
|
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
|
|
del tp[provider]
|
|
set_throttled_providers(str(tp))
|
|
|
|
reason, until, throttle_desc = tp.get(provider, (None, None, None))
|
|
|
|
if reason:
|
|
now = datetime.datetime.now()
|
|
if now >= until:
|
|
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
|
|
del tp[provider]
|
|
set_throttled_providers(str(tp))
|
|
|
|
event_stream(type='badges')
|
|
|
|
|
|
def list_throttled_providers():
|
|
update_throttled_provider()
|
|
throttled_providers = []
|
|
existing_providers = provider_registry.names()
|
|
providers = [x for x in settings.general.enabled_providers if x in existing_providers]
|
|
for provider in providers:
|
|
reason, until, throttle_desc = tp.get(provider, (None, None, None))
|
|
throttled_providers.append([provider, reason, pretty.date(until)])
|
|
return throttled_providers
|
|
|
|
|
|
def reset_throttled_providers(only_auth_or_conf_error=False):
|
|
for provider in list(tp):
|
|
if only_auth_or_conf_error and tp[provider][0] not in ['AuthenticationError', 'ConfigurationError',
|
|
'PaymentRequired']:
|
|
continue
|
|
del tp[provider]
|
|
set_throttled_providers(str(tp))
|
|
update_throttled_provider()
|
|
if only_auth_or_conf_error:
|
|
logging.info('BAZARR throttled providers have been reset (only AuthenticationError, ConfigurationError and '
|
|
'PaymentRequired).')
|
|
else:
|
|
logging.info('BAZARR throttled providers have been reset.')
|
|
|
|
|
|
def get_throttled_providers():
|
|
providers = {}
|
|
try:
|
|
if os.path.exists(os.path.join(args.config_dir, 'config', 'throttled_providers.dat')):
|
|
with open(os.path.normpath(os.path.join(args.config_dir, 'config', 'throttled_providers.dat')), 'r') as \
|
|
handle:
|
|
providers = eval(handle.read())
|
|
except Exception:
|
|
# set empty content in throttled_providers.dat
|
|
logging.error("Invalid content in throttled_providers.dat. Resetting")
|
|
set_throttled_providers(providers)
|
|
finally:
|
|
return providers
|
|
|
|
|
|
def set_throttled_providers(data):
|
|
with open(os.path.normpath(os.path.join(args.config_dir, 'config', 'throttled_providers.dat')), 'w+') as handle:
|
|
handle.write(data)
|
|
|
|
|
|
tp = get_throttled_providers()
|
|
if not isinstance(tp, dict):
|
|
raise ValueError('tp should be a dict')
|