diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 3a2a751f5..4e44ae236 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -155,7 +155,7 @@ validators = [ Validator('general.days_to_upgrade_subs', must_exist=True, default=7, is_type_of=int, gte=0, lte=30), Validator('general.upgrade_manual', must_exist=True, default=True, is_type_of=bool), Validator('general.anti_captcha_provider', must_exist=True, default=None, is_type_of=(NoneType, str), - is_in=[None, 'anti-captcha', 'death-by-captcha']), + is_in=[None, 'anti-captcha', 'death-by-captcha', 'captchaai']), Validator('general.wanted_search_frequency', must_exist=True, default=6, is_type_of=int, is_in=[6, 12, 24, 168, ONE_HUNDRED_YEARS_IN_HOURS]), Validator('general.wanted_search_frequency_movie', must_exist=True, default=6, is_type_of=int, @@ -385,6 +385,9 @@ validators = [ Validator('deathbycaptcha.username', must_exist=True, default='', is_type_of=str, cast=str), Validator('deathbycaptcha.password', must_exist=True, default='', is_type_of=str, cast=str), + # captchaai section + Validator('captchaai.captchaai_key', must_exist=True, default='', is_type_of=str), + # napisy24 section Validator('napisy24.username', must_exist=True, default='', is_type_of=str, cast=str), Validator('napisy24.password', must_exist=True, default='', is_type_of=str, cast=str), @@ -753,7 +756,8 @@ def save_settings(settings_items): os.environ["SZ_HI_EXTENSION"] = value or "" if key in ['settings-general-anti_captcha_provider', 'settings-anticaptcha-anti_captcha_key', - 'settings-deathbycaptcha-username', 'settings-deathbycaptcha-password']: + 'settings-deathbycaptcha-username', 'settings-deathbycaptcha-password', + 'settings-captchaai-captchaai_key']: configure_captcha = True if key in ['update_schedule', 'settings-general-use_sonarr', 'settings-general-use_radarr', @@ -981,6 +985,9 @@ def configure_captcha_func(): os.environ["ANTICAPTCHA_CLASS"] = 'DeathByCaptchaProxyLess' os.environ["ANTICAPTCHA_ACCOUNT_KEY"] = str(':'.join( {settings.deathbycaptcha.username, settings.deathbycaptcha.password})) + elif settings.general.anti_captcha_provider == 'captchaai' and settings.captchaai.captchaai_key != "": + os.environ["ANTICAPTCHA_CLASS"] = 'CaptchaAIProxyLess' + os.environ["ANTICAPTCHA_ACCOUNT_KEY"] = str(settings.captchaai.captchaai_key) else: os.environ["ANTICAPTCHA_CLASS"] = '' diff --git a/custom_libs/subliminal_patch/pitcher.py b/custom_libs/subliminal_patch/pitcher.py index d5da50f01..5e2b4533c 100644 --- a/custom_libs/subliminal_patch/pitcher.py +++ b/custom_libs/subliminal_patch/pitcher.py @@ -5,6 +5,8 @@ import os import time import logging import json +import base64 +import requests from subliminal.cache import region from dogpile.cache.api import NO_VALUE from python_anticaptcha import AnticaptchaClient, NoCaptchaTaskProxylessTask, NoCaptchaTask, ImageToTextTask, \ @@ -255,6 +257,206 @@ class DBCPitcher(DBCProxyLessPitcher): return payload +@registry.register +class CaptchaAIProxyLessPitcher(Pitcher): + name = "CaptchaAIProxyLess" + source = "captchaai.com" + host = "https://ocr.captchaai.com" + timeout = 180 + + def __init__(self, website_name, website_url, website_key, tries=3, host=None, *args, **kwargs): + super(CaptchaAIProxyLessPitcher, self).__init__(website_name, website_url, website_key, tries=tries, *args, + **kwargs) + self.host = host or self.host + + def get_client(self): + # CaptchaAI exposes a 2Captcha-compatible HTTP API (in.php/res.php), so a + # plain requests session is all that is needed. + return requests.Session() + + def get_job(self): + # The reCAPTCHA task is created in _throw() through the in.php endpoint. + pass + + @property + def in_params(self): + return { + "key": self.client_key, + "method": "userrecaptcha", + "googlekey": self.website_key, + "pageurl": self.website_url, + "json": 1, + } + + def _throw(self): + self.client = self.get_client() + for i in range(self.tries): + try: + resp = self.client.post("%s/in.php" % self.host, data=self.in_params, timeout=30) + payload = resp.json() + if payload.get("status") != 1: + request = payload.get("request") + if request == "ERROR_ZERO_BALANCE": + logger.error("%s: No balance left on captcha solving service. Exiting", self.website_name) + return + elif request == "ERROR_NO_SLOT_AVAILABLE": + logger.info("%s: No captcha solving slot available, retrying", self.website_name) + time.sleep(5.0) + continue + elif request in ("ERROR_KEY_DOES_NOT_EXIST", "ERROR_WRONG_USER_KEY"): + logger.error("%s: Bad CaptchaAI API key", self.website_name) + return + logger.error("%s: CaptchaAI returned an error: %s", self.website_name, request) + if i >= self.tries - 1: + return + continue + + job_id = payload.get("request") + deadline = time.time() + self.timeout + while time.time() < deadline: + time.sleep(5.0) + res = self.client.get( + "%s/res.php" % self.host, + params={"key": self.client_key, "action": "get", "id": job_id, "json": 1}, + timeout=30, + ) + res_payload = res.json() + if res_payload.get("status") == 1: + self.success = True + return res_payload.get("request") + if res_payload.get("request") == "CAPCHA_NOT_READY": + continue + logger.error("%s: CaptchaAI returned an error: %s", self.website_name, + res_payload.get("request")) + break + logger.info("%s: Captcha solving timed out, retrying", self.website_name) + except Exception: + if i >= self.tries - 1: + logger.error("%s: Captcha solving finally failed. Exiting", self.website_name) + return + logger.info("%s: Captcha solving failed, retrying", self.website_name) + time.sleep(5.0) + + +@registry.register +class CaptchaAIPitcher(CaptchaAIProxyLessPitcher): + name = "CaptchaAI" + proxy = None + needs_proxy = True + proxy_type = "HTTP" + user_agent = None + + def __init__(self, *args, **kwargs): + self.proxy = kwargs.pop("proxy", None) + self.user_agent = kwargs.pop("user_agent", None) + # cookies/is_invisible are accepted for call-site compatibility but unused. + kwargs.pop("cookies", None) + kwargs.pop("is_invisible", None) + super(CaptchaAIPitcher, self).__init__(*args, **kwargs) + + @property + def in_params(self): + params = super(CaptchaAIPitcher, self).in_params + if self.proxy: + params.update({"proxy": self.proxy, "proxytype": self.proxy_type}) + if self.user_agent: + params.update({"userAgent": self.user_agent}) + return params + + +@registry.register +class CaptchaAIImageToTextPitcher(Pitcher): + name = "CaptchaAIImageToText" + source = "captchaai.com" + host = "https://ocr.captchaai.com" + timeout = 180 + image_fp = None + + def __init__(self, website_name, image_fp, tries=3, client_key=None, *args, **kwargs): + super().__init__(website_name, tries, client_key, *args, **kwargs) + self.tries = tries + self.client_key = client_key or os.environ.get("ANTICAPTCHA_ACCOUNT_KEY") + if not self.client_key: + raise Exception("CaptchaAI key not given, exiting") + self.website_name = website_name + self.image_fp = image_fp + self.success = False + self.solve_time = None + + def get_client(self): + # CaptchaAI exposes a 2Captcha-compatible HTTP API (in.php/res.php), so a + # plain requests session is all that is needed. + return requests.Session() + + def get_job(self): + # The image task is created in _throw() through the in.php endpoint. + pass + + @property + def in_params(self): + # Read the image bytes and submit them as base64 (method=base64). + try: + self.image_fp.seek(0) + except (AttributeError, ValueError): + pass + body = base64.b64encode(self.image_fp.read()).decode("ascii") + return { + "key": self.client_key, + "method": "base64", + "body": body, + "json": 1, + } + + def _throw(self): + self.client = self.get_client() + for i in range(self.tries): + try: + resp = self.client.post("%s/in.php" % self.host, data=self.in_params, timeout=30) + payload = resp.json() + if payload.get("status") != 1: + request = payload.get("request") + if request == "ERROR_ZERO_BALANCE": + logger.error("%s: No balance left on captcha solving service. Exiting", self.website_name) + return + elif request == "ERROR_NO_SLOT_AVAILABLE": + logger.info("%s: No captcha solving slot available, retrying", self.website_name) + time.sleep(5.0) + continue + elif request in ("ERROR_KEY_DOES_NOT_EXIST", "ERROR_WRONG_USER_KEY"): + logger.error("%s: Bad CaptchaAI API key", self.website_name) + return + logger.error("%s: CaptchaAI returned an error: %s", self.website_name, request) + if i >= self.tries - 1: + return + continue + + job_id = payload.get("request") + deadline = time.time() + self.timeout + while time.time() < deadline: + time.sleep(5.0) + res = self.client.get( + "%s/res.php" % self.host, + params={"key": self.client_key, "action": "get", "id": job_id, "json": 1}, + timeout=30, + ) + res_payload = res.json() + if res_payload.get("status") == 1: + self.success = True + return res_payload.get("request") + if res_payload.get("request") == "CAPCHA_NOT_READY": + continue + logger.error("%s: CaptchaAI returned an error: %s", self.website_name, + res_payload.get("request")) + break + logger.info("%s: Captcha solving timed out, retrying", self.website_name) + except Exception: + if i >= self.tries - 1: + logger.error("%s: Captcha solving finally failed. Exiting", self.website_name) + return + logger.info("%s: Captcha solving failed, retrying", self.website_name) + time.sleep(5.0) + + @registry.register class AntiCaptchaImageToTextPitcher(Pitcher): name = "AntiCaptchaImageToText" diff --git a/custom_libs/subliminal_patch/providers/zimuku.py b/custom_libs/subliminal_patch/providers/zimuku.py index ffce3e21d..6c10d21bc 100644 --- a/custom_libs/subliminal_patch/providers/zimuku.py +++ b/custom_libs/subliminal_patch/providers/zimuku.py @@ -124,9 +124,14 @@ class ZimukuProvider(Provider): return img_fp fp = bmp_to_image(image_content) - pitcher = pitchers.get_pitcher("AntiCaptchaImageToText" if - os.environ["ANTICAPTCHA_CLASS"] == 'AntiCaptchaProxyLess' else - "DeathByCaptchaImageToText")("Zimuku", fp) + anticaptcha_class = os.environ["ANTICAPTCHA_CLASS"] + if anticaptcha_class == 'AntiCaptchaProxyLess': + image_pitcher = "AntiCaptchaImageToText" + elif anticaptcha_class == 'CaptchaAIProxyLess': + image_pitcher = "CaptchaAIImageToText" + else: + image_pitcher = "DeathByCaptchaImageToText" + pitcher = pitchers.get_pitcher(image_pitcher)("Zimuku", fp) return pitcher.throw() i = -1 diff --git a/frontend/src/pages/Settings/Providers/index.tsx b/frontend/src/pages/Settings/Providers/index.tsx index 43966d810..4d13d7af1 100644 --- a/frontend/src/pages/Settings/Providers/index.tsx +++ b/frontend/src/pages/Settings/Providers/index.tsx @@ -62,6 +62,17 @@ const SettingsProvidersView: FunctionComponent = () => { Link to subscribe + value === "captchaai"} + > + + CaptchaAI.com + Link to subscribe +
[] = [ label: "Death by Captcha", value: "death-by-captcha", }, + { + label: "CaptchaAI", + value: "captchaai", + }, ]; diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts index 51f845700..1f163fd4a 100644 --- a/frontend/src/types/settings.d.ts +++ b/frontend/src/types/settings.d.ts @@ -12,6 +12,7 @@ interface Settings { // Anitcaptcha anticaptcha: Settings.Anticaptcha; deathbycaptcha: Settings.DeathByCaptche; + captchaai: Settings.CaptchaAI; // Providers opensubtitlescom: Settings.OpenSubtitlesCom; addic7ed: Settings.Addic7ed; @@ -216,6 +217,10 @@ declare namespace Settings { password?: string; } + interface CaptchaAI { + captchaai_key?: string; + } + // Providers interface BaseProvider {