From 55c5384f9cd3b168167343a8177a07732cf1af94 Mon Sep 17 00:00:00 2001 From: Jeff Byrnes Date: Tue, 28 Nov 2023 16:12:04 -0500 Subject: [PATCH 01/49] Updated to apprise v1.6.0 Changelog: https://github.com/caronc/apprise/releases Highlights: * v1.6.0 * Notifiarr * v1.5.0 * Pushy * PushDeer * PushMe * RSyslog * v1.4.5 * WhatsApp * Burst SMS --- libs/_yaml/__init__.py | 33 ++ libs/apprise/Apprise.py | 89 ++- libs/apprise/AppriseAsset.py | 6 +- libs/apprise/AppriseAttachment.py | 6 +- libs/apprise/AppriseConfig.py | 6 +- libs/apprise/AppriseLocale.py | 231 +++++--- libs/apprise/URLBase.py | 100 +++- libs/apprise/__init__.py | 8 +- libs/apprise/attachment/AttachBase.py | 9 +- libs/apprise/attachment/AttachFile.py | 6 +- libs/apprise/attachment/AttachHTTP.py | 6 +- libs/apprise/attachment/__init__.py | 6 +- libs/apprise/cli.py | 6 +- libs/apprise/common.py | 6 +- libs/apprise/config/ConfigBase.py | 284 +++++++-- libs/apprise/config/ConfigFile.py | 6 +- libs/apprise/config/ConfigHTTP.py | 6 +- libs/apprise/config/ConfigMemory.py | 6 +- libs/apprise/config/__init__.py | 6 +- libs/apprise/conversion.py | 6 +- libs/apprise/decorators/CustomNotifyPlugin.py | 7 +- libs/apprise/decorators/__init__.py | 6 +- libs/apprise/decorators/notify.py | 6 +- libs/apprise/i18n/en/LC_MESSAGES/apprise.mo | Bin 455 -> 3959 bytes libs/apprise/logger.py | 6 +- libs/apprise/plugins/NotifyAppriseAPI.py | 24 +- libs/apprise/plugins/NotifyBark.py | 9 +- libs/apprise/plugins/NotifyBase.py | 42 +- libs/apprise/plugins/NotifyBoxcar.py | 25 +- libs/apprise/plugins/NotifyBulkSMS.py | 9 +- libs/apprise/plugins/NotifyBurstSMS.py | 460 ++++++++++++++ libs/apprise/plugins/NotifyClickSend.py | 6 +- libs/apprise/plugins/NotifyD7Networks.py | 7 +- libs/apprise/plugins/NotifyDBus.py | 6 +- libs/apprise/plugins/NotifyDapnet.py | 6 +- libs/apprise/plugins/NotifyDingTalk.py | 15 +- libs/apprise/plugins/NotifyDiscord.py | 257 +++++--- libs/apprise/plugins/NotifyEmail.py | 30 +- libs/apprise/plugins/NotifyEmby.py | 6 +- libs/apprise/plugins/NotifyEnigma2.py | 6 +- libs/apprise/plugins/NotifyFCM/__init__.py | 8 +- libs/apprise/plugins/NotifyFCM/color.py | 6 +- libs/apprise/plugins/NotifyFCM/common.py | 6 +- libs/apprise/plugins/NotifyFCM/oauth.py | 17 +- libs/apprise/plugins/NotifyFCM/priority.py | 6 +- libs/apprise/plugins/NotifyFaast.py | 6 +- libs/apprise/plugins/NotifyFlock.py | 13 +- libs/apprise/plugins/NotifyForm.py | 11 +- libs/apprise/plugins/NotifyGitter.py | 425 ------------- libs/apprise/plugins/NotifyGnome.py | 6 +- libs/apprise/plugins/NotifyGoogleChat.py | 6 +- libs/apprise/plugins/NotifyGotify.py | 7 +- libs/apprise/plugins/NotifyGrowl.py | 6 +- libs/apprise/plugins/NotifyGuilded.py | 6 +- libs/apprise/plugins/NotifyHomeAssistant.py | 6 +- libs/apprise/plugins/NotifyIFTTT.py | 7 +- libs/apprise/plugins/NotifyJSON.py | 77 +-- libs/apprise/plugins/NotifyJoin.py | 7 +- libs/apprise/plugins/NotifyKavenegar.py | 6 +- libs/apprise/plugins/NotifyKumulos.py | 6 +- libs/apprise/plugins/NotifyLametric.py | 8 +- libs/apprise/plugins/NotifyLine.py | 7 +- libs/apprise/plugins/NotifyMQTT.py | 10 +- libs/apprise/plugins/NotifyMSG91.py | 226 +++---- libs/apprise/plugins/NotifyMSTeams.py | 6 +- libs/apprise/plugins/NotifyMacOSX.py | 9 +- libs/apprise/plugins/NotifyMailgun.py | 18 +- libs/apprise/plugins/NotifyMastodon.py | 25 +- libs/apprise/plugins/NotifyMatrix.py | 256 ++++++-- libs/apprise/plugins/NotifyMatterMost.py | 12 +- libs/apprise/plugins/NotifyMattermost.py | 372 ++++++++++++ libs/apprise/plugins/NotifyMessageBird.py | 6 +- libs/apprise/plugins/NotifyMisskey.py | 7 +- libs/apprise/plugins/NotifyNextcloud.py | 46 +- libs/apprise/plugins/NotifyNextcloudTalk.py | 62 +- libs/apprise/plugins/NotifyNotica.py | 18 +- libs/apprise/plugins/NotifyNotifiarr.py | 472 +++++++++++++++ libs/apprise/plugins/NotifyNotifico.py | 6 +- libs/apprise/plugins/NotifyNtfy.py | 59 +- libs/apprise/plugins/NotifyOffice365.py | 13 +- libs/apprise/plugins/NotifyOneSignal.py | 7 +- libs/apprise/plugins/NotifyOpsgenie.py | 6 +- libs/apprise/plugins/NotifyPagerDuty.py | 8 +- libs/apprise/plugins/NotifyPagerTree.py | 6 +- libs/apprise/plugins/NotifyParsePlatform.py | 8 +- libs/apprise/plugins/NotifyPopcornNotify.py | 7 +- libs/apprise/plugins/NotifyProwl.py | 6 +- libs/apprise/plugins/NotifyPushBullet.py | 26 +- libs/apprise/plugins/NotifyPushDeer.py | 218 +++++++ libs/apprise/plugins/NotifyPushMe.py | 221 +++++++ libs/apprise/plugins/NotifyPushSafer.py | 11 +- libs/apprise/plugins/NotifyPushed.py | 6 +- libs/apprise/plugins/NotifyPushjet.py | 6 +- libs/apprise/plugins/NotifyPushover.py | 166 +++--- libs/apprise/plugins/NotifyPushy.py | 384 ++++++++++++ libs/apprise/plugins/NotifyRSyslog.py | 376 ++++++++++++ libs/apprise/plugins/NotifyReddit.py | 37 +- libs/apprise/plugins/NotifyRocketChat.py | 6 +- libs/apprise/plugins/NotifyRyver.py | 11 +- libs/apprise/plugins/NotifySES.py | 22 +- libs/apprise/plugins/NotifySMSEagle.py | 13 +- libs/apprise/plugins/NotifySMTP2Go.py | 13 +- libs/apprise/plugins/NotifySNS.py | 13 +- libs/apprise/plugins/NotifySendGrid.py | 7 +- libs/apprise/plugins/NotifyServerChan.py | 8 +- libs/apprise/plugins/NotifySignalAPI.py | 13 +- libs/apprise/plugins/NotifySimplePush.py | 10 +- libs/apprise/plugins/NotifySinch.py | 6 +- libs/apprise/plugins/NotifySlack.py | 25 +- libs/apprise/plugins/NotifySparkPost.py | 16 +- libs/apprise/plugins/NotifySpontit.py | 7 +- libs/apprise/plugins/NotifyStreamlabs.py | 9 +- libs/apprise/plugins/NotifySyslog.py | 193 +----- libs/apprise/plugins/NotifyTechulusPush.py | 6 +- libs/apprise/plugins/NotifyTelegram.py | 110 +++- libs/apprise/plugins/NotifyTwilio.py | 6 +- libs/apprise/plugins/NotifyTwist.py | 8 +- libs/apprise/plugins/NotifyTwitter.py | 28 +- libs/apprise/plugins/NotifyVoipms.py | 8 +- libs/apprise/plugins/NotifyVonage.py | 6 +- libs/apprise/plugins/NotifyWebexTeams.py | 6 +- libs/apprise/plugins/NotifyWhatsApp.py | 559 ++++++++++++++++++ libs/apprise/plugins/NotifyWindows.py | 6 +- libs/apprise/plugins/NotifyXBMC.py | 6 +- libs/apprise/plugins/NotifyXML.py | 22 +- libs/apprise/plugins/NotifyZulip.py | 7 +- libs/apprise/plugins/__init__.py | 37 +- libs/apprise/utils.py | 28 +- libs/version.txt | 2 +- 129 files changed, 4920 insertions(+), 1837 deletions(-) create mode 100644 libs/_yaml/__init__.py create mode 100644 libs/apprise/plugins/NotifyBurstSMS.py delete mode 100644 libs/apprise/plugins/NotifyGitter.py create mode 100644 libs/apprise/plugins/NotifyMattermost.py create mode 100644 libs/apprise/plugins/NotifyNotifiarr.py create mode 100644 libs/apprise/plugins/NotifyPushDeer.py create mode 100644 libs/apprise/plugins/NotifyPushMe.py create mode 100644 libs/apprise/plugins/NotifyPushy.py create mode 100644 libs/apprise/plugins/NotifyRSyslog.py create mode 100644 libs/apprise/plugins/NotifyWhatsApp.py diff --git a/libs/_yaml/__init__.py b/libs/_yaml/__init__.py new file mode 100644 index 000000000..7baa8c4b6 --- /dev/null +++ b/libs/_yaml/__init__.py @@ -0,0 +1,33 @@ +# This is a stub package designed to roughly emulate the _yaml +# extension module, which previously existed as a standalone module +# and has been moved into the `yaml` package namespace. +# It does not perfectly mimic its old counterpart, but should get +# close enough for anyone who's relying on it even when they shouldn't. +import yaml + +# in some circumstances, the yaml module we imoprted may be from a different version, so we need +# to tread carefully when poking at it here (it may not have the attributes we expect) +if not getattr(yaml, '__with_libyaml__', False): + from sys import version_info + + exc = ModuleNotFoundError if version_info >= (3, 6) else ImportError + raise exc("No module named '_yaml'") +else: + from yaml._yaml import * + import warnings + warnings.warn( + 'The _yaml extension module is now located at yaml._yaml' + ' and its location is subject to change. To use the' + ' LibYAML-based parser and emitter, import from `yaml`:' + ' `from yaml import CLoader as Loader, CDumper as Dumper`.', + DeprecationWarning + ) + del warnings + # Don't `del yaml` here because yaml is actually an existing + # namespace member of _yaml. + +__name__ = '_yaml' +# If the module is top-level (i.e. not a part of any specific package) +# then the attribute should be set to ''. +# https://docs.python.org/3.8/library/types.html +__package__ = '' diff --git a/libs/apprise/Apprise.py b/libs/apprise/Apprise.py index 8c2cf5330..4c83c481f 100644 --- a/libs/apprise/Apprise.py +++ b/libs/apprise/Apprise.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -458,7 +454,7 @@ class Apprise: logger.error(msg) raise TypeError(msg) - if not (title or body): + if not (title or body or attach): msg = "No message content specified to deliver" logger.error(msg) raise TypeError(msg) @@ -498,25 +494,29 @@ class Apprise: # If our code reaches here, we either did not define a tag (it # was set to None), or we did define a tag and the logic above # determined we need to notify the service it's associated with - if server.notify_format not in conversion_body_map: - # Perform Conversion - conversion_body_map[server.notify_format] = \ - convert_between( - body_format, server.notify_format, content=body) + + # First we need to generate a key we will use to determine if we + # need to build our data out. Entries without are merged with + # the body at this stage. + key = server.notify_format if server.title_maxlen > 0\ + else f'_{server.notify_format}' + + if key not in conversion_title_map: # Prepare our title - conversion_title_map[server.notify_format] = \ - '' if not title else title + conversion_title_map[key] = '' if not title else title - # Tidy Title IF required (hence it will become part of the - # body) - if server.title_maxlen <= 0 and \ - conversion_title_map[server.notify_format]: + # Conversion of title only occurs for services where the title + # is blended with the body (title_maxlen <= 0) + if conversion_title_map[key] and server.title_maxlen <= 0: + conversion_title_map[key] = convert_between( + body_format, server.notify_format, + content=conversion_title_map[key]) - conversion_title_map[server.notify_format] = \ - convert_between( - body_format, server.notify_format, - content=conversion_title_map[server.notify_format]) + # Our body is always converted no matter what + conversion_body_map[key] = \ + convert_between( + body_format, server.notify_format, content=body) if interpret_escapes: # @@ -526,13 +526,13 @@ class Apprise: try: # Added overhead required due to Python 3 Encoding Bug # identified here: https://bugs.python.org/issue21331 - conversion_body_map[server.notify_format] = \ - conversion_body_map[server.notify_format]\ + conversion_body_map[key] = \ + conversion_body_map[key]\ .encode('ascii', 'backslashreplace')\ .decode('unicode-escape') - conversion_title_map[server.notify_format] = \ - conversion_title_map[server.notify_format]\ + conversion_title_map[key] = \ + conversion_title_map[key]\ .encode('ascii', 'backslashreplace')\ .decode('unicode-escape') @@ -543,8 +543,8 @@ class Apprise: raise TypeError(msg) kwargs = dict( - body=conversion_body_map[server.notify_format], - title=conversion_title_map[server.notify_format], + body=conversion_body_map[key], + title=conversion_title_map[key], notify_type=notify_type, attach=attach, body_format=body_format @@ -685,6 +685,11 @@ class Apprise: # Placeholder - populated below 'details': None, + # Let upstream service know of the plugins that support + # attachments + 'attachment_support': getattr( + plugin, 'attachment_support', False), + # Differentiat between what is a custom loaded plugin and # which is native. 'category': getattr(plugin, 'category', None) @@ -810,6 +815,36 @@ class Apprise: # If we reach here, then we indexed out of range raise IndexError('list index out of range') + def __getstate__(self): + """ + Pickle Support dumps() + """ + attributes = { + 'asset': self.asset, + # Prepare our URL list as we need to extract the associated tags + # and asset details associated with it + 'urls': [{ + 'url': server.url(privacy=False), + 'tag': server.tags if server.tags else None, + 'asset': server.asset} for server in self.servers], + 'locale': self.locale, + 'debug': self.debug, + 'location': self.location, + } + + return attributes + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.servers = list() + self.asset = state['asset'] + self.locale = state['locale'] + self.location = state['location'] + for entry in state['urls']: + self.add(entry['url'], asset=entry['asset'], tag=entry['tag']) + def __bool__(self): """ Allows the Apprise object to be wrapped in an 'if statement'. diff --git a/libs/apprise/AppriseAsset.py b/libs/apprise/AppriseAsset.py index 34821e278..835c3b6ad 100644 --- a/libs/apprise/AppriseAsset.py +++ b/libs/apprise/AppriseAsset.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/AppriseAttachment.py b/libs/apprise/AppriseAttachment.py index 0a3913ed0..e00645d2d 100644 --- a/libs/apprise/AppriseAttachment.py +++ b/libs/apprise/AppriseAttachment.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/AppriseConfig.py b/libs/apprise/AppriseConfig.py index 8f2857776..07e7b48ed 100644 --- a/libs/apprise/AppriseConfig.py +++ b/libs/apprise/AppriseConfig.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/AppriseLocale.py b/libs/apprise/AppriseLocale.py index ce61d0c9b..c80afae27 100644 --- a/libs/apprise/AppriseLocale.py +++ b/libs/apprise/AppriseLocale.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -33,14 +29,13 @@ import ctypes import locale import contextlib +import os +import re from os.path import join from os.path import dirname from os.path import abspath from .logger import logger -# Define our translation domain -DOMAIN = 'apprise' -LOCALE_DIR = abspath(join(dirname(__file__), 'i18n')) # This gets toggled to True if we succeed GETTEXT_LOADED = False @@ -49,51 +44,41 @@ try: # Initialize gettext import gettext - # install() creates a _() in our builtins - gettext.install(DOMAIN, localedir=LOCALE_DIR) - # Toggle our flag GETTEXT_LOADED = True except ImportError: - # gettext isn't available; no problem, just fall back to using - # the library features without multi-language support. - import builtins - builtins.__dict__['_'] = lambda x: x # pragma: no branch + # gettext isn't available; no problem; Use the library features without + # multi-language support. + pass -class LazyTranslation: +class AppriseLocale: """ - Doesn't translate anything until str() or unicode() references - are made. + A wrapper class to gettext so that we can manipulate multiple lanaguages + on the fly if required. """ - def __init__(self, text, *args, **kwargs): - """ - Store our text - """ - self.text = text - super().__init__(*args, **kwargs) + # Define our translation domain + _domain = 'apprise' - def __str__(self): - return gettext.gettext(self.text) + # The path to our translations + _locale_dir = abspath(join(dirname(__file__), 'i18n')) + # Locale regular expression + _local_re = re.compile( + r'^((?PC)|(?P([a-z]{2}))([_:](?P[a-z]{2}))?)' + r'(\.(?P[a-z0-9-]+))?$', re.IGNORECASE) -# Lazy translation handling -def gettext_lazy(text): - """ - A dummy function that can be referenced - """ - return LazyTranslation(text=text) + # Define our default encoding + _default_encoding = 'utf-8' + # The function to assign `_` by default + _fn = 'gettext' -class AppriseLocale: - """ - A wrapper class to gettext so that we can manipulate multiple lanaguages - on the fly if required. - - """ + # The language we should fall back to if all else fails + _default_language = 'en' def __init__(self, language=None): """ @@ -110,25 +95,55 @@ class AppriseLocale: # Get our language self.lang = AppriseLocale.detect_language(language) + # Our mapping to our _fn + self.__fn_map = None + if GETTEXT_LOADED is False: # We're done return - if self.lang: + # Add language + self.add(self.lang) + + def add(self, lang=None, set_default=True): + """ + Add a language to our list + """ + lang = lang if lang else self._default_language + if lang not in self._gtobjs: # Load our gettext object and install our language try: - self._gtobjs[self.lang] = gettext.translation( - DOMAIN, localedir=LOCALE_DIR, languages=[self.lang]) + self._gtobjs[lang] = gettext.translation( + self._domain, localedir=self._locale_dir, languages=[lang], + fallback=False) + + # The non-intrusive method of applying the gettext change to + # the global namespace only + self.__fn_map = getattr(self._gtobjs[lang], self._fn) - # Install our language - self._gtobjs[self.lang].install() + except FileNotFoundError: + # The translation directory does not exist + logger.debug( + 'Could not load translation path: %s', + join(self._locale_dir, lang)) - except IOError: - # This occurs if we can't access/load our translations - pass + # Fallback (handle case where self.lang does not exist) + if self.lang not in self._gtobjs: + self._gtobjs[self.lang] = gettext + self.__fn_map = getattr(self._gtobjs[self.lang], self._fn) + + return False + + logger.trace('Loaded language %s', lang) + + if set_default: + logger.debug('Language set to %s', lang) + self.lang = lang + + return True @contextlib.contextmanager - def lang_at(self, lang): + def lang_at(self, lang, mapto=_fn): """ The syntax works as: with at.lang_at('fr'): @@ -138,50 +153,36 @@ class AppriseLocale: """ if GETTEXT_LOADED is False: - # yield - yield + # Do nothing + yield None # we're done return # Tidy the language lang = AppriseLocale.detect_language(lang, detect_fallback=False) - - # Now attempt to load it - try: - if lang in self._gtobjs: - if lang != self.lang: - # Install our language only if we aren't using it - # already - self._gtobjs[lang].install() - - else: - self._gtobjs[lang] = gettext.translation( - DOMAIN, localedir=LOCALE_DIR, languages=[self.lang]) - - # Install our language - self._gtobjs[lang].install() - + if lang not in self._gtobjs and not self.add(lang, set_default=False): + # Do Nothing + yield getattr(self._gtobjs[self.lang], mapto) + else: # Yield - yield + yield getattr(self._gtobjs[lang], mapto) - except (IOError, KeyError): - # This occurs if we can't access/load our translations - # Yield reguardless - yield + return - finally: - # Fall back to our previous language - if lang != self.lang and lang in self._gtobjs: - # Install our language - self._gtobjs[self.lang].install() + @property + def gettext(self): + """ + Return the current language gettext() function - return + Useful for assigning to `_` + """ + return self._gtobjs[self.lang].gettext @staticmethod def detect_language(lang=None, detect_fallback=True): """ - returns the language (if it's retrievable) + Returns the language (if it's retrievable) """ # We want to only use the 2 character version of this language # hence en_CA becomes en, en_US becomes en. @@ -190,6 +191,17 @@ class AppriseLocale: # no detection enabled; we're done return None + # Posix lookup + lookup = os.environ.get + localename = None + for variable in ('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE'): + localename = lookup(variable, None) + if localename: + result = AppriseLocale._local_re.match(localename) + if result and result.group('lang'): + return result.group('lang').lower() + + # Windows handling if hasattr(ctypes, 'windll'): windll = ctypes.windll.kernel32 try: @@ -203,11 +215,12 @@ class AppriseLocale: # Fallback to posix detection pass + # Built in locale library check try: - # Detect language - lang = locale.getdefaultlocale()[0] + # Acquire our locale + lang = locale.getlocale()[0] - except ValueError as e: + except (ValueError, TypeError) as e: # This occurs when an invalid locale was parsed from the # environment variable. While we still return None in this # case, we want to better notify the end user of this. Users @@ -217,9 +230,57 @@ class AppriseLocale: 'Language detection failure / {}'.format(str(e))) return None - except TypeError: - # None is returned if the default can't be determined - # we're done in this case - return None - return None if not lang else lang[0:2].lower() + + def __getstate__(self): + """ + Pickle Support dumps() + """ + state = self.__dict__.copy() + + # Remove the unpicklable entries. + del state['_gtobjs'] + del state['_AppriseLocale__fn_map'] + return state + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.__dict__.update(state) + # Our mapping to our _fn + self.__fn_map = None + self._gtobjs = {} + self.add(state['lang'], set_default=True) + + +# +# Prepare our default LOCALE Singleton +# +LOCALE = AppriseLocale() + + +class LazyTranslation: + """ + Doesn't translate anything until str() or unicode() references + are made. + + """ + def __init__(self, text, *args, **kwargs): + """ + Store our text + """ + self.text = text + + super().__init__(*args, **kwargs) + + def __str__(self): + return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text + + +# Lazy translation handling +def gettext_lazy(text): + """ + A dummy function that can be referenced + """ + return LazyTranslation(text=text) diff --git a/libs/apprise/URLBase.py b/libs/apprise/URLBase.py index 4b33920ea..1cea66d15 100644 --- a/libs/apprise/URLBase.py +++ b/libs/apprise/URLBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -204,7 +200,14 @@ class URLBase: self.verify_certificate = parse_bool(kwargs.get('verify', True)) # Secure Mode - self.secure = kwargs.get('secure', False) + self.secure = kwargs.get('secure', None) + try: + if not isinstance(self.secure, bool): + # Attempt to detect + self.secure = kwargs.get('schema', '')[-1].lower() == 's' + + except (TypeError, IndexError): + self.secure = False self.host = URLBase.unquote(kwargs.get('host')) self.port = kwargs.get('port') @@ -228,6 +231,11 @@ class URLBase: # Always unquote the password if it exists self.password = URLBase.unquote(self.password) + # Store our full path consistently ensuring it ends with a `/' + self.fullpath = URLBase.unquote(kwargs.get('fullpath')) + if not isinstance(self.fullpath, str) or not self.fullpath: + self.fullpath = '/' + # Store our Timeout Variables if 'rto' in kwargs: try: @@ -307,7 +315,36 @@ class URLBase: arguments provied. """ - raise NotImplementedError("url() is implimented by the child class.") + + # Our default parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=URLBase.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=URLBase.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema='https' if self.secure else 'http', + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=URLBase.quote(self.fullpath, safe='/') + if self.fullpath else '/', + params=URLBase.urlencode(params), + ) def __contains__(self, tags): """ @@ -583,6 +620,33 @@ class URLBase: """ return (self.socket_connect_timeout, self.socket_read_timeout) + @property + def request_auth(self): + """This is primarily used to fullfill the `auth` keyword argument + that is used by requests.get() and requests.put() calls. + """ + return (self.user, self.password) if self.user else None + + @property + def request_url(self): + """ + Assemble a simple URL that can be used by the requests library + + """ + + # Acquire our schema + schema = 'https' if self.secure else 'http' + + # Prepare our URL + url = '%s://%s' % (schema, self.host) + + # Apply Port information if present + if isinstance(self.port, int): + url += ':%d' % self.port + + # Append our full path + return url + self.fullpath + def url_parameters(self, *args, **kwargs): """ Provides a default set of args to work with. This can greatly @@ -603,7 +667,8 @@ class URLBase: } @staticmethod - def parse_url(url, verify_host=True, plus_to_space=False): + def parse_url(url, verify_host=True, plus_to_space=False, + strict_port=False): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. @@ -624,13 +689,13 @@ class URLBase: results = parse_url( url, default_schema='unknown', verify_host=verify_host, - plus_to_space=plus_to_space) + plus_to_space=plus_to_space, strict_port=strict_port) if not results: # We're done; we failed to parse our url return results - # if our URL ends with an 's', then assueme our secure flag is set. + # if our URL ends with an 's', then assume our secure flag is set. results['secure'] = (results['schema'][-1] == 's') # Support SSL Certificate 'verify' keyword. Default to being enabled @@ -650,6 +715,21 @@ class URLBase: if 'user' in results['qsd']: results['user'] = results['qsd']['user'] + # parse_url() always creates a 'password' and 'user' entry in the + # results returned. Entries are set to None if they weren't specified + if results['password'] is None and 'user' in results['qsd']: + # Handle cases where the user= provided in 2 locations, we want + # the original to fall back as a being a password (if one wasn't + # otherwise defined) + # e.g. + # mailtos://PASSWORD@hostname?user=admin@mail-domain.com + # - the PASSWORD gets lost in the parse url() since a user= + # over-ride is specified. + presults = parse_url(results['url']) + if presults: + # Store our Password + results['password'] = presults['user'] + # Store our socket read timeout if specified if 'rto' in results['qsd']: results['rto'] = results['qsd']['rto'] diff --git a/libs/apprise/__init__.py b/libs/apprise/__init__.py index 3a9136e96..f8bb5c752 100644 --- a/libs/apprise/__init__.py +++ b/libs/apprise/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -31,7 +27,7 @@ # POSSIBILITY OF SUCH DAMAGE. __title__ = 'Apprise' -__version__ = '1.4.0' +__version__ = '1.6.0' __author__ = 'Chris Caron' __license__ = 'BSD' __copywrite__ = 'Copyright (C) 2023 Chris Caron ' diff --git a/libs/apprise/attachment/AttachBase.py b/libs/apprise/attachment/AttachBase.py index 2b05c8497..c1cadbf91 100644 --- a/libs/apprise/attachment/AttachBase.py +++ b/libs/apprise/attachment/AttachBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -68,7 +64,8 @@ class AttachBase(URLBase): # set to zero (0), then no check is performed # 1 MB = 1048576 bytes # 5 MB = 5242880 bytes - max_file_size = 5242880 + # 1 GB = 1048576000 bytes + max_file_size = 1048576000 # By default all attachments types are inaccessible. # Developers of items identified in the attachment plugin directory diff --git a/libs/apprise/attachment/AttachFile.py b/libs/apprise/attachment/AttachFile.py index f89b915eb..d30855553 100644 --- a/libs/apprise/attachment/AttachFile.py +++ b/libs/apprise/attachment/AttachFile.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/attachment/AttachHTTP.py b/libs/apprise/attachment/AttachHTTP.py index d8b46ff28..0c859477e 100644 --- a/libs/apprise/attachment/AttachHTTP.py +++ b/libs/apprise/attachment/AttachHTTP.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/attachment/__init__.py b/libs/apprise/attachment/__init__.py index 1b0e1bfe6..ba7620a45 100644 --- a/libs/apprise/attachment/__init__.py +++ b/libs/apprise/attachment/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/cli.py b/libs/apprise/cli.py index a3335bbb5..130351802 100644 --- a/libs/apprise/cli.py +++ b/libs/apprise/cli.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/common.py b/libs/apprise/common.py index 8380c405e..aaf746eaa 100644 --- a/libs/apprise/common.py +++ b/libs/apprise/common.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/config/ConfigBase.py b/libs/apprise/config/ConfigBase.py index 5eb73ebcb..adddc4f56 100644 --- a/libs/apprise/config/ConfigBase.py +++ b/libs/apprise/config/ConfigBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -356,6 +352,77 @@ class ConfigBase(URLBase): # missing and/or expired. return True + @staticmethod + def __normalize_tag_groups(group_tags): + """ + Used to normalize a tag assign map which looks like: + { + 'group': set('{tag1}', '{group1}', '{tag2}'), + 'group1': set('{tag2}','{tag3}'), + } + + Then normalized it (merging groups); with respect to the above, the + output would be: + { + 'group': set('{tag1}', '{tag2}', '{tag3}), + 'group1': set('{tag2}','{tag3}'), + } + + """ + # Prepare a key set list we can use + tag_groups = set([str(x) for x in group_tags.keys()]) + + def _expand(tags, ignore=None): + """ + Expands based on tag provided and returns a set + + this also updates the group_tags while it goes + """ + + # Prepare ourselves a return set + results = set() + ignore = set() if ignore is None else ignore + + # track groups + groups = set() + + for tag in tags: + if tag in ignore: + continue + + # Track our groups + groups.add(tag) + + # Store what we know is worth keping + results |= group_tags[tag] - tag_groups + + # Get simple tag assignments + found = group_tags[tag] & tag_groups + if not found: + continue + + for gtag in found: + if gtag in ignore: + continue + + # Go deeper (recursion) + ignore.add(tag) + group_tags[gtag] = _expand(set([gtag]), ignore=ignore) + results |= group_tags[gtag] + + # Pop ignore + ignore.remove(tag) + + return results + + for tag in tag_groups: + # Get our tags + group_tags[tag] |= _expand(set([tag])) + if not group_tags[tag]: + ConfigBase.logger.warning( + 'The group {} has no tags assigned to it'.format(tag)) + del group_tags[tag] + @staticmethod def parse_url(url, verify_host=True): """Parses the URL and returns it broken apart into a dictionary. @@ -541,6 +608,9 @@ class ConfigBase(URLBase): # as additional configuration entries when loaded. include + # Assign tag contents to a group identifier + = + """ # A list of loaded Notification Services servers = list() @@ -549,6 +619,12 @@ class ConfigBase(URLBase): # the include keyword configs = list() + # Track all of the tags we want to assign later on + group_tags = {} + + # Track our entries to preload + preloaded = [] + # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() @@ -556,7 +632,7 @@ class ConfigBase(URLBase): valid_line_re = re.compile( r'^\s*(?P([;#]+(?P.*))|' r'(\s*(?P[a-z0-9, \t_-]+)\s*=|=)?\s*' - r'(?P[a-z0-9]{2,9}://.*)|' + r'((?P[a-z0-9]{1,12}://.*)|(?P[a-z0-9, \t_-]+))|' r'include\s+(?P.+))?\s*$', re.I) try: @@ -582,8 +658,13 @@ class ConfigBase(URLBase): # otherwise. return (list(), list()) - url, config = result.group('url'), result.group('config') - if not (url or config): + # Retrieve our line + url, assign, config = \ + result.group('url'), \ + result.group('assign'), \ + result.group('config') + + if not (url or config or assign): # Comment/empty line; do nothing continue @@ -603,6 +684,33 @@ class ConfigBase(URLBase): loggable_url = url if not asset.secure_logging \ else cwe312_url(url) + if assign: + groups = set(parse_list(result.group('tags'), cast=str)) + if not groups: + # no tags were assigned + ConfigBase.logger.warning( + 'Unparseable tag assignment - no group(s) ' + 'on line {}'.format(line)) + continue + + # Get our tags + tags = set(parse_list(assign, cast=str)) + if not tags: + # no tags were assigned + ConfigBase.logger.warning( + 'Unparseable tag assignment - no tag(s) to assign ' + 'on line {}'.format(line)) + continue + + # Update our tag group map + for tag_group in groups: + if tag_group not in group_tags: + group_tags[tag_group] = set() + + # ensure our tag group is never included in the assignment + group_tags[tag_group] |= tags - set([tag_group]) + continue + # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging) @@ -615,25 +723,57 @@ class ConfigBase(URLBase): # Build a list of tags to associate with the newly added # notifications if any were set - results['tag'] = set(parse_list(result.group('tags'))) + results['tag'] = set(parse_list(result.group('tags'), cast=str)) # Set our Asset Object results['asset'] = asset + # Store our preloaded entries + preloaded.append({ + 'results': results, + 'line': line, + 'loggable_url': loggable_url, + }) + + # + # Normalize Tag Groups + # - Expand Groups of Groups so that they don't exist + # + ConfigBase.__normalize_tag_groups(group_tags) + + # + # URL Processing + # + for entry in preloaded: + # Point to our results entry for easier reference below + results = entry['results'] + + # + # Apply our tag groups if they're defined + # + for group, tags in group_tags.items(): + # Detect if anything assigned to this tag also maps back to a + # group. If so we want to add the group to our list + if next((True for tag in results['tag'] + if tag in tags), False): + results['tag'].add(group) + try: # Attempt to create an instance of our plugin using the # parsed URL information - plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + plugin = common.NOTIFY_SCHEMA_MAP[ + results['schema']](**results) # Create log entry of loaded URL ConfigBase.logger.debug( - 'Loaded URL: %s', plugin.url(privacy=asset.secure_logging)) + 'Loaded URL: %s', plugin.url( + privacy=results['asset'].secure_logging)) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.warning( 'Could not load URL {} on line {}.'.format( - loggable_url, line)) + entry['loggable_url'], entry['line'])) ConfigBase.logger.debug('Loading Exception: %s' % str(e)) continue @@ -665,6 +805,12 @@ class ConfigBase(URLBase): # the include keyword configs = list() + # Group Assignments + group_tags = {} + + # Track our entries to preload + preloaded = [] + try: # Load our data (safely) result = yaml.load(content, Loader=yaml.SafeLoader) @@ -746,7 +892,45 @@ class ConfigBase(URLBase): tags = result.get('tag', None) if tags and isinstance(tags, (list, tuple, str)): # Store any preset tags - global_tags = set(parse_list(tags)) + global_tags = set(parse_list(tags, cast=str)) + + # + # groups root directive + # + groups = result.get('groups', None) + if not isinstance(groups, (list, tuple)): + # Not a problem; we simply have no group entry + groups = list() + + # Iterate over each group defined and store it + for no, entry in enumerate(groups): + if not isinstance(entry, dict): + ConfigBase.logger.warning( + 'No assignment for group {}, entry #{}'.format( + entry, no + 1)) + continue + + for _groups, tags in entry.items(): + for group in parse_list(_groups, cast=str): + if isinstance(tags, (list, tuple)): + _tags = set() + for e in tags: + if isinstance(e, dict): + _tags |= set(e.keys()) + else: + _tags |= set(parse_list(e, cast=str)) + + # Final assignment + tags = _tags + + else: + tags = set(parse_list(tags, cast=str)) + + if group not in group_tags: + group_tags[group] = tags + + else: + group_tags[group] |= tags # # include root directive @@ -938,8 +1122,8 @@ class ConfigBase(URLBase): # The below ensures our tags are set correctly if 'tag' in _results: # Tidy our list up - _results['tag'] = \ - set(parse_list(_results['tag'])) | global_tags + _results['tag'] = set( + parse_list(_results['tag'], cast=str)) | global_tags else: # Just use the global settings @@ -965,29 +1149,59 @@ class ConfigBase(URLBase): # Prepare our Asset Object _results['asset'] = asset - # Now we generate our plugin - try: - # Attempt to create an instance of our plugin using the - # parsed URL information - plugin = common.\ - NOTIFY_SCHEMA_MAP[_results['schema']](**_results) + # Store our preloaded entries + preloaded.append({ + 'results': _results, + 'entry': no + 1, + 'item': entry, + }) - # Create log entry of loaded URL - ConfigBase.logger.debug( - 'Loaded URL: {}'.format( - plugin.url(privacy=asset.secure_logging))) + # + # Normalize Tag Groups + # - Expand Groups of Groups so that they don't exist + # + ConfigBase.__normalize_tag_groups(group_tags) - except Exception as e: - # the arguments are invalid or can not be used. - ConfigBase.logger.warning( - 'Could not load Apprise YAML configuration ' - 'entry #{}, item #{}' - .format(no + 1, entry)) - ConfigBase.logger.debug('Loading Exception: %s' % str(e)) - continue + # + # URL Processing + # + for entry in preloaded: + # Point to our results entry for easier reference below + results = entry['results'] + + # + # Apply our tag groups if they're defined + # + for group, tags in group_tags.items(): + # Detect if anything assigned to this tag also maps back to a + # group. If so we want to add the group to our list + if next((True for tag in results['tag'] + if tag in tags), False): + results['tag'].add(group) + + # Now we generate our plugin + try: + # Attempt to create an instance of our plugin using the + # parsed URL information + plugin = common.\ + NOTIFY_SCHEMA_MAP[results['schema']](**results) - # if we reach here, we successfully loaded our data - servers.append(plugin) + # Create log entry of loaded URL + ConfigBase.logger.debug( + 'Loaded URL: %s', plugin.url( + privacy=results['asset'].secure_logging)) + + except Exception as e: + # the arguments are invalid or can not be used. + ConfigBase.logger.warning( + 'Could not load Apprise YAML configuration ' + 'entry #{}, item #{}' + .format(entry['entry'], entry['item'])) + ConfigBase.logger.debug('Loading Exception: %s' % str(e)) + continue + + # if we reach here, we successfully loaded our data + servers.append(plugin) return (servers, configs) diff --git a/libs/apprise/config/ConfigFile.py b/libs/apprise/config/ConfigFile.py index b2c211766..719355130 100644 --- a/libs/apprise/config/ConfigFile.py +++ b/libs/apprise/config/ConfigFile.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/config/ConfigHTTP.py b/libs/apprise/config/ConfigHTTP.py index 5813b90a9..8e8677c24 100644 --- a/libs/apprise/config/ConfigHTTP.py +++ b/libs/apprise/config/ConfigHTTP.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/config/ConfigMemory.py b/libs/apprise/config/ConfigMemory.py index ec44e9b4f..110e04a3c 100644 --- a/libs/apprise/config/ConfigMemory.py +++ b/libs/apprise/config/ConfigMemory.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/config/__init__.py b/libs/apprise/config/__init__.py index 7d03a34a8..4b7e3fd78 100644 --- a/libs/apprise/config/__init__.py +++ b/libs/apprise/config/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/conversion.py b/libs/apprise/conversion.py index 77c9aa5e5..d3781f606 100644 --- a/libs/apprise/conversion.py +++ b/libs/apprise/conversion.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/decorators/CustomNotifyPlugin.py b/libs/apprise/decorators/CustomNotifyPlugin.py index 9c8e7cb1d..5ccfded55 100644 --- a/libs/apprise/decorators/CustomNotifyPlugin.py +++ b/libs/apprise/decorators/CustomNotifyPlugin.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -28,6 +24,7 @@ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ..plugins.NotifyBase import NotifyBase diff --git a/libs/apprise/decorators/__init__.py b/libs/apprise/decorators/__init__.py index 699fd0da4..5b089bbf5 100644 --- a/libs/apprise/decorators/__init__.py +++ b/libs/apprise/decorators/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/decorators/notify.py b/libs/apprise/decorators/notify.py index 36842b419..07b4ceb1e 100644 --- a/libs/apprise/decorators/notify.py +++ b/libs/apprise/decorators/notify.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo index 925d178f068a9c3b83368ac2a1dc65e69eabe7f1..0236722fd244ec05141fa5179d8aa6cd3772af5b 100644 GIT binary patch literal 3959 zcmeH|?~7Db7{^bkrIS{!M1c}Vuqff~&Tg*S=&G|n)Ybhl?aZc8gm-7o&R)H9&vfsd zbvGg^iC$zuU|=ak^umIEy(p-ppnM@h7T6E$MIjMV22oJyi-^A8d!O0er2im?v*&Z3 z=RD7Ip7We@&wkf3|9OSq{rFpfzu&K>H2eE>fl}8(JPl^RufQeXIpHthV#t4i*MWdLB=@@-Uz-5((Zlmdhi5@rp};a zoNq+_9%Md0fz104kbc|@^Ur`tS=|EC?pBcgt7ZN!na_*74P?HfB9DQzuY>c!X^|fV zY4;R}CDkF2@sEI9_ZY}^-xd9Fkaj1*rQl~E^F1f?KZD%oMUZ~iU{TI52C2VUcst1W zognMf12T`2=v|QUElB^zLDu!4=#PSo_om1nf{gb$$T;7E+{XnFsi;3e+W!sGZV8Hu zp;`_y{wk39wt=+o6m|*wgo7aMM?uzc56CzML{ovtQ(%%e$an{ZheUrAWS+-B=JO87 zx_$!A15b&38a#59QeT3MQ^%$lcM7E60pW8Xe?STG27i29W2q6Qtb(AnQ5~GXDn1IFE?@B#0%{L6G?z2I=<- z$o$^`S@+{0?LG$Se_H0hmiZqEey_>=TcZC!cnU;QXVI}v=RxLiQS|c>gz;NI z#$N@pu5F^<45F!fMBW23UI;SIV<7kO49NTN0*IzwMn}81LDu6VknvA~%=ZjP`?JDx z!XJgdg0#N`vX1kR6yq!ed0tCJUJfRCgN)ZETqpX?AoJV-GM`-_>uNyWYhUC#sPct= zz0*vqe7S6*=(3^i?`=*LO*u4iQ!3egCZJ#&L9B~3t5kHNja0r~_r0^Yv~}J@0oCe!1$5su|d?RnJ`&rMvz5% zO>D}srvLE%z=W#TOnPO*P4#+F-FK(;R%>HU*qZAFs<#m)S*X5A6U329XZ3)sTD3K_ zHQfg@KOH6+CCb8t4U8VLYFlU#HFf zd*e(Ythuoo4y$e;RZzo|CLH(eeho{b3T_nbw_!yUY#6IT=%HNkGzNCk!z#(%M@$vv zRw>eQjhLF9G|fX9v9=be;vkYMnh^0-u{>dFu1brg=bXQ2cq=e$geFZRb<-uUW^5x? zMI3#FeTb6oKut?-ST(Vhvza5tthmgkNAPcH&U9K}u}y@iY1s~8?u>h)na!h!JBM6ZUU4b8)j6?Cw(gaiQ=<{em6{=*nTkmkx26_Tpo4`YVov%xSk?4?U#S-nyo> zGc#i9D48=DRlSPS-Ka)R$>y|=i{4SSeq~FQ-$rZM)-r|RlG78K;pbDe9Q?3!#AhRz5+Np9eByESmhWwWW>fE&e5DRhI#cVipoP;n%td)yE& z%*OvnVN+(n4XO>i9yx7-8Nn$Tw?>WLI54oq_gZaO-IOW#`0Ra$-(i%~K|K+pu8s{l z9d_Lj=#^c%{mu=U!T!O%+4{7%W;1wmV!RbjX}XTJV%&{3JS<+YL6;}oFfwshBOZ6w z&)RVp<0f>H>+A*9oL)cX#hGme4-`s;(oW}ew>xHh-O<|4C9XWtSDtA8@4fOw|G%H; Fe*o8VrcwX^ delta 98 zcmew^cbwVpo)F7a1|VPrVi_P-0dbIk4v;+)D5(n)3n @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyAppriseAPI.py b/libs/apprise/plugins/NotifyAppriseAPI.py index b8765496f..3c85b8ac6 100644 --- a/libs/apprise/plugins/NotifyAppriseAPI.py +++ b/libs/apprise/plugins/NotifyAppriseAPI.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -77,6 +73,9 @@ class NotifyAppriseAPI(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_apprise_api' + # Support attachments + attachment_support = True + # Depending on the number of transactions/notifications taking place, this # could take a while. 30 seconds should be enough to perform the task socket_read_timeout = 30.0 @@ -164,10 +163,6 @@ class NotifyAppriseAPI(NotifyBase): """ super().__init__(**kwargs) - self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, str): - self.fullpath = '/' - self.token = validate_regex( token, *self.template_tokens['token']['regex']) if not self.token: @@ -260,7 +255,7 @@ class NotifyAppriseAPI(NotifyBase): attachments = [] files = [] - if attach: + if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: @@ -310,7 +305,10 @@ class NotifyAppriseAPI(NotifyBase): if self.method == AppriseAPIMethod.JSON: headers['Content-Type'] = 'application/json' - payload['attachments'] = attachments + + if attachments: + payload['attachments'] = attachments + payload = dumps(payload) if self.__tags: @@ -328,8 +326,8 @@ class NotifyAppriseAPI(NotifyBase): url += ':%d' % self.port fullpath = self.fullpath.strip('/') - url += '/{}/'.format(fullpath) if fullpath else '/' - url += 'notify/{}'.format(self.token) + url += '{}'.format('/' + fullpath) if fullpath else '' + url += '/notify/{}'.format(self.token) # Some entries can not be over-ridden headers.update({ diff --git a/libs/apprise/plugins/NotifyBark.py b/libs/apprise/plugins/NotifyBark.py index f1c6d7bf9..edef82bd8 100644 --- a/libs/apprise/plugins/NotifyBark.py +++ b/libs/apprise/plugins/NotifyBark.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -127,10 +123,10 @@ class NotifyBark(NotifyBase): # Define object templates templates = ( + '{schema}://{host}/{targets}', '{schema}://{host}:{port}/{targets}', '{schema}://{user}:{password}@{host}/{targets}', '{schema}://{user}:{password}@{host}:{port}/{targets}', - '{schema}://{user}:{password}@{host}/{targets}', ) # Define our template arguments @@ -163,6 +159,7 @@ class NotifyBark(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/NotifyBase.py index 1b07baa71..5138c15c8 100644 --- a/libs/apprise/plugins/NotifyBase.py +++ b/libs/apprise/plugins/NotifyBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -139,6 +135,18 @@ class NotifyBase(URLBase): # Default Overflow Mode overflow_mode = OverflowMode.UPSTREAM + # Support Attachments; this defaults to being disabled. + # Since apprise allows you to send attachments without a body or title + # defined, by letting Apprise know the plugin won't support attachments + # up front, it can quickly pass over and ignore calls to these end points. + + # You must set this to true if your application can handle attachments. + # You must also consider a flow change to your notification if this is set + # to True as well as now there will be cases where both the body and title + # may not be set. There will never be a case where a body, or attachment + # isn't set in the same call to your notify() function. + attachment_support = False + # Default Title HTML Tagging # When a title is specified for a notification service that doesn't accept # titles, by default apprise tries to give a plesant view and convert the @@ -316,7 +324,7 @@ class NotifyBase(URLBase): the_cors = (do_send(**kwargs2) for kwargs2 in send_calls) return all(await asyncio.gather(*the_cors)) - def _build_send_calls(self, body, title=None, + def _build_send_calls(self, body=None, title=None, notify_type=NotifyType.INFO, overflow=None, attach=None, body_format=None, **kwargs): """ @@ -339,6 +347,28 @@ class NotifyBase(URLBase): # bad attachments raise + # Handle situations where the body is None + body = '' if not body else body + + elif not (body or attach): + # If there is not an attachment at the very least, a body must be + # present + msg = "No message body or attachment was specified." + self.logger.warning(msg) + raise TypeError(msg) + + if not body and not self.attachment_support: + # If no body was specified, then we know that an attachment + # was. This is logic checked earlier in the code. + # + # Knowing this, if the plugin itself doesn't support sending + # attachments, there is nothing further to do here, just move + # along. + msg = f"{self.service_name} does not support attachments; " \ + " service skipped" + self.logger.warning(msg) + raise TypeError(msg) + # Handle situations where the title is None title = '' if not title else title diff --git a/libs/apprise/plugins/NotifyBoxcar.py b/libs/apprise/plugins/NotifyBoxcar.py index 8e7045c7b..9d3be6aec 100644 --- a/libs/apprise/plugins/NotifyBoxcar.py +++ b/libs/apprise/plugins/NotifyBoxcar.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -151,6 +147,12 @@ class NotifyBoxcar(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'access': { + 'alias_of': 'access_key', + }, + 'secret': { + 'alias_of': 'secret_key', + }, }) def __init__(self, access, secret, targets=None, include_image=True, @@ -234,8 +236,7 @@ class NotifyBoxcar(NotifyBase): if title: payload['aps']['@title'] = title - if body: - payload['aps']['alert'] = body + payload['aps']['alert'] = body if self._tags: payload['tags'] = {'or': self._tags} @@ -381,6 +382,16 @@ class NotifyBoxcar(NotifyBase): results['targets'] += \ NotifyBoxcar.parse_list(results['qsd'].get('to')) + # Access + if 'access' in results['qsd'] and results['qsd']['access']: + results['access'] = NotifyBoxcar.unquote( + results['qsd']['access'].strip()) + + # Secret + if 'secret' in results['qsd'] and results['qsd']['secret']: + results['secret'] = NotifyBoxcar.unquote( + results['qsd']['secret'].strip()) + # Include images with our message results['include_image'] = \ parse_bool(results['qsd'].get('image', True)) diff --git a/libs/apprise/plugins/NotifyBulkSMS.py b/libs/apprise/plugins/NotifyBulkSMS.py index 814badaef..cf82a87a4 100644 --- a/libs/apprise/plugins/NotifyBulkSMS.py +++ b/libs/apprise/plugins/NotifyBulkSMS.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -121,11 +117,13 @@ class NotifyBulkSMS(NotifyBase): 'user': { 'name': _('User Name'), 'type': 'string', + 'required': True, }, 'password': { 'name': _('Password'), 'type': 'string', 'private': True, + 'required': True, }, 'target_phone': { 'name': _('Target Phone No'), @@ -144,6 +142,7 @@ class NotifyBulkSMS(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyBurstSMS.py b/libs/apprise/plugins/NotifyBurstSMS.py new file mode 100644 index 000000000..59219b3d1 --- /dev/null +++ b/libs/apprise/plugins/NotifyBurstSMS.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# Sign-up with https://burstsms.com/ +# +# Define your API Secret here and acquire your API Key +# - https://can.transmitsms.com/profile +# +import requests + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class BurstSMSCountryCode: + # Australia + AU = 'au' + # New Zeland + NZ = 'nz' + # United Kingdom + UK = 'gb' + # United States + US = 'us' + + +BURST_SMS_COUNTRY_CODES = ( + BurstSMSCountryCode.AU, + BurstSMSCountryCode.NZ, + BurstSMSCountryCode.UK, + BurstSMSCountryCode.US, +) + + +class NotifyBurstSMS(NotifyBase): + """ + A wrapper for Burst SMS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Burst SMS' + + # The services URL + service_url = 'https://burstsms.com/' + + # The default protocol + secure_protocol = 'burstsms' + + # The maximum amount of SMS Messages that can reside within a single + # batch transfer based on: + # https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c + default_batch_size = 500 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_burst_sms' + + # Burst SMS uses the http protocol with JSON requests + notify_url = 'https://api.transmitsms.com/send-sms.json' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{apikey}:{secret}@{sender_id}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + 'private': True, + }, + 'secret': { + 'name': _('API Secret'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'sender_id': { + 'name': _('Sender ID'), + 'type': 'string', + 'required': True, + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'sender_id', + }, + 'key': { + 'alias_of': 'apikey', + }, + 'secret': { + 'alias_of': 'secret', + }, + 'country': { + 'name': _('Country'), + 'type': 'choice:string', + 'values': BURST_SMS_COUNTRY_CODES, + 'default': BurstSMSCountryCode.US, + }, + # Validity + # Expire a message send if it is undeliverable (defined in minutes) + # If set to Zero (0); this is the default and sets the max validity + # period + 'validity': { + 'name': _('validity'), + 'type': 'int', + 'default': 0 + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, apikey, secret, source, targets=None, country=None, + validity=None, batch=None, **kwargs): + """ + Initialize Burst SMS Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Burst SMS API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # API Secret (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret']['regex']) + if not self.secret: + msg = 'An invalid Burst SMS API Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + if not country: + self.country = self.template_args['country']['default'] + + else: + self.country = country.lower().strip() + if country not in BURST_SMS_COUNTRY_CODES: + msg = 'An invalid Burst SMS country ' \ + '({}) was specified.'.format(country) + self.logger.warning(msg) + raise TypeError(msg) + + # Set our Validity + self.validity = self.template_args['validity']['default'] + if validity: + try: + self.validity = int(validity) + + except (ValueError, TypeError): + msg = 'The Burst SMS Validity specified ({}) is invalid.'\ + .format(validity) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch + + # The Sender ID + self.source = validate_regex(source) + if not self.source: + msg = 'The Account Sender ID specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['full']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Burst SMS Notification + """ + + if not self.targets: + self.logger.warning( + 'There are no valid Burst SMS targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + } + + # Prepare our authentication + auth = (self.apikey, self.secret) + + # Prepare our payload + payload = { + 'countrycode': self.country, + 'message': body, + + # Sender ID + 'from': self.source, + + # The to gets populated in the loop below + 'to': None, + } + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Create a copy of the targets list + targets = list(self.targets) + + for index in range(0, len(targets), batch_size): + + # Prepare our user + payload['to'] = ','.join(self.targets[index:index + batch_size]) + + # Some Debug Logging + self.logger.debug('Burst SMS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Burst SMS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBurstSMS.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Burst SMS notification to {} ' + 'target(s): {}{}error={}.'.format( + len(self.targets[index:index + batch_size]), + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Burst SMS notification to %d target(s).' % + len(self.targets[index:index + batch_size])) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Burst SMS ' + 'notification to %d target(s).' % + len(self.targets[index:index + batch_size])) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'country': self.country, + 'batch': 'yes' if self.batch else 'no', + } + + if self.validity: + params['validity'] = str(self.validity) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format( + schema=self.secure_protocol, + key=self.pprint(self.apikey, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), + source=NotifyBurstSMS.quote(self.source, safe=''), + targets='/'.join( + [NotifyBurstSMS.quote(x, safe='') for x in self.targets]), + params=NotifyBurstSMS.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The hostname is our source (Sender ID) + results['source'] = NotifyBurstSMS.unquote(results['host']) + + # Get any remaining targets + results['targets'] = NotifyBurstSMS.split_path(results['fullpath']) + + # Get our account_side and auth_token from the user/pass config + results['apikey'] = NotifyBurstSMS.unquote(results['user']) + results['secret'] = NotifyBurstSMS.unquote(results['password']) + + # API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + # Extract the API Key from an argument + results['apikey'] = \ + NotifyBurstSMS.unquote(results['qsd']['key']) + + # API Secret + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyBurstSMS.unquote(results['qsd']['secret']) + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyBurstSMS.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyBurstSMS.unquote(results['qsd']['source']) + + # Support country + if 'country' in results['qsd'] and len(results['qsd']['country']): + results['country'] = \ + NotifyBurstSMS.unquote(results['qsd']['country']) + + # Support validity value + if 'validity' in results['qsd'] and len(results['qsd']['validity']): + results['validity'] = \ + NotifyBurstSMS.unquote(results['qsd']['validity']) + + # Get Batch Mode Flag + if 'batch' in results['qsd'] and len(results['qsd']['batch']): + results['batch'] = parse_bool(results['qsd']['batch']) + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyBurstSMS.parse_phone_no(results['qsd']['to']) + + return results diff --git a/libs/apprise/plugins/NotifyClickSend.py b/libs/apprise/plugins/NotifyClickSend.py index ed6e462fc..670e74e80 100644 --- a/libs/apprise/plugins/NotifyClickSend.py +++ b/libs/apprise/plugins/NotifyClickSend.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyD7Networks.py b/libs/apprise/plugins/NotifyD7Networks.py index 3d0ee8aa4..3e7787da8 100644 --- a/libs/apprise/plugins/NotifyD7Networks.py +++ b/libs/apprise/plugins/NotifyD7Networks.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -114,6 +110,7 @@ class NotifyD7Networks(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyDBus.py b/libs/apprise/plugins/NotifyDBus.py index 62a1093c8..7d357aa75 100644 --- a/libs/apprise/plugins/NotifyDBus.py +++ b/libs/apprise/plugins/NotifyDBus.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyDapnet.py b/libs/apprise/plugins/NotifyDapnet.py index 1b718286a..5848b6886 100644 --- a/libs/apprise/plugins/NotifyDapnet.py +++ b/libs/apprise/plugins/NotifyDapnet.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyDingTalk.py b/libs/apprise/plugins/NotifyDingTalk.py index ae2a9b499..91bfcd6fb 100644 --- a/libs/apprise/plugins/NotifyDingTalk.py +++ b/libs/apprise/plugins/NotifyDingTalk.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -103,13 +99,18 @@ class NotifyDingTalk(NotifyBase): 'regex': (r'^[a-z0-9]+$', 'i'), }, 'secret': { - 'name': _('Token'), + 'name': _('Secret'), 'type': 'string', 'private': True, 'regex': (r'^[a-z0-9]+$', 'i'), }, - 'targets': { + 'target_phone_no': { 'name': _('Target Phone No'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), 'type': 'list:string', }, }) diff --git a/libs/apprise/plugins/NotifyDiscord.py b/libs/apprise/plugins/NotifyDiscord.py index fff76eef2..f87b66944 100644 --- a/libs/apprise/plugins/NotifyDiscord.py +++ b/libs/apprise/plugins/NotifyDiscord.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -50,6 +46,9 @@ import re import requests from json import dumps +from datetime import timedelta +from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..common import NotifyImageSize @@ -81,9 +80,23 @@ class NotifyDiscord(NotifyBase): # Discord Webhook notify_url = 'https://discord.com/api/webhooks' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 + # Discord is kind enough to return how many more requests we're allowed to + # continue to make within it's header response as: + # X-RateLimit-Reset: The epoc time (in seconds) we can expect our + # rate-limit to be reset. + # X-RateLimit-Remaining: an integer identifying how many requests we're + # still allow to make. + request_rate_per_sec = 0 + + # Taken right from google.auth.helpers: + clock_skew = timedelta(seconds=10) + # The maximum allowable characters allowed in the body per message body_maxlen = 2000 @@ -135,6 +148,13 @@ class NotifyDiscord(NotifyBase): 'name': _('Avatar URL'), 'type': 'string', }, + 'href': { + 'name': _('URL'), + 'type': 'string', + }, + 'url': { + 'alias_of': 'href', + }, # Send a message to the specified thread within a webhook's channel. # The thread will automatically be unarchived. 'thread': { @@ -166,7 +186,8 @@ class NotifyDiscord(NotifyBase): def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, footer=False, footer_logo=True, include_image=False, - fields=True, avatar_url=None, thread=None, **kwargs): + fields=True, avatar_url=None, href=None, thread=None, + **kwargs): """ Initialize Discord Object @@ -215,6 +236,15 @@ class NotifyDiscord(NotifyBase): # dynamically generated avatar url images self.avatar_url = avatar_url + # A URL to have the title link to + self.href = href + + # For Tracking Purposes + self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) + + # Default to 1.0 + self.ratelimit_remaining = 1.0 + return def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, @@ -235,61 +265,6 @@ class NotifyDiscord(NotifyBase): # Acquire image_url image_url = self.image_url(notify_type) - # our fields variable - fields = [] - - if self.notify_format == NotifyFormat.MARKDOWN: - # Use embeds for payload - payload['embeds'] = [{ - 'author': { - 'name': self.app_id, - 'url': self.app_url, - }, - 'title': title, - 'description': body, - - # Our color associated with our notification - 'color': self.color(notify_type, int), - }] - - if self.footer: - # Acquire logo URL - logo_url = self.image_url(notify_type, logo=True) - - # Set Footer text to our app description - payload['embeds'][0]['footer'] = { - 'text': self.app_desc, - } - - if self.footer_logo and logo_url: - payload['embeds'][0]['footer']['icon_url'] = logo_url - - if self.include_image and image_url: - payload['embeds'][0]['thumbnail'] = { - 'url': image_url, - 'height': 256, - 'width': 256, - } - - if self.fields: - # Break titles out so that we can sort them in embeds - description, fields = self.extract_markdown_sections(body) - - # Swap first entry for description - payload['embeds'][0]['description'] = description - if fields: - # Apply our additional parsing for a better presentation - payload['embeds'][0]['fields'] = \ - fields[:self.discord_max_fields] - - # Remove entry from head of fields - fields = fields[self.discord_max_fields:] - - else: - # not markdown - payload['content'] = \ - body if not title else "{}\r\n{}".format(title, body) - if self.avatar and (image_url or self.avatar_url): payload['avatar_url'] = \ self.avatar_url if self.avatar_url else image_url @@ -298,22 +273,84 @@ class NotifyDiscord(NotifyBase): # Optionally override the default username of the webhook payload['username'] = self.user + # Associate our thread_id with our message params = {'thread_id': self.thread_id} if self.thread_id else None - if not self._send(payload, params=params): - # We failed to post our message - return False - # Process any remaining fields IF set - if fields: - payload['embeds'][0]['description'] = '' - for i in range(0, len(fields), self.discord_max_fields): - payload['embeds'][0]['fields'] = \ - fields[i:i + self.discord_max_fields] - if not self._send(payload): - # We failed to post our message - return False + if body: + # our fields variable + fields = [] + + if self.notify_format == NotifyFormat.MARKDOWN: + # Use embeds for payload + payload['embeds'] = [{ + 'author': { + 'name': self.app_id, + 'url': self.app_url, + }, + 'title': title, + 'description': body, + + # Our color associated with our notification + 'color': self.color(notify_type, int), + }] + + if self.href: + payload['embeds'][0]['url'] = self.href + + if self.footer: + # Acquire logo URL + logo_url = self.image_url(notify_type, logo=True) + + # Set Footer text to our app description + payload['embeds'][0]['footer'] = { + 'text': self.app_desc, + } + + if self.footer_logo and logo_url: + payload['embeds'][0]['footer']['icon_url'] = logo_url + + if self.include_image and image_url: + payload['embeds'][0]['thumbnail'] = { + 'url': image_url, + 'height': 256, + 'width': 256, + } + + if self.fields: + # Break titles out so that we can sort them in embeds + description, fields = self.extract_markdown_sections(body) + + # Swap first entry for description + payload['embeds'][0]['description'] = description + if fields: + # Apply our additional parsing for a better + # presentation + payload['embeds'][0]['fields'] = \ + fields[:self.discord_max_fields] + + # Remove entry from head of fields + fields = fields[self.discord_max_fields:] - if attach: + else: + # not markdown + payload['content'] = \ + body if not title else "{}\r\n{}".format(title, body) + + if not self._send(payload, params=params): + # We failed to post our message + return False + + # Process any remaining fields IF set + if fields: + payload['embeds'][0]['description'] = '' + for i in range(0, len(fields), self.discord_max_fields): + payload['embeds'][0]['fields'] = \ + fields[i:i + self.discord_max_fields] + if not self._send(payload): + # We failed to post our message + return False + + if attach and self.attachment_support: # Update our payload; the idea is to preserve it's other detected # and assigned values for re-use here too payload.update({ @@ -336,14 +373,15 @@ class NotifyDiscord(NotifyBase): for attachment in attach: self.logger.info( 'Posting Discord Attachment {}'.format(attachment.name)) - if not self._send(payload, attach=attachment): + if not self._send(payload, params=params, attach=attachment): # We failed to post our message return False # Otherwise return return True - def _send(self, payload, attach=None, params=None, **kwargs): + def _send(self, payload, attach=None, params=None, rate_limit=1, + **kwargs): """ Wrapper to the requests (post) object """ @@ -365,8 +403,25 @@ class NotifyDiscord(NotifyBase): )) self.logger.debug('Discord Payload: %s' % str(payload)) - # Always call throttle before any remote server i/o is made - self.throttle() + # By default set wait to None + wait = None + + if self.ratelimit_remaining <= 0.0: + # Determine how long we should wait for or if we should wait at + # all. This isn't fool-proof because we can't be sure the client + # time (calling this script) is completely synced up with the + # Discord server. One would hope we're on NTP and our clocks are + # the same allowing this to role smoothly: + + now = datetime.now(timezone.utc).replace(tzinfo=None) + if now < self.ratelimit_reset: + # We need to throttle for the difference in seconds + wait = abs( + (self.ratelimit_reset - now + self.clock_skew) + .total_seconds()) + + # Always call throttle before any remote server i/o is made; + self.throttle(wait=wait) # Perform some simple error checking if isinstance(attach, AttachBase): @@ -401,6 +456,22 @@ class NotifyDiscord(NotifyBase): verify=self.verify_certificate, timeout=self.request_timeout, ) + + # Handle rate limiting (if specified) + try: + # Store our rate limiting (if provided) + self.ratelimit_remaining = \ + float(r.headers.get( + 'X-RateLimit-Remaining')) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Reset')), + timezone.utc).replace(tzinfo=None) + + except (TypeError, ValueError): + # This is returned if we could not retrieve this + # information gracefully accept this state and move on + pass + if r.status_code not in ( requests.codes.ok, requests.codes.no_content): @@ -408,6 +479,20 @@ class NotifyDiscord(NotifyBase): status_str = \ NotifyBase.http_response_code_lookup(r.status_code) + if r.status_code == requests.codes.too_many_requests \ + and rate_limit > 0: + + # handle rate limiting + self.logger.warning( + 'Discord rate limiting in effect; ' + 'blocking for %.2f second(s)', + self.ratelimit_remaining) + + # Try one more time before failing + return self._send( + payload=payload, attach=attach, params=params, + rate_limit=rate_limit - 1, **kwargs) + self.logger.warning( 'Failed to send {}to Discord notification: ' '{}{}error={}.'.format( @@ -465,6 +550,9 @@ class NotifyDiscord(NotifyBase): if self.avatar_url: params['avatar_url'] = self.avatar_url + if self.href: + params['href'] = self.href + if self.thread_id: params['thread'] = self.thread_id @@ -536,10 +624,23 @@ class NotifyDiscord(NotifyBase): results['avatar_url'] = \ NotifyDiscord.unquote(results['qsd']['avatar_url']) + # Extract url if it was specified + if 'href' in results['qsd']: + results['href'] = \ + NotifyDiscord.unquote(results['qsd']['href']) + + elif 'url' in results['qsd']: + results['href'] = \ + NotifyDiscord.unquote(results['qsd']['url']) + # Markdown is implied + results['format'] = NotifyFormat.MARKDOWN + # Extract thread id if it was specified if 'thread' in results['qsd']: results['thread'] = \ NotifyDiscord.unquote(results['qsd']['thread']) + # Markdown is implied + results['format'] = NotifyFormat.MARKDOWN return results diff --git a/libs/apprise/plugins/NotifyEmail.py b/libs/apprise/plugins/NotifyEmail.py index e55de7314..db70c8ef6 100644 --- a/libs/apprise/plugins/NotifyEmail.py +++ b/libs/apprise/plugins/NotifyEmail.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -43,6 +39,7 @@ from email import charset from socket import error as SocketError from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -340,6 +337,9 @@ class NotifyEmail(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_email' + # Support attachments + attachment_support = True + # Default Notify Format notify_format = NotifyFormat.HTML @@ -384,8 +384,13 @@ class NotifyEmail(NotifyBase): 'min': 1, 'max': 65535, }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { - 'name': _('Target Emails'), + 'name': _('Targets'), 'type': 'list:string', }, }) @@ -764,7 +769,7 @@ class NotifyEmail(NotifyBase): else: base = MIMEText(body, 'plain', 'utf-8') - if attach: + if attach and self.attachment_support: mixed = MIMEMultipart("mixed") mixed.attach(base) # Now store our attachments @@ -805,7 +810,8 @@ class NotifyEmail(NotifyBase): base['To'] = formataddr((to_name, to_addr), charset='utf-8') base['Message-ID'] = make_msgid(domain=self.smtp_host) base['Date'] = \ - datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + datetime.now(timezone.utc)\ + .strftime("%a, %d %b %Y %H:%M:%S +0000") base['X-Application'] = self.app_id if cc: @@ -1030,6 +1036,10 @@ class NotifyEmail(NotifyBase): # add one to ourselves results['targets'] = NotifyEmail.split_path(results['fullpath']) + # Attempt to detect 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + # Attempt to detect 'from' email address if 'from' in results['qsd'] and len(results['qsd']['from']): from_addr = NotifyEmail.unquote(results['qsd']['from']) @@ -1048,10 +1058,6 @@ class NotifyEmail(NotifyBase): # Extract from name to associate with from address from_addr = NotifyEmail.unquote(results['qsd']['name']) - # Attempt to detect 'to' email address - if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'].append(results['qsd']['to']) - # Store SMTP Host if specified if 'smtp' in results['qsd'] and len(results['qsd']['smtp']): # Extract the smtp server diff --git a/libs/apprise/plugins/NotifyEmby.py b/libs/apprise/plugins/NotifyEmby.py index 23d4c6114..99f3a9ab1 100644 --- a/libs/apprise/plugins/NotifyEmby.py +++ b/libs/apprise/plugins/NotifyEmby.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyEnigma2.py b/libs/apprise/plugins/NotifyEnigma2.py index 10d581792..054726469 100644 --- a/libs/apprise/plugins/NotifyEnigma2.py +++ b/libs/apprise/plugins/NotifyEnigma2.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyFCM/__init__.py b/libs/apprise/plugins/NotifyFCM/__init__.py index d8857d340..57b03499b 100644 --- a/libs/apprise/plugins/NotifyFCM/__init__.py +++ b/libs/apprise/plugins/NotifyFCM/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -157,7 +153,6 @@ class NotifyFCM(NotifyBase): 'project': { 'name': _('Project ID'), 'type': 'string', - 'required': True, }, 'target_device': { 'name': _('Target Device'), @@ -173,6 +168,7 @@ class NotifyFCM(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyFCM/color.py b/libs/apprise/plugins/NotifyFCM/color.py index 46d0f2a71..69474a30c 100644 --- a/libs/apprise/plugins/NotifyFCM/color.py +++ b/libs/apprise/plugins/NotifyFCM/color.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyFCM/common.py b/libs/apprise/plugins/NotifyFCM/common.py index 0ec10eec6..af71f8817 100644 --- a/libs/apprise/plugins/NotifyFCM/common.py +++ b/libs/apprise/plugins/NotifyFCM/common.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyFCM/oauth.py b/libs/apprise/plugins/NotifyFCM/oauth.py index a76bc6987..f0961039d 100644 --- a/libs/apprise/plugins/NotifyFCM/oauth.py +++ b/libs/apprise/plugins/NotifyFCM/oauth.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -47,6 +43,7 @@ from cryptography.hazmat.primitives import asymmetric from cryptography.exceptions import UnsupportedAlgorithm from datetime import datetime from datetime import timedelta +from datetime import timezone from json.decoder import JSONDecodeError from urllib.parse import urlencode as _urlencode @@ -106,7 +103,7 @@ class GoogleOAuth: # Our keys we build using the provided content self.__refresh_token = None self.__access_token = None - self.__access_token_expiry = datetime.utcnow() + self.__access_token_expiry = datetime.now(timezone.utc) def load(self, path): """ @@ -117,7 +114,7 @@ class GoogleOAuth: self.content = None self.private_key = None self.__access_token = None - self.__access_token_expiry = datetime.utcnow() + self.__access_token_expiry = datetime.now(timezone.utc) try: with open(path, mode="r", encoding=self.encoding) as fp: @@ -199,7 +196,7 @@ class GoogleOAuth: 'token with.') return None - if self.__access_token_expiry > datetime.utcnow(): + if self.__access_token_expiry > datetime.now(timezone.utc): # Return our no-expired key return self.__access_token @@ -209,7 +206,7 @@ class GoogleOAuth: key_identifier = self.content.get('private_key_id') # Generate our Assertion - now = datetime.utcnow() + now = datetime.now(timezone.utc) expiry = now + self.access_token_lifetime_sec payload = { @@ -301,7 +298,7 @@ class GoogleOAuth: if 'expires_in' in response: delta = timedelta(seconds=int(response['expires_in'])) self.__access_token_expiry = \ - delta + datetime.utcnow() - self.clock_skew + delta + datetime.now(timezone.utc) - self.clock_skew else: # Allow some grace before we expire diff --git a/libs/apprise/plugins/NotifyFCM/priority.py b/libs/apprise/plugins/NotifyFCM/priority.py index 81976cb63..966a0e149 100644 --- a/libs/apprise/plugins/NotifyFCM/priority.py +++ b/libs/apprise/plugins/NotifyFCM/priority.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyFaast.py b/libs/apprise/plugins/NotifyFaast.py index 3e55e1200..be3eff28d 100644 --- a/libs/apprise/plugins/NotifyFaast.py +++ b/libs/apprise/plugins/NotifyFaast.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyFlock.py b/libs/apprise/plugins/NotifyFlock.py index 60b337e82..71a15da53 100644 --- a/libs/apprise/plugins/NotifyFlock.py +++ b/libs/apprise/plugins/NotifyFlock.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -97,8 +93,8 @@ class NotifyFlock(NotifyBase): # Define object templates templates = ( '{schema}://{token}', - '{schema}://{user}@{token}', - '{schema}://{user}@{token}/{targets}', + '{schema}://{botname}@{token}', + '{schema}://{botname}@{token}/{targets}', '{schema}://{token}/{targets}', ) @@ -111,9 +107,10 @@ class NotifyFlock(NotifyBase): 'private': True, 'required': True, }, - 'user': { + 'botname': { 'name': _('Bot Name'), 'type': 'string', + 'map_to': 'user', }, 'to_user': { 'name': _('To User ID'), diff --git a/libs/apprise/plugins/NotifyForm.py b/libs/apprise/plugins/NotifyForm.py index 3ef8d21b4..066f299b2 100644 --- a/libs/apprise/plugins/NotifyForm.py +++ b/libs/apprise/plugins/NotifyForm.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -99,6 +95,9 @@ class NotifyForm(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_Form' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -345,7 +344,7 @@ class NotifyForm(NotifyBase): # Track our potential attachments files = [] - if attach: + if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: diff --git a/libs/apprise/plugins/NotifyGitter.py b/libs/apprise/plugins/NotifyGitter.py deleted file mode 100644 index 805d69c8c..000000000 --- a/libs/apprise/plugins/NotifyGitter.py +++ /dev/null @@ -1,425 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 3-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -# Once you visit: https://developer.gitter.im/apps you'll get a personal -# access token that will look something like this: -# b5647881d563fm846dfbb2c27d1fe8f669b8f026 - -# Don't worry about generating an app; this token is all you need to form -# you're URL with. The syntax is as follows: -# gitter://{token}/{channel} - -# Hence a URL might look like the following: -# gitter://b5647881d563fm846dfbb2c27d1fe8f669b8f026/apprise - -# Note: You must have joined the channel to send a message to it! - -# Official API reference: https://developer.gitter.im/docs/user-resource - -import re -import requests -from json import loads -from json import dumps -from datetime import datetime - -from .NotifyBase import NotifyBase -from ..common import NotifyImageSize -from ..common import NotifyFormat -from ..common import NotifyType -from ..utils import parse_list -from ..utils import parse_bool -from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ - -# API Gitter URL -GITTER_API_URL = 'https://api.gitter.im/v1' - -# Used to break path apart into list of targets -TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') - - -class NotifyGitter(NotifyBase): - """ - A wrapper for Gitter Notifications - """ - - # The default descriptive name associated with the Notification - service_name = 'Gitter' - - # The services URL - service_url = 'https://gitter.im/' - - # All notification requests are secure - secure_protocol = 'gitter' - - # A URL that takes you to the setup/help of the specific protocol - setup_url = 'https://github.com/caronc/apprise/wiki/Notify_gitter' - - # Allows the user to specify the NotifyImageSize object - image_size = NotifyImageSize.XY_32 - - # Gitter does not support a title - title_maxlen = 0 - - # Gitter is kind enough to return how many more requests we're allowed to - # continue to make within it's header response as: - # X-RateLimit-Reset: The epoc time (in seconds) we can expect our - # rate-limit to be reset. - # X-RateLimit-Remaining: an integer identifying how many requests we're - # still allow to make. - request_rate_per_sec = 0 - - # For Tracking Purposes - ratelimit_reset = datetime.utcnow() - - # Default to 1 - ratelimit_remaining = 1 - - # Default Notification Format - notify_format = NotifyFormat.MARKDOWN - - # Define object templates - templates = ( - '{schema}://{token}/{targets}/', - ) - - # Define our template tokens - template_tokens = dict(NotifyBase.template_tokens, **{ - 'token': { - 'name': _('Token'), - 'type': 'string', - 'private': True, - 'required': True, - 'regex': (r'^[a-z0-9]{40}$', 'i'), - }, - 'targets': { - 'name': _('Rooms'), - 'type': 'list:string', - }, - }) - - # Define our template arguments - template_args = dict(NotifyBase.template_args, **{ - 'image': { - 'name': _('Include Image'), - 'type': 'bool', - 'default': False, - 'map_to': 'include_image', - }, - 'to': { - 'alias_of': 'targets', - }, - }) - - def __init__(self, token, targets, include_image=False, **kwargs): - """ - Initialize Gitter Object - """ - super().__init__(**kwargs) - - # Secret Key (associated with project) - self.token = validate_regex( - token, *self.template_tokens['token']['regex']) - if not self.token: - msg = 'An invalid Gitter API Token ' \ - '({}) was specified.'.format(token) - self.logger.warning(msg) - raise TypeError(msg) - - # Parse our targets - self.targets = parse_list(targets) - if not self.targets: - msg = 'There are no valid Gitter targets to notify.' - self.logger.warning(msg) - raise TypeError(msg) - - # Used to track maping of rooms to their numeric id lookup for - # messaging - self._room_mapping = None - - # Track whether or not we want to send an image with our notification - # or not. - self.include_image = include_image - - return - - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): - """ - Perform Gitter Notification - """ - - # error tracking (used for function return) - has_error = False - - # Set up our image for display if configured to do so - image_url = None if not self.include_image \ - else self.image_url(notify_type) - - if image_url: - body = '![alt]({})\n{}'.format(image_url, body) - - if self._room_mapping is None: - # Populate our room mapping - self._room_mapping = {} - postokay, response = self._fetch(url='rooms') - if not postokay: - return False - - # Response generally looks like this: - # [ - # { - # noindex: False, - # oneToOne: False, - # avatarUrl: 'https://path/to/avatar/url', - # url: '/apprise-notifications/community', - # public: True, - # tags: [], - # lurk: False, - # uri: 'apprise-notifications/community', - # lastAccessTime: '2019-03-25T00:12:28.144Z', - # topic: '', - # roomMember: True, - # groupId: '5c981cecd73408ce4fbbad2f', - # githubType: 'REPO_CHANNEL', - # unreadItems: 0, - # mentions: 0, - # security: 'PUBLIC', - # userCount: 1, - # id: '5c981cecd73408ce4fbbad31', - # name: 'apprise/community' - # } - # ] - for entry in response: - self._room_mapping[entry['name'].lower().split('/')[0]] = { - # The ID of the room - 'id': entry['id'], - - # A descriptive name (useful for logging) - 'uri': entry['uri'], - } - - # Create a copy of the targets list - targets = list(self.targets) - while len(targets): - target = targets.pop(0).lower() - - if target not in self._room_mapping: - self.logger.warning( - 'Failed to locate Gitter room {}'.format(target)) - - # Flag our error - has_error = True - continue - - # prepare our payload - payload = { - 'text': body, - } - - # Our Notification URL - notify_url = 'rooms/{}/chatMessages'.format( - self._room_mapping[target]['id']) - - # Perform our query - postokay, response = self._fetch( - notify_url, payload=dumps(payload), method='POST') - - if not postokay: - # Flag our error - has_error = True - - return not has_error - - def _fetch(self, url, payload=None, method='GET'): - """ - Wrapper to request object - - """ - - # Prepare our headers: - headers = { - 'User-Agent': self.app_id, - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + self.token, - } - if payload: - # Only set our header payload if it's defined - headers['Content-Type'] = 'application/json' - - # Default content response object - content = {} - - # Update our URL - url = '{}/{}'.format(GITTER_API_URL, url) - - # Some Debug Logging - self.logger.debug('Gitter {} URL: {} (cert_verify={})'.format( - method, - url, self.verify_certificate)) - if payload: - self.logger.debug('Gitter Payload: {}' .format(payload)) - - # By default set wait to None - wait = None - - if self.ratelimit_remaining <= 0: - # Determine how long we should wait for or if we should wait at - # all. This isn't fool-proof because we can't be sure the client - # time (calling this script) is completely synced up with the - # Gitter server. One would hope we're on NTP and our clocks are - # the same allowing this to role smoothly: - - now = datetime.utcnow() - if now < self.ratelimit_reset: - # We need to throttle for the difference in seconds - # We add 0.5 seconds to the end just to allow a grace - # period. - wait = (self.ratelimit_reset - now).total_seconds() + 0.5 - - # Always call throttle before any remote server i/o is made - self.throttle(wait=wait) - - # fetch function - fn = requests.post if method == 'POST' else requests.get - try: - r = fn( - url, - data=payload, - headers=headers, - verify=self.verify_certificate, - timeout=self.request_timeout, - ) - - if r.status_code != requests.codes.ok: - # We had a problem - status_str = \ - NotifyGitter.http_response_code_lookup(r.status_code) - - self.logger.warning( - 'Failed to send Gitter {} to {}: ' - '{}error={}.'.format( - method, - url, - ', ' if status_str else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - - # Mark our failure - return (False, content) - - try: - content = loads(r.content) - - except (AttributeError, TypeError, ValueError): - # ValueError = r.content is Unparsable - # TypeError = r.content is None - # AttributeError = r is None - content = {} - - try: - self.ratelimit_remaining = \ - int(r.headers.get('X-RateLimit-Remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('X-RateLimit-Reset'))) - - except (TypeError, ValueError): - # This is returned if we could not retrieve this information - # gracefully accept this state and move on - pass - - except requests.RequestException as e: - self.logger.warning( - 'Exception received when sending Gitter {} to {}: '. - format(method, url)) - self.logger.debug('Socket Exception: %s' % str(e)) - - # Mark our failure - return (False, content) - - return (True, content) - - def url(self, privacy=False, *args, **kwargs): - """ - Returns the URL built dynamically based on specified arguments. - """ - - # Define any URL parameters - params = { - 'image': 'yes' if self.include_image else 'no', - } - - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - return '{schema}://{token}/{targets}/?{params}'.format( - schema=self.secure_protocol, - token=self.pprint(self.token, privacy, safe=''), - targets='/'.join( - [NotifyGitter.quote(x, safe='') for x in self.targets]), - params=NotifyGitter.urlencode(params)) - - def __len__(self): - """ - Returns the number of targets associated with this notification - """ - return len(self.targets) - - @staticmethod - def parse_url(url): - """ - Parses the URL and returns enough arguments that can allow - us to re-instantiate this object. - - """ - results = NotifyBase.parse_url(url, verify_host=False) - if not results: - # We're done early as we couldn't load the results - return results - - results['token'] = NotifyGitter.unquote(results['host']) - - # Get our entries; split_path() looks after unquoting content for us - # by default - results['targets'] = NotifyGitter.split_path(results['fullpath']) - - # Support the 'to' variable so that we can support targets this way too - # The 'to' makes it easier to use yaml configuration - if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'] += NotifyGitter.parse_list(results['qsd']['to']) - - # Include images with our message - results['include_image'] = \ - parse_bool(results['qsd'].get('image', False)) - - return results diff --git a/libs/apprise/plugins/NotifyGnome.py b/libs/apprise/plugins/NotifyGnome.py index 9476c78a3..f27c286cb 100644 --- a/libs/apprise/plugins/NotifyGnome.py +++ b/libs/apprise/plugins/NotifyGnome.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyGoogleChat.py b/libs/apprise/plugins/NotifyGoogleChat.py index f65b6541e..7119e7429 100644 --- a/libs/apprise/plugins/NotifyGoogleChat.py +++ b/libs/apprise/plugins/NotifyGoogleChat.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyGotify.py b/libs/apprise/plugins/NotifyGotify.py index 379225681..e20aa03da 100644 --- a/libs/apprise/plugins/NotifyGotify.py +++ b/libs/apprise/plugins/NotifyGotify.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -134,7 +130,6 @@ class NotifyGotify(NotifyBase): 'type': 'string', 'map_to': 'fullpath', 'default': '/', - 'required': True, }, 'port': { 'name': _('Port'), diff --git a/libs/apprise/plugins/NotifyGrowl.py b/libs/apprise/plugins/NotifyGrowl.py index 9240d62c5..790945f00 100644 --- a/libs/apprise/plugins/NotifyGrowl.py +++ b/libs/apprise/plugins/NotifyGrowl.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyGuilded.py b/libs/apprise/plugins/NotifyGuilded.py index 8bb9aeeaa..066cddee8 100644 --- a/libs/apprise/plugins/NotifyGuilded.py +++ b/libs/apprise/plugins/NotifyGuilded.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyHomeAssistant.py b/libs/apprise/plugins/NotifyHomeAssistant.py index a403356ab..25d8f5fb4 100644 --- a/libs/apprise/plugins/NotifyHomeAssistant.py +++ b/libs/apprise/plugins/NotifyHomeAssistant.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyIFTTT.py b/libs/apprise/plugins/NotifyIFTTT.py index 04c6911ef..2c386c6b6 100644 --- a/libs/apprise/plugins/NotifyIFTTT.py +++ b/libs/apprise/plugins/NotifyIFTTT.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -30,7 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# # For this plugin to work, you need to add the Maker applet to your profile # Simply visit https://ifttt.com/search and search for 'Webhooks' # Or if you're signed in, click here: https://ifttt.com/maker_webhooks diff --git a/libs/apprise/plugins/NotifyJSON.py b/libs/apprise/plugins/NotifyJSON.py index f1a9cc04e..a8ab7adc3 100644 --- a/libs/apprise/plugins/NotifyJSON.py +++ b/libs/apprise/plugins/NotifyJSON.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -80,6 +76,9 @@ class NotifyJSON(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_JSON' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -179,19 +178,6 @@ class NotifyJSON(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - # A payload map allows users to over-ride the default mapping if - # they're detected with the :overide=value. Normally this would - # create a new key and assign it the value specified. However - # if the key you specify is actually an internally mapped one, - # then a re-mapping takes place using the value - self.payload_map = { - JSONPayloadField.VERSION: JSONPayloadField.VERSION, - JSONPayloadField.TITLE: JSONPayloadField.TITLE, - JSONPayloadField.MESSAGE: JSONPayloadField.MESSAGE, - JSONPayloadField.ATTACHMENTS: JSONPayloadField.ATTACHMENTS, - JSONPayloadField.MESSAGETYPE: JSONPayloadField.MESSAGETYPE, - } - self.params = {} if params: # Store our extra headers @@ -202,21 +188,10 @@ class NotifyJSON(NotifyBase): # Store our extra headers self.headers.update(headers) - self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) - for key in list(self.payload_extras.keys()): - # Any values set in the payload to alter a system related one - # alters the system key. Hence :message=msg maps the 'message' - # variable that otherwise already contains the payload to be - # 'msg' instead (containing the payload) - if key in self.payload_map: - self.payload_map[key] = self.payload_extras[key].strip() - self.payload_overrides[key] = \ - self.payload_extras[key].strip() - del self.payload_extras[key] return @@ -242,8 +217,6 @@ class NotifyJSON(NotifyBase): # Append our payload extra's into our parameters params.update( {':{}'.format(k): v for k, v in self.payload_extras.items()}) - params.update( - {':{}'.format(k): v for k, v in self.payload_overrides.items()}) # Determine Authentication auth = '' @@ -289,7 +262,7 @@ class NotifyJSON(NotifyBase): # Track our potential attachments attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: @@ -317,22 +290,30 @@ class NotifyJSON(NotifyBase): self.logger.debug('I/O Exception: %s' % str(e)) return False - # prepare JSON Object - payload = {} - for key, value in ( - (JSONPayloadField.VERSION, self.json_version), - (JSONPayloadField.TITLE, title), - (JSONPayloadField.MESSAGE, body), - (JSONPayloadField.ATTACHMENTS, attachments), - (JSONPayloadField.MESSAGETYPE, notify_type)): - - if not self.payload_map[key]: - # Do not store element in payload response - continue - payload[self.payload_map[key]] = value - - # Apply any/all payload over-rides defined - payload.update(self.payload_extras) + # Prepare JSON Object + payload = { + JSONPayloadField.VERSION: self.json_version, + JSONPayloadField.TITLE: title, + JSONPayloadField.MESSAGE: body, + JSONPayloadField.ATTACHMENTS: attachments, + JSONPayloadField.MESSAGETYPE: notify_type, + } + + for key, value in self.payload_extras.items(): + + if key in payload: + if not value: + # Do not store element in payload response + del payload[key] + + else: + # Re-map + payload[value] = payload[key] + del payload[key] + + else: + # Append entry + payload[key] = value auth = None if self.user: diff --git a/libs/apprise/plugins/NotifyJoin.py b/libs/apprise/plugins/NotifyJoin.py index e6210a5f3..92af6c3f1 100644 --- a/libs/apprise/plugins/NotifyJoin.py +++ b/libs/apprise/plugins/NotifyJoin.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -174,7 +170,6 @@ class NotifyJoin(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', - 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyKavenegar.py b/libs/apprise/plugins/NotifyKavenegar.py index 8905e2431..d1df47c9e 100644 --- a/libs/apprise/plugins/NotifyKavenegar.py +++ b/libs/apprise/plugins/NotifyKavenegar.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyKumulos.py b/libs/apprise/plugins/NotifyKumulos.py index 27e0995c9..6072340f8 100644 --- a/libs/apprise/plugins/NotifyKumulos.py +++ b/libs/apprise/plugins/NotifyKumulos.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyLametric.py b/libs/apprise/plugins/NotifyLametric.py index 1b98b6946..516ec27ca 100644 --- a/libs/apprise/plugins/NotifyLametric.py +++ b/libs/apprise/plugins/NotifyLametric.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -370,6 +366,7 @@ class NotifyLametric(NotifyBase): # Device Mode '{schema}://{apikey}@{host}', + '{schema}://{user}:{apikey}@{host}', '{schema}://{apikey}@{host}:{port}', '{schema}://{user}:{apikey}@{host}:{port}', ) @@ -404,7 +401,6 @@ class NotifyLametric(NotifyBase): 'host': { 'name': _('Hostname'), 'type': 'string', - 'required': True, }, 'port': { 'name': _('Port'), diff --git a/libs/apprise/plugins/NotifyLine.py b/libs/apprise/plugins/NotifyLine.py index 817a998c8..09d72fed8 100644 --- a/libs/apprise/plugins/NotifyLine.py +++ b/libs/apprise/plugins/NotifyLine.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -102,6 +98,7 @@ class NotifyLine(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True }, }) diff --git a/libs/apprise/plugins/NotifyMQTT.py b/libs/apprise/plugins/NotifyMQTT.py index c8ee7cbce..2372c8b45 100644 --- a/libs/apprise/plugins/NotifyMQTT.py +++ b/libs/apprise/plugins/NotifyMQTT.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -427,6 +423,10 @@ class NotifyMQTT(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) return False + if not has_error: + # Verbal notice + self.logger.info('Sent MQTT notification') + return not has_error def url(self, privacy=False, *args, **kwargs): diff --git a/libs/apprise/plugins/NotifyMSG91.py b/libs/apprise/plugins/NotifyMSG91.py index 75834c399..225a2d3d9 100644 --- a/libs/apprise/plugins/NotifyMSG91.py +++ b/libs/apprise/plugins/NotifyMSG91.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -35,50 +31,31 @@ # Get your (authkey) from the dashboard here: # - https://world.msg91.com/user/index.php#api # +# Note: You will need to define a template for this to work +# # Get details on the API used in this plugin here: -# - https://world.msg91.com/apidoc/textsms/send-sms.php - +# - https://docs.msg91.com/reference/send-sms +import re import requests - +from json import dumps from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import is_phone_no -from ..utils import parse_phone_no +from ..utils import parse_phone_no, parse_bool from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -class MSG91Route: +class MSG91PayloadField: """ - Transactional SMS Routes - route=1 for promotional, route=4 for transactional SMS. + Identifies the fields available in the JSON Payload """ - PROMOTIONAL = 1 - TRANSACTIONAL = 4 + BODY = 'body' + MESSAGETYPE = 'type' -# Used for verification -MSG91_ROUTES = ( - MSG91Route.PROMOTIONAL, - MSG91Route.TRANSACTIONAL, -) - - -class MSG91Country: - """ - Optional value that can be specified on the MSG91 api - """ - INTERNATIONAL = 0 - USA = 1 - INDIA = 91 - - -# Used for verification -MSG91_COUNTRIES = ( - MSG91Country.INTERNATIONAL, - MSG91Country.USA, - MSG91Country.INDIA, -) +# Add entries here that are reserved +RESERVED_KEYWORDS = ('mobiles', ) class NotifyMSG91(NotifyBase): @@ -99,7 +76,7 @@ class NotifyMSG91(NotifyBase): setup_url = 'https://github.com/caronc/apprise/wiki/Notify_msg91' # MSG91 uses the http protocol with JSON requests - notify_url = 'https://world.msg91.com/api/sendhttp.php' + notify_url = 'https://control.msg91.com/api/v5/flow/' # The maximum length of the body body_maxlen = 160 @@ -108,14 +85,24 @@ class NotifyMSG91(NotifyBase): # cause any title (if defined) to get placed into the message body. title_maxlen = 0 + # Our supported mappings and component keys + component_key_re = re.compile( + r'(?P((?P[a-z0-9_-])?|(?Pbody|type)))', re.IGNORECASE) + # Define object templates templates = ( - '{schema}://{authkey}/{targets}', - '{schema}://{sender}@{authkey}/{targets}', + '{schema}://{template}@{authkey}/{targets}', ) # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ + 'template': { + 'name': _('Template ID'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9 _-]+$', 'i'), + }, 'authkey': { 'name': _('Authentication Key'), 'type': 'string', @@ -133,10 +120,7 @@ class NotifyMSG91(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', - }, - 'sender': { - 'name': _('Sender ID'), - 'type': 'string', + 'required': True, }, }) @@ -145,21 +129,23 @@ class NotifyMSG91(NotifyBase): 'to': { 'alias_of': 'targets', }, - 'route': { - 'name': _('Route'), - 'type': 'choice:int', - 'values': MSG91_ROUTES, - 'default': MSG91Route.TRANSACTIONAL, - }, - 'country': { - 'name': _('Country'), - 'type': 'choice:int', - 'values': MSG91_COUNTRIES, + 'short_url': { + 'name': _('Short URL'), + 'type': 'bool', + 'default': False, }, }) - def __init__(self, authkey, targets=None, sender=None, route=None, - country=None, **kwargs): + # Define any kwargs we're using + template_kwargs = { + 'template_mapping': { + 'name': _('Template Mapping'), + 'prefix': ':', + }, + } + + def __init__(self, template, authkey, targets=None, short_url=None, + template_mapping=None, **kwargs): """ Initialize MSG91 Object """ @@ -174,39 +160,20 @@ class NotifyMSG91(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - if route is None: - self.route = self.template_args['route']['default'] + # Template ID + self.template = validate_regex( + template, *self.template_tokens['template']['regex']) + if not self.template: + msg = 'An invalid MSG91 Template ID ' \ + '({}) was specified.'.format(template) + self.logger.warning(msg) + raise TypeError(msg) - else: - try: - self.route = int(route) - if self.route not in MSG91_ROUTES: - # Let outer except catch thi - raise ValueError() - - except (ValueError, TypeError): - msg = 'The MSG91 route specified ({}) is invalid.'\ - .format(route) - self.logger.warning(msg) - raise TypeError(msg) - - if country: - try: - self.country = int(country) - if self.country not in MSG91_COUNTRIES: - # Let outer except catch thi - raise ValueError() - - except (ValueError, TypeError): - msg = 'The MSG91 country specified ({}) is invalid.'\ - .format(country) - self.logger.warning(msg) - raise TypeError(msg) - else: - self.country = country + if short_url is None: + self.short_url = self.template_args['short_url']['default'] - # Store our sender - self.sender = sender + else: + self.short_url = parse_bool(short_url) # Parse our targets self.targets = list() @@ -224,6 +191,11 @@ class NotifyMSG91(NotifyBase): # store valid phone number self.targets.append(result['full']) + self.template_mapping = {} + if template_mapping: + # Store our extra payload entries + self.template_mapping.update(template_mapping) + return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): @@ -239,23 +211,55 @@ class NotifyMSG91(NotifyBase): # Prepare our headers headers = { 'User-Agent': self.app_id, - 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Type': 'application/json', + 'authkey': self.authkey, } + # Base + recipient_payload = { + 'mobiles': None, + # Keyword Tokens + MSG91PayloadField.BODY: body, + MSG91PayloadField.MESSAGETYPE: notify_type, + } + + # Prepare Recipient Payload Object + for key, value in self.template_mapping.items(): + + if key in RESERVED_KEYWORDS: + self.logger.warning( + 'Ignoring MSG91 custom payload entry %s', key) + continue + + if key in recipient_payload: + if not value: + # Do not store element in payload response + del recipient_payload[key] + + else: + # Re-map + recipient_payload[value] = recipient_payload[key] + del recipient_payload[key] + + else: + # Append entry + recipient_payload[key] = value + + # Prepare our recipients + recipients = [] + for target in self.targets: + recipient = recipient_payload.copy() + recipient['mobiles'] = target + recipients.append(recipient) + # Prepare our payload payload = { - 'sender': self.sender if self.sender else self.app_id, - 'authkey': self.authkey, - 'message': body, - 'response': 'json', + 'template_id': self.template, + 'short_url': 1 if self.short_url else 0, # target phone numbers are sent with a comma delimiter - 'mobiles': ','.join(self.targets), - 'route': str(self.route), + 'recipients': recipients, } - if self.country: - payload['country'] = str(self.country) - # Some Debug Logging self.logger.debug('MSG91 POST URL: {} (cert_verify={})'.format( self.notify_url, self.verify_certificate)) @@ -267,7 +271,7 @@ class NotifyMSG91(NotifyBase): try: r = requests.post( self.notify_url, - data=payload, + data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, @@ -313,17 +317,20 @@ class NotifyMSG91(NotifyBase): # Define any URL parameters params = { - 'route': str(self.route), + 'short_url': str(self.short_url), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - if self.country: - params['country'] = str(self.country) + # Payload body extras prefixed with a ':' sign + # Append our payload extras into our parameters + params.update( + {':{}'.format(k): v for k, v in self.template_mapping.items()}) - return '{schema}://{authkey}/{targets}/?{params}'.format( + return '{schema}://{template}@{authkey}/{targets}/?{params}'.format( schema=self.secure_protocol, + template=self.pprint(self.template, privacy, safe=''), authkey=self.pprint(self.authkey, privacy, safe=''), targets='/'.join( [NotifyMSG91.quote(x, safe='') for x in self.targets]), @@ -333,7 +340,8 @@ class NotifyMSG91(NotifyBase): """ Returns the number of targets associated with this notification """ - return len(self.targets) + targets = len(self.targets) + return targets if targets > 0 else 1 @staticmethod def parse_url(url): @@ -355,11 +363,11 @@ class NotifyMSG91(NotifyBase): # The hostname is our authentication key results['authkey'] = NotifyMSG91.unquote(results['host']) - if 'route' in results['qsd'] and len(results['qsd']['route']): - results['route'] = results['qsd']['route'] + # The template id is kept in the user field + results['template'] = NotifyMSG91.unquote(results['user']) - if 'country' in results['qsd'] and len(results['qsd']['country']): - results['country'] = results['qsd']['country'] + if 'short_url' in results['qsd'] and len(results['qsd']['short_url']): + results['short_url'] = parse_bool(results['qsd']['short_url']) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration @@ -367,4 +375,10 @@ class NotifyMSG91(NotifyBase): results['targets'] += \ NotifyMSG91.parse_phone_no(results['qsd']['to']) + # store any additional payload extra's defined + results['template_mapping'] = { + NotifyMSG91.unquote(x): NotifyMSG91.unquote(y) + for x, y in results['qsd:'].items() + } + return results diff --git a/libs/apprise/plugins/NotifyMSTeams.py b/libs/apprise/plugins/NotifyMSTeams.py index 19f9fe34f..e82fdb8ca 100644 --- a/libs/apprise/plugins/NotifyMSTeams.py +++ b/libs/apprise/plugins/NotifyMSTeams.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyMacOSX.py b/libs/apprise/plugins/NotifyMacOSX.py index 59c0620a3..ae08da112 100644 --- a/libs/apprise/plugins/NotifyMacOSX.py +++ b/libs/apprise/plugins/NotifyMacOSX.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -197,8 +193,7 @@ class NotifyMacOSX(NotifyBase): self.logger.debug('MacOSX CMD: {}'.format(' '.join(cmd))) # Send our notification - output = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = subprocess.Popen(cmd) # Wait for process to complete output.wait() diff --git a/libs/apprise/plugins/NotifyMailgun.py b/libs/apprise/plugins/NotifyMailgun.py index 3139e3416..5afebc52b 100644 --- a/libs/apprise/plugins/NotifyMailgun.py +++ b/libs/apprise/plugins/NotifyMailgun.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -121,6 +117,9 @@ class NotifyMailgun(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mailgun' + # Support attachments + attachment_support = True + # Default Notify Format notify_format = NotifyFormat.HTML @@ -152,8 +151,13 @@ class NotifyMailgun(NotifyBase): 'private': True, 'required': True, }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { - 'name': _('Target Emails'), + 'name': _('Targets'), 'type': 'list:string', }, }) @@ -366,7 +370,7 @@ class NotifyMailgun(NotifyBase): # Track our potential files files = {} - if attach: + if attach and self.attachment_support: for idx, attachment in enumerate(attach): # Perform some simple error checking if not attachment: diff --git a/libs/apprise/plugins/NotifyMastodon.py b/libs/apprise/plugins/NotifyMastodon.py index 74d13952a..90c39e14b 100644 --- a/libs/apprise/plugins/NotifyMastodon.py +++ b/libs/apprise/plugins/NotifyMastodon.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -35,6 +31,7 @@ import requests from copy import deepcopy from json import dumps, loads from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -110,6 +107,10 @@ class NotifyMastodon(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mastodon' + # Support attachments + attachment_support = True + + # Allows the user to specify the NotifyImageSize object # Allows the user to specify the NotifyImageSize object; this is supported # through the webhook image_size = NotifyImageSize.XY_128 @@ -150,7 +151,7 @@ class NotifyMastodon(NotifyBase): request_rate_per_sec = 0 # For Tracking Purposes - ratelimit_reset = datetime.utcnow() + ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1000; users can send up to 1000 DM's and 2400 toot a day # This value only get's adjusted if the server sets it that way @@ -413,11 +414,10 @@ class NotifyMastodon(NotifyBase): else: targets.add(myself) - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: - # Perform some simple error checking if not attachment: # We could not access the attachment @@ -577,7 +577,7 @@ class NotifyMastodon(NotifyBase): _payload = deepcopy(payload) _payload['media_ids'] = media_ids - if no: + if no or not body: # strip text and replace it with the image representation _payload['status'] = \ '{:02d}/{:02d}'.format(no + 1, len(batches)) @@ -834,7 +834,7 @@ class NotifyMastodon(NotifyBase): # Mastodon server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds # We add 0.5 seconds to the end just to allow a grace @@ -892,8 +892,9 @@ class NotifyMastodon(NotifyBase): # Capture rate limiting if possible self.ratelimit_remaining = \ int(r.headers.get('X-RateLimit-Remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('X-RateLimit-Limit'))) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Limit')), timezone.utc + ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information diff --git a/libs/apprise/plugins/NotifyMatrix.py b/libs/apprise/plugins/NotifyMatrix.py index c0b524a0d..8f3e77ff9 100644 --- a/libs/apprise/plugins/NotifyMatrix.py +++ b/libs/apprise/plugins/NotifyMatrix.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -53,8 +49,11 @@ from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ # Define default path -MATRIX_V2_API_PATH = '/_matrix/client/r0' MATRIX_V1_WEBHOOK_PATH = '/api/v1/matrix/hook' +MATRIX_V2_API_PATH = '/_matrix/client/r0' +MATRIX_V3_API_PATH = '/_matrix/client/v3' +MATRIX_V3_MEDIA_PATH = '/_matrix/media/v3' +MATRIX_V2_MEDIA_PATH = '/_matrix/media/r0' # Extend HTTP Error Messages MATRIX_HTTP_ERROR_MAP = { @@ -88,6 +87,21 @@ MATRIX_MESSAGE_TYPES = ( ) +class MatrixVersion: + # Version 2 + V2 = "2" + + # Version 3 + V3 = "3" + + +# webhook modes are placed into this list for validation purposes +MATRIX_VERSIONS = ( + MatrixVersion.V2, + MatrixVersion.V3, +) + + class MatrixWebhookMode: # Webhook Mode is disabled DISABLED = "off" @@ -128,6 +142,9 @@ class NotifyMatrix(NotifyBase): # The default secure protocol secure_protocol = 'matrixs' + # Support Attachments + attachment_support = True + # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_matrix' @@ -147,6 +164,9 @@ class NotifyMatrix(NotifyBase): # Throttle a wee-bit to avoid thrashing request_rate_per_sec = 0.5 + # Our Matrix API Version + matrix_api_version = '3' + # How many retry attempts we'll make in the event the server asks us to # throttle back. default_retries = 2 @@ -175,7 +195,6 @@ class NotifyMatrix(NotifyBase): 'host': { 'name': _('Hostname'), 'type': 'string', - 'required': True, }, 'port': { 'name': _('Port'), @@ -194,6 +213,7 @@ class NotifyMatrix(NotifyBase): }, 'token': { 'name': _('Access Token'), + 'private': True, 'map_to': 'password', }, 'target_user': { @@ -234,6 +254,12 @@ class NotifyMatrix(NotifyBase): 'values': MATRIX_WEBHOOK_MODES, 'default': MatrixWebhookMode.DISABLED, }, + 'version': { + 'name': _('Matrix API Verion'), + 'type': 'choice:string', + 'values': MATRIX_VERSIONS, + 'default': MatrixVersion.V3, + }, 'msgtype': { 'name': _('Message Type'), 'type': 'choice:string', @@ -248,7 +274,7 @@ class NotifyMatrix(NotifyBase): }, }) - def __init__(self, targets=None, mode=None, msgtype=None, + def __init__(self, targets=None, mode=None, msgtype=None, version=None, include_image=False, **kwargs): """ Initialize Matrix Object @@ -282,6 +308,14 @@ class NotifyMatrix(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Setup our version + self.version = self.template_args['version']['default'] \ + if not isinstance(version, str) else version + if self.version not in MATRIX_VERSIONS: + msg = 'The version specified ({}) is invalid.'.format(version) + self.logger.warning(msg) + raise TypeError(msg) + # Setup our message type self.msgtype = self.template_args['msgtype']['default'] \ if not isinstance(msgtype, str) else msgtype.lower() @@ -521,7 +555,8 @@ class NotifyMatrix(NotifyBase): return payload def _send_server_notification(self, body, title='', - notify_type=NotifyType.INFO, **kwargs): + notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Direct Matrix Server Notification (no webhook) """ @@ -548,6 +583,13 @@ class NotifyMatrix(NotifyBase): # Initiaize our error tracking has_error = False + attachments = None + if attach and self.attachment_support: + attachments = self._send_attachments(attach) + if attachments is False: + # take an early exit + return False + while len(rooms) > 0: # Get our room @@ -568,23 +610,47 @@ class NotifyMatrix(NotifyBase): image_url = None if not self.include_image else \ self.image_url(notify_type) - if image_url: - # Define our payload - image_payload = { - 'msgtype': 'm.image', - 'url': image_url, - 'body': '{}'.format(notify_type if not title else title), - } - # Build our path + # Build our path + if self.version == MatrixVersion.V3: + path = '/rooms/{}/send/m.room.message/0'.format( + NotifyMatrix.quote(room_id)) + + else: path = '/rooms/{}/send/m.room.message'.format( NotifyMatrix.quote(room_id)) - # Post our content - postokay, response = self._fetch(path, payload=image_payload) - if not postokay: - # Mark our failure - has_error = True - continue + if self.version == MatrixVersion.V2: + # + # Attachments don't work beyond V2 at this time + # + if image_url: + # Define our payload + image_payload = { + 'msgtype': 'm.image', + 'url': image_url, + 'body': '{}'.format( + notify_type if not title else title), + } + + # Post our content + postokay, response = self._fetch( + path, payload=image_payload) + if not postokay: + # Mark our failure + has_error = True + continue + + if attachments: + for attachment in attachments: + attachment['room_id'] = room_id + attachment['type'] = 'm.room.message' + + postokay, response = self._fetch( + path, payload=attachment) + if not postokay: + # Mark our failure + has_error = True + continue # Define our payload payload = { @@ -615,12 +681,10 @@ class NotifyMatrix(NotifyBase): ) }) - # Build our path - path = '/rooms/{}/send/m.room.message'.format( - NotifyMatrix.quote(room_id)) - # Post our content - postokay, response = self._fetch(path, payload=payload) + method = 'PUT' if self.version == MatrixVersion.V3 else 'POST' + postokay, response = self._fetch( + path, payload=payload, method=method) if not postokay: # Notify our user self.logger.warning( @@ -632,6 +696,62 @@ class NotifyMatrix(NotifyBase): return not has_error + def _send_attachments(self, attach): + """ + Posts all of the provided attachments + """ + + payloads = [] + if self.version != MatrixVersion.V2: + self.logger.warning( + 'Add ?v=2 to Apprise URL to support Attachments') + return next((False for a in attach if not a), []) + + for attachment in attach: + if not attachment: + # invalid attachment (bad file) + return False + + if not re.match(r'^image/', attachment.mimetype, re.I): + # unsuppored at this time + continue + + postokay, response = \ + self._fetch('/upload', attachment=attachment) + if not (postokay and isinstance(response, dict)): + # Failed to perform upload + return False + + # If we get here, we'll have a response that looks like: + # { + # "content_uri": "mxc://example.com/a-unique-key" + # } + + if self.version == MatrixVersion.V3: + # Prepare our payload + payloads.append({ + "body": attachment.name, + "info": { + "mimetype": attachment.mimetype, + "size": len(attachment), + }, + "msgtype": "m.image", + "url": response.get('content_uri'), + }) + + else: + # Prepare our payload + payloads.append({ + "info": { + "mimetype": attachment.mimetype, + }, + "msgtype": "m.image", + "body": "tta.webp", + "url": response.get('content_uri'), + }) + + return payloads + def _register(self): """ Register with the service if possible. @@ -695,12 +815,23 @@ class NotifyMatrix(NotifyBase): 'user/pass combo is missing.') return False - # Prepare our Registration Payload - payload = { - 'type': 'm.login.password', - 'user': self.user, - 'password': self.password, - } + # Prepare our Authentication Payload + if self.version == MatrixVersion.V3: + payload = { + 'type': 'm.login.password', + 'identifier': { + 'type': 'm.id.user', + 'user': self.user, + }, + 'password': self.password, + } + + else: + payload = { + 'type': 'm.login.password', + 'user': self.user, + 'password': self.password, + } # Build our URL postokay, response = self._fetch('/login', payload=payload) @@ -970,7 +1101,8 @@ class NotifyMatrix(NotifyBase): return None - def _fetch(self, path, payload=None, params=None, method='POST'): + def _fetch(self, path, payload=None, params=None, attachment=None, + method='POST'): """ Wrapper to request.post() to manage it's response better and make the send() function cleaner and easier to maintain. @@ -983,6 +1115,7 @@ class NotifyMatrix(NotifyBase): headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json', + 'Accept': 'application/json', } if self.access_token is not None: @@ -991,19 +1124,39 @@ class NotifyMatrix(NotifyBase): default_port = 443 if self.secure else 80 url = \ - '{schema}://{hostname}:{port}{matrix_api}{path}'.format( + '{schema}://{hostname}{port}'.format( schema='https' if self.secure else 'http', hostname=self.host, port='' if self.port is None - or self.port == default_port else self.port, - matrix_api=MATRIX_V2_API_PATH, - path=path) + or self.port == default_port else f':{self.port}') + + if path == '/upload': + if self.version == MatrixVersion.V3: + url += MATRIX_V3_MEDIA_PATH + path + + else: + url += MATRIX_V2_MEDIA_PATH + path + + params = {'filename': attachment.name} + with open(attachment.path, 'rb') as fp: + payload = fp.read() + + # Update our content type + headers['Content-Type'] = attachment.mimetype + + else: + if self.version == MatrixVersion.V3: + url += MATRIX_V3_API_PATH + path + + else: + url += MATRIX_V2_API_PATH + path # Our response object response = {} # fetch function - fn = requests.post if method == 'POST' else requests.get + fn = requests.post if method == 'POST' else ( + requests.put if method == 'PUT' else requests.get) # Define how many attempts we'll make if we get caught in a throttle # event @@ -1024,13 +1177,16 @@ class NotifyMatrix(NotifyBase): try: r = fn( url, - data=dumps(payload), + data=dumps(payload) if not attachment else payload, params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) + self.logger.debug( + 'Matrix Response: code=%d, %s' % ( + r.status_code, str(r.content))) response = loads(r.content) if r.status_code == 429: @@ -1094,6 +1250,13 @@ class NotifyMatrix(NotifyBase): # Return; we're done return (False, response) + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'unknown file')) + self.logger.debug('I/O Exception: %s' % str(e)) + return (False, {}) + return (True, response) # If we get here, we ran out of retries @@ -1160,6 +1323,7 @@ class NotifyMatrix(NotifyBase): params = { 'image': 'yes' if self.include_image else 'no', 'mode': self.mode, + 'version': self.version, 'msgtype': self.msgtype, } @@ -1257,6 +1421,14 @@ class NotifyMatrix(NotifyBase): if 'token' in results['qsd'] and len(results['qsd']['token']): results['password'] = NotifyMatrix.unquote(results['qsd']['token']) + # Support the use of the version= or v= keyword + if 'version' in results['qsd'] and len(results['qsd']['version']): + results['version'] = \ + NotifyMatrix.unquote(results['qsd']['version']) + + elif 'v' in results['qsd'] and len(results['qsd']['v']): + results['version'] = NotifyMatrix.unquote(results['qsd']['v']) + return results @staticmethod @@ -1266,7 +1438,7 @@ class NotifyMatrix(NotifyBase): """ result = re.match( - r'^https?://webhooks\.t2bot\.io/api/v1/matrix/hook/' + r'^https?://webhooks\.t2bot\.io/api/v[0-9]+/matrix/hook/' r'(?P[A-Z0-9_-]+)/?' r'(?P\?.+)?$', url, re.I) diff --git a/libs/apprise/plugins/NotifyMatterMost.py b/libs/apprise/plugins/NotifyMatterMost.py index e62f653c4..859fed311 100644 --- a/libs/apprise/plugins/NotifyMatterMost.py +++ b/libs/apprise/plugins/NotifyMatterMost.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -91,11 +87,11 @@ class NotifyMattermost(NotifyBase): # Define object templates templates = ( '{schema}://{host}/{token}', - '{schema}://{host}/{token}:{port}', + '{schema}://{host}:{port}/{token}', + '{schema}://{host}/{fullpath}/{token}', + '{schema}://{host}:{port}/{fullpath}/{token}', '{schema}://{botname}@{host}/{token}', '{schema}://{botname}@{host}:{port}/{token}', - '{schema}://{host}/{fullpath}/{token}', - '{schema}://{host}/{fullpath}{token}:{port}', '{schema}://{botname}@{host}/{fullpath}/{token}', '{schema}://{botname}@{host}:{port}/{fullpath}/{token}', ) diff --git a/libs/apprise/plugins/NotifyMattermost.py b/libs/apprise/plugins/NotifyMattermost.py new file mode 100644 index 000000000..859fed311 --- /dev/null +++ b/libs/apprise/plugins/NotifyMattermost.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# Create an incoming webhook; the website will provide you with something like: +# http://localhost:8065/hooks/yobjmukpaw3r3urc5h6i369yima +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# |-- this is the webhook --| +# +# You can effectively turn the url above to read this: +# mmost://localhost:8065/yobjmukpaw3r3urc5h6i369yima +# - swap http with mmost +# - drop /hooks/ reference + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_bool +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Some Reference Locations: +# - https://docs.mattermost.com/developer/webhooks-incoming.html +# - https://docs.mattermost.com/administration/config-settings.html + + +class NotifyMattermost(NotifyBase): + """ + A wrapper for Mattermost Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Mattermost' + + # The services URL + service_url = 'https://mattermost.com/' + + # The default protocol + protocol = 'mmost' + + # The default secure protocol + secure_protocol = 'mmosts' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mattermost' + + # The default Mattermost port + default_port = 8065 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 4000 + + # Mattermost does not have a title + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{host}/{token}', + '{schema}://{host}:{port}/{token}', + '{schema}://{host}/{fullpath}/{token}', + '{schema}://{host}:{port}/{fullpath}/{token}', + '{schema}://{botname}@{host}/{token}', + '{schema}://{botname}@{host}:{port}/{token}', + '{schema}://{botname}@{host}/{fullpath}/{token}', + '{schema}://{botname}@{host}:{port}/{fullpath}/{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'token': { + 'name': _('Webhook Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'fullpath': { + 'name': _('Path'), + 'type': 'string', + }, + 'botname': { + 'name': _('Bot Name'), + 'type': 'string', + 'map_to': 'user', + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'channels': { + 'name': _('Channels'), + 'type': 'list:string', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'to': { + 'alias_of': 'channels', + }, + }) + + def __init__(self, token, fullpath=None, channels=None, + include_image=False, **kwargs): + """ + Initialize Mattermost Object + """ + super().__init__(**kwargs) + + if self.secure: + self.schema = 'https' + + else: + self.schema = 'http' + + # our full path + self.fullpath = '' if not isinstance( + fullpath, str) else fullpath.strip() + + # Authorization Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid Mattermost Authorization Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Optional Channels (strip off any channel prefix entries if present) + self.channels = [x.lstrip('#') for x in parse_list(channels)] + + if not self.port: + self.port = self.default_port + + # Place a thumbnail image inline with the message body + self.include_image = include_image + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Mattermost Notification + """ + + # Create a copy of our channels, otherwise place a dummy entry + channels = list(self.channels) if self.channels else [None, ] + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare JSON Object + payload = { + 'text': body, + 'icon_url': None, + } + + # Acquire our image url if configured to do so + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + if image_url: + # Set our image configuration if told to do so + payload['icon_url'] = image_url + + # Set our user + payload['username'] = self.user if self.user else self.app_id + + # For error tracking + has_error = False + + while len(channels): + # Pop a channel off of the list + channel = channels.pop(0) + + if channel: + payload['channel'] = channel + + url = '{}://{}:{}{}/hooks/{}'.format( + self.schema, self.host, self.port, self.fullpath, + self.token) + + self.logger.debug('Mattermost POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Mattermost Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyMattermost.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Mattermost notification{}: ' + '{}{}error={}.'.format( + '' if not channel + else ' to channel {}'.format(channel), + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Flag our error + has_error = True + continue + + else: + self.logger.info( + 'Sent Mattermost notification{}.'.format( + '' if not channel + else ' to channel {}'.format(channel))) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Mattermost ' + 'notification{}.'.format( + '' if not channel + else ' to channel {}'.format(channel))) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Flag our error + has_error = True + continue + + # Return our overall status + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'image': 'yes' if self.include_image else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.channels: + # historically the value only accepted one channel and is + # therefore identified as 'channel'. Channels have always been + # optional, so that is why this setting is nested in an if block + params['channel'] = ','.join( + [NotifyMattermost.quote(x, safe='') for x in self.channels]) + + default_port = 443 if self.secure else self.default_port + default_schema = self.secure_protocol if self.secure else self.protocol + + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifyMattermost.quote(self.user, safe=''), + ) + + return \ + '{schema}://{botname}{hostname}{port}{fullpath}{token}' \ + '/?{params}'.format( + schema=default_schema, + botname=botname, + # never encode hostname since we're expecting it to be a valid + # one + hostname=self.host, + port='' if not self.port or self.port == default_port + else ':{}'.format(self.port), + fullpath='/' if not self.fullpath else '{}/'.format( + NotifyMattermost.quote(self.fullpath, safe='/')), + token=self.pprint(self.token, privacy, safe=''), + params=NotifyMattermost.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + # Acquire our tokens; the last one will always be our token + # all entries before it will be our path + tokens = NotifyMattermost.split_path(results['fullpath']) + + results['token'] = None if not tokens else tokens.pop() + + # Store our path + results['fullpath'] = '' if not tokens \ + else '/{}'.format('/'.join(tokens)) + + # Define our optional list of channels to notify + results['channels'] = list() + + # Support both 'to' (for yaml configuration) and channel= + if 'to' in results['qsd'] and len(results['qsd']['to']): + # Allow the user to specify the channel to post to + results['channels'].append( + NotifyMattermost.parse_list(results['qsd']['to'])) + + if 'channel' in results['qsd'] and len(results['qsd']['channel']): + # Allow the user to specify the channel to post to + results['channels'].append( + NotifyMattermost.parse_list(results['qsd']['channel'])) + + # Image manipulation + results['include_image'] = \ + parse_bool(results['qsd'].get('image', False)) + + return results diff --git a/libs/apprise/plugins/NotifyMessageBird.py b/libs/apprise/plugins/NotifyMessageBird.py index f477df489..4cb9d7b56 100644 --- a/libs/apprise/plugins/NotifyMessageBird.py +++ b/libs/apprise/plugins/NotifyMessageBird.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyMisskey.py b/libs/apprise/plugins/NotifyMisskey.py index 54c4e628a..57633a515 100644 --- a/libs/apprise/plugins/NotifyMisskey.py +++ b/libs/apprise/plugins/NotifyMisskey.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -29,6 +25,7 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. + # 1. visit https://misskey-hub.net/ and see what it's all about if you want. # Choose a service you want to create an account on from here: # https://misskey-hub.net/en/instances.html diff --git a/libs/apprise/plugins/NotifyNextcloud.py b/libs/apprise/plugins/NotifyNextcloud.py index 6bb79a7ef..b1d623d0b 100644 --- a/libs/apprise/plugins/NotifyNextcloud.py +++ b/libs/apprise/plugins/NotifyNextcloud.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -67,6 +63,8 @@ class NotifyNextcloud(NotifyBase): # Define object templates templates = ( + '{schema}://{host}/{targets}', + '{schema}://{host}:{port}/{targets}', '{schema}://{user}:{password}@{host}/{targets}', '{schema}://{user}:{password}@{host}:{port}/{targets}', ) @@ -116,6 +114,10 @@ class NotifyNextcloud(NotifyBase): 'min': 1, 'default': 21, }, + 'url_prefix': { + 'name': _('URL Prefix'), + 'type': 'string', + }, 'to': { 'alias_of': 'targets', }, @@ -129,17 +131,15 @@ class NotifyNextcloud(NotifyBase): }, } - def __init__(self, targets=None, version=None, headers=None, **kwargs): + def __init__(self, targets=None, version=None, headers=None, + url_prefix=None, **kwargs): """ Initialize Nextcloud Object """ super().__init__(**kwargs) + # Store our targets self.targets = parse_list(targets) - if len(self.targets) == 0: - msg = 'At least one Nextcloud target user must be specified.' - self.logger.warning(msg) - raise TypeError(msg) self.version = self.template_args['version']['default'] if version is not None: @@ -155,6 +155,10 @@ class NotifyNextcloud(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Support URL Prefix + self.url_prefix = '' if not url_prefix \ + else url_prefix.strip('/') + self.headers = {} if headers: # Store our extra headers @@ -167,6 +171,11 @@ class NotifyNextcloud(NotifyBase): Perform Nextcloud Notification """ + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no Nextcloud targets to notify.') + return False + # Prepare our Header headers = { 'User-Agent': self.app_id, @@ -198,11 +207,11 @@ class NotifyNextcloud(NotifyBase): auth = (self.user, self.password) # Nextcloud URL based on version used - notify_url = '{schema}://{host}/ocs/v2.php/'\ + notify_url = '{schema}://{host}/{url_prefix}/ocs/v2.php/'\ 'apps/admin_notifications/' \ 'api/v1/notifications/{target}' \ if self.version < 21 else \ - '{schema}://{host}/ocs/v2.php/'\ + '{schema}://{host}/{url_prefix}/ocs/v2.php/'\ 'apps/notifications/'\ 'api/v2/admin_notifications/{target}' @@ -210,6 +219,7 @@ class NotifyNextcloud(NotifyBase): schema='https' if self.secure else 'http', host=self.host if not isinstance(self.port, int) else '{}:{}'.format(self.host, self.port), + url_prefix=self.url_prefix, target=target, ) @@ -279,6 +289,9 @@ class NotifyNextcloud(NotifyBase): # Set our version params['version'] = str(self.version) + if self.url_prefix: + params['url_prefix'] = self.url_prefix + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) @@ -316,7 +329,8 @@ class NotifyNextcloud(NotifyBase): """ Returns the number of targets associated with this notification """ - return len(self.targets) + targets = len(self.targets) + return targets if targets else 1 @staticmethod def parse_url(url): @@ -345,6 +359,12 @@ class NotifyNextcloud(NotifyBase): results['version'] = \ NotifyNextcloud.unquote(results['qsd']['version']) + # Support URL Prefixes + if 'url_prefix' in results['qsd'] \ + and len(results['qsd']['url_prefix']): + results['url_prefix'] = \ + NotifyNextcloud.unquote(results['qsd']['url_prefix']) + # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results['headers'] = { diff --git a/libs/apprise/plugins/NotifyNextcloudTalk.py b/libs/apprise/plugins/NotifyNextcloudTalk.py index 8a1dc4294..4f6dc0541 100644 --- a/libs/apprise/plugins/NotifyNextcloudTalk.py +++ b/libs/apprise/plugins/NotifyNextcloudTalk.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -96,6 +92,11 @@ class NotifyNextcloudTalk(NotifyBase): 'private': True, 'required': True, }, + 'target_room_id': { + 'name': _('Room ID'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { 'name': _('Targets'), 'type': 'list:string', @@ -103,6 +104,14 @@ class NotifyNextcloudTalk(NotifyBase): }, }) + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'url_prefix': { + 'name': _('URL Prefix'), + 'type': 'string', + }, + }) + # Define any kwargs we're using template_kwargs = { 'headers': { @@ -111,7 +120,7 @@ class NotifyNextcloudTalk(NotifyBase): }, } - def __init__(self, targets=None, headers=None, **kwargs): + def __init__(self, targets=None, headers=None, url_prefix=None, **kwargs): """ Initialize Nextcloud Talk Object """ @@ -122,11 +131,12 @@ class NotifyNextcloudTalk(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Store our targets self.targets = parse_list(targets) - if len(self.targets) == 0: - msg = 'At least one Nextcloud Talk Room ID must be specified.' - self.logger.warning(msg) - raise TypeError(msg) + + # Support URL Prefix + self.url_prefix = '' if not url_prefix \ + else url_prefix.strip('/') self.headers = {} if headers: @@ -140,6 +150,12 @@ class NotifyNextcloudTalk(NotifyBase): Perform Nextcloud Talk Notification """ + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning( + 'There were no Nextcloud Talk targets to notify.') + return False + # Prepare our Header headers = { 'User-Agent': self.app_id, @@ -171,13 +187,14 @@ class NotifyNextcloudTalk(NotifyBase): } # Nextcloud Talk URL - notify_url = '{schema}://{host}'\ + notify_url = '{schema}://{host}/{url_prefix}'\ '/ocs/v2.php/apps/spreed/api/v1/chat/{target}' notify_url = notify_url.format( schema='https' if self.secure else 'http', host=self.host if not isinstance(self.port, int) else '{}:{}'.format(self.host, self.port), + url_prefix=self.url_prefix, target=target, ) @@ -200,7 +217,8 @@ class NotifyNextcloudTalk(NotifyBase): verify=self.verify_certificate, timeout=self.request_timeout, ) - if r.status_code != requests.codes.created: + if r.status_code not in ( + requests.codes.created, requests.codes.ok): # We had a problem status_str = \ NotifyNextcloudTalk.http_response_code_lookup( @@ -240,6 +258,14 @@ class NotifyNextcloudTalk(NotifyBase): Returns the URL built dynamically based on specified arguments. """ + # Our default set of parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + if self.url_prefix: + params['url_prefix'] = self.url_prefix + # Determine Authentication auth = '{user}:{password}@'.format( user=NotifyNextcloudTalk.quote(self.user, safe=''), @@ -249,7 +275,7 @@ class NotifyNextcloudTalk(NotifyBase): default_port = 443 if self.secure else 80 - return '{schema}://{auth}{hostname}{port}/{targets}' \ + return '{schema}://{auth}{hostname}{port}/{targets}?{params}' \ .format( schema=self.secure_protocol if self.secure else self.protocol, @@ -261,13 +287,15 @@ class NotifyNextcloudTalk(NotifyBase): else ':{}'.format(self.port), targets='/'.join([NotifyNextcloudTalk.quote(x) for x in self.targets]), + params=NotifyNextcloudTalk.urlencode(params), ) def __len__(self): """ Returns the number of targets associated with this notification """ - return len(self.targets) + targets = len(self.targets) + return targets if targets else 1 @staticmethod def parse_url(url): @@ -286,6 +314,12 @@ class NotifyNextcloudTalk(NotifyBase): results['targets'] = \ NotifyNextcloudTalk.split_path(results['fullpath']) + # Support URL Prefixes + if 'url_prefix' in results['qsd'] \ + and len(results['qsd']['url_prefix']): + results['url_prefix'] = \ + NotifyNextcloudTalk.unquote(results['qsd']['url_prefix']) + # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results['headers'] = { diff --git a/libs/apprise/plugins/NotifyNotica.py b/libs/apprise/plugins/NotifyNotica.py index 90bf7ef1c..f95baba3f 100644 --- a/libs/apprise/plugins/NotifyNotica.py +++ b/libs/apprise/plugins/NotifyNotica.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -112,12 +108,12 @@ class NotifyNotica(NotifyBase): '{schema}://{user}:{password}@{host}:{port}/{token}', # Self-hosted notica servers (with custom path) - '{schema}://{host}{path}{token}', - '{schema}://{host}:{port}{path}{token}', - '{schema}://{user}@{host}{path}{token}', - '{schema}://{user}@{host}:{port}{path}{token}', - '{schema}://{user}:{password}@{host}{path}{token}', - '{schema}://{user}:{password}@{host}:{port}{path}{token}', + '{schema}://{host}{path}/{token}', + '{schema}://{host}:{port}/{path}/{token}', + '{schema}://{user}@{host}/{path}/{token}', + '{schema}://{user}@{host}:{port}{path}/{token}', + '{schema}://{user}:{password}@{host}{path}/{token}', + '{schema}://{user}:{password}@{host}:{port}/{path}/{token}', ) # Define our template tokens diff --git a/libs/apprise/plugins/NotifyNotifiarr.py b/libs/apprise/plugins/NotifyNotifiarr.py new file mode 100644 index 000000000..748e3b7aa --- /dev/null +++ b/libs/apprise/plugins/NotifyNotifiarr.py @@ -0,0 +1,472 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +import requests +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..AppriseLocale import gettext_lazy as _ +from ..common import NotifyImageSize +from ..utils import parse_list, parse_bool +from ..utils import validate_regex + +# Used to break path apart into list of channels +CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + +CHANNEL_REGEX = re.compile( + r'^\s*(\#|\%35)?(?P[0-9]+)', re.I) + +# For API Details see: +# https://notifiarr.wiki/Client/Installation + +# Another good example: +# https://notifiarr.wiki/en/Website/ \ +# Integrations/Passthrough#payload-example-1 + + +class NotifyNotifiarr(NotifyBase): + """ + A wrapper for Notifiarr Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Notifiarr' + + # The services URL + service_url = 'https://notifiarr.com/' + + # The default secure protocol + secure_protocol = 'notifiarr' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_notifiarr' + + # The Notification URL + notify_url = 'https://notifiarr.com/api/v1/notification/apprise' + + # Notifiarr Throttling (knowing in advance reduces 429 responses) + # define('NOTIFICATION_LIMIT_SECOND_USER', 5); + # define('NOTIFICATION_LIMIT_SECOND_PATRON', 15); + + # Throttle requests ever so slightly + request_rate_per_sec = 0.04 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_256 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our apikeys; these are the minimum apikeys required required to + # be passed into this function (as arguments). The syntax appends any + # previously defined in the base package and builds onto them + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'key': { + 'alias_of': 'apikey', + }, + 'apikey': { + 'alias_of': 'apikey', + }, + 'discord_user': { + 'name': _('Ping Discord User'), + 'type': 'int', + }, + 'discord_role': { + 'name': _('Ping Discord Role'), + 'type': 'int', + }, + 'event': { + 'name': _('Discord Event ID'), + 'type': 'int', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + 'source': { + 'name': _('Source'), + 'type': 'string', + }, + 'from': { + 'alias_of': 'source' + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, apikey=None, include_image=None, + discord_user=None, discord_role=None, + event=None, targets=None, source=None, **kwargs): + """ + Initialize Notifiarr Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + self.apikey = apikey + if not self.apikey: + msg = 'An invalid Notifiarr APIKey ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Place a thumbnail image inline with the message body + self.include_image = include_image \ + if isinstance(include_image, bool) \ + else self.template_args['image']['default'] + + # Set up our user if specified + self.discord_user = 0 + if discord_user: + try: + self.discord_user = int(discord_user) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr User ID ' \ + '({}) was specified.'.format(discord_user) + self.logger.warning(msg) + raise TypeError(msg) + + # Set up our role if specified + self.discord_role = 0 + if discord_role: + try: + self.discord_role = int(discord_role) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr Role ID ' \ + '({}) was specified.'.format(discord_role) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare our source (if set) + self.source = validate_regex(source) + + self.event = 0 + if event: + try: + self.event = int(event) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr Discord Event ID ' \ + '({}) was specified.'.format(event) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare our targets + self.targets = { + 'channels': [], + 'invalid': [], + } + + for target in parse_list(targets): + result = CHANNEL_REGEX.match(target) + if result: + # Store role information + self.targets['channels'].append(int(result.group('channel'))) + continue + + self.logger.warning( + 'Dropped invalid channel ' + '({}) specified.'.format(target), + ) + self.targets['invalid'].append(target) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'image': 'yes' if self.include_image else 'no', + } + + if self.source: + params['source'] = self.source + + if self.discord_user: + params['discord_user'] = self.discord_user + + if self.discord_role: + params['discord_role'] = self.discord_role + + if self.event: + params['event'] = self.event + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}' \ + '/{targets}?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyNotifiarr.quote(x, safe='+#@') for x in chain( + # Channels + ['#{}'.format(x) for x in self.targets['channels']], + # Pass along the same invalid entries as were provided + self.targets['invalid'], + )]), + params=NotifyNotifiarr.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Notifiarr Notification + """ + + if not self.targets['channels']: + # There were no services to notify + self.logger.warning( + 'There were no Notifiarr channels to notify.') + return False + + # No error to start with + has_error = False + + # Acquire image_url + image_url = self.image_url(notify_type) + + for idx, channel in enumerate(self.targets['channels']): + # prepare Notifiarr Object + payload = { + 'source': self.source if self.source else self.app_id, + 'type': notify_type, + 'notification': { + 'update': True if self.event else False, + 'name': self.app_id, + 'event': str(self.event) + if self.event else "", + }, + 'discord': { + 'color': self.color(notify_type), + 'ping': { + 'pingUser': self.discord_user + if not idx and self.discord_user else 0, + 'pingRole': self.discord_role + if not idx and self.discord_role else 0, + }, + 'text': { + 'title': title, + 'content': '', + 'description': body, + 'footer': self.app_desc, + }, + 'ids': { + 'channel': channel, + } + } + } + + if self.include_image and image_url: + payload['discord']['text']['icon'] = image_url + payload['discord']['images'] = { + 'thumbnail': image_url, + } + + if not self._send(payload): + has_error = True + + return not has_error + + def _send(self, payload): + """ + Send notification + """ + self.logger.debug('Notifiarr POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Notifiarr Payload: %s' % str(payload)) + + # Prepare HTTP Headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accept': 'text/plain', + 'X-api-Key': self.apikey, + } + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code < 200 or r.status_code >= 300: + # We had a problem + status_str = \ + NotifyNotifiarr.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Notifiarr %s notification: ' + '%serror=%s.', + status_str, + ', ' if status_str else '', + str(r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Notifiarr notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Notifiarr ' + 'Chat notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets['channels']) + len(self.targets['invalid']) + return targets if targets > 0 else 1 + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get channels + results['targets'] = NotifyNotifiarr.split_path(results['fullpath']) + + if 'discord_user' in results['qsd'] and \ + len(results['qsd']['discord_user']): + results['discord_user'] = \ + NotifyNotifiarr.unquote( + results['qsd']['discord_user']) + + if 'discord_role' in results['qsd'] and \ + len(results['qsd']['discord_role']): + results['discord_role'] = \ + NotifyNotifiarr.unquote(results['qsd']['discord_role']) + + if 'event' in results['qsd'] and \ + len(results['qsd']['event']): + results['event'] = \ + NotifyNotifiarr.unquote(results['qsd']['event']) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', False)) + + # Track if we need to extract the hostname as a target + host_is_potential_target = False + + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyNotifiarr.unquote(results['qsd']['source']) + + elif 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyNotifiarr.unquote(results['qsd']['from']) + + # Set our apikey if found as an argument + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyNotifiarr.unquote(results['qsd']['apikey']) + + host_is_potential_target = True + + elif 'key' in results['qsd'] and len(results['qsd']['key']): + results['apikey'] = \ + NotifyNotifiarr.unquote(results['qsd']['key']) + + host_is_potential_target = True + + else: + # Pop the first element (this is the api key) + results['apikey'] = \ + NotifyNotifiarr.unquote(results['host']) + + if host_is_potential_target is True and results['host']: + results['targets'].append(NotifyNotifiarr.unquote(results['host'])) + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += [x for x in filter( + bool, CHANNEL_LIST_DELIM.split( + NotifyNotifiarr.unquote(results['qsd']['to'])))] + + return results diff --git a/libs/apprise/plugins/NotifyNotifico.py b/libs/apprise/plugins/NotifyNotifico.py index 9b1661bf6..8636e2e00 100644 --- a/libs/apprise/plugins/NotifyNotifico.py +++ b/libs/apprise/plugins/NotifyNotifico.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyNtfy.py b/libs/apprise/plugins/NotifyNtfy.py index 87587c041..ceab5a2a3 100644 --- a/libs/apprise/plugins/NotifyNtfy.py +++ b/libs/apprise/plugins/NotifyNtfy.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -172,6 +168,9 @@ class NotifyNtfy(NotifyBase): # Default upstream/cloud host if none is defined cloud_notify_url = 'https://ntfy.sh' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 @@ -405,14 +404,14 @@ class NotifyNtfy(NotifyBase): # Retrieve our topic topic = topics.pop() - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach): - # First message only includes the text - _body = body if not no else None - _title = title if not no else None + # First message only includes the text (if defined) + _body = body if not no and body else None + _title = title if not no and title else None # Perform some simple error checking if not attachment: @@ -453,10 +452,6 @@ class NotifyNtfy(NotifyBase): 'User-Agent': self.app_id, } - # Some default values for our request object to which we'll update - # depending on what our payload is - files = None - # See https://ntfy.sh/docs/publish/#publish-as-json data = {} @@ -494,11 +489,23 @@ class NotifyNtfy(NotifyBase): data['topic'] = topic virt_payload = data + if self.attach: + virt_payload['attach'] = self.attach + + if self.filename: + virt_payload['filename'] = self.filename + else: # Point our payload to our parameters virt_payload = params notify_url += '/{topic}'.format(topic=topic) + # Prepare our Header + virt_payload['filename'] = attach.name + + with open(attach.path, 'rb') as fp: + data = fp.read() + if image_url: headers['X-Icon'] = image_url @@ -523,18 +530,6 @@ class NotifyNtfy(NotifyBase): if self.__tags: headers['X-Tags'] = ",".join(self.__tags) - if isinstance(attach, AttachBase): - # Prepare our Header - params['filename'] = attach.name - - # prepare our files object - files = {'file': (attach.name, open(attach.path, 'rb'))} - - elif self.attach is not None: - data['attach'] = self.attach - if self.filename is not None: - data['filename'] = self.filename - self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % ( notify_url, self.verify_certificate, )) @@ -547,13 +542,15 @@ class NotifyNtfy(NotifyBase): # Default response type response = None + if not attach: + data = dumps(data) + try: r = requests.post( notify_url, params=params if params else None, - data=dumps(data) if data else None, + data=data, headers=headers, - files=files, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, @@ -608,7 +605,6 @@ class NotifyNtfy(NotifyBase): notify_url) + 'notification.' ) self.logger.debug('Socket Exception: %s' % str(e)) - return False, response except (OSError, IOError) as e: self.logger.warning( @@ -616,13 +612,8 @@ class NotifyNtfy(NotifyBase): attach.name if isinstance(attach, AttachBase) else virt_payload)) self.logger.debug('I/O Exception: %s' % str(e)) - return False, response - finally: - # Close our file (if it's open) stored in the second element - # of our files tuple (index 1) - if files: - files['file'][1].close() + return False, response def url(self, privacy=False, *args, **kwargs): """ diff --git a/libs/apprise/plugins/NotifyOffice365.py b/libs/apprise/plugins/NotifyOffice365.py index 658a21526..f445bc49d 100644 --- a/libs/apprise/plugins/NotifyOffice365.py +++ b/libs/apprise/plugins/NotifyOffice365.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -148,8 +144,13 @@ class NotifyOffice365(NotifyBase): 'private': True, 'required': True, }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { - 'name': _('Target Emails'), + 'name': _('Targets'), 'type': 'list:string', }, }) diff --git a/libs/apprise/plugins/NotifyOneSignal.py b/libs/apprise/plugins/NotifyOneSignal.py index ce56bbdd9..39dd7f206 100644 --- a/libs/apprise/plugins/NotifyOneSignal.py +++ b/libs/apprise/plugins/NotifyOneSignal.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -146,6 +142,7 @@ class NotifyOneSignal(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyOpsgenie.py b/libs/apprise/plugins/NotifyOpsgenie.py index 0639c1ed2..29cd0a202 100644 --- a/libs/apprise/plugins/NotifyOpsgenie.py +++ b/libs/apprise/plugins/NotifyOpsgenie.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyPagerDuty.py b/libs/apprise/plugins/NotifyPagerDuty.py index a2417275b..1592f93c9 100644 --- a/libs/apprise/plugins/NotifyPagerDuty.py +++ b/libs/apprise/plugins/NotifyPagerDuty.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -142,7 +138,7 @@ class NotifyPagerDuty(NotifyBase): }, # Optional but triggers V2 API 'integrationkey': { - 'name': _('Routing Key'), + 'name': _('Integration Key'), 'type': 'string', 'private': True, 'required': True diff --git a/libs/apprise/plugins/NotifyPagerTree.py b/libs/apprise/plugins/NotifyPagerTree.py index 65a19f613..a1579c30c 100644 --- a/libs/apprise/plugins/NotifyPagerTree.py +++ b/libs/apprise/plugins/NotifyPagerTree.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyParsePlatform.py b/libs/apprise/plugins/NotifyParsePlatform.py index 69efb61c7..f3d7d635e 100644 --- a/libs/apprise/plugins/NotifyParsePlatform.py +++ b/libs/apprise/plugins/NotifyParsePlatform.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -30,8 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# Official API reference: https://developer.gitter.im/docs/user-resource - import re import requests from json import dumps diff --git a/libs/apprise/plugins/NotifyPopcornNotify.py b/libs/apprise/plugins/NotifyPopcornNotify.py index a36aed9f9..47a296147 100644 --- a/libs/apprise/plugins/NotifyPopcornNotify.py +++ b/libs/apprise/plugins/NotifyPopcornNotify.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -93,6 +89,7 @@ class NotifyPopcornNotify(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, } }) diff --git a/libs/apprise/plugins/NotifyProwl.py b/libs/apprise/plugins/NotifyProwl.py index cebe07010..80f0aca3a 100644 --- a/libs/apprise/plugins/NotifyProwl.py +++ b/libs/apprise/plugins/NotifyProwl.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyPushBullet.py b/libs/apprise/plugins/NotifyPushBullet.py index 07b2a43a0..61e8db2d5 100644 --- a/libs/apprise/plugins/NotifyPushBullet.py +++ b/libs/apprise/plugins/NotifyPushBullet.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -75,6 +71,9 @@ class NotifyPushBullet(NotifyBase): # PushBullet uses the http protocol with JSON requests notify_url = 'https://api.pushbullet.com/v2/{}' + # Support attachments + attachment_support = True + # Define object templates templates = ( '{schema}://{accesstoken}', @@ -150,7 +149,7 @@ class NotifyPushBullet(NotifyBase): # Build a list of our attachments attachments = [] - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: @@ -261,14 +260,15 @@ class NotifyPushBullet(NotifyBase): "PushBullet recipient {} parsed as a device" .format(recipient)) - okay, response = self._send( - self.notify_url.format('pushes'), payload) - if not okay: - has_error = True - continue + if body: + okay, response = self._send( + self.notify_url.format('pushes'), payload) + if not okay: + has_error = True + continue - self.logger.info( - 'Sent PushBullet notification to "%s".' % (recipient)) + self.logger.info( + 'Sent PushBullet notification to "%s".' % (recipient)) for attach_payload in attachments: # Send our attachments to our same user (already prepared as diff --git a/libs/apprise/plugins/NotifyPushDeer.py b/libs/apprise/plugins/NotifyPushDeer.py new file mode 100644 index 000000000..76805c34b --- /dev/null +++ b/libs/apprise/plugins/NotifyPushDeer.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import requests + +from ..common import NotifyType +from .NotifyBase import NotifyBase +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Syntax: +# schan://{key}/ + + +class NotifyPushDeer(NotifyBase): + """ + A wrapper for PushDeer Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PushDeer' + + # The services URL + service_url = 'https://www.pushdeer.com/' + + # Insecure Protocol Access + protocol = 'pushdeer' + + # Secure Protocol + secure_protocol = 'pushdeers' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_PushDeer' + + # Default hostname + default_hostname = 'api2.pushdeer.com' + + # PushDeer API + notify_url = '{schema}://{host}:{port}/message/push?pushkey={pushKey}' + + # Define object templates + templates = ( + '{schema}://{pushkey}', + '{schema}://{host}/{pushkey}', + '{schema}://{host}:{port}/{pushkey}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'pushkey': { + 'name': _('Pushkey'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + }) + + def __init__(self, pushkey, **kwargs): + """ + Initialize PushDeer Object + """ + super().__init__(**kwargs) + + # PushKey (associated with project) + self.push_key = validate_regex( + pushkey, *self.template_tokens['pushkey']['regex']) + if not self.push_key: + msg = 'An invalid PushDeer API Pushkey ' \ + '({}) was specified.'.format(pushkey) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PushDeer Notification + """ + + # Prepare our persistent_notification.create payload + payload = { + 'text': title if title else body, + 'type': 'text', + 'desp': body if title else '', + } + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Set host + host = self.default_hostname + if self.host: + host = self.host + + # Set port + port = 443 if self.secure else 80 + if self.port: + port = self.port + + # Our Notification URL + notify_url = self.notify_url.format( + schema=schema, host=host, port=port, pushKey=self.push_key) + + # Some Debug Logging + self.logger.debug('PushDeer URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('PushDeer Payload: {}'.format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=payload, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushDeer.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send PushDeer notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + else: + self.logger.info('Sent PushDeer notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending PushDeer ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def url(self, privacy=False): + """ + Returns the URL built dynamically based on specified arguments. + """ + + if self.host: + url = '{schema}://{host}{port}/{pushkey}' + else: + url = '{schema}://{pushkey}' + + return url.format( + schema=self.secure_protocol if self.secure else self.protocol, + host=self.host, + port='' if not self.port else ':{}'.format(self.port), + pushkey=self.pprint(self.push_key, privacy, safe='')) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't parse the URL + return results + + fullpaths = NotifyPushDeer.split_path(results['fullpath']) + + if len(fullpaths) == 0: + results['pushkey'] = results['host'] + results['host'] = None + else: + results['pushkey'] = fullpaths.pop() + + return results diff --git a/libs/apprise/plugins/NotifyPushMe.py b/libs/apprise/plugins/NotifyPushMe.py new file mode 100644 index 000000000..8ef3c79c5 --- /dev/null +++ b/libs/apprise/plugins/NotifyPushMe.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import validate_regex +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyPushMe(NotifyBase): + """ + A wrapper for PushMe Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PushMe' + + # The services URL + service_url = 'https://push.i-i.me/' + + # Insecure protocol (for those self hosted requests) + protocol = 'pushme' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushme' + + # PushMe URL + notify_url = 'https://push.i-i.me/' + + # Define object templates + templates = ( + '{schema}://{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'token': { + 'alias_of': 'token', + }, + 'push_key': { + 'alias_of': 'token', + }, + 'status': { + 'name': _('Show Status'), + 'type': 'bool', + 'default': True, + }, + }) + + def __init__(self, token, status=None, **kwargs): + """ + Initialize PushMe Object + """ + super().__init__(**kwargs) + + # Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid PushMe Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Set Status type + self.status = status + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PushMe Notification + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Prepare our payload + params = { + 'push_key': self.token, + 'title': title if not self.status + else '{} {}'.format(self.asset.ascii(notify_type), title), + 'content': body, + 'type': 'markdown' + if self.notify_format == NotifyFormat.MARKDOWN else 'text' + } + + self.logger.debug('PushMe POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('PushMe Payload: %s' % str(params)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + params=params, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushMe.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send PushMe notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent PushMe notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending PushMe notification.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'status': 'yes' if self.status else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Official URLs are easy to assemble + return '{schema}://{token}/?{params}'.format( + schema=self.protocol, + token=self.pprint(self.token, privacy, safe=''), + params=NotifyPushMe.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Store our token using the host + results['token'] = NotifyPushMe.unquote(results['host']) + + # The 'token' makes it easier to use yaml configuration + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyPushMe.unquote(results['qsd']['token']) + + elif 'push_key' in results['qsd'] and len(results['qsd']['push_key']): + # Support 'push_key' if specified + results['token'] = NotifyPushMe.unquote(results['qsd']['push_key']) + + # Get status switch + results['status'] = \ + parse_bool(results['qsd'].get('status', True)) + + return results diff --git a/libs/apprise/plugins/NotifyPushSafer.py b/libs/apprise/plugins/NotifyPushSafer.py index 19bff2bd0..9873bd8e1 100644 --- a/libs/apprise/plugins/NotifyPushSafer.py +++ b/libs/apprise/plugins/NotifyPushSafer.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -336,6 +332,9 @@ class NotifyPushSafer(NotifyBase): # The default secure protocol secure_protocol = 'psafers' + # Support attachments + attachment_support = True + # Number of requests to a allow per second request_rate_per_sec = 1.2 @@ -546,7 +545,7 @@ class NotifyPushSafer(NotifyBase): # Initialize our list of attachments attachments = [] - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: diff --git a/libs/apprise/plugins/NotifyPushed.py b/libs/apprise/plugins/NotifyPushed.py index b5ec3f6de..96e2e89d4 100644 --- a/libs/apprise/plugins/NotifyPushed.py +++ b/libs/apprise/plugins/NotifyPushed.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyPushjet.py b/libs/apprise/plugins/NotifyPushjet.py index c6e36a393..50ee16e41 100644 --- a/libs/apprise/plugins/NotifyPushjet.py +++ b/libs/apprise/plugins/NotifyPushjet.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyPushover.py b/libs/apprise/plugins/NotifyPushover.py index 64b94774c..4a76e7d54 100644 --- a/libs/apprise/plugins/NotifyPushover.py +++ b/libs/apprise/plugins/NotifyPushover.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -32,6 +28,7 @@ import re import requests +from itertools import chain from .NotifyBase import NotifyBase from ..common import NotifyType @@ -46,7 +43,7 @@ from ..attachment.AttachBase import AttachBase PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' # Used to detect a Device -VALIDATE_DEVICE = re.compile(r'^[a-z0-9_]{1,25}$', re.I) +VALIDATE_DEVICE = re.compile(r'^\s*(?P[a-z0-9_-]{1,25})\s*$', re.I) # Priorities @@ -164,6 +161,9 @@ class NotifyPushover(NotifyBase): # Pushover uses the http protocol with JSON requests notify_url = 'https://api.pushover.net/1/messages.json' + # Support attachments + attachment_support = True + # The maximum allowable characters allowed in the body per message body_maxlen = 1024 @@ -201,7 +201,7 @@ class NotifyPushover(NotifyBase): 'target_device': { 'name': _('Target Device'), 'type': 'string', - 'regex': (r'^[a-z0-9_]{1,25}$', 'i'), + 'regex': (r'^[a-z0-9_-]{1,25}$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -276,10 +276,30 @@ class NotifyPushover(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - self.targets = parse_list(targets) - if len(self.targets) == 0: + # Track our valid devices + targets = parse_list(targets) + + # Track any invalid entries + self.invalid_targets = list() + + if len(targets) == 0: self.targets = (PUSHOVER_SEND_TO_ALL, ) + else: + self.targets = [] + for target in targets: + result = VALIDATE_DEVICE.match(target) + if result: + # Store device information + self.targets.append(result.group('device')) + continue + + self.logger.warning( + 'Dropped invalid Pushover device ' + '({}) specified.'.format(target), + ) + self.invalid_targets.append(target) + # Setup supplemental url self.supplemental_url = supplemental_url self.supplemental_url_title = supplemental_url_title @@ -288,9 +308,8 @@ class NotifyPushover(NotifyBase): self.sound = NotifyPushover.default_pushover_sound \ if not isinstance(sound, str) else sound.lower() if self.sound and self.sound not in PUSHOVER_SOUNDS: - msg = 'The sound specified ({}) is invalid.'.format(sound) - self.logger.warning(msg) - raise TypeError(msg) + msg = 'Using custom sound specified ({}). '.format(sound) + self.logger.debug(msg) # The Priority of the message self.priority = int( @@ -338,77 +357,67 @@ class NotifyPushover(NotifyBase): Perform Pushover Notification """ - # error tracking (used for function return) - has_error = False + if not self.targets: + # There were no services to notify + self.logger.warning( + 'There were no Pushover targets to notify.') + return False - # Create a copy of the devices list - devices = list(self.targets) - while len(devices): - device = devices.pop(0) + # prepare JSON Object + payload = { + 'token': self.token, + 'user': self.user_key, + 'priority': str(self.priority), + 'title': title if title else self.app_desc, + 'message': body, + 'device': ','.join(self.targets), + 'sound': self.sound, + } - if VALIDATE_DEVICE.match(device) is None: - self.logger.warning( - 'The device specified (%s) is invalid.' % device, - ) + if self.supplemental_url: + payload['url'] = self.supplemental_url - # Mark our failure - has_error = True - continue - - # prepare JSON Object - payload = { - 'token': self.token, - 'user': self.user_key, - 'priority': str(self.priority), - 'title': title if title else self.app_desc, - 'message': body, - 'device': device, - 'sound': self.sound, - } - - if self.supplemental_url: - payload['url'] = self.supplemental_url - if self.supplemental_url_title: - payload['url_title'] = self.supplemental_url_title - - if self.notify_format == NotifyFormat.HTML: - # https://pushover.net/api#html - payload['html'] = 1 - elif self.notify_format == NotifyFormat.MARKDOWN: - payload['message'] = convert_between( - NotifyFormat.MARKDOWN, NotifyFormat.HTML, body) - payload['html'] = 1 - - if self.priority == PushoverPriority.EMERGENCY: - payload.update({'retry': self.retry, 'expire': self.expire}) + if self.supplemental_url_title: + payload['url_title'] = self.supplemental_url_title - if attach: - # Create a copy of our payload - _payload = payload.copy() - - # Send with attachments - for attachment in attach: - # Simple send - if not self._send(_payload, attachment): - # Mark our failure - has_error = True - # clean exit from our attachment loop - break + if self.notify_format == NotifyFormat.HTML: + # https://pushover.net/api#html + payload['html'] = 1 + + elif self.notify_format == NotifyFormat.MARKDOWN: + payload['message'] = convert_between( + NotifyFormat.MARKDOWN, NotifyFormat.HTML, body) + payload['html'] = 1 + + if self.priority == PushoverPriority.EMERGENCY: + payload.update({'retry': self.retry, 'expire': self.expire}) + if attach and self.attachment_support: + # Create a copy of our payload + _payload = payload.copy() + + # Send with attachments + for no, attachment in enumerate(attach): + if no or not body: # To handle multiple attachments, clean up our message - _payload['title'] = '...' _payload['message'] = attachment.name - # No need to alarm for each consecutive attachment uploaded - # afterwards - _payload['sound'] = PushoverSound.NONE - else: - # Simple send - if not self._send(payload): + if not self._send(_payload, attachment): # Mark our failure - has_error = True + return False - return not has_error + # Clear our title if previously set + _payload['title'] = '' + + # No need to alarm for each consecutive attachment uploaded + # afterwards + _payload['sound'] = PushoverSound.NONE + + else: + # Simple send + return self._send(payload) + + return True def _send(self, payload, attach=None): """ @@ -562,8 +571,9 @@ class NotifyPushover(NotifyBase): params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Escape our devices - devices = '/'.join([NotifyPushover.quote(x, safe='') - for x in self.targets]) + devices = '/'.join( + [NotifyPushover.quote(x, safe='') + for x in chain(self.targets, self.invalid_targets)]) if devices == PUSHOVER_SEND_TO_ALL: # keyword is reserved for internal usage only; it's safe to remove @@ -577,12 +587,6 @@ class NotifyPushover(NotifyBase): devices=devices, params=NotifyPushover.urlencode(params)) - def __len__(self): - """ - Returns the number of targets associated with this notification - """ - return len(self.targets) - @staticmethod def parse_url(url): """ diff --git a/libs/apprise/plugins/NotifyPushy.py b/libs/apprise/plugins/NotifyPushy.py new file mode 100644 index 000000000..2a8a456b3 --- /dev/null +++ b/libs/apprise/plugins/NotifyPushy.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# API reference: https://pushy.me/docs/api/send-notifications +import re +import requests +from itertools import chain + +from json import dumps, loads +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Used to detect a Device and Topic +VALIDATE_DEVICE = re.compile(r'^@(?P[a-z0-9]+)$', re.I) +VALIDATE_TOPIC = re.compile(r'^[#]?(?P[a-z0-9]+)$', re.I) + +# Extend HTTP Error Messages +PUSHY_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + + +class NotifyPushy(NotifyBase): + """ + A wrapper for Pushy Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushy' + + # The services URL + service_url = 'https://pushy.me/' + + # All Pushy requests are secure + secure_protocol = 'pushy' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushy' + + # Pushy uses the http protocol with JSON requests + notify_url = 'https://api.pushy.me/push?api_key={apikey}' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 4096 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('Secret API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_topic': { + 'name': _('Target Topic'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'sound': { + # Specify something like ping.aiff + 'name': _('Sound'), + 'type': 'string', + }, + 'badge': { + 'name': _('Badge'), + 'type': 'int', + 'min': 0, + }, + 'to': { + 'alias_of': 'targets', + }, + 'key': { + 'alias_of': 'apikey', + }, + }) + + def __init__(self, apikey, targets=None, sound=None, badge=None, **kwargs): + """ + Initialize Pushy Object + """ + super().__init__(**kwargs) + + # Access Token (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Pushy Secret API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Get our targets + self.devices = [] + self.topics = [] + + for target in parse_list(targets): + result = VALIDATE_TOPIC.match(target) + if result: + self.topics.append(result.group('topic')) + continue + + result = VALIDATE_DEVICE.match(target) + if result: + self.devices.append(result.group('device')) + continue + + self.logger.warning( + 'Dropped invalid topic/device ' + '({}) specified.'.format(target), + ) + + # Setup our sound + self.sound = sound + + # Badge + try: + # Acquire our badge count if we can: + # - We accept both the integer form as well as a string + # representation + self.badge = int(badge) + if self.badge < 0: + raise ValueError() + + except TypeError: + # NoneType means use Default; this is an okay exception + self.badge = None + + except ValueError: + self.badge = None + self.logger.warning( + 'The specified Pushy badge ({}) is not valid ', badge) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Pushy Notification + """ + + if len(self.topics) + len(self.devices) == 0: + # There were no services to notify + self.logger.warning('There were no Pushy targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Default Header + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accepts': 'application/json', + } + + # Our URL + notify_url = self.notify_url.format(apikey=self.apikey) + + # Default content response object + content = {} + + # Create a copy of targets (topics and devices) + targets = list(self.topics) + list(self.devices) + while len(targets): + target = targets.pop(0) + + # prepare JSON Object + payload = { + # Mandatory fields + 'to': target, + "data": { + "message": body, + }, + "notification": { + 'body': body, + } + } + + # Optional payload items + if title: + payload['notification']['title'] = title + + if self.sound: + payload['notification']['sound'] = self.sound + + if self.badge is not None: + payload['notification']['badge'] = self.badge + + self.logger.debug('Pushy POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Pushy Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Sample response + # See: https://pushy.me/docs/api/send-notifications + # { + # "success": true, + # "id": "5ea9b214b47cad768a35f13a", + # "info": { + # "devices": 1 + # "failed": ['abc'] + # } + # } + try: + content = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + content = { + "success": False, + "id": '', + "info": {}, + } + + if r.status_code != requests.codes.ok \ + or not content.get('success'): + + # We had a problem + status_str = \ + NotifyPushy.http_response_code_lookup( + r.status_code, PUSHY_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Pushy notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + has_error = True + continue + + else: + self.logger.info( + 'Sent Pushy notification to %s.' % target) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pushy:%s ' + 'notification', target) + self.logger.debug('Socket Exception: %s' % str(e)) + + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = {} + if self.sound: + params['sound'] = self.sound + + if self.badge is not None: + params['badge'] = str(self.badge) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyPushy.quote(x, safe='@#') for x in chain( + # Topics are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.topics], + # Devices + ['@{}'.format(x) for x in self.devices], + )]), + params=NotifyPushy.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.topics) + len(self.devices) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Token + results['apikey'] = NotifyPushy.unquote(results['host']) + + # Retrieve all of our targets + results['targets'] = NotifyPushy.split_path(results['fullpath']) + + # Get the sound + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = \ + NotifyPushy.unquote(results['qsd']['sound']) + + # Badge + if 'badge' in results['qsd'] and results['qsd']['badge']: + results['badge'] = NotifyPushy.unquote( + results['qsd']['badge'].strip()) + + # Support key variable to store Secret API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + results['apikey'] = results['qsd']['key'] + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPushy.parse_list(results['qsd']['to']) + + return results diff --git a/libs/apprise/plugins/NotifyRSyslog.py b/libs/apprise/plugins/NotifyRSyslog.py new file mode 100644 index 000000000..473e4c5cb --- /dev/null +++ b/libs/apprise/plugins/NotifyRSyslog.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import os +import socket + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class syslog: + """ + Extrapoloated information from the syslog library so that this plugin + would not be dependent on it. + """ + # Notification Categories + LOG_KERN = 0 + LOG_USER = 8 + LOG_MAIL = 16 + LOG_DAEMON = 24 + LOG_AUTH = 32 + LOG_SYSLOG = 40 + LOG_LPR = 48 + LOG_NEWS = 56 + LOG_UUCP = 64 + LOG_CRON = 72 + LOG_LOCAL0 = 128 + LOG_LOCAL1 = 136 + LOG_LOCAL2 = 144 + LOG_LOCAL3 = 152 + LOG_LOCAL4 = 160 + LOG_LOCAL5 = 168 + LOG_LOCAL6 = 176 + LOG_LOCAL7 = 184 + + # Notification Types + LOG_INFO = 6 + LOG_NOTICE = 5 + LOG_WARNING = 4 + LOG_CRIT = 2 + + +class SyslogFacility: + """ + All of the supported facilities + """ + KERN = 'kern' + USER = 'user' + MAIL = 'mail' + DAEMON = 'daemon' + AUTH = 'auth' + SYSLOG = 'syslog' + LPR = 'lpr' + NEWS = 'news' + UUCP = 'uucp' + CRON = 'cron' + LOCAL0 = 'local0' + LOCAL1 = 'local1' + LOCAL2 = 'local2' + LOCAL3 = 'local3' + LOCAL4 = 'local4' + LOCAL5 = 'local5' + LOCAL6 = 'local6' + LOCAL7 = 'local7' + + +SYSLOG_FACILITY_MAP = { + SyslogFacility.KERN: syslog.LOG_KERN, + SyslogFacility.USER: syslog.LOG_USER, + SyslogFacility.MAIL: syslog.LOG_MAIL, + SyslogFacility.DAEMON: syslog.LOG_DAEMON, + SyslogFacility.AUTH: syslog.LOG_AUTH, + SyslogFacility.SYSLOG: syslog.LOG_SYSLOG, + SyslogFacility.LPR: syslog.LOG_LPR, + SyslogFacility.NEWS: syslog.LOG_NEWS, + SyslogFacility.UUCP: syslog.LOG_UUCP, + SyslogFacility.CRON: syslog.LOG_CRON, + SyslogFacility.LOCAL0: syslog.LOG_LOCAL0, + SyslogFacility.LOCAL1: syslog.LOG_LOCAL1, + SyslogFacility.LOCAL2: syslog.LOG_LOCAL2, + SyslogFacility.LOCAL3: syslog.LOG_LOCAL3, + SyslogFacility.LOCAL4: syslog.LOG_LOCAL4, + SyslogFacility.LOCAL5: syslog.LOG_LOCAL5, + SyslogFacility.LOCAL6: syslog.LOG_LOCAL6, + SyslogFacility.LOCAL7: syslog.LOG_LOCAL7, +} + +SYSLOG_FACILITY_RMAP = { + syslog.LOG_KERN: SyslogFacility.KERN, + syslog.LOG_USER: SyslogFacility.USER, + syslog.LOG_MAIL: SyslogFacility.MAIL, + syslog.LOG_DAEMON: SyslogFacility.DAEMON, + syslog.LOG_AUTH: SyslogFacility.AUTH, + syslog.LOG_SYSLOG: SyslogFacility.SYSLOG, + syslog.LOG_LPR: SyslogFacility.LPR, + syslog.LOG_NEWS: SyslogFacility.NEWS, + syslog.LOG_UUCP: SyslogFacility.UUCP, + syslog.LOG_CRON: SyslogFacility.CRON, + syslog.LOG_LOCAL0: SyslogFacility.LOCAL0, + syslog.LOG_LOCAL1: SyslogFacility.LOCAL1, + syslog.LOG_LOCAL2: SyslogFacility.LOCAL2, + syslog.LOG_LOCAL3: SyslogFacility.LOCAL3, + syslog.LOG_LOCAL4: SyslogFacility.LOCAL4, + syslog.LOG_LOCAL5: SyslogFacility.LOCAL5, + syslog.LOG_LOCAL6: SyslogFacility.LOCAL6, + syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, +} + +# Used as a lookup when handling the Apprise -> Syslog Mapping +SYSLOG_PUBLISH_MAP = { + NotifyType.INFO: syslog.LOG_INFO, + NotifyType.SUCCESS: syslog.LOG_NOTICE, + NotifyType.FAILURE: syslog.LOG_CRIT, + NotifyType.WARNING: syslog.LOG_WARNING, +} + + +class NotifyRSyslog(NotifyBase): + """ + A wrapper for Remote Syslog Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Remote Syslog' + + # The services URL + service_url = 'https://tools.ietf.org/html/rfc5424' + + # The default protocol + protocol = 'rsyslog' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_rsyslog' + + # Disable throttle rate for RSyslog requests + request_rate_per_sec = 0 + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{host}/{facility}', + '{schema}://{host}:{port}/{facility}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'facility': { + 'name': _('Facility'), + 'type': 'choice:string', + 'values': [k for k in SYSLOG_FACILITY_MAP.keys()], + 'default': SyslogFacility.USER, + 'required': True, + }, + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + 'default': 514, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'facility': { + # We map back to the same element defined in template_tokens + 'alias_of': 'facility', + }, + 'logpid': { + 'name': _('Log PID'), + 'type': 'bool', + 'default': True, + 'map_to': 'log_pid', + }, + }) + + def __init__(self, facility=None, log_pid=True, **kwargs): + """ + Initialize RSyslog Object + """ + super().__init__(**kwargs) + + if facility: + try: + self.facility = SYSLOG_FACILITY_MAP[facility] + + except KeyError: + msg = 'An invalid syslog facility ' \ + '({}) was specified.'.format(facility) + self.logger.warning(msg) + raise TypeError(msg) + + else: + self.facility = \ + SYSLOG_FACILITY_MAP[ + self.template_tokens['facility']['default']] + + # Include PID with each message. + self.log_pid = log_pid + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform RSyslog Notification + """ + + if title: + # Format title + body = '{}: {}'.format(title, body) + + # Always call throttle before any remote server i/o is made + self.throttle() + host = self.host + port = self.port if self.port \ + else self.template_tokens['port']['default'] + + if self.log_pid: + payload = '<%d>- %d - %s' % ( + SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8, + os.getpid(), body) + + else: + payload = '<%d>- %s' % ( + SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8, body) + + # send UDP packet to upstream server + self.logger.debug( + 'RSyslog Host: %s:%d/%s', + host, port, SYSLOG_FACILITY_RMAP[self.facility]) + self.logger.debug('RSyslog Payload: %s' % str(payload)) + + # our sent bytes + sent = 0 + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(self.socket_connect_timeout) + sent = sock.sendto(payload.encode('utf-8'), (host, port)) + sock.close() + + except socket.gaierror as e: + self.logger.warning( + 'A connection error occurred sending RSyslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except socket.timeout as e: + self.logger.warning( + 'A connection timeout occurred sending RSyslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + if sent < len(payload): + self.logger.warning( + 'RSyslog sent %d byte(s) but intended to send %d byte(s)', + sent, len(payload)) + return False + + self.logger.info('Sent RSyslog notification.') + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'logpid': 'yes' if self.log_pid else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{hostname}{port}/{facility}/?{params}'.format( + schema=self.protocol, + hostname=NotifyRSyslog.quote(self.host, safe=''), + port='' if self.port is None + or self.port == self.template_tokens['port']['default'] + else ':{}'.format(self.port), + facility=self.template_tokens['facility']['default'] + if self.facility not in SYSLOG_FACILITY_RMAP + else SYSLOG_FACILITY_RMAP[self.facility], + params=NotifyRSyslog.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + tokens = [] + + # Get our path values + tokens.extend(NotifyRSyslog.split_path(results['fullpath'])) + + # Initialization + facility = None + + if tokens: + # Store the last entry as the facility + facility = tokens[-1].lower() + + # However if specified on the URL, that will over-ride what was + # identified + if 'facility' in results['qsd'] and len(results['qsd']['facility']): + facility = results['qsd']['facility'].lower() + + if facility and facility not in SYSLOG_FACILITY_MAP: + # Find first match; if no match is found we set the result + # to the matching key. This allows us to throw a TypeError + # during the __init__() call. The benifit of doing this + # check here is if we do have a valid match, we can support + # short form matches like 'u' which will match against user + facility = next((f for f in SYSLOG_FACILITY_MAP.keys() + if f.startswith(facility)), facility) + + # Save facility if set + if facility: + results['facility'] = facility + + # Include PID as part of the message logged + results['log_pid'] = parse_bool( + results['qsd'].get( + 'logpid', + NotifyRSyslog.template_args['logpid']['default'])) + + return results diff --git a/libs/apprise/plugins/NotifyReddit.py b/libs/apprise/plugins/NotifyReddit.py index 5cb22a726..b25e76d0b 100644 --- a/libs/apprise/plugins/NotifyReddit.py +++ b/libs/apprise/plugins/NotifyReddit.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -30,7 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# # 1. Visit https://www.reddit.com/prefs/apps and scroll to the bottom # 2. Click on the button that reads 'are you a developer? create an app...' # 3. Set the mode to `script`, @@ -56,6 +51,7 @@ import requests from json import loads from datetime import timedelta from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -133,12 +129,6 @@ class NotifyReddit(NotifyBase): # still allow to make. request_rate_per_sec = 0 - # For Tracking Purposes - ratelimit_reset = datetime.utcnow() - - # Default to 1.0 - ratelimit_remaining = 1.0 - # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) @@ -185,6 +175,7 @@ class NotifyReddit(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -275,7 +266,7 @@ class NotifyReddit(NotifyBase): # Our keys we build using the provided content self.__refresh_token = None self.__access_token = None - self.__access_token_expiry = datetime.utcnow() + self.__access_token_expiry = datetime.now(timezone.utc) self.kind = kind.strip().lower() \ if isinstance(kind, str) \ @@ -324,6 +315,13 @@ class NotifyReddit(NotifyBase): if not self.subreddits: self.logger.warning( 'No subreddits were identified to be notified') + + # For Rate Limit Tracking Purposes + self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) + + # Default to 1.0 + self.ratelimit_remaining = 1.0 + return def url(self, privacy=False, *args, **kwargs): @@ -417,10 +415,10 @@ class NotifyReddit(NotifyBase): if 'expires_in' in response: delta = timedelta(seconds=int(response['expires_in'])) self.__access_token_expiry = \ - delta + datetime.utcnow() - self.clock_skew + delta + datetime.now(timezone.utc) - self.clock_skew else: self.__access_token_expiry = self.access_token_lifetime_sec + \ - datetime.utcnow() - self.clock_skew + datetime.now(timezone.utc) - self.clock_skew # The Refresh Token self.__refresh_token = response.get( @@ -544,10 +542,10 @@ class NotifyReddit(NotifyBase): # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the - # Gitter server. One would hope we're on NTP and our clocks are + # Reddit server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds wait = abs( @@ -671,8 +669,9 @@ class NotifyReddit(NotifyBase): self.ratelimit_remaining = \ float(r.headers.get( 'X-RateLimit-Remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('X-RateLimit-Reset'))) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Reset')), timezone.utc + ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information diff --git a/libs/apprise/plugins/NotifyRocketChat.py b/libs/apprise/plugins/NotifyRocketChat.py index ca6b5cd83..6384386e7 100644 --- a/libs/apprise/plugins/NotifyRocketChat.py +++ b/libs/apprise/plugins/NotifyRocketChat.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyRyver.py b/libs/apprise/plugins/NotifyRyver.py index b8b34a3c4..70f2fa436 100644 --- a/libs/apprise/plugins/NotifyRyver.py +++ b/libs/apprise/plugins/NotifyRyver.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -91,7 +87,7 @@ class NotifyRyver(NotifyBase): # Define object templates templates = ( '{schema}://{organization}/{token}', - '{schema}://{user}@{organization}/{token}', + '{schema}://{botname}@{organization}/{token}', ) # Define our template tokens @@ -109,9 +105,10 @@ class NotifyRyver(NotifyBase): 'private': True, 'regex': (r'^[A-Z0-9]{15}$', 'i'), }, - 'user': { + 'botname': { 'name': _('Bot Name'), 'type': 'string', + 'map_to': 'user', }, }) diff --git a/libs/apprise/plugins/NotifySES.py b/libs/apprise/plugins/NotifySES.py index fb0017036..37a0342ac 100644 --- a/libs/apprise/plugins/NotifySES.py +++ b/libs/apprise/plugins/NotifySES.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -89,6 +85,7 @@ import base64 import requests from hashlib import sha256 from datetime import datetime +from datetime import timezone from collections import OrderedDict from xml.etree import ElementTree from email.mime.text import MIMEText @@ -135,6 +132,9 @@ class NotifySES(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ses' + # Support attachments + attachment_support = True + # AWS is pretty good for handling data load so request limits # can occur in much shorter bursts request_rate_per_sec = 2.5 @@ -156,6 +156,7 @@ class NotifySES(NotifyBase): 'name': _('From Email'), 'type': 'string', 'map_to': 'from_addr', + 'required': True, }, 'access_key_id': { 'name': _('Access Key ID'), @@ -173,6 +174,7 @@ class NotifySES(NotifyBase): 'name': _('Region'), 'type': 'string', 'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'), + 'required': True, 'map_to': 'region_name', }, 'targets': { @@ -424,7 +426,8 @@ class NotifySES(NotifyBase): content = MIMEText(body, 'plain', 'utf-8') # Create a Multipart container if there is an attachment - base = MIMEMultipart() if attach else content + base = MIMEMultipart() \ + if attach and self.attachment_support else content # TODO: Deduplicate with `NotifyEmail`? base['Subject'] = Header(title, 'utf-8') @@ -436,10 +439,11 @@ class NotifySES(NotifyBase): base['Reply-To'] = formataddr(reply_to, charset='utf-8') base['Cc'] = ','.join(cc) base['Date'] = \ - datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + datetime.now( + timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") base['X-Application'] = self.app_id - if attach: + if attach and self.attachment_support: # First attach our body to our content as the first element base.attach(content) @@ -585,7 +589,7 @@ class NotifySES(NotifyBase): } # Get a reference time (used for header construction) - reference = datetime.utcnow() + reference = datetime.now(timezone.utc) # Provide Content-Length headers['Content-Length'] = str(len(payload)) diff --git a/libs/apprise/plugins/NotifySMSEagle.py b/libs/apprise/plugins/NotifySMSEagle.py index 747831e10..3db131fbc 100644 --- a/libs/apprise/plugins/NotifySMSEagle.py +++ b/libs/apprise/plugins/NotifySMSEagle.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -112,6 +108,9 @@ class NotifySMSEagle(NotifyBase): # The path we send our notification to notify_path = '/jsonrpc/sms' + # Support attachments + attachment_support = True + # The maxumum length of the text message # The actual limit is 160 but SMSEagle looks after the handling # of large messages in it's upstream service @@ -145,6 +144,7 @@ class NotifySMSEagle(NotifyBase): 'token': { 'name': _('Access Token'), 'type': 'string', + 'required': True, }, 'target_phone': { 'name': _('Target Phone No'), @@ -170,6 +170,7 @@ class NotifySMSEagle(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, } }) @@ -338,7 +339,7 @@ class NotifySMSEagle(NotifyBase): has_error = False attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: diff --git a/libs/apprise/plugins/NotifySMTP2Go.py b/libs/apprise/plugins/NotifySMTP2Go.py index 3634ba6a8..45f6615cb 100644 --- a/libs/apprise/plugins/NotifySMTP2Go.py +++ b/libs/apprise/plugins/NotifySMTP2Go.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -91,6 +87,9 @@ class NotifySMTP2Go(NotifyBase): # Notify URL notify_url = 'https://api.smtp2go.com/v3/email/send' + # Support attachments + attachment_support = True + # Default Notify Format notify_format = NotifyFormat.HTML @@ -294,8 +293,8 @@ class NotifySMTP2Go(NotifyBase): # Track our potential attachments attachments = [] - if attach: - for idx, attachment in enumerate(attach): + if attach and self.attachment_support: + for attachment in attach: # Perform some simple error checking if not attachment: # We could not access the attachment diff --git a/libs/apprise/plugins/NotifySNS.py b/libs/apprise/plugins/NotifySNS.py index c1d2ed932..5edac727c 100644 --- a/libs/apprise/plugins/NotifySNS.py +++ b/libs/apprise/plugins/NotifySNS.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -35,6 +31,7 @@ import hmac import requests from hashlib import sha256 from datetime import datetime +from datetime import timezone from collections import OrderedDict from xml.etree import ElementTree from itertools import chain @@ -102,7 +99,7 @@ class NotifySNS(NotifyBase): # Define object templates templates = ( - '{schema}://{access_key_id}/{secret_access_key}{region}/{targets}', + '{schema}://{access_key_id}/{secret_access_key}/{region}/{targets}', ) # Define our template tokens @@ -124,6 +121,7 @@ class NotifySNS(NotifyBase): 'type': 'string', 'required': True, 'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'), + 'required': True, 'map_to': 'region_name', }, 'target_phone_no': { @@ -142,6 +140,7 @@ class NotifySNS(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -396,7 +395,7 @@ class NotifySNS(NotifyBase): } # Get a reference time (used for header construction) - reference = datetime.utcnow() + reference = datetime.now(timezone.utc) # Provide Content-Length headers['Content-Length'] = str(len(payload)) diff --git a/libs/apprise/plugins/NotifySendGrid.py b/libs/apprise/plugins/NotifySendGrid.py index d1ae8a4d4..b7f4a8a6c 100644 --- a/libs/apprise/plugins/NotifySendGrid.py +++ b/libs/apprise/plugins/NotifySendGrid.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -30,7 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# # You will need an API Key for this plugin to work. # From the Settings -> API Keys you can click "Create API Key" if you don't # have one already. The key must have at least the "Mail Send" permission diff --git a/libs/apprise/plugins/NotifyServerChan.py b/libs/apprise/plugins/NotifyServerChan.py index 6fa8c5570..87a294a39 100644 --- a/libs/apprise/plugins/NotifyServerChan.py +++ b/libs/apprise/plugins/NotifyServerChan.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -68,7 +64,7 @@ class NotifyServerChan(NotifyBase): # Define object templates templates = ( - '{schema}://{token}/', + '{schema}://{token}', ) # Define our template tokens diff --git a/libs/apprise/plugins/NotifySignalAPI.py b/libs/apprise/plugins/NotifySignalAPI.py index 589499f8d..a2a31de10 100644 --- a/libs/apprise/plugins/NotifySignalAPI.py +++ b/libs/apprise/plugins/NotifySignalAPI.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -68,6 +64,9 @@ class NotifySignalAPI(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_signal' + # Support attachments + attachment_support = True + # The maximum targets to include when doing batch transfers default_batch_size = 10 @@ -234,7 +233,7 @@ class NotifySignalAPI(NotifyBase): has_error = False attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: @@ -281,7 +280,7 @@ class NotifySignalAPI(NotifyBase): payload = { 'message': "{}{}".format( '' if not self.status else '{} '.format( - self.asset.ascii(notify_type)), body), + self.asset.ascii(notify_type)), body).rstrip(), "number": self.source, "recipients": [] } diff --git a/libs/apprise/plugins/NotifySimplePush.py b/libs/apprise/plugins/NotifySimplePush.py index 25066067c..d6bd2ab6b 100644 --- a/libs/apprise/plugins/NotifySimplePush.py +++ b/libs/apprise/plugins/NotifySimplePush.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -109,12 +105,12 @@ class NotifySimplePush(NotifyBase): # Used for encrypted logins 'password': { - 'name': _('Encrypted Password'), + 'name': _('Password'), 'type': 'string', 'private': True, }, 'salt': { - 'name': _('Encrypted Salt'), + 'name': _('Salt'), 'type': 'string', 'private': True, 'map_to': 'user', diff --git a/libs/apprise/plugins/NotifySinch.py b/libs/apprise/plugins/NotifySinch.py index 0756f76b3..b2c5683fa 100644 --- a/libs/apprise/plugins/NotifySinch.py +++ b/libs/apprise/plugins/NotifySinch.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifySlack.py b/libs/apprise/plugins/NotifySlack.py index 0d85d25fe..bbd2bf242 100644 --- a/libs/apprise/plugins/NotifySlack.py +++ b/libs/apprise/plugins/NotifySlack.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -143,6 +139,10 @@ class NotifySlack(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_slack' + # Support attachments + attachment_support = True + + # The maximum targets to include when doing batch transfers # Slack Webhook URL webhook_url = 'https://hooks.slack.com/services' @@ -165,10 +165,10 @@ class NotifySlack(NotifyBase): # Define object templates templates = ( # Webhook - '{schema}://{token_a}/{token_b}{token_c}', + '{schema}://{token_a}/{token_b}/{token_c}', '{schema}://{botname}@{token_a}/{token_b}{token_c}', - '{schema}://{token_a}/{token_b}{token_c}/{targets}', - '{schema}://{botname}@{token_a}/{token_b}{token_c}/{targets}', + '{schema}://{token_a}/{token_b}/{token_c}/{targets}', + '{schema}://{botname}@{token_a}/{token_b}/{token_c}/{targets}', # Bot '{schema}://{access_token}/', @@ -198,7 +198,6 @@ class NotifySlack(NotifyBase): 'name': _('Token A'), 'type': 'string', 'private': True, - 'required': True, 'regex': (r'^[A-Z0-9]+$', 'i'), }, # Token required as part of the Webhook request @@ -207,7 +206,6 @@ class NotifySlack(NotifyBase): 'name': _('Token B'), 'type': 'string', 'private': True, - 'required': True, 'regex': (r'^[A-Z0-9]+$', 'i'), }, # Token required as part of the Webhook request @@ -216,7 +214,6 @@ class NotifySlack(NotifyBase): 'name': _('Token C'), 'type': 'string', 'private': True, - 'required': True, 'regex': (r'^[A-Za-z0-9]+$', 'i'), }, 'target_encoded_id': { @@ -525,7 +522,8 @@ class NotifySlack(NotifyBase): # Include the footer only if specified to do so payload['attachments'][0]['footer'] = self.app_id - if attach and self.mode is SlackMode.WEBHOOK: + if attach and self.attachment_support \ + and self.mode is SlackMode.WEBHOOK: # Be friendly; let the user know why they can't send their # attachments if using the Webhook mode self.logger.warning( @@ -603,7 +601,8 @@ class NotifySlack(NotifyBase): ' to {}'.format(channel) if channel is not None else '')) - if attach and self.mode is SlackMode.BOT and attach_channel_list: + if attach and self.attachment_support and \ + self.mode is SlackMode.BOT and attach_channel_list: # Send our attachments (can only be done in bot mode) for attachment in attach: diff --git a/libs/apprise/plugins/NotifySparkPost.py b/libs/apprise/plugins/NotifySparkPost.py index 25024bc5f..282f55093 100644 --- a/libs/apprise/plugins/NotifySparkPost.py +++ b/libs/apprise/plugins/NotifySparkPost.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -118,6 +114,9 @@ class NotifySparkPost(NotifyBase): # The services URL service_url = 'https://sparkpost.com/' + # Support attachments + attachment_support = True + # All notification requests are secure secure_protocol = 'sparkpost' @@ -225,7 +224,7 @@ class NotifySparkPost(NotifyBase): } def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, - region_name=None, headers=None, tokens=None, batch=False, + region_name=None, headers=None, tokens=None, batch=None, **kwargs): """ Initialize SparkPost Object @@ -296,7 +295,8 @@ class NotifySparkPost(NotifyBase): self.tokens.update(tokens) # Prepare Batch Mode Flag - self.batch = batch + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch if targets: # Validate recipients (to:) and drop bad ones: @@ -542,7 +542,7 @@ class NotifySparkPost(NotifyBase): else: payload['content']['text'] = body - if attach: + if attach and self.attachment_support: # Prepare ourselves an attachment object payload['content']['attachments'] = [] diff --git a/libs/apprise/plugins/NotifySpontit.py b/libs/apprise/plugins/NotifySpontit.py index 01d4e1980..4705fc058 100644 --- a/libs/apprise/plugins/NotifySpontit.py +++ b/libs/apprise/plugins/NotifySpontit.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -135,7 +131,6 @@ class NotifySpontit(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', - 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyStreamlabs.py b/libs/apprise/plugins/NotifyStreamlabs.py index 3489519a5..56b577e49 100644 --- a/libs/apprise/plugins/NotifyStreamlabs.py +++ b/libs/apprise/plugins/NotifyStreamlabs.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -277,8 +273,7 @@ class NotifyStreamlabs(NotifyBase): return - def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, - **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Streamlabs notification call (either donation or alert) """ diff --git a/libs/apprise/plugins/NotifySyslog.py b/libs/apprise/plugins/NotifySyslog.py index 433aab9c5..3ff1f2576 100644 --- a/libs/apprise/plugins/NotifySyslog.py +++ b/libs/apprise/plugins/NotifySyslog.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -30,14 +26,11 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import os import syslog -import socket from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import parse_bool -from ..utils import is_hostname from ..AppriseLocale import gettext_lazy as _ @@ -107,20 +100,13 @@ SYSLOG_FACILITY_RMAP = { syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, } - -class SyslogMode: - # A local query - LOCAL = "local" - - # A remote query - REMOTE = "remote" - - -# webhook modes are placed ito this list for validation purposes -SYSLOG_MODES = ( - SyslogMode.LOCAL, - SyslogMode.REMOTE, -) +# Used as a lookup when handling the Apprise -> Syslog Mapping +SYSLOG_PUBLISH_MAP = { + NotifyType.INFO: syslog.LOG_INFO, + NotifyType.SUCCESS: syslog.LOG_NOTICE, + NotifyType.FAILURE: syslog.LOG_CRIT, + NotifyType.WARNING: syslog.LOG_WARNING, +} class NotifySyslog(NotifyBase): @@ -134,8 +120,8 @@ class NotifySyslog(NotifyBase): # The services URL service_url = 'https://tools.ietf.org/html/rfc5424' - # The default secure protocol - secure_protocol = 'syslog' + # The default protocol + protocol = 'syslog' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_syslog' @@ -148,10 +134,6 @@ class NotifySyslog(NotifyBase): templates = ( '{schema}://', '{schema}://{facility}', - '{schema}://{host}', - '{schema}://{host}:{port}', - '{schema}://{host}/{facility}', - '{schema}://{host}:{port}/{facility}', ) # Define our template tokens @@ -162,18 +144,6 @@ class NotifySyslog(NotifyBase): 'values': [k for k in SYSLOG_FACILITY_MAP.keys()], 'default': SyslogFacility.USER, }, - 'host': { - 'name': _('Hostname'), - 'type': 'string', - 'required': True, - }, - 'port': { - 'name': _('Port'), - 'type': 'int', - 'min': 1, - 'max': 65535, - 'default': 514, - }, }) # Define our template arguments @@ -182,12 +152,6 @@ class NotifySyslog(NotifyBase): # We map back to the same element defined in template_tokens 'alias_of': 'facility', }, - 'mode': { - 'name': _('Syslog Mode'), - 'type': 'choice:string', - 'values': SYSLOG_MODES, - 'default': SyslogMode.LOCAL, - }, 'logpid': { 'name': _('Log PID'), 'type': 'bool', @@ -202,8 +166,8 @@ class NotifySyslog(NotifyBase): }, }) - def __init__(self, facility=None, mode=None, log_pid=True, - log_perror=False, **kwargs): + def __init__(self, facility=None, log_pid=True, log_perror=False, + **kwargs): """ Initialize Syslog Object """ @@ -223,14 +187,6 @@ class NotifySyslog(NotifyBase): SYSLOG_FACILITY_MAP[ self.template_tokens['facility']['default']] - self.mode = self.template_args['mode']['default'] \ - if not isinstance(mode, str) else mode.lower() - - if self.mode not in SYSLOG_MODES: - msg = 'The mode specified ({}) is invalid.'.format(mode) - self.logger.warning(msg) - raise TypeError(msg) - # Logging Options self.logoptions = 0 @@ -249,7 +205,7 @@ class NotifySyslog(NotifyBase): if log_perror: self.logoptions |= syslog.LOG_PERROR - # Initialize our loggig + # Initialize our logging syslog.openlog( self.app_id, logoption=self.logoptions, facility=self.facility) return @@ -259,7 +215,7 @@ class NotifySyslog(NotifyBase): Perform Syslog Notification """ - _pmap = { + SYSLOG_PUBLISH_MAP = { NotifyType.INFO: syslog.LOG_INFO, NotifyType.SUCCESS: syslog.LOG_NOTICE, NotifyType.FAILURE: syslog.LOG_CRIT, @@ -272,70 +228,17 @@ class NotifySyslog(NotifyBase): # Always call throttle before any remote server i/o is made self.throttle() - if self.mode == SyslogMode.LOCAL: - try: - syslog.syslog(_pmap[notify_type], body) + try: + syslog.syslog(SYSLOG_PUBLISH_MAP[notify_type], body) - except KeyError: - # An invalid notification type was specified - self.logger.warning( - 'An invalid notification type ' - '({}) was specified.'.format(notify_type)) - return False - - else: # SyslogMode.REMOTE - - host = self.host - port = self.port if self.port \ - else self.template_tokens['port']['default'] - if self.log_pid: - payload = '<%d>- %d - %s' % ( - _pmap[notify_type] + self.facility * 8, os.getpid(), body) - - else: - payload = '<%d>- %s' % ( - _pmap[notify_type] + self.facility * 8, body) - - # send UDP packet to upstream server - self.logger.debug( - 'Syslog Host: %s:%d/%s', - host, port, SYSLOG_FACILITY_RMAP[self.facility]) - self.logger.debug('Syslog Payload: %s' % str(payload)) - - # our sent bytes - sent = 0 + except KeyError: + # An invalid notification type was specified + self.logger.warning( + 'An invalid notification type ' + '({}) was specified.'.format(notify_type)) + return False - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(self.socket_connect_timeout) - sent = sock.sendto(payload.encode('utf-8'), (host, port)) - sock.close() - - except socket.gaierror as e: - self.logger.warning( - 'A connection error occurred sending Syslog ' - 'notification to %s:%d/%s', host, port, - SYSLOG_FACILITY_RMAP[self.facility] - ) - self.logger.debug('Socket Exception: %s' % str(e)) - return False - - except socket.timeout as e: - self.logger.warning( - 'A connection timeout occurred sending Syslog ' - 'notification to %s:%d/%s', host, port, - SYSLOG_FACILITY_RMAP[self.facility] - ) - self.logger.debug('Socket Exception: %s' % str(e)) - return False - - if sent < len(payload): - self.logger.warning( - 'Syslog sent %d byte(s) but intended to send %d byte(s)', - sent, len(payload)) - return False - - self.logger.info('Sent Syslog (%s) notification.', self.mode) + self.logger.info('Sent Syslog notification.') return True @@ -348,31 +251,16 @@ class NotifySyslog(NotifyBase): params = { 'logperror': 'yes' if self.log_perror else 'no', 'logpid': 'yes' if self.log_pid else 'no', - 'mode': self.mode, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - if self.mode == SyslogMode.LOCAL: - return '{schema}://{facility}/?{params}'.format( - facility=self.template_tokens['facility']['default'] - if self.facility not in SYSLOG_FACILITY_RMAP - else SYSLOG_FACILITY_RMAP[self.facility], - schema=self.secure_protocol, - params=NotifySyslog.urlencode(params), - ) - - # Remote mode: - return '{schema}://{hostname}{port}/{facility}/?{params}'.format( - schema=self.secure_protocol, - hostname=NotifySyslog.quote(self.host, safe=''), - port='' if self.port is None - or self.port == self.template_tokens['port']['default'] - else ':{}'.format(self.port), + return '{schema}://{facility}/?{params}'.format( facility=self.template_tokens['facility']['default'] if self.facility not in SYSLOG_FACILITY_RMAP else SYSLOG_FACILITY_RMAP[self.facility], + schema=self.protocol, params=NotifySyslog.urlencode(params), ) @@ -395,21 +283,12 @@ class NotifySyslog(NotifyBase): # Get our path values tokens.extend(NotifySyslog.split_path(results['fullpath'])) + # Initialization facility = None - if len(tokens) > 1 and is_hostname(tokens[0]): - # syslog://hostname/facility - results['mode'] = SyslogMode.REMOTE - - # Store our facility as the first path entry - facility = tokens[-1] - elif tokens: - # This is a bit ambigious... it could be either: - # syslog://facility -or- syslog://hostname - - # First lets test it as a facility; we'll correct this - # later on if nessisary - facility = tokens[-1] + if tokens: + # Store the last entry as the facility + facility = tokens[-1].lower() # However if specified on the URL, that will over-ride what was # identified @@ -425,20 +304,6 @@ class NotifySyslog(NotifyBase): facility = next((f for f in SYSLOG_FACILITY_MAP.keys() if f.startswith(facility)), facility) - # Attempt to solve our ambiguity - if len(tokens) == 1 and is_hostname(tokens[0]) and ( - results['port'] or facility not in SYSLOG_FACILITY_MAP): - - # facility is likely hostname; update our guessed mode - results['mode'] = SyslogMode.REMOTE - - # Reset our facility value - facility = None - - # Set mode if not otherwise set - if 'mode' in results['qsd'] and len(results['qsd']['mode']): - results['mode'] = NotifySyslog.unquote(results['qsd']['mode']) - # Save facility if set if facility: results['facility'] = facility diff --git a/libs/apprise/plugins/NotifyTechulusPush.py b/libs/apprise/plugins/NotifyTechulusPush.py index 0f3e79e53..3e2085c53 100644 --- a/libs/apprise/plugins/NotifyTechulusPush.py +++ b/libs/apprise/plugins/NotifyTechulusPush.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyTelegram.py b/libs/apprise/plugins/NotifyTelegram.py index d5a52be60..1727fe87d 100644 --- a/libs/apprise/plugins/NotifyTelegram.py +++ b/libs/apprise/plugins/NotifyTelegram.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -84,6 +80,23 @@ IS_CHAT_ID_RE = re.compile( ) +class TelegramContentPlacement: + """ + The Telegram Content Placement + """ + # Before Attachments + BEFORE = "before" + # After Attachments + AFTER = "after" + + +# Identify Placement Categories +TELEGRAM_CONTENT_PLACEMENT = ( + TelegramContentPlacement.BEFORE, + TelegramContentPlacement.AFTER, +) + + class NotifyTelegram(NotifyBase): """ A wrapper for Telegram Notifications @@ -106,6 +119,9 @@ class NotifyTelegram(NotifyBase): # Telegram uses the http protocol with JSON requests notify_url = 'https://api.telegram.org/bot' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 @@ -319,11 +335,17 @@ class NotifyTelegram(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'content': { + 'name': _('Content Placement'), + 'type': 'choice:string', + 'values': TELEGRAM_CONTENT_PLACEMENT, + 'default': TelegramContentPlacement.BEFORE, + }, }) def __init__(self, bot_token, targets, detect_owner=True, include_image=False, silent=None, preview=None, topic=None, - **kwargs): + content=None, **kwargs): """ Initialize Telegram Object """ @@ -349,6 +371,15 @@ class NotifyTelegram(NotifyBase): self.preview = self.template_args['preview']['default'] \ if preview is None else bool(preview) + # Setup our content placement + self.content = self.template_args['content']['default'] \ + if not isinstance(content, str) else content.lower() + if self.content and self.content not in TELEGRAM_CONTENT_PLACEMENT: + msg = 'The content placement specified ({}) is invalid.'\ + .format(content) + self.logger.warning(msg) + raise TypeError(msg) + if topic: try: self.topic = int(topic) @@ -439,11 +470,14 @@ class NotifyTelegram(NotifyBase): # content can arrive together. self.throttle() + payload = {'chat_id': chat_id} + if self.topic: + payload['message_thread_id'] = self.topic + try: with open(path, 'rb') as f: # Configure file payload (for upload) files = {key: (file_name, f)} - payload = {'chat_id': chat_id} self.logger.debug( 'Telegram attachment POST URL: %s (cert_verify=%r)' % ( @@ -680,6 +714,10 @@ class NotifyTelegram(NotifyBase): # Prepare our payload based on HTML or TEXT payload['text'] = body + # Handle payloads without a body specified (but an attachment present) + attach_content = \ + TelegramContentPlacement.AFTER if not body else self.content + # Create a copy of the chat_ids list targets = list(self.targets) while len(targets): @@ -713,6 +751,20 @@ class NotifyTelegram(NotifyBase): 'Failed to send Telegram type image to {}.', payload['chat_id']) + if attach and self.attachment_support and \ + attach_content == TelegramContentPlacement.AFTER: + # Send our attachments now (if specified and if it exists) + if not self._send_attachments( + chat_id=payload['chat_id'], notify_type=notify_type, + attach=attach): + + has_error = True + continue + + if not body: + # Nothing more to do; move along to the next attachment + continue + # Always call throttle before any remote server i/o is made; # Telegram throttles to occur before sending the image so that # content can arrive together. @@ -775,19 +827,36 @@ class NotifyTelegram(NotifyBase): self.logger.info('Sent Telegram notification.') - if attach: - # Send our attachments now (if specified and if it exists) - for attachment in attach: - if not self.send_media( - payload['chat_id'], notify_type, - attach=attachment): + if attach and self.attachment_support \ + and attach_content == TelegramContentPlacement.BEFORE: + # Send our attachments now (if specified and if it exists) as + # it was identified to send the content before the attachments + # which is now done. + if not self._send_attachments( + chat_id=payload['chat_id'], + notify_type=notify_type, + attach=attach): - # We failed; don't continue - has_error = True - break + has_error = True + continue - self.logger.info( - 'Sent Telegram attachment: {}.'.format(attachment)) + return not has_error + + def _send_attachments(self, chat_id, notify_type, attach): + """ + Sends our attachments + """ + has_error = False + # Send our attachments now (if specified and if it exists) + for attachment in attach: + if not self.send_media(chat_id, notify_type, attach=attachment): + + # We failed; don't continue + has_error = True + break + + self.logger.info( + 'Sent Telegram attachment: {}.'.format(attachment)) return not has_error @@ -802,6 +871,7 @@ class NotifyTelegram(NotifyBase): 'detect': 'yes' if self.detect_owner else 'no', 'silent': 'yes' if self.silent else 'no', 'preview': 'yes' if self.preview else 'no', + 'content': self.content, } if self.topic: @@ -885,6 +955,10 @@ class NotifyTelegram(NotifyBase): # Store our chat ids (as these are the remaining entries) results['targets'] = entries + # content to be displayed 'before' or 'after' attachments + if 'content' in results['qsd'] and len(results['qsd']['content']): + results['content'] = results['qsd']['content'] + # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): diff --git a/libs/apprise/plugins/NotifyTwilio.py b/libs/apprise/plugins/NotifyTwilio.py index 08a3b2917..ab4c88e32 100644 --- a/libs/apprise/plugins/NotifyTwilio.py +++ b/libs/apprise/plugins/NotifyTwilio.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyTwist.py b/libs/apprise/plugins/NotifyTwist.py index ea7b19760..36a55313a 100644 --- a/libs/apprise/plugins/NotifyTwist.py +++ b/libs/apprise/plugins/NotifyTwist.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -106,10 +102,12 @@ class NotifyTwist(NotifyBase): 'name': _('Password'), 'type': 'string', 'private': True, + 'required': True, }, 'email': { 'name': _('Email'), 'type': 'string', + 'required': True, }, 'target_channel': { 'name': _('Target Channel'), diff --git a/libs/apprise/plugins/NotifyTwitter.py b/libs/apprise/plugins/NotifyTwitter.py index 7862d0042..3647c8b39 100644 --- a/libs/apprise/plugins/NotifyTwitter.py +++ b/libs/apprise/plugins/NotifyTwitter.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -36,6 +32,7 @@ import re import requests from copy import deepcopy from datetime import datetime +from datetime import timezone from requests_oauthlib import OAuth1 from json import dumps from json import loads @@ -82,11 +79,14 @@ class NotifyTwitter(NotifyBase): service_url = 'https://twitter.com/' # The default secure protocol is twitter. - secure_protocol = ('twitter', 'tweet') + secure_protocol = ('x', 'twitter', 'tweet') # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twitter' + # Support attachments + attachment_support = True + # Do not set body_maxlen as it is set in a property value below # since the length varies depending if we are doing a direct message # or a tweet @@ -124,13 +124,14 @@ class NotifyTwitter(NotifyBase): request_rate_per_sec = 0 # For Tracking Purposes - ratelimit_reset = datetime.utcnow() + ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1000; users can send up to 1000 DM's and 2400 tweets a day # This value only get's adjusted if the server sets it that way ratelimit_remaining = 1 templates = ( + '{schema}://{ckey}/{csecret}/{akey}/{asecret}', '{schema}://{ckey}/{csecret}/{akey}/{asecret}/{targets}', ) @@ -283,7 +284,7 @@ class NotifyTwitter(NotifyBase): # Build a list of our attachments attachments = [] - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: @@ -412,7 +413,7 @@ class NotifyTwitter(NotifyBase): _payload = deepcopy(payload) _payload['media_ids'] = media_ids - if no: + if no or not body: # strip text and replace it with the image representation _payload['status'] = \ '{:02d}/{:02d}'.format(no + 1, len(batches)) @@ -512,7 +513,7 @@ class NotifyTwitter(NotifyBase): 'additional_owners': ','.join([str(x) for x in targets.values()]) } - if no: + if no or not body: # strip text and replace it with the image representation _data['text'] = \ '{:02d}/{:02d}'.format(no + 1, len(attachments)) @@ -678,7 +679,7 @@ class NotifyTwitter(NotifyBase): # Twitter server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds # We add 0.5 seconds to the end just to allow a grace @@ -736,8 +737,9 @@ class NotifyTwitter(NotifyBase): # Capture rate limiting if possible self.ratelimit_remaining = \ int(r.headers.get('x-rate-limit-remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('x-rate-limit-reset'))) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('x-rate-limit-reset')), timezone.utc + ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information diff --git a/libs/apprise/plugins/NotifyVoipms.py b/libs/apprise/plugins/NotifyVoipms.py index a4ec5ae1b..c39da4dfe 100644 --- a/libs/apprise/plugins/NotifyVoipms.py +++ b/libs/apprise/plugins/NotifyVoipms.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -78,7 +74,6 @@ class NotifyVoipms(NotifyBase): # Define object templates templates = ( - '{schema}://{password}:{email}', '{schema}://{password}:{email}/{from_phone}/{targets}', ) @@ -111,6 +106,7 @@ class NotifyVoipms(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) diff --git a/libs/apprise/plugins/NotifyVonage.py b/libs/apprise/plugins/NotifyVonage.py index bc3ab0647..48d823195 100644 --- a/libs/apprise/plugins/NotifyVonage.py +++ b/libs/apprise/plugins/NotifyVonage.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyWebexTeams.py b/libs/apprise/plugins/NotifyWebexTeams.py index 6b953b711..67ed4e4b8 100644 --- a/libs/apprise/plugins/NotifyWebexTeams.py +++ b/libs/apprise/plugins/NotifyWebexTeams.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyWhatsApp.py b/libs/apprise/plugins/NotifyWhatsApp.py new file mode 100644 index 000000000..efa90f89b --- /dev/null +++ b/libs/apprise/plugins/NotifyWhatsApp.py @@ -0,0 +1,559 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# API Source: +# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages +# +# 1. Register a developer account with Meta: +# https://developers.facebook.com/docs/whatsapp/cloud-api/get-started +# 2. Enable 2 Factor Authentication (2FA) with your account (if not done +# already) +# 3. Create a App using WhatsApp Product. There are 2 to create an app from +# Do NOT chose the WhatsApp Webhook one (choose the other) +# +# When you click on the API Setup section of your new app you need to record +# both the access token and the From Phone Number ID. Note that this not the +# from phone number itself, but it's ID. It's displayed below and contains +# way more numbers then your typical phone number + +import re +import requests +from json import loads, dumps +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyWhatsApp(NotifyBase): + """ + A wrapper for WhatsApp Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'WhatsApp' + + # The services URL + service_url = \ + 'https://developers.facebook.com/docs/whatsapp/cloud-api/get-started' + + # All notification requests are secure + secure_protocol = 'whatsapp' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # Facebook Graph version + fb_graph_version = 'v17.0' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_whatsapp' + + # WhatsApp Message Notification URL + notify_url = 'https://graph.facebook.com/{fb_ver}/{phone_id}/messages' + + # The maximum length of the body + body_maxlen = 1024 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{token}@{from_phone_id}/{targets}', + '{schema}://{template}:{token}@{from_phone_id}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'template': { + 'name': _('Template Name'), + 'type': 'string', + 'required': False, + 'regex': (r'^[^\s]+$', 'i'), + }, + 'from_phone_id': { + 'name': _('From Phone ID'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[0-9]+$', 'i'), + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + 'language': { + 'name': _('Language'), + 'type': 'string', + 'default': 'en_US', + 'regex': (r'^[^0-9\s]+$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone_id', + }, + 'token': { + 'alias_of': 'token', + }, + 'template': { + 'alias_of': 'template', + }, + 'lang': { + 'alias_of': 'language', + }, + }) + + # Our supported mappings and component keys + component_key_re = re.compile( + r'(?P((?P[1-9][0-9]*)|(?Pbody|type)))', re.IGNORECASE) + + # Define any kwargs we're using + template_kwargs = { + 'template_mapping': { + 'name': _('Template Mapping'), + 'prefix': ':', + }, + } + + def __init__(self, token, from_phone_id, template=None, targets=None, + language=None, template_mapping=None, **kwargs): + """ + Initialize WhatsApp Object + """ + super().__init__(**kwargs) + + # The Access Token associated with the account + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid WhatsApp Access Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # The From Phone ID associated with the account + self.from_phone_id = validate_regex( + from_phone_id, *self.template_tokens['from_phone_id']['regex']) + if not self.from_phone_id: + msg = 'An invalid WhatsApp From Phone ID ' \ + '({}) was specified.'.format(from_phone_id) + self.logger.warning(msg) + raise TypeError(msg) + + # The template to associate with the message + if template: + self.template = validate_regex( + template, *self.template_tokens['template']['regex']) + if not self.template: + msg = 'An invalid WhatsApp Template Name ' \ + '({}) was specified.'.format(template) + self.logger.warning(msg) + raise TypeError(msg) + + # The Template language Code to use + if language: + self.language = validate_regex( + language, *self.template_tokens['language']['regex']) + if not self.language: + msg = 'An invalid WhatsApp Template Language Code ' \ + '({}) was specified.'.format(language) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.language = self.template_tokens['language']['default'] + else: + # + # Message Mode + # + self.template = None + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append('+{}'.format(result['full'])) + + self.template_mapping = {} + if template_mapping: + # Store our extra payload entries + self.template_mapping.update(template_mapping) + + # Validate Mapping and prepare Components + self.components = dict() + self.component_keys = list() + for key, val in self.template_mapping.items(): + matched = self.component_key_re.match(key) + if not matched: + msg = 'An invalid Template Component ID ' \ + '({}) was specified.'.format(key) + self.logger.warning(msg) + raise TypeError(msg) + + if matched.group('id'): + # + # Manual Component Assigment (by id) + # + index = matched.group('id') + map_to = { + "type": "text", + "text": val, + } + + else: # matched.group('map') + map_to = matched.group('map').lower() + matched = self.component_key_re.match(val) + if not (matched and matched.group('id')): + msg = 'An invalid Template Component Mapping ' \ + '(:{}={}) was specified.'.format(key, val) + self.logger.warning(msg) + raise TypeError(msg) + index = matched.group('id') + + if index in self.components: + msg = 'The Template Component index ' \ + '({}) was already assigned.'.format(key) + self.logger.warning(msg) + raise TypeError(msg) + + self.components[index] = map_to + self.component_keys = self.components.keys() + # Adjust sorting and assume that the user put the order correctly; + # if not Facebook just won't be very happy and will reject the + # message + sorted(self.component_keys) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform WhatsApp Notification + """ + + if not self.targets: + self.logger.warning( + 'There are no valid WhatsApp targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our URL + url = self.notify_url.format( + fb_ver=self.fb_graph_version, + phone_id=self.from_phone_id, + ) + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.token}', + } + + payload = { + 'messaging_product': 'whatsapp', + # The To gets populated in the loop below + 'to': None, + } + + if not self.template: + # + # Send Message + # + payload.update({ + 'recipient_type': "individual", + 'type': 'text', + 'text': {"body": body}, + }) + + else: + # + # Send Template + # + payload.update({ + 'type': 'template', + "template": { + "name": self.template, + "language": {"code": self.language}, + }, + }) + + if self.components: + payload['template']['components'] = [ + { + "type": "body", + "parameters": [], + } + ] + for key in self.component_keys: + if isinstance(self.components[key], dict): + # Manual Assignment + payload['template']['components'][0]["parameters"]\ + .append(self.components[key]) + continue + + # Mapping of body and/or notify type + payload['template']['components'][0]["parameters"].append({ + "type": "text", + "text": body if self.components[key] == 'body' + else notify_type, + }) + + # Create a copy of the targets list + targets = list(self.targets) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Some Debug Logging + self.logger.debug('WhatsApp POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('WhatsApp Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + try: + # Update our status response if we can + json_response = loads(r.content) + status_code = \ + json_response['error'].get('code', status_code) + status_str = \ + json_response['error'].get('message', status_str) + + except (AttributeError, TypeError, ValueError, KeyError): + # KeyError = r.content is parseable but does not + # contain 'error' + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send WhatsApp notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent WhatsApp notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending WhatsApp:%s ' % ( + target) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = {} + if self.template: + # Add language to our URL + params['lang'] = self.language + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Payload body extras prefixed with a ':' sign + # Append our payload extras into our parameters + params.update( + {':{}'.format(k): v for k, v in self.template_mapping.items()}) + + return '{schema}://{template}{token}@{from_id}/{targets}/?{params}'\ + .format( + schema=self.secure_protocol, + from_id=self.pprint( + self.from_phone_id, privacy, safe=''), + token=self.pprint(self.token, privacy, safe=''), + template='' if not self.template + else '{}:'.format( + NotifyWhatsApp.quote(self.template, safe='')), + targets='/'.join( + [NotifyWhatsApp.quote(x, safe='') for x in self.targets]), + params=NotifyWhatsApp.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyWhatsApp.split_path(results['fullpath']) + + # The hostname is our From Phone ID + results['from_phone_id'] = NotifyWhatsApp.unquote(results['host']) + + # Determine if we have a Template, otherwise load our token + if results['password']: + # + # Template Mode + # + results['template'] = NotifyWhatsApp.unquote(results['user']) + results['token'] = NotifyWhatsApp.unquote(results['password']) + + else: + # + # Message Mode + # + results['token'] = NotifyWhatsApp.unquote(results['user']) + + # Access token + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Extract the account sid from an argument + results['token'] = \ + NotifyWhatsApp.unquote(results['qsd']['token']) + + # Template + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = results['qsd']['template'] + + # Template Language + if 'lang' in results['qsd'] and len(results['qsd']['lang']): + results['language'] = results['qsd']['lang'] + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['from_phone_id'] = \ + NotifyWhatsApp.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and \ + len(results['qsd']['source']): + results['from_phone_id'] = \ + NotifyWhatsApp.unquote(results['qsd']['source']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyWhatsApp.parse_phone_no(results['qsd']['to']) + + # store any additional payload extra's defined + results['template_mapping'] = { + NotifyWhatsApp.unquote(x): NotifyWhatsApp.unquote(y) + for x, y in results['qsd:'].items() + } + + return results diff --git a/libs/apprise/plugins/NotifyWindows.py b/libs/apprise/plugins/NotifyWindows.py index 70f438894..226cf92bf 100644 --- a/libs/apprise/plugins/NotifyWindows.py +++ b/libs/apprise/plugins/NotifyWindows.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyXBMC.py b/libs/apprise/plugins/NotifyXBMC.py index 963a74d88..a973989ac 100644 --- a/libs/apprise/plugins/NotifyXBMC.py +++ b/libs/apprise/plugins/NotifyXBMC.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/libs/apprise/plugins/NotifyXML.py b/libs/apprise/plugins/NotifyXML.py index 04cdac10e..20eeb114c 100644 --- a/libs/apprise/plugins/NotifyXML.py +++ b/libs/apprise/plugins/NotifyXML.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -79,6 +75,9 @@ class NotifyXML(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_XML' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -213,9 +212,6 @@ class NotifyXML(NotifyBase): # Store our extra headers self.headers.update(headers) - # Set our xsd url - self.xsd_url = self.xsd_default_url.format(version=self.xsd_ver) - self.payload_overrides = {} self.payload_extras = {} if payload: @@ -237,11 +233,13 @@ class NotifyXML(NotifyBase): self.payload_map[key] = v self.payload_overrides[key] = v - # Over-ride XSD URL as data is no longer known - self.xsd_url = None - else: self.payload_extras[key] = v + + # Set our xsd url + self.xsd_url = None if self.payload_overrides or self.payload_extras \ + else self.xsd_default_url.format(version=self.xsd_ver) + return def url(self, privacy=False, *args, **kwargs): @@ -340,7 +338,7 @@ class NotifyXML(NotifyBase): ['<{}>{}'.format(k, v, k) for k, v in payload_base.items()]) attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: diff --git a/libs/apprise/plugins/NotifyZulip.py b/libs/apprise/plugins/NotifyZulip.py index f9521ae19..f0d0cd8d5 100644 --- a/libs/apprise/plugins/NotifyZulip.py +++ b/libs/apprise/plugins/NotifyZulip.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -131,6 +127,7 @@ class NotifyZulip(NotifyBase): 'name': _('Bot Name'), 'type': 'string', 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + 'required': True, }, 'organization': { 'name': _('Organization'), diff --git a/libs/apprise/plugins/__init__.py b/libs/apprise/plugins/__init__.py index 5560568b7..27afef05c 100644 --- a/libs/apprise/plugins/__init__.py +++ b/libs/apprise/plugins/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -165,6 +161,9 @@ def _sanitize_token(tokens, default_delimiter): """ + # Used for tracking groups + group_map = {} + # Iterate over our tokens for key in tokens.keys(): @@ -181,14 +180,27 @@ def _sanitize_token(tokens, default_delimiter): # Default type to key tokens[key]['map_to'] = key + # Track our map_to objects + if tokens[key]['map_to'] not in group_map: + group_map[tokens[key]['map_to']] = set() + group_map[tokens[key]['map_to']].add(key) + if 'type' not in tokens[key]: # Default type to string tokens[key]['type'] = 'string' - elif tokens[key]['type'].startswith('list') \ - and 'delim' not in tokens[key]: - # Default list delimiter (if not otherwise specified) - tokens[key]['delim'] = default_delimiter + elif tokens[key]['type'].startswith('list'): + if 'delim' not in tokens[key]: + # Default list delimiter (if not otherwise specified) + tokens[key]['delim'] = default_delimiter + + if key in group_map[tokens[key]['map_to']]: # pragma: no branch + # Remove ourselves from the list + group_map[tokens[key]['map_to']].remove(key) + + # Pointing to the set directly so we can dynamically update + # ourselves + tokens[key]['group'] = group_map[tokens[key]['map_to']] elif tokens[key]['type'].startswith('choice') \ and 'default' not in tokens[key] \ @@ -266,6 +278,13 @@ def details(plugin): # # Identifies if the entry specified is required or not # 'required': True, # + # # Identifies all tokens detected to be associated with the + # # list:string + # # This is ony present in list:string objects and is only set + # # if this element acts as an alias for several other + # # kwargs/fields. + # 'group': [], + # # # Identify a default value # 'default': 'http', # diff --git a/libs/apprise/utils.py b/libs/apprise/utils.py index 561a5a232..8d644ce90 100644 --- a/libs/apprise/utils.py +++ b/libs/apprise/utils.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -36,6 +32,7 @@ import json import contextlib import os import hashlib +import locale from itertools import chain from os.path import expanduser from functools import reduce @@ -142,14 +139,14 @@ NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r'^-(?P.*)\s*') NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r'^:(?P.*)\s*') # Used for attempting to acquire the schema if the URL can't be parsed. -GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{2,9})://.*$', re.I) +GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{1,12})://.*$', re.I) # Used for validating that a provided entry is indeed a schema # this is slightly different then the GET_SCHEMA_RE above which # insists the schema is only valid with a :// entry. this one # extrapolates the individual entries URL_DETAILS_RE = re.compile( - r'\s*(?P[a-z0-9]{2,9})(://(?P.*))?$', re.I) + r'\s*(?P[a-z0-9]{1,12})(://(?P.*))?$', re.I) # Regular expression based and expanded from: # http://www.regular-expressions.info/email.html @@ -193,7 +190,7 @@ CALL_SIGN_DETECTION_RE = re.compile( # Regular expression used to destinguish between multiple URLs URL_DETECTION_RE = re.compile( - r'([a-z0-9]+?:\/\/.*?)(?=$|[\s,]+[a-z0-9]{2,9}?:\/\/)', re.I) + r'([a-z0-9]+?:\/\/.*?)(?=$|[\s,]+[a-z0-9]{1,12}?:\/\/)', re.I) EMAIL_DETECTION_RE = re.compile( r'[\s,]*([^@]+@.*?)(?=$|[\s,]+' @@ -1119,7 +1116,7 @@ def urlencode(query, doseq=False, safe='', encoding=None, errors=None): errors=errors) -def parse_list(*args): +def parse_list(*args, cast=None): """ Take a string list and break it into a delimited list of arguments. This funciton also supports @@ -1142,6 +1139,9 @@ def parse_list(*args): result = [] for arg in args: + if not isinstance(arg, (str, set, list, bool, tuple)) and arg and cast: + arg = cast(arg) + if isinstance(arg, str): result += re.split(STRING_DELIMITERS, arg) @@ -1154,7 +1154,6 @@ def parse_list(*args): # Since Python v3 returns a filter (iterator) whereas Python v2 returned # a list, we need to change it into a list object to remain compatible with # both distribution types. - # TODO: Review after dropping support for Python 2. return sorted([x for x in filter(bool, list(set(result)))]) @@ -1488,7 +1487,7 @@ def environ(*remove, **update): # Create a backup of our environment for restoration purposes env_orig = os.environ.copy() - + loc_orig = locale.getlocale() try: os.environ.update(update) [os.environ.pop(k, None) for k in remove] @@ -1497,6 +1496,13 @@ def environ(*remove, **update): finally: # Restore our snapshot os.environ = env_orig.copy() + try: + # Restore locale + locale.setlocale(locale.LC_ALL, loc_orig) + + except locale.Error: + # Thrown in py3.6 + pass def apply_template(template, app_mode=TemplateType.RAW, **kwargs): diff --git a/libs/version.txt b/libs/version.txt index ced825452..e175db66d 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -2,7 +2,7 @@ alembic==1.10.3 aniso8601==9.0.1 argparse==1.4.0 -apprise==1.4.0 +apprise==1.6.0 apscheduler==3.9.1 attrs==22.1.0 charset-normalizer==3.1.0 From fd5b087f929b6e00164ad80f7e131c8a4b279653 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Tue, 28 Nov 2023 20:05:46 -0500 Subject: [PATCH 02/49] Fixed upgrade subtitles tas not removed from scheduler when disabled in settings. #2308 --- bazarr/app/scheduler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bazarr/app/scheduler.py b/bazarr/app/scheduler.py index 8abdab14e..ca1684673 100644 --- a/bazarr/app/scheduler.py +++ b/bazarr/app/scheduler.py @@ -284,6 +284,8 @@ class Scheduler: upgrade_subtitles, IntervalTrigger(hours=int(settings.general.upgrade_frequency)), max_instances=1, coalesce=True, misfire_grace_time=15, id='upgrade_subtitles', name='Upgrade previously downloaded Subtitles', replace_existing=True) + else: + self.aps_scheduler.remove_job(job_id='upgrade_subtitles') def __randomize_interval_task(self): for job in self.aps_scheduler.get_jobs(): From 17312af481abc38474263d5e15cadb0d6804aca3 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Tue, 28 Nov 2023 20:11:01 -0500 Subject: [PATCH 03/49] no log: added try/catch --- bazarr/app/scheduler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bazarr/app/scheduler.py b/bazarr/app/scheduler.py index ca1684673..81d368fe5 100644 --- a/bazarr/app/scheduler.py +++ b/bazarr/app/scheduler.py @@ -285,7 +285,10 @@ class Scheduler: coalesce=True, misfire_grace_time=15, id='upgrade_subtitles', name='Upgrade previously downloaded Subtitles', replace_existing=True) else: - self.aps_scheduler.remove_job(job_id='upgrade_subtitles') + try: + self.aps_scheduler.remove_job(job_id='upgrade_subtitles') + except JobLookupError: + pass def __randomize_interval_task(self): for job in self.aps_scheduler.get_jobs(): From c97a98d4f2141a34fd798612457f678f10ce0461 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Sat, 2 Dec 2023 10:29:36 -0500 Subject: [PATCH 04/49] Fixed usage of number only username or password for providers, proxy or postgres. #2309 --- bazarr/app/config.py | 66 ++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 92f0192c3..70b4b2948 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -184,13 +184,13 @@ validators = [ is_in=[None, 'socks5', 'http']), Validator('proxy.url', must_exist=True, default='', is_type_of=str), Validator('proxy.port', must_exist=True, default='', is_type_of=(str, int)), - Validator('proxy.username', must_exist=True, default='', is_type_of=str), - Validator('proxy.password', must_exist=True, default='', is_type_of=str), + Validator('proxy.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('proxy.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('proxy.exclude', must_exist=True, default=["localhost", "127.0.0.1"], is_type_of=list), # opensubtitles.org section - Validator('opensubtitles.username', must_exist=True, default='', is_type_of=str), - Validator('opensubtitles.password', must_exist=True, default='', is_type_of=str), + Validator('opensubtitles.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('opensubtitles.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('opensubtitles.use_tag_search', must_exist=True, default=False, is_type_of=bool), Validator('opensubtitles.vip', must_exist=True, default=False, is_type_of=bool), Validator('opensubtitles.ssl', must_exist=True, default=False, is_type_of=bool), @@ -198,13 +198,13 @@ validators = [ Validator('opensubtitles.skip_wrong_fps', must_exist=True, default=False, is_type_of=bool), # opensubtitles.com section - Validator('opensubtitlescom.username', must_exist=True, default='', is_type_of=str), - Validator('opensubtitlescom.password', must_exist=True, default='', is_type_of=str), + Validator('opensubtitlescom.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('opensubtitlescom.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('opensubtitlescom.use_hash', must_exist=True, default=True, is_type_of=bool), # addic7ed section - Validator('addic7ed.username', must_exist=True, default='', is_type_of=str), - Validator('addic7ed.password', must_exist=True, default='', is_type_of=str), + Validator('addic7ed.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('addic7ed.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('addic7ed.cookies', must_exist=True, default='', is_type_of=str), Validator('addic7ed.user_agent', must_exist=True, default='', is_type_of=str), Validator('addic7ed.vip', must_exist=True, default=False, is_type_of=bool), @@ -217,57 +217,57 @@ validators = [ Validator('subf2m.user_agent', must_exist=True, default='', is_type_of=str), # hdbits section - Validator('hdbits.username', must_exist=True, default='', is_type_of=str), - Validator('hdbits.passkey', must_exist=True, default='', is_type_of=str), + Validator('hdbits.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('hdbits.passkey', must_exist=True, default='', is_type_of=str, cast=str), # whisperai section Validator('whisperai.endpoint', must_exist=True, default='http://127.0.0.1:9000', is_type_of=str), Validator('whisperai.timeout', must_exist=True, default=3600, is_type_of=int, gte=1), # legendasdivx section - Validator('legendasdivx.username', must_exist=True, default='', is_type_of=str), - Validator('legendasdivx.password', must_exist=True, default='', is_type_of=str), + Validator('legendasdivx.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('legendasdivx.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('legendasdivx.skip_wrong_fps', must_exist=True, default=False, is_type_of=bool), # ktuvit section Validator('ktuvit.email', must_exist=True, default='', is_type_of=str), - Validator('ktuvit.hashed_password', must_exist=True, default='', is_type_of=str), + Validator('ktuvit.hashed_password', must_exist=True, default='', is_type_of=str, cast=str), # xsubs section - Validator('xsubs.username', must_exist=True, default='', is_type_of=str), - Validator('xsubs.password', must_exist=True, default='', is_type_of=str), + Validator('xsubs.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('xsubs.password', must_exist=True, default='', is_type_of=str, cast=str), # assrt section - Validator('assrt.token', must_exist=True, default='', is_type_of=str), + Validator('assrt.token', must_exist=True, default='', is_type_of=str, cast=str), # anticaptcha section Validator('anticaptcha.anti_captcha_key', must_exist=True, default='', is_type_of=str), # deathbycaptcha section - Validator('deathbycaptcha.username', must_exist=True, default='', is_type_of=str), - Validator('deathbycaptcha.password', must_exist=True, default='', is_type_of=str), + 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), # napisy24 section - Validator('napisy24.username', must_exist=True, default='', is_type_of=str), - Validator('napisy24.password', must_exist=True, default='', is_type_of=str), + 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), # subscene section - Validator('subscene.username', must_exist=True, default='', is_type_of=str), - Validator('subscene.password', must_exist=True, default='', is_type_of=str), + Validator('subscene.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('subscene.password', must_exist=True, default='', is_type_of=str, cast=str), # betaseries section - Validator('betaseries.token', must_exist=True, default='', is_type_of=str), + Validator('betaseries.token', must_exist=True, default='', is_type_of=str, cast=str), # analytics section Validator('analytics.enabled', must_exist=True, default=True, is_type_of=bool), # titlovi section - Validator('titlovi.username', must_exist=True, default='', is_type_of=str), - Validator('titlovi.password', must_exist=True, default='', is_type_of=str), + Validator('titlovi.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('titlovi.password', must_exist=True, default='', is_type_of=str, cast=str), # titulky section - Validator('titulky.username', must_exist=True, default='', is_type_of=str), - Validator('titulky.password', must_exist=True, default='', is_type_of=str), + Validator('titulky.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('titulky.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('titulky.approved_only', must_exist=True, default=False, is_type_of=bool), # embeddedsubtitles section @@ -277,10 +277,10 @@ validators = [ Validator('embeddedsubtitles.unknown_as_english', must_exist=True, default=False, is_type_of=bool), # karagarga section - Validator('karagarga.username', must_exist=True, default='', is_type_of=str), - Validator('karagarga.password', must_exist=True, default='', is_type_of=str), - Validator('karagarga.f_username', must_exist=True, default='', is_type_of=str), - Validator('karagarga.f_password', must_exist=True, default='', is_type_of=str), + Validator('karagarga.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('karagarga.password', must_exist=True, default='', is_type_of=str, cast=str), + Validator('karagarga.f_username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('karagarga.f_password', must_exist=True, default='', is_type_of=str, cast=str), # subsync section Validator('subsync.use_subsync', must_exist=True, default=False, is_type_of=bool), @@ -323,8 +323,8 @@ validators = [ Validator('postgresql.host', must_exist=True, default='localhost', is_type_of=str), Validator('postgresql.port', must_exist=True, default=5432, is_type_of=int, gte=1, lte=65535), Validator('postgresql.database', must_exist=True, default='', is_type_of=str), - Validator('postgresql.username', must_exist=True, default='', is_type_of=str), - Validator('postgresql.password', must_exist=True, default='', is_type_of=str), + Validator('postgresql.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('postgresql.password', must_exist=True, default='', is_type_of=str, cast=str), ] From 4b7cdbc5f38c68549e4c2e454f946078db6fb338 Mon Sep 17 00:00:00 2001 From: Julien Voisin Date: Sat, 2 Dec 2023 16:24:56 +0000 Subject: [PATCH 05/49] Added support for 7z by upgrading rarfile dependency - Bump rarfile.py from 4.0 to 4.1 - Add support for 7zip for archive extractions, since unar is a steaming pile of C# and unrar is non-free, having some easy-to-package software to handle archive makes it easier to run Bazarr on Linux distributions. --- bazarr/init.py | 20 +++- libs/rarfile.py | 287 +++++++++++++++++++++++++++++++++++------------ libs/version.txt | 2 +- 3 files changed, 232 insertions(+), 77 deletions(-) diff --git a/bazarr/init.py b/bazarr/init.py index 03264bf72..9db2b73ef 100644 --- a/bazarr/init.py +++ b/bazarr/init.py @@ -194,16 +194,28 @@ def init_binaries(): exe = get_binary("unar") rarfile.UNAR_TOOL = exe rarfile.UNRAR_TOOL = None - rarfile.tool_setup(unrar=False, unar=True, bsdtar=False, force=True) + rarfile.SEVENZIP_TOOL = None + rarfile.tool_setup(unrar=False, unar=True, bsdtar=False, sevenzip=False, force=True) except (BinaryNotFound, rarfile.RarCannotExec): try: exe = get_binary("unrar") rarfile.UNRAR_TOOL = exe rarfile.UNAR_TOOL = None - rarfile.tool_setup(unrar=True, unar=False, bsdtar=False, force=True) + rarfile.SEVENZIP_TOOL = None + rarfile.tool_setup(unrar=True, unar=False, bsdtar=False, sevenzip=False, force=True) except (BinaryNotFound, rarfile.RarCannotExec): - logging.exception("BAZARR requires a rar archive extraction utilities (unrar, unar) and it can't be found.") - raise BinaryNotFound + try: + exe = get_binary("7z") + rarfile.UNRAR_TOOL = None + rarfile.UNAR_TOOL = None + rarfile.SEVENZIP_TOOL = "7z" + rarfile.tool_setup(unrar=False, unar=False, bsdtar=False, sevenzip=True, force=True) + except (BinaryNotFound, rarfile.RarCannotExec): + logging.exception("BAZARR requires a rar archive extraction utilities (unrar, unar, 7zip) and it can't be found.") + raise BinaryNotFound + else: + logging.debug("Using 7zip from: %s", exe) + return exe else: logging.debug("Using UnRAR from: %s", exe) return exe diff --git a/libs/rarfile.py b/libs/rarfile.py index 58a59abb6..0399d7922 100644 --- a/libs/rarfile.py +++ b/libs/rarfile.py @@ -59,12 +59,14 @@ import sys import warnings from binascii import crc32, hexlify from datetime import datetime, timezone -from hashlib import blake2s, pbkdf2_hmac, sha1 +from hashlib import blake2s, pbkdf2_hmac, sha1, sha256 from pathlib import Path from struct import Struct, pack, unpack from subprocess import DEVNULL, PIPE, STDOUT, Popen from tempfile import mkstemp +AES = None + # only needed for encrypted headers try: try: @@ -90,10 +92,10 @@ class AES_CBC_Decrypt: self.decrypt = ciph.decryptor().update -__version__ = "4.0" +__version__ = "4.1" # export only interesting items -__all__ = ["is_rarfile", "is_rarfile_sfx", "RarInfo", "RarFile", "RarExtFile"] +__all__ = ["get_rar_version", "is_rarfile", "is_rarfile_sfx", "RarInfo", "RarFile", "RarExtFile"] ## ## Module configuration. Can be tuned after importing. @@ -108,6 +110,12 @@ UNAR_TOOL = "unar" #: executable for bsdtar tool BSDTAR_TOOL = "bsdtar" +#: executable for p7zip/7z tool +SEVENZIP_TOOL = "7z" + +#: executable for alternative 7z tool +SEVENZIP2_TOOL = "7zz" + #: default fallback charset DEFAULT_CHARSET = "windows-1252" @@ -280,6 +288,9 @@ DOS_MODE_SYSTEM = 0x04 DOS_MODE_HIDDEN = 0x02 DOS_MODE_READONLY = 0x01 +RAR5_PW_CHECK_SIZE = 8 +RAR5_PW_SUM_SIZE = 4 + ## ## internal constants ## @@ -298,17 +309,7 @@ _BAD_CHARS = r"""\x00-\x1F<>|"?*""" RC_BAD_CHARS_UNIX = re.compile(r"[%s]" % _BAD_CHARS) RC_BAD_CHARS_WIN32 = re.compile(r"[%s:^\\]" % _BAD_CHARS) - -def _get_rar_version(xfile): - """Check quickly whether file is rar archive. - """ - with XFile(xfile) as fd: - buf = fd.read(len(RAR5_ID)) - if buf.startswith(RAR_ID): - return RAR_V3 - elif buf.startswith(RAR5_ID): - return RAR_V5 - return 0 +FORCE_TOOL = False def _find_sfx_header(xfile): @@ -340,10 +341,27 @@ def _find_sfx_header(xfile): ## Public interface ## + +def get_rar_version(xfile): + """Check quickly whether file is rar archive. + """ + with XFile(xfile) as fd: + buf = fd.read(len(RAR5_ID)) + if buf.startswith(RAR_ID): + return RAR_V3 + elif buf.startswith(RAR5_ID): + return RAR_V5 + return 0 + + def is_rarfile(xfile): """Check quickly whether file is rar archive. """ - return _get_rar_version(xfile) > 0 + try: + return get_rar_version(xfile) > 0 + except OSError: + # File not found or not accessible, ignore + return False def is_rarfile_sfx(xfile): @@ -634,6 +652,25 @@ class RarInfo: class RarFile: """Parse RAR structure, provide access to files in archive. + + Parameters: + + file + archive file name or file-like object. + mode + only "r" is supported. + charset + fallback charset to use, if filenames are not already Unicode-enabled. + info_callback + debug callback, gets to see all archive entries. + crc_check + set to False to disable CRC checks + errors + Either "stop" to quietly stop parsing on errors, + or "strict" to raise errors. Default is "stop". + part_only + If True, read only single file and allow it to be middle-part + of multi-volume archive. """ #: File name, if available. Unicode string or None. @@ -643,25 +680,7 @@ class RarFile: comment = None def __init__(self, file, mode="r", charset=None, info_callback=None, - crc_check=True, errors="stop"): - """Open and parse a RAR archive. - - Parameters: - - file - archive file name or file-like object. - mode - only "r" is supported. - charset - fallback charset to use, if filenames are not already Unicode-enabled. - info_callback - debug callback, gets to see all archive entries. - crc_check - set to False to disable CRC checks - errors - Either "stop" to quietly stop parsing on errors, - or "strict" to raise errors. Default is "stop". - """ + crc_check=True, errors="stop", part_only=False): if is_filelike(file): self.filename = getattr(file, "name", None) else: @@ -673,6 +692,7 @@ class RarFile: self._charset = charset or DEFAULT_CHARSET self._info_callback = info_callback self._crc_check = crc_check + self._part_only = part_only self._password = None self._file_parser = None @@ -740,6 +760,14 @@ class RarFile: """ return self._file_parser.getinfo(name) + def getinfo_orig(self, name): + """Return RarInfo for file source. + + RAR5: if name is hard-linked or copied file, + returns original entry with original filename. + """ + return self._file_parser.getinfo_orig(name) + def open(self, name, mode="r", pwd=None): """Returns file-like object (:class:`RarExtFile`) from where the data can be read. @@ -880,12 +908,12 @@ class RarFile: if ver == RAR_V3: p3 = RAR3Parser(self._rarfile, self._password, self._crc_check, self._charset, self._strict, self._info_callback, - sfx_ofs) + sfx_ofs, self._part_only) self._file_parser = p3 # noqa elif ver == RAR_V5: p5 = RAR5Parser(self._rarfile, self._password, self._crc_check, self._charset, self._strict, self._info_callback, - sfx_ofs) + sfx_ofs, self._part_only) self._file_parser = p5 # noqa else: raise NotRarFile("Not a RAR file") @@ -984,7 +1012,8 @@ class CommonParser: _password = None comment = None - def __init__(self, rarfile, password, crc_check, charset, strict, info_cb, sfx_offset): + def __init__(self, rarfile, password, crc_check, charset, strict, + info_cb, sfx_offset, part_only): self._rarfile = rarfile self._password = password self._crc_check = crc_check @@ -995,6 +1024,7 @@ class CommonParser: self._info_map = {} self._vol_list = [] self._sfx_offset = sfx_offset + self._part_only = part_only def has_header_encryption(self): """Returns True if headers are encrypted @@ -1043,7 +1073,16 @@ class CommonParser: try: return self._info_map[fname] except KeyError: - raise NoRarEntry("No such file: %s" % fname) + raise NoRarEntry("No such file: %s" % fname) from None + + def getinfo_orig(self, member): + inf = self.getinfo(member) + if inf.file_redir: + redir_type, redir_flags, redir_name = inf.file_redir + # cannot leave to unrar as it expects copied file to exist + if redir_type in (RAR5_XREDIR_FILE_COPY, RAR5_XREDIR_HARD_LINK): + inf = self.getinfo(redir_name) + return inf def parse(self): """Process file.""" @@ -1080,7 +1119,7 @@ class CommonParser: if raise_need_first_vol: # did not find ENDARC with VOLNR raise NeedFirstVolume("Need to start from first volume", None) - if more_vols: + if more_vols and not self._part_only: volume += 1 fd.close() try: @@ -1098,6 +1137,7 @@ class CommonParser: endarc = False self._vol_list.append(volfile) self._main = None + self._hdrenc_main = None continue break h.volume = volume @@ -1105,7 +1145,7 @@ class CommonParser: if h.type == RAR_BLOCK_MAIN and not self._main: self._main = h - if volume == 0 and (h.flags & RAR_MAIN_NEWNUMBERING): + if volume == 0 and (h.flags & RAR_MAIN_NEWNUMBERING) and not self._part_only: # RAR 2.x does not set FIRSTVOLUME, # so check it only if NEWNUMBERING is used if (h.flags & RAR_MAIN_FIRSTVOLUME) == 0: @@ -1137,7 +1177,8 @@ class CommonParser: more_vols = True # RAR 2.x does not set RAR_MAIN_FIRSTVOLUME if volume == 0 and h.flags & RAR_FILE_SPLIT_BEFORE: - raise_need_first_vol = True + if not self._part_only: + raise_need_first_vol = True if h.needs_password(): self._needs_password = True @@ -1241,6 +1282,8 @@ class CommonParser: return self._open_unrar(self._rarfile, inf, pwd) def _open_clear(self, inf): + if FORCE_TOOL: + return self._open_unrar(self._rarfile, inf) return DirectReader(self, inf) def _open_hack_core(self, inf, pwd, prefix, suffix): @@ -1288,7 +1331,7 @@ class CommonParser: # not giving filename avoids encoding related problems fn = None if not tmpfile or force_file: - fn = inf.filename + fn = inf.filename.replace("/", os.path.sep) # read from unrar pipe cmd = setup.open_cmdline(pwd, rarfile, fn) @@ -1379,6 +1422,9 @@ class RAR3Parser(CommonParser): buf = fd.read(S_BLK_HDR.size) if not buf: return None + if len(buf) < S_BLK_HDR.size: + self._set_error("Unexpected EOF when reading header") + return None t = S_BLK_HDR.unpack_from(buf) h.header_crc, h.type, h.flags, h.header_size = t @@ -1751,14 +1797,18 @@ class RAR5Parser(CommonParser): # AES encrypted headers _last_aes256_key = (-1, None, None) # (kdf_count, salt, key) + def _get_utf8_password(self): + pwd = self._password + if isinstance(pwd, str): + return pwd.encode("utf8") + return pwd + def _gen_key(self, kdf_count, salt): if self._last_aes256_key[:2] == (kdf_count, salt): return self._last_aes256_key[2] if kdf_count > 24: raise BadRarFile("Too large kdf_count") - pwd = self._password - if isinstance(pwd, str): - pwd = pwd.encode("utf8") + pwd = self._get_utf8_password() key = pbkdf2_hmac("sha256", pwd, salt, 1 << kdf_count) self._last_aes256_key = (kdf_count, salt, key) return key @@ -1776,8 +1826,17 @@ class RAR5Parser(CommonParser): """ header_offset = fd.tell() - preload = 4 + 3 + preload = 4 + 1 start_bytes = fd.read(preload) + if len(start_bytes) < preload: + self._set_error("Unexpected EOF when reading header") + return None + while start_bytes[-1] & 0x80: + b = fd.read(1) + if not b: + self._set_error("Unexpected EOF when reading header") + return None + start_bytes += b header_crc, pos = load_le32(start_bytes, 0) hdrlen, pos = load_vint(start_bytes, pos) if hdrlen > 2 * 1024 * 1024: @@ -1912,15 +1971,39 @@ class RAR5Parser(CommonParser): h.flags |= RAR_ENDARC_NEXT_VOLUME return h + def _check_password(self, check_value, kdf_count_shift, salt): + if len(check_value) != RAR5_PW_CHECK_SIZE + RAR5_PW_SUM_SIZE: + return + + hdr_check = check_value[:RAR5_PW_CHECK_SIZE] + hdr_sum = check_value[RAR5_PW_CHECK_SIZE:] + sum_hash = sha256(hdr_check).digest() + if sum_hash[:RAR5_PW_SUM_SIZE] != hdr_sum: + return + + kdf_count = (1 << kdf_count_shift) + 32 + pwd = self._get_utf8_password() + pwd_hash = pbkdf2_hmac("sha256", pwd, salt, kdf_count) + + pwd_check = bytearray(RAR5_PW_CHECK_SIZE) + len_mask = RAR5_PW_CHECK_SIZE - 1 + for i, v in enumerate(pwd_hash): + pwd_check[i & len_mask] ^= v + + if pwd_check != hdr_check: + raise RarWrongPassword() + def _parse_encryption_block(self, h, hdata, pos): h.encryption_algo, pos = load_vint(hdata, pos) h.encryption_flags, pos = load_vint(hdata, pos) h.encryption_kdf_count, pos = load_byte(hdata, pos) h.encryption_salt, pos = load_bytes(hdata, 16, pos) if h.encryption_flags & RAR5_ENC_FLAG_HAS_CHECKVAL: - h.encryption_check_value = load_bytes(hdata, 12, pos) + h.encryption_check_value, pos = load_bytes(hdata, 12, pos) if h.encryption_algo != RAR5_XENC_CIPHER_AES256: raise BadRarFile("Unsupported header encryption cipher") + if h.encryption_check_value and self._password: + self._check_password(h.encryption_check_value, h.encryption_kdf_count, h.encryption_salt) self._hdrenc_main = h return h @@ -2157,6 +2240,7 @@ class RarExtFile(io.RawIOBase): _remain = 0 _returncode = 0 _md_context = None + _seeking = False def _open_extfile(self, parser, inf): self.name = inf.filename @@ -2165,7 +2249,10 @@ class RarExtFile(io.RawIOBase): if self._fd: self._fd.close() - md_class = self._inf._md_class or NoHashContext + if self._seeking: + md_class = NoHashContext + else: + md_class = self._inf._md_class or NoHashContext self._md_context = md_class() self._fd = None self._remain = self._inf.file_size @@ -2256,7 +2343,9 @@ class RarExtFile(io.RawIOBase): """ # disable crc check when seeking - self._md_context = NoHashContext() + if not self._seeking: + self._md_context = NoHashContext() + self._seeking = True fsize = self._inf.file_size cur_ofs = self.tell() @@ -2437,6 +2526,11 @@ class DirectReader(RarExtFile): def _read(self, cnt): """Read from potentially multi-volume archive.""" + pos = self._fd.tell() + need = self._cur.data_offset + self._cur.add_size - self._cur_avail + if pos != need: + self._fd.seek(need, 0) + buf = [] while cnt > 0: # next vol needed? @@ -2790,7 +2884,7 @@ def load_le32(buf, pos): end = pos + 4 if end > len(buf): raise BadRarFile("cannot load le32") - return S_LONG.unpack_from(buf, pos)[0], pos + 4 + return S_LONG.unpack_from(buf, pos)[0], end def load_bytes(buf, num, pos): @@ -2833,36 +2927,61 @@ def load_windowstime(buf, pos): return dt, pos +# +# volume numbering +# + +_rc_num = re.compile('^[0-9]+$') + + def _next_newvol(volfile): """New-style next volume """ + name, ext = os.path.splitext(volfile) + if ext.lower() in ("", ".exe", ".sfx"): + volfile = name + ".rar" i = len(volfile) - 1 while i >= 0: - if volfile[i] >= "0" and volfile[i] <= "9": - return _inc_volname(volfile, i) + if "0" <= volfile[i] <= "9": + return _inc_volname(volfile, i, False) + if volfile[i] in ("/", os.sep): + break i -= 1 raise BadRarName("Cannot construct volume name: " + volfile) + def _next_oldvol(volfile): """Old-style next volume """ - # rar -> r00 - if volfile[-4:].lower() == ".rar": - return volfile[:-2] + "00" - return _inc_volname(volfile, len(volfile) - 1) + name, ext = os.path.splitext(volfile) + if ext.lower() in ("", ".exe", ".sfx"): + ext = ".rar" + sfx = ext[2:] + if _rc_num.match(sfx): + ext = _inc_volname(ext, len(ext) - 1, True) + else: + # .rar -> .r00 + ext = ext[:2] + "00" + return name + ext -def _inc_volname(volfile, i): +def _inc_volname(volfile, i, inc_chars): """increase digits with carry, otherwise just increment char """ fn = list(volfile) while i >= 0: - if fn[i] != "9": + if fn[i] == "9": + fn[i] = "0" + i -= 1 + if i < 0: + fn.insert(0, "1") + elif "0" <= fn[i] < "9" or inc_chars: fn[i] = chr(ord(fn[i]) + 1) break - fn[i] = "0" - i -= 1 + else: + fn.insert(i + 1, "1") + break return "".join(fn) @@ -2957,7 +3076,7 @@ def rar3_decompress(vers, meth, data, declen=0, flags=0, crc=0, pwd=None, salt=N # file header fname = b"data" date = ((2010 - 1980) << 25) + (12 << 21) + (31 << 16) - mode = 0x20 + mode = DOS_MODE_ARCHIVE fhdr = S_FILE_HDR.pack(len(data), declen, RAR_OS_MSDOS, crc, date, vers, meth, len(fname), mode) fhdr += fname @@ -3038,17 +3157,12 @@ def to_datetime(t): pass # sanitize invalid values - mday = (0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + mday = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) mon = max(1, min(mon, 12)) day = max(1, min(day, mday[mon])) h = min(h, 23) m = min(m, 59) s = min(s, 59) - if mon == 2 and day == 29: - try: - return datetime(year, mon, day, h, m, s) - except ValueError: - day = 28 return datetime(year, mon, day, h, m, s) @@ -3075,7 +3189,7 @@ class nsdatetime(datetime): __slots__ = ("nanosecond",) nanosecond: int #: Number of nanoseconds, 0 <= nanosecond < 999999999 - def __new__(cls, year, month, day, hour=0, minute=0, second=0, + def __new__(cls, year, month=None, day=None, hour=0, minute=0, second=0, microsecond=0, tzinfo=None, *, fold=0, nanosecond=0): usec, mod = divmod(nanosecond, 1000) if nanosecond else (microsecond, 0) if mod == 0: @@ -3165,9 +3279,9 @@ def custom_popen(cmd): creationflags=creationflags) except OSError as ex: if ex.errno == errno.ENOENT: - raise RarCannotExec("Unrar not installed?") + raise RarCannotExec("Unrar not installed?") from None if ex.errno == errno.EACCES or ex.errno == errno.EPERM: - raise RarCannotExec("Cannot execute unrar") + raise RarCannotExec("Cannot execute unrar") from None raise return p @@ -3244,6 +3358,8 @@ class ToolSetup: def get_cmdline(self, key, pwd, nodash=False): cmdline = list(self.setup[key]) cmdline[0] = globals()[cmdline[0]] + if key == "check_cmd": + return cmdline self.add_password_arg(cmdline, pwd) if not nodash: cmdline.append("--") @@ -3259,7 +3375,10 @@ class ToolSetup: if not isinstance(pwd, str): pwd = pwd.decode("utf8") args = self.setup["password"] - if isinstance(args, str): + if args is None: + tool = self.setup["open_cmd"][0] + raise RarCannotExec(f"{tool} does not support passwords") + elif isinstance(args, str): cmdline.append(args + pwd) else: cmdline.extend(args) @@ -3303,10 +3422,30 @@ BSDTAR_CONFIG = { "errmap": [None], } +SEVENZIP_CONFIG = { + "open_cmd": ("SEVENZIP_TOOL", "e", "-so", "-bb0"), + "check_cmd": ("SEVENZIP_TOOL", "i"), + "password": "-p", + "no_password": ("-p",), + "errmap": [None, + RarWarning, RarFatalError, None, None, # 1..4 + None, None, RarUserError, RarMemoryError] # 5..8 +} + +SEVENZIP2_CONFIG = { + "open_cmd": ("SEVENZIP2_TOOL", "e", "-so", "-bb0"), + "check_cmd": ("SEVENZIP2_TOOL", "i"), + "password": "-p", + "no_password": ("-p",), + "errmap": [None, + RarWarning, RarFatalError, None, None, # 1..4 + None, None, RarUserError, RarMemoryError] # 5..8 +} + CURRENT_SETUP = None -def tool_setup(unrar=True, unar=True, bsdtar=True, force=False): +def tool_setup(unrar=True, unar=True, bsdtar=True, sevenzip=True, sevenzip2=True, force=False): """Pick a tool, return cached ToolSetup. """ global CURRENT_SETUP @@ -3319,6 +3458,10 @@ def tool_setup(unrar=True, unar=True, bsdtar=True, force=False): lst.append(UNRAR_CONFIG) if unar: lst.append(UNAR_CONFIG) + if sevenzip: + lst.append(SEVENZIP_CONFIG) + if sevenzip2: + lst.append(SEVENZIP2_CONFIG) if bsdtar: lst.append(BSDTAR_CONFIG) diff --git a/libs/version.txt b/libs/version.txt index e175db66d..ac120f6b4 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -32,7 +32,7 @@ python-engineio==4.3.4 python-socketio==5.7.2 pytz==2023.3 pytz_deprecation_shim==0.1.0.post0 -rarfile==4.0 +rarfile==4.1 requests==2.28.1 semver==2.13.0 signalrcore==0.9.5 From a3b32c3ebfdb8d6c37127bb1af27fcca2f0a46c1 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Tue, 5 Dec 2023 21:50:41 -0500 Subject: [PATCH 06/49] Silented engine.io exception when client session as expired on server side --- bazarr/app/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bazarr/app/app.py b/bazarr/app/app.py index c5a5bf89d..6814cd38d 100644 --- a/bazarr/app/app.py +++ b/bazarr/app/app.py @@ -35,7 +35,7 @@ def create_app(): app.config["DEBUG"] = False socketio.init_app(app, path=f'{base_url.rstrip("/")}/api/socket.io', cors_allowed_origins='*', - async_mode='threading', allow_upgrades=False, transports='polling') + async_mode='threading', allow_upgrades=False, transports='polling', engineio_logger=False) @app.errorhandler(404) def page_not_found(_): From 058a00594e676cf8f87f910fec9c300c8ef856d0 Mon Sep 17 00:00:00 2001 From: Jimmy Lynn Date: Wed, 6 Dec 2023 12:47:04 -0500 Subject: [PATCH 07/49] Updated zimuku URL --- libs/subliminal_patch/providers/zimuku.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/subliminal_patch/providers/zimuku.py b/libs/subliminal_patch/providers/zimuku.py index 99bfb3d6c..c7709e223 100644 --- a/libs/subliminal_patch/providers/zimuku.py +++ b/libs/subliminal_patch/providers/zimuku.py @@ -98,7 +98,7 @@ class ZimukuProvider(Provider): video_types = (Episode, Movie) logger.info(str(supported_languages)) - server_url = "https://so.zimuku.org" + server_url = "https://srtku.com" search_url = "/search?q={}" subtitle_class = ZimukuSubtitle From 256ceeb598e918cd26867ab583c8748193b31b88 Mon Sep 17 00:00:00 2001 From: S Dellysse Date: Fri, 8 Dec 2023 14:20:00 -0500 Subject: [PATCH 08/49] Fixed exception being raised when setting all numeric password to access the web UI --- bazarr/app/config.py | 2 +- bazarr/utilities/helper.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 70b4b2948..b69963eaa 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -519,7 +519,7 @@ def save_settings(settings_items): if key == 'settings-auth-password': if value != settings.auth.password and value is not None: - value = hashlib.md5(value.encode('utf-8')).hexdigest() + value = hashlib.md5(f"{value}".encode('utf-8')).hexdigest() if key == 'settings-general-debug': configure_debug = True diff --git a/bazarr/utilities/helper.py b/bazarr/utilities/helper.py index fd20f6f3b..b381f2e15 100644 --- a/bazarr/utilities/helper.py +++ b/bazarr/utilities/helper.py @@ -14,7 +14,7 @@ def check_credentials(user, pw, request, log_success=True): ip_addr = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) username = settings.auth.username password = settings.auth.password - if hashlib.md5(pw.encode('utf-8')).hexdigest() == password and user == username: + if hashlib.md5(f"{pw}".encode('utf-8')).hexdigest() == password and user == username: if log_success: logging.info(f'Successful authentication from {ip_addr} for user {user}') return True From 5e68fac4d175f2999b5b05bf7bdd02b76cdf942d Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Mon, 11 Dec 2023 20:03:56 -0300 Subject: [PATCH 09/49] Fixed function to prevent usage of Python 3.7 --- bazarr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bazarr.py b/bazarr.py index c85aee411..e0f214f93 100644 --- a/bazarr.py +++ b/bazarr.py @@ -13,7 +13,7 @@ from bazarr.app.get_args import args def check_python_version(): python_version = platform.python_version_tuple() - minimum_py3_tuple = (3, 7, 0) + minimum_py3_tuple = (3, 8, 0) minimum_py3_str = ".".join(str(i) for i in minimum_py3_tuple) if int(python_version[0]) < minimum_py3_tuple[0]: From cbfe8482a8fb4ca90c5f3caa1746458d58ffc643 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Wed, 13 Dec 2023 22:20:27 -0500 Subject: [PATCH 10/49] no log: fix dev server port --- frontend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/README.md b/frontend/README.md index c03f7b391..135ccb4f6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -59,7 +59,7 @@ In the project directory, you can run: ### `npm start` Runs the app in the development mode. -Open `http://localhost:3000` to view it in the browser. +Open `http://localhost:5173` to view it in the browser. The page will reload if you make edits. You will also see any lint errors in the console. From f2cd79ec20692f52b7bdfd7698a52b4aa3c154b4 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Thu, 14 Dec 2023 11:27:13 -0500 Subject: [PATCH 11/49] Fixed numeric password issue for authentication. #2326 --- bazarr/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index b69963eaa..3bef51cb1 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -122,8 +122,8 @@ validators = [ Validator('auth.apikey', must_exist=True, default=hexlify(os.urandom(16)).decode(), is_type_of=str), Validator('auth.type', must_exist=True, default=None, is_type_of=(NoneType, str), is_in=[None, 'basic', 'form']), - Validator('auth.username', must_exist=True, default='', is_type_of=str), - Validator('auth.password', must_exist=True, default='', is_type_of=str), + Validator('auth.username', must_exist=True, default='', is_type_of=str, cast=str), + Validator('auth.password', must_exist=True, default='', is_type_of=str, cast=str), # cors section Validator('cors.enabled', must_exist=True, default=False, is_type_of=bool), From 72bd52ce79405cc8adca427d2f5887a50273839e Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Fri, 15 Dec 2023 07:47:19 -0500 Subject: [PATCH 12/49] Fixed output encoding for subtitles synchronization. #2323 --- bazarr/subtitles/tools/subsyncer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bazarr/subtitles/tools/subsyncer.py b/bazarr/subtitles/tools/subsyncer.py index cee72dd5d..30945a8d0 100644 --- a/bazarr/subtitles/tools/subsyncer.py +++ b/bazarr/subtitles/tools/subsyncer.py @@ -53,7 +53,7 @@ class SubSyncer: self.ffmpeg_path = os.path.dirname(ffmpeg_exe) unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path, '--vad', - self.vad, '--log-dir-path', self.log_dir_path] + self.vad, '--log-dir-path', self.log_dir_path, '--output-encoding', 'same'] if settings.subsync.force_audio: unparsed_args.append('--no-fix-framerate') unparsed_args.append('--reference-stream') From 7c40bfec1e390552c6f54c93e7cc5336307ed237 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Tue, 26 Dec 2023 22:37:52 -0500 Subject: [PATCH 13/49] Added db migration version to create the missing monitored column in TableShows table of some old instances. --- migrations/versions/30f37e2e15e1_.py | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 migrations/versions/30f37e2e15e1_.py diff --git a/migrations/versions/30f37e2e15e1_.py b/migrations/versions/30f37e2e15e1_.py new file mode 100644 index 000000000..039637c64 --- /dev/null +++ b/migrations/versions/30f37e2e15e1_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 30f37e2e15e1 +Revises: cee6a710cb71 +Create Date: 2023-12-26 21:32:39.283484 + +""" +from alembic import op +import sqlalchemy as sa +from app.database import TableShows + + +# revision identifiers, used by Alembic. +revision = '30f37e2e15e1' +down_revision = 'cee6a710cb71' +branch_labels = None +depends_on = None + +bind = op.get_context().bind +insp = sa.inspect(bind) +tables = insp.get_table_names() +sqlite = bind.engine.name == 'sqlite' + + +def column_exists(table_name, column_name): + columns = insp.get_columns(table_name) + return any(c["name"] == column_name for c in columns) + + +def upgrade(): + if not column_exists('table_shows', 'monitored'): + with op.batch_alter_table('table_shows', schema=None) as batch_op: + batch_op.add_column(sa.Column('monitored', sa.Text(), nullable=True)) + op.execute(sa.update(TableShows).values({TableShows.monitored: 'True'})) + + +def downgrade(): + pass From 9e75acd5493791879a3037a77c89ccddcb3844f4 Mon Sep 17 00:00:00 2001 From: JayZed Date: Tue, 26 Dec 2023 22:48:02 -0500 Subject: [PATCH 14/49] Added filter to prevent useless Session is disconnected warning in logs --- bazarr/app/logger.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bazarr/app/logger.py b/bazarr/app/logger.py index abf832aa9..b2a3eeffd 100644 --- a/bazarr/app/logger.py +++ b/bazarr/app/logger.py @@ -55,6 +55,36 @@ class NoExceptionFormatter(logging.Formatter): def formatException(self, record): return '' + +class UnwantedWaitressMessageFilter(logging.Filter): + def filter(self, record): + if settings.general.debug == True: + # no filtering in debug mode + return True + + unwantedMessages = [ + "Exception while serving /api/socket.io/", + ['Session is disconnected', 'Session not found' ], + + "Exception while serving /api/socket.io/", + ["'Session is disconnected'", "'Session not found'" ], + + "Exception while serving /api/socket.io/", + ['"Session is disconnected"', '"Session not found"' ] + ] + + wanted = True + listLength = len(unwantedMessages) + for i in range(0, listLength, 2): + if record.msg == unwantedMessages[i]: + exceptionTuple = record.exc_info + if exceptionTuple != None: + if str(exceptionTuple[1]) in unwantedMessages[i+1]: + wanted = False + break + + return wanted + def configure_logging(debug=False): warnings.simplefilter('ignore', category=ResourceWarning) @@ -129,6 +159,7 @@ def configure_logging(debug=False): logging.getLogger("ga4mp.ga4mp").setLevel(logging.ERROR) logging.getLogger("waitress").setLevel(logging.ERROR) + logging.getLogger("waitress").addFilter(UnwantedWaitressMessageFilter()) logging.getLogger("knowit").setLevel(logging.CRITICAL) logging.getLogger("enzyme").setLevel(logging.CRITICAL) logging.getLogger("guessit").setLevel(logging.WARNING) From 5739b9ad08b6c0c7eef14aad21863c8a08a88852 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Tue, 26 Dec 2023 23:54:29 -0500 Subject: [PATCH 15/49] Added opensubtitlescom settings to include AI translated subtitles in search results. --- bazarr/app/config.py | 1 + bazarr/app/get_providers.py | 1 + frontend/src/pages/Settings/Providers/list.ts | 5 +++++ libs/subliminal_patch/providers/opensubtitlescom.py | 9 ++++++--- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 3bef51cb1..2f5ff9a7f 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -201,6 +201,7 @@ validators = [ Validator('opensubtitlescom.username', must_exist=True, default='', is_type_of=str, cast=str), Validator('opensubtitlescom.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('opensubtitlescom.use_hash', must_exist=True, default=True, is_type_of=bool), + Validator('opensubtitlescom.include_ai_translated', must_exist=True, default=False, is_type_of=bool), # addic7ed section Validator('addic7ed.username', must_exist=True, default='', is_type_of=str, cast=str), diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py index 28a8b1aeb..fea4b6094 100644 --- a/bazarr/app/get_providers.py +++ b/bazarr/app/get_providers.py @@ -240,6 +240,7 @@ def get_providers_auth(): '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': { diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index 0f1375756..81ab6141a 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -286,6 +286,11 @@ export const ProviderList: Readonly = [ key: "use_hash", name: "Use Hash", }, + { + type: "switch", + key: "include_ai_translated", + name: "Include AI translated subtitles in search results", + }, ], }, { diff --git a/libs/subliminal_patch/providers/opensubtitlescom.py b/libs/subliminal_patch/providers/opensubtitlescom.py index 8576dc2e0..615bc8cf1 100644 --- a/libs/subliminal_patch/providers/opensubtitlescom.py +++ b/libs/subliminal_patch/providers/opensubtitlescom.py @@ -162,7 +162,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): video_types = (Episode, Movie) - def __init__(self, username=None, password=None, use_hash=True, api_key=None): + def __init__(self, username=None, password=None, use_hash=True, include_ai_translated=False, api_key=None): if not all((username, password)): raise ConfigurationError('Username and password must be specified') @@ -181,6 +181,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): self.password = password self.video = None self.use_hash = use_hash + self.include_ai_translated = include_ai_translated self._started = None def initialize(self): @@ -300,7 +301,8 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): res = self.retry( lambda: self.checked( lambda: self.session.get(self.server_url + 'subtitles', - params=(('ai_translated', 'exclude'), + params=(('ai_translated', 'exclude' if not self.include_ai_translated + else 'include'), ('episode_number', self.video.episode), ('imdb_id', imdb_id if not title_id else None), ('languages', langs), @@ -317,7 +319,8 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): res = self.retry( lambda: self.checked( lambda: self.session.get(self.server_url + 'subtitles', - params=(('ai_translated', 'exclude'), + params=(('ai_translated', 'exclude' if not self.include_ai_translated + else 'include'), ('id', title_id if title_id else None), ('imdb_id', imdb_id if not title_id else None), ('languages', langs), From 7d14798e692710898e94238777897730102bacf4 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Thu, 28 Dec 2023 10:02:55 -0500 Subject: [PATCH 16/49] Added support for pt-pt language code in subtitles filename. #2306 --- bazarr/languages/custom_lang.py | 106 ++++++++++---------------------- 1 file changed, 34 insertions(+), 72 deletions(-) diff --git a/bazarr/languages/custom_lang.py b/bazarr/languages/custom_lang.py index c97ae665f..e1090e2c7 100644 --- a/bazarr/languages/custom_lang.py +++ b/bazarr/languages/custom_lang.py @@ -24,7 +24,9 @@ class CustomLanguage: _possible_matches = ("pt-br", "pob", "pb", "brazilian", "brasil", "brazil") _extensions = (".pt-br", ".pob", ".pb") _extensions_forced = (".pt-br.forced", ".pob.forced", ".pb.forced") - _extensions_hi = (".pt-br.hi", ".pob.hi", ".pb.hi") + _extensions_hi = (".pt-br.hi", ".pob.hi", ".pb.hi", + ".pt-br.cc", ".pob.cc", ".pb.cc", + ".pt-br.sdh", ".pob.sdh", ".pb.sdh") def subzero_language(self): return Language(self.official_alpha3, self.iso) @@ -45,7 +47,7 @@ class CustomLanguage: @classmethod def register(cls, table): - "Register the custom language subclasses in the database." + """Register the custom language subclasses in the database.""" for sub in cls.__subclasses__(): database.execute( @@ -107,6 +109,23 @@ class BrazilianPortuguese(CustomLanguage): pass +class Portuguese(CustomLanguage): + alpha2 = "pt" + alpha3 = "por" + language = "pt-PT" + official_alpha2 = "pt" + official_alpha3 = "por" + name = "Portuguese" + iso = "PT" + _scripts = [] + _possible_matches = ("pt-pt", "por", "pt") + _extensions = (".pt-pt", ".por", ".pt") + _extensions_forced = (".pt-pt.forced", ".por.forced", ".pt.forced") + _extensions_hi = (".pt-pt.hi", ".por.hi", ".pt.hi", + ".pt-pt.cc", ".por.cc", ".pt.cc", + ".pt-pt.sdh", ".por.sdh", ".pt.sdh") + + class ChineseTraditional(CustomLanguage): alpha2 = "zt" alpha3 = "zht" @@ -119,67 +138,27 @@ class ChineseTraditional(CustomLanguage): # We'll use literals for now _scripts = ("Hant",) _extensions = ( - ".cht", - ".tc", - ".zh-tw", - ".zht", - ".zh-hant", - ".zhhant", - ".zh_hant", - ".hant", - ".big5", - ".traditional", + ".cht", ".tc", ".zh-tw", ".zht", ".zh-hant", ".zhhant", ".zh_hant", ".hant", ".big5", ".traditional", ) _extensions_forced = ( - ".cht.forced", - ".tc.forced", - ".zht.forced", - "hant.forced", - ".big5.forced", - "繁體中文.forced", - "雙語.forced", + ".cht.forced", ".tc.forced", ".zht.forced", "hant.forced", ".big5.forced", "繁體中文.forced", "雙語.forced", ".zh-tw.forced", ) _extensions_hi = ( - ".cht.hi", - ".tc.hi", - ".zht.hi", - "hant.hi", - ".big5.hi", - "繁體中文.hi", - "雙語.hi", - ".zh-tw.hi", + ".cht.hi", ".tc.hi", ".zht.hi", "hant.hi", ".big5.hi", "繁體中文.hi", "雙語.hi", ".zh-tw.hi", ) _extensions_fuzzy = ("繁", "雙語") _extensions_disamb_fuzzy = ("简", "双语") _extensions_disamb = ( - ".chs", - ".sc", - ".zhs", - ".zh-hans", - ".hans", - ".zh_hans", - ".zhhans", - ".gb", - ".simplified", + ".chs", ".sc", ".zhs", ".zh-hans", ".hans", ".zh_hans", ".zhhans", ".gb", ".simplified", ) _extensions_disamb_forced = ( - ".chs.forced", - ".sc.forced", - ".zhs.forced", - "hans.forced", - ".gb.forced", - "简体中文.forced", - "双语.forced", + ".chs.forced", ".sc.forced", ".zhs.forced", "hans.forced", ".gb.forced", "简体中文.forced", "双语.forced", ) _extensions_disamb_hi = ( - ".chs.hi", - ".sc.hi", - ".zhs.hi", - "hans.hi", - ".gb.hi", - "简体中文.hi", - "双语.hi", + ".chs.hi", ".sc.hi", ".zhs.hi", "hans.hi", ".gb.hi", "简体中文.hi", "双语.hi", + ".chs.cc", ".sc.cc", ".zhs.cc", "hans.cc", ".gb.cc", "简体中文.cc", "双语.cc", + ".chs.sdh", ".sc.sdh", ".zhs.sdh", "hans.sdh", ".gb.sdh", "简体中文.sdh", "双语.sdh", ) @classmethod @@ -231,31 +210,14 @@ class LatinAmericanSpanish(CustomLanguage): iso = "MX" # Not fair, but ok _scripts = ("419",) _possible_matches = ( - "es-la", - "spa-la", - "spl", - "mx", - "latin", - "mexic", - "argent", - "latam", + "es-la", "spa-la", "spl", "mx", "latin", "mexic", "argent", "latam", ) _extensions = (".es-la", ".spl", ".spa-la", ".ea", ".es-mx", ".lat", ".es.ar") _extensions_forced = ( - ".es-la.forced", - ".spl.forced", - ".spa-la.forced", - ".ea.forced", - ".es-mx.forced", - ".lat.forced", - ".es.ar.forced", + ".es-la.forced", ".spl.forced", ".spa-la.forced", ".ea.forced", ".es-mx.forced", ".lat.forced", ".es.ar.forced", ) _extensions_hi = ( - ".es-la.hi", - ".spl.hi", - ".spa-la.hi", - ".ea.hi", - ".es-mx.hi", - ".lat.hi", - ".es.ar.hi", + ".es-la.hi", ".spl.hi", ".spa-la.hi", ".ea.hi", ".es-mx.hi", ".lat.hi", ".es.ar.hi", + ".es-la.cc", ".spl.cc", ".spa-la.cc", ".ea.cc", ".es-mx.cc", ".lat.cc", ".es.ar.cc", + ".es-la.sdh", ".spl.sdh", ".spa-la.sdh", ".ea.sdh", ".es-mx.sdh", ".lat.sdh", ".es.ar.sdh", ) From eaa77bd5117aca682106dbbf0e6d92cdb791f031 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Thu, 28 Dec 2023 13:17:01 -0500 Subject: [PATCH 17/49] Fixed zimuku provider protection bypass. #2334 --- libs/subliminal_patch/providers/zimuku.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/libs/subliminal_patch/providers/zimuku.py b/libs/subliminal_patch/providers/zimuku.py index c7709e223..7c66bfc6a 100644 --- a/libs/subliminal_patch/providers/zimuku.py +++ b/libs/subliminal_patch/providers/zimuku.py @@ -20,7 +20,7 @@ from subzero.language import Language from guessit import guessit from requests import Session from six import text_type -from random import randint +from random import randint, randrange from python_anticaptcha import AnticaptchaClient, ImageToTextTask from subliminal.providers import ParserBeautifulSoup @@ -109,7 +109,7 @@ class ZimukuProvider(Provider): verify_token = "" code = "" location_re = re.compile( - r'self\.location = "(.*)" \+ stringToHex\(text\)') + r'self\.location = "(.*)" \+ stringToHex\(') verification_image_re = re.compile(r'') def yunsuo_bypass(self, url, *args, **kwargs): @@ -139,7 +139,10 @@ class ZimukuProvider(Provider): # mock js script logic tr = self.location_re.findall(r.text) verification_image = self.verification_image_re.findall(r.text) - self.code = parse_verification_image(verification_image[0]) + if len(verification_image): + self.code = parse_verification_image(verification_image[0]) + else: + self.code = f"{randrange(800, 1920)},{randrange(600, 1080)}" self.session.cookies.set("srcurl", string_to_hex(r.url)) if tr: verify_resp = self.session.get( From c7e0da3da7b450b38c87c77ab93b9f90b7e2aab6 Mon Sep 17 00:00:00 2001 From: JayZed Date: Thu, 28 Dec 2023 13:22:16 -0500 Subject: [PATCH 18/49] Added permanent label over sliders to show current value --- frontend/src/pages/Settings/components/forms.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/Settings/components/forms.tsx b/frontend/src/pages/Settings/components/forms.tsx index 9457abd41..d0efa8e91 100644 --- a/frontend/src/pages/Settings/components/forms.tsx +++ b/frontend/src/pages/Settings/components/forms.tsx @@ -146,6 +146,7 @@ export const Slider: FunctionComponent = (props) => { From fe52153c715c6d792cd63b6675033ad6910e4eb7 Mon Sep 17 00:00:00 2001 From: JayZed Date: Thu, 28 Dec 2023 13:25:15 -0500 Subject: [PATCH 19/49] Added "Manually" option for remaining tasks in Scheduler settings page --- bazarr/app/config.py | 20 ++-- bazarr/app/scheduler.py | 104 +++++++++++------- .../src/pages/Settings/Scheduler/index.tsx | 18 +-- .../src/pages/Settings/Scheduler/options.ts | 4 +- frontend/src/types/settings.d.ts | 1 - 5 files changed, 82 insertions(+), 65 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 2f5ff9a7f..0106aacf9 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -34,6 +34,9 @@ def validate_ip_address(ip_string): return False +ONE_HUNDRED_YEARS_IN_MINUTES = 52560000 +ONE_HUNDRED_YEARS_IN_HOURS = 876000 + class Validator(OriginalValidator): # Give the ability to personalize messages sent by the original dynasync Validator class. default_messages = MappingProxyType( @@ -99,14 +102,15 @@ validators = [ Validator('general.subfolder', must_exist=True, default='current', is_type_of=str), Validator('general.subfolder_custom', must_exist=True, default='', is_type_of=str), Validator('general.upgrade_subs', must_exist=True, default=True, is_type_of=bool), - Validator('general.upgrade_frequency', must_exist=True, default=12, is_type_of=int, is_in=[6, 12, 24]), + Validator('general.upgrade_frequency', must_exist=True, default=12, is_type_of=int, + is_in=[6, 12, 24, ONE_HUNDRED_YEARS_IN_HOURS]), 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']), - Validator('general.wanted_search_frequency', must_exist=True, default=6, is_type_of=int, is_in=[6, 12, 24]), + Validator('general.wanted_search_frequency', must_exist=True, default=6, is_type_of=int, is_in=[6, 12, 24, ONE_HUNDRED_YEARS_IN_HOURS]), Validator('general.wanted_search_frequency_movie', must_exist=True, default=6, is_type_of=int, - is_in=[6, 12, 24]), + is_in=[6, 12, 24, ONE_HUNDRED_YEARS_IN_HOURS]), Validator('general.subzero_mods', must_exist=True, default='', is_type_of=str), Validator('general.dont_notify_manual_actions', must_exist=True, default=False, is_type_of=bool), Validator('general.hi_extension', must_exist=True, default='hi', is_type_of=str, is_in=['hi', 'cc', 'sdh']), @@ -151,9 +155,7 @@ validators = [ Validator('sonarr.full_update_hour', must_exist=True, default=4, is_type_of=int, gte=0, lte=23), Validator('sonarr.only_monitored', must_exist=True, default=False, is_type_of=bool), Validator('sonarr.series_sync', must_exist=True, default=60, is_type_of=int, - is_in=[15, 60, 180, 360, 720, 1440]), - Validator('sonarr.episodes_sync', must_exist=True, default=60, is_type_of=int, - is_in=[15, 60, 180, 360, 720, 1440]), + is_in=[15, 60, 180, 360, 720, 1440, ONE_HUNDRED_YEARS_IN_MINUTES]), Validator('sonarr.excluded_tags', must_exist=True, default=[], is_type_of=list), Validator('sonarr.excluded_series_types', must_exist=True, default=[], is_type_of=list), Validator('sonarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool), @@ -174,7 +176,7 @@ validators = [ Validator('radarr.full_update_hour', must_exist=True, default=4, is_type_of=int, gte=0, lte=23), Validator('radarr.only_monitored', must_exist=True, default=False, is_type_of=bool), Validator('radarr.movies_sync', must_exist=True, default=60, is_type_of=int, - is_in=[15, 60, 180, 360, 720, 1440]), + is_in=[15, 60, 180, 360, 720, 1440, ONE_HUNDRED_YEARS_IN_MINUTES]), Validator('radarr.excluded_tags', must_exist=True, default=[], is_type_of=list), Validator('radarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool), Validator('radarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool), @@ -409,8 +411,6 @@ str_keys = ['chmod'] # Increase Sonarr and Radarr sync interval since we now use SignalR feed to update in real time if settings.sonarr.series_sync < 15: settings.sonarr.series_sync = 60 -if settings.sonarr.episodes_sync < 15: - settings.sonarr.episodes_sync = 60 if settings.radarr.movies_sync < 15: settings.radarr.movies_sync = 60 @@ -534,7 +534,7 @@ def save_settings(settings_items): if key in ['update_schedule', 'settings-general-use_sonarr', 'settings-general-use_radarr', 'settings-general-auto_update', 'settings-general-upgrade_subs', - 'settings-sonarr-series_sync', 'settings-sonarr-episodes_sync', 'settings-radarr-movies_sync', + 'settings-sonarr-series_sync', 'settings-radarr-movies_sync', 'settings-sonarr-full_update', 'settings-sonarr-full_update_day', 'settings-sonarr-full_update_hour', 'settings-radarr-full_update', 'settings-radarr-full_update_day', 'settings-radarr-full_update_hour', 'settings-general-wanted_search_frequency', 'settings-general-wanted_search_frequency_movie', diff --git a/bazarr/app/scheduler.py b/bazarr/app/scheduler.py index 81d368fe5..cbd68886e 100644 --- a/bazarr/app/scheduler.py +++ b/bazarr/app/scheduler.py @@ -36,6 +36,20 @@ if not args.no_update: else: from .check_update import check_releases +from dateutil.relativedelta import relativedelta + +NO_INTERVAL = "None" +NEVER_DATE = "Never" +ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 + +def a_long_time_from_now(job): + # currently defined as more than a year from now + delta = job.next_run_time - datetime.now(job.next_run_time.tzinfo) + return delta.total_seconds() > ONE_YEAR_IN_SECONDS + +def in_a_century(): + century = datetime.now() + relativedelta(years=100) + return century.year class Scheduler: @@ -106,7 +120,9 @@ class Scheduler: ('minute', 60), ('second', 1) ] - + if seconds > ONE_YEAR_IN_SECONDS: + # more than a year is None + return NO_INTERVAL strings = [] for period_name, period_seconds in periods: if seconds > period_seconds: @@ -118,14 +134,11 @@ class Scheduler: def get_time_from_cron(cron): year = str(cron[0]) - if year == "2100": - return "Never" - day = str(cron[4]) hour = str(cron[5]) if day == "*": - text = "everyday" + text = "every day" else: text = f"every {day_name[int(day)]}" @@ -136,12 +149,20 @@ class Scheduler: task_list = [] for job in self.aps_scheduler.get_jobs(): - next_run = 'Never' + next_run = NEVER_DATE if job.next_run_time: - next_run = pretty.date(job.next_run_time.replace(tzinfo=None)) - if isinstance(job.trigger, CronTrigger): - if job.next_run_time and str(job.trigger.__getstate__()['fields'][0]) != "2100": + if a_long_time_from_now(job): + # Never for IntervalTrigger jobs + next_run = NEVER_DATE + else: next_run = pretty.date(job.next_run_time.replace(tzinfo=None)) + if isinstance(job.trigger, CronTrigger): + if a_long_time_from_now(job): + # Never for CronTrigger jobs + next_run = NEVER_DATE + else: + if job.next_run_time: + next_run = pretty.date(job.next_run_time.replace(tzinfo=None)) if job.id in self.__running_tasks: running = True @@ -149,13 +170,21 @@ class Scheduler: running = False if isinstance(job.trigger, IntervalTrigger): - interval = f"every {get_time_from_interval(job.trigger.__getstate__()['interval'])}" + interval = get_time_from_interval(job.trigger.__getstate__()['interval']) + if interval != NO_INTERVAL: + interval = f"every {interval}" + # else: + # interval = "100 Year Interval" task_list.append({'name': job.name, 'interval': interval, 'next_run_in': next_run, 'next_run_time': next_run, 'job_id': job.id, 'job_running': running}) elif isinstance(job.trigger, CronTrigger): - task_list.append({'name': job.name, 'interval': get_time_from_cron(job.trigger.fields), - 'next_run_in': next_run, 'next_run_time': next_run, 'job_id': job.id, - 'job_running': running}) + if a_long_time_from_now(job): + interval = NO_INTERVAL + else: + interval = get_time_from_cron(job.trigger.fields) + task_list.append({'name': job.name, 'interval': interval, + 'next_run_in': next_run, 'next_run_time': next_run, 'job_id': job.id, + 'job_running': running}) return task_list @@ -175,29 +204,23 @@ class Scheduler: def __cache_cleanup_task(self): self.aps_scheduler.add_job(cache_maintenance, IntervalTrigger(hours=24), max_instances=1, coalesce=True, - misfire_grace_time=15, id='cache_cleanup', name='Cache maintenance') + misfire_grace_time=15, id='cache_cleanup', name='Cache Maintenance') def __check_health_task(self): self.aps_scheduler.add_job(check_health, IntervalTrigger(hours=6), max_instances=1, coalesce=True, - misfire_grace_time=15, id='check_health', name='Check health') + misfire_grace_time=15, id='check_health', name='Check Health') def __automatic_backup(self): backup = settings.backup.frequency if backup == "Daily": - self.aps_scheduler.add_job( - backup_to_zip, CronTrigger(hour=settings.backup.hour), max_instances=1, coalesce=True, - misfire_grace_time=15, id='backup', name='Backup database and configuration file', - replace_existing=True) + trigger = CronTrigger(hour=settings.backup.hour) elif backup == "Weekly": - self.aps_scheduler.add_job( - backup_to_zip, CronTrigger(day_of_week=settings.backup.day, hour=settings.backup.hour), - max_instances=1, coalesce=True, misfire_grace_time=15, id='backup', - name='Backup database and configuration file', replace_existing=True) + trigger = CronTrigger(day_of_week=settings.backup.day, hour=settings.backup.hour) elif backup == "Manually": - try: - self.aps_scheduler.remove_job(job_id='backup') - except JobLookupError: - pass + trigger = CronTrigger(year=in_a_century()) + self.aps_scheduler.add_job(backup_to_zip, trigger, + max_instances=1, coalesce=True, misfire_grace_time=15, id='backup', + name='Backup Database and Configuration File', replace_existing=True) def __sonarr_full_update_task(self): if settings.general.use_sonarr: @@ -206,18 +229,18 @@ class Scheduler: self.aps_scheduler.add_job( update_all_episodes, CronTrigger(hour=settings.sonarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_episodes', - name='Index all Episode Subtitles from disk', replace_existing=True) + name='Index All Episode Subtitles from Disk', replace_existing=True) elif full_update == "Weekly": self.aps_scheduler.add_job( update_all_episodes, CronTrigger(day_of_week=settings.sonarr.full_update_day, hour=settings.sonarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_episodes', - name='Index all Episode Subtitles from disk', replace_existing=True) + name='Index All Episode Subtitles from Disk', replace_existing=True) elif full_update == "Manually": self.aps_scheduler.add_job( - update_all_episodes, CronTrigger(year='2100'), max_instances=1, coalesce=True, + update_all_episodes, CronTrigger(year=in_a_century()), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_episodes', - name='Index all Episode Subtitles from disk', replace_existing=True) + name='Index All Episode Subtitles from Disk', replace_existing=True) def __radarr_full_update_task(self): if settings.general.use_radarr: @@ -226,17 +249,17 @@ class Scheduler: self.aps_scheduler.add_job( update_all_movies, CronTrigger(hour=settings.radarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, - id='update_all_movies', name='Index all Movie Subtitles from disk', replace_existing=True) + id='update_all_movies', name='Index All Movie Subtitles from Disk', replace_existing=True) elif full_update == "Weekly": self.aps_scheduler.add_job( update_all_movies, CronTrigger(day_of_week=settings.radarr.full_update_day, hour=settings.radarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_movies', - name='Index all Movie Subtitles from disk', replace_existing=True) + name='Index All Movie Subtitles from Disk', replace_existing=True) elif full_update == "Manually": self.aps_scheduler.add_job( - update_all_movies, CronTrigger(year='2100'), max_instances=1, coalesce=True, misfire_grace_time=15, - id='update_all_movies', name='Index all Movie Subtitles from disk', replace_existing=True) + update_all_movies, CronTrigger(year=in_a_century()), max_instances=1, coalesce=True, misfire_grace_time=15, + id='update_all_movies', name='Index All Movie Subtitles from Disk', replace_existing=True) def __update_bazarr_task(self): if not args.no_update and os.environ["BAZARR_VERSION"] != '': @@ -248,7 +271,7 @@ class Scheduler: misfire_grace_time=15, id='update_bazarr', name=task_name, replace_existing=True) else: self.aps_scheduler.add_job( - check_if_new_update, CronTrigger(year='2100'), hour=4, id='update_bazarr', name=task_name, + check_if_new_update, CronTrigger(year=in_a_century()), hour=4, id='update_bazarr', name=task_name, replace_existing=True) self.aps_scheduler.add_job( check_releases, IntervalTrigger(hours=3), max_instances=1, coalesce=True, misfire_grace_time=15, @@ -269,13 +292,13 @@ class Scheduler: wanted_search_missing_subtitles_series, IntervalTrigger(hours=int(settings.general.wanted_search_frequency)), max_instances=1, coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_series', replace_existing=True, - name='Search for wanted Series Subtitles') + name='Search for Missing Series Subtitles') if settings.general.use_radarr: self.aps_scheduler.add_job( wanted_search_missing_subtitles_movies, IntervalTrigger(hours=int(settings.general.wanted_search_frequency_movie)), max_instances=1, coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_movies', - name='Search for wanted Movies Subtitles', replace_existing=True) + name='Search for Missing Movies Subtitles', replace_existing=True) def __upgrade_subtitles_task(self): if settings.general.upgrade_subs and \ @@ -283,7 +306,7 @@ class Scheduler: self.aps_scheduler.add_job( upgrade_subtitles, IntervalTrigger(hours=int(settings.general.upgrade_frequency)), max_instances=1, coalesce=True, misfire_grace_time=15, id='upgrade_subtitles', - name='Upgrade previously downloaded Subtitles', replace_existing=True) + name='Upgrade Previously Downloaded Subtitles', replace_existing=True) else: try: self.aps_scheduler.remove_job(job_id='upgrade_subtitles') @@ -293,6 +316,9 @@ class Scheduler: def __randomize_interval_task(self): for job in self.aps_scheduler.get_jobs(): if isinstance(job.trigger, IntervalTrigger): + # do not randomize the Never jobs + if job.trigger.interval.total_seconds() > ONE_YEAR_IN_SECONDS: + continue self.aps_scheduler.modify_job(job.id, next_run_time=datetime.now(tz=self.timezone) + timedelta(seconds=randrange( diff --git a/frontend/src/pages/Settings/Scheduler/index.tsx b/frontend/src/pages/Settings/Scheduler/index.tsx index e2a236197..3bd6da91a 100644 --- a/frontend/src/pages/Settings/Scheduler/index.tsx +++ b/frontend/src/pages/Settings/Scheduler/index.tsx @@ -12,7 +12,6 @@ import { backupOptions, dayOptions, diskUpdateOptions, - episodesSyncOptions, moviesSyncOptions, seriesSyncOptions, upgradeOptions, @@ -32,26 +31,19 @@ const SettingsSchedulerView: FunctionComponent = () => {
- - -
@@ -88,7 +80,7 @@ const SettingsSchedulerView: FunctionComponent = () => { @@ -144,7 +136,7 @@ const SettingsSchedulerView: FunctionComponent = () => {
diff --git a/frontend/src/pages/Settings/Scheduler/options.ts b/frontend/src/pages/Settings/Scheduler/options.ts index ab193774b..c2eadcbae 100644 --- a/frontend/src/pages/Settings/Scheduler/options.ts +++ b/frontend/src/pages/Settings/Scheduler/options.ts @@ -1,6 +1,7 @@ import { SelectorOption } from "@/components"; export const seriesSyncOptions: SelectorOption[] = [ + { label: "Manually", value: 52560000 }, { label: "15 Minutes", value: 15 }, { label: "1 Hour", value: 60 }, { label: "3 Hours", value: 180 }, @@ -9,8 +10,6 @@ export const seriesSyncOptions: SelectorOption[] = [ { label: "24 Hours", value: 1440 }, ]; -export const episodesSyncOptions = seriesSyncOptions; - export const moviesSyncOptions = seriesSyncOptions; export const diskUpdateOptions: SelectorOption[] = [ @@ -32,6 +31,7 @@ export const dayOptions: SelectorOption[] = [ ]; export const upgradeOptions: SelectorOption[] = [ + { label: "Manually", value: 876000 }, { label: "6 Hours", value: 6 }, { label: "12 Hours", value: 12 }, { label: "24 Hours", value: 24 }, diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts index 55112c873..89cb42a6d 100644 --- a/frontend/src/types/settings.d.ts +++ b/frontend/src/types/settings.d.ts @@ -144,7 +144,6 @@ declare namespace Settings { full_update_hour: number; only_monitored: boolean; series_sync: number; - episodes_sync: number; excluded_tags: string[]; excluded_series_types: SonarrSeriesType[]; } From 6b304e0ce778a4abfd1ede216cf35875fc76d3d6 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Thu, 28 Dec 2023 14:41:10 -0500 Subject: [PATCH 20/49] Increased podnapisi connection timeout to prevent provider throttling when their servers are slower than usual. --- libs/subliminal_patch/providers/podnapisi.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/subliminal_patch/providers/podnapisi.py b/libs/subliminal_patch/providers/podnapisi.py index f567f25ac..2cf80a4cc 100644 --- a/libs/subliminal_patch/providers/podnapisi.py +++ b/libs/subliminal_patch/providers/podnapisi.py @@ -12,7 +12,6 @@ from zipfile import ZipFile from guessit import guessit -from requests import Session from requests.adapters import HTTPAdapter from subliminal.utils import sanitize @@ -203,7 +202,7 @@ class PodnapisiProvider(_PodnapisiProvider, ProviderSubtitleArchiveMixin): # query the server content = None try: - content = self.session.get(self.server_url + 'search/old', params=params, timeout=10).content + content = self.session.get(self.server_url + 'search/old', params=params, timeout=30).content xml = etree.fromstring(content) except etree.ParseError: logger.error("Wrong data returned: %r", content) @@ -279,7 +278,7 @@ class PodnapisiProvider(_PodnapisiProvider, ProviderSubtitleArchiveMixin): def download_subtitle(self, subtitle): # download as a zip logger.info('Downloading subtitle %r', subtitle) - r = self.session.get(self.server_url + subtitle.pid + '/download', params={'container': 'zip'}, timeout=10) + r = self.session.get(self.server_url + subtitle.pid + '/download', params={'container': 'zip'}, timeout=30) r.raise_for_status() # open the zip From 9379d1b7f843e46b16483a0d54a17b4453b21590 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Thu, 28 Dec 2023 14:52:35 -0500 Subject: [PATCH 21/49] Increased supersubtitles connection timeout and properly deal with invalid JSON returned from their API. --- .../subliminal_patch/providers/supersubtitles.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/subliminal_patch/providers/supersubtitles.py b/libs/subliminal_patch/providers/supersubtitles.py index 80fc276c0..c3ecb06a3 100644 --- a/libs/subliminal_patch/providers/supersubtitles.py +++ b/libs/subliminal_patch/providers/supersubtitles.py @@ -189,7 +189,7 @@ class SuperSubtitlesProvider(Provider, ProviderSubtitleArchiveMixin): url = self.server_url + "index.php?tipus=adatlap&azon=a_" + str(sub_id) # url = https://www.feliratok.eu/index.php?tipus=adatlap&azon=a_1518600916 logger.info('Get IMDB id from URL %s', url) - r = self.session.get(url, timeout=10).content + r = self.session.get(url, timeout=30).content soup = ParserBeautifulSoup(r, ['lxml']) links = soup.find_all("a") @@ -220,13 +220,17 @@ class SuperSubtitlesProvider(Provider, ProviderSubtitleArchiveMixin): url = self.server_url + "index.php?term=" + series + "&nyelv=0&action=autoname" # url = self.server_url + "index.php?term=" + "fla"+ "&nyelv=0&action=autoname" logger.info('Get series id from URL %s', url) - r = self.session.get(url, timeout=10) + r = self.session.get(url, timeout=30) # r is something like this: # [{"name":"DC\u2019s Legends of Tomorrow (2016)","ID":"3725"},{"name":"Miles from Tomorrowland (2015)", # "ID":"3789"},{"name":"No Tomorrow (2016)","ID":"4179"}] - results = r.json() + try: + results = r.json() + except JSONDecodeError: + logger.error('Unable to parse returned JSON from URL %s', url) + return None # check all of the results: for result in results: @@ -374,7 +378,7 @@ class SuperSubtitlesProvider(Provider, ProviderSubtitleArchiveMixin): url += "&rtol=" + str(episode) try: - results = self.session.get(url, timeout=10).json() + results = self.session.get(url, timeout=30).json() except JSONDecodeError: # provider returned improper JSON results = None @@ -447,7 +451,7 @@ class SuperSubtitlesProvider(Provider, ProviderSubtitleArchiveMixin): subtitles = [] logger.info('URL for subtitles %s', url) - r = self.session.get(url, timeout=10).content + r = self.session.get(url, timeout=30).content soup = ParserBeautifulSoup(r, ['lxml']) tables = soup.find_all("table") @@ -537,7 +541,7 @@ class SuperSubtitlesProvider(Provider, ProviderSubtitleArchiveMixin): return subtitles def download_subtitle(self, subtitle): - r = self.session.get(subtitle.page_link, timeout=10) + r = self.session.get(subtitle.page_link, timeout=30) r.raise_for_status() archive = get_archive_from_bytes(r.content) From cb71b11fd2332a5175e9cb4d87cd26faf98d1f42 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Thu, 28 Dec 2023 16:03:23 -0500 Subject: [PATCH 22/49] Improved error handling for betaseries provider. --- libs/subliminal_patch/providers/betaseries.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/libs/subliminal_patch/providers/betaseries.py b/libs/subliminal_patch/providers/betaseries.py index ea4d248e8..a11179406 100644 --- a/libs/subliminal_patch/providers/betaseries.py +++ b/libs/subliminal_patch/providers/betaseries.py @@ -1,20 +1,16 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -import json import logging import os -import re import io import rarfile import zipfile -from babelfish import language_converters from guessit import guessit from requests import Session -from subzero.language import Language -from subliminal import Movie, Episode, ProviderError, __short_version__ -from subliminal.exceptions import AuthenticationError, ConfigurationError, DownloadLimitExceeded, ProviderError +from subliminal import Episode +from subliminal.exceptions import AuthenticationError, ConfigurationError from subliminal_patch.subtitle import Subtitle, guess_matches from subliminal.subtitle import fix_line_ending, SUBTITLE_EXTENSIONS from subliminal_patch.providers import Provider @@ -77,7 +73,6 @@ class BetaSeriesProvider(Provider): def query(self, languages, video): # query the server - result = None self.video = video matches = set() if video.tvdb_id: @@ -101,6 +96,8 @@ class BetaSeriesProvider(Provider): logger.debug('Searching subtitles %r', params) res = self.session.get( server_url + 'shows/episodes', params=params, timeout=10) + if res.status_code == 400: + raise AuthenticationError("Invalid token provided") res.raise_for_status() result = res.json() matches.add('series_tvdb_id') @@ -115,9 +112,9 @@ class BetaSeriesProvider(Provider): # parse the subtitles subtitles = [] - if 'episode' in result: + if 'episode' in result and 'subtitles' in result['episode']: subs = result['episode']['subtitles'] - elif 'episodes' in result: + elif 'episodes' in result and len(result['episodes'] and 'subtitles' in result['episodes'][0]): subs = result['episodes'][0]['subtitles'] else: return [] From 3b2d734301168eb95f12e41361a749e5e92f9dd9 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Fri, 29 Dec 2023 14:54:58 -0500 Subject: [PATCH 23/49] no log: fixed year in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d6c6b7b3..2ec370c1a 100644 --- a/README.md +++ b/README.md @@ -100,4 +100,4 @@ If you need something that is not already part of Bazarr, feel free to create a ### License - [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -- Copyright 2010-2019 +- Copyright 2010-2024 From c4f103e39be7cb1fa02066611640b6ba57d9c292 Mon Sep 17 00:00:00 2001 From: Vitiko <59455966+vitiko98@users.noreply.github.com> Date: Fri, 29 Dec 2023 20:33:36 -0400 Subject: [PATCH 24/49] Added settings to exclude subtitles from specific provider to be synced --- bazarr/app/config.py | 40 ++++++++++++++++++- bazarr/subtitles/processing.py | 33 ++++++++------- .../src/pages/Settings/Subtitles/index.tsx | 9 +++++ .../src/pages/Settings/Subtitles/options.ts | 8 ++++ 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 0106aacf9..eebd5729b 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -293,6 +293,9 @@ validators = [ Validator('subsync.subsync_movie_threshold', must_exist=True, default=70, is_type_of=int, gte=0, lte=100), Validator('subsync.debug', must_exist=True, default=False, is_type_of=bool), Validator('subsync.force_audio', must_exist=True, default=False, is_type_of=bool), + Validator('subsync.checker', must_exist=True, default={}, is_type_of=dict), + Validator('subsync.checker.blacklisted_providers', must_exist=True, default=[], is_type_of=list), + Validator('subsync.checker.blacklisted_languages', must_exist=True, default=[], is_type_of=list), # series_scores section Validator('series_scores.hash', must_exist=True, default=359, is_type_of=int), @@ -402,7 +405,9 @@ array_keys = ['excluded_tags', 'enabled_providers', 'path_mappings', 'path_mappings_movie', - 'language_equals'] + 'language_equals', + 'blacklisted_languages', + 'blacklisted_providers'] empty_values = ['', 'None', 'null', 'undefined', None, []] @@ -628,7 +633,10 @@ def save_settings(settings_items): reset_throttled_providers(only_auth_or_conf_error=True) if settings_keys[0] == 'settings': - settings[settings_keys[1]][settings_keys[2]] = value + if len(settings_keys) == 3: + settings[settings_keys[1]][settings_keys[2]] = value + elif len(settings_keys) == 4: + settings[settings_keys[1]][settings_keys[2]][settings_keys[3]] = value if settings_keys[0] == 'subzero': mod = settings_keys[1] @@ -775,3 +783,31 @@ def configure_proxy_func(): def get_scores(): settings = get_settings() return {"movie": settings["movie_scores"], "episode": settings["series_scores"]} + + +def sync_checker(subtitle): + " This function can be extended with settings. It only takes a Subtitle argument" + + logging.debug("Checker data [%s] for %s", settings.subsync.checker, subtitle) + + bl_providers = settings.subsync.checker.blacklisted_providers + + # TODO + # bl_languages = settings.subsync.checker.blacklisted_languages + + verdicts = set() + + # You can add more inner checkers. The following is a verfy basic one for providers, + # but you can make your own functions, etc to handle more complex stuff. You have + # subtitle data to compare. + + verdicts.add(subtitle.provider_name not in bl_providers) + + met = False not in verdicts + + if met is True: + logging.debug("BAZARR Sync checker passed.") + return True + else: + logging.debug("BAZARR Sync checker not passed. Won't sync.") + return False diff --git a/bazarr/subtitles/processing.py b/bazarr/subtitles/processing.py index 9325d1904..34538b147 100644 --- a/bazarr/subtitles/processing.py +++ b/bazarr/subtitles/processing.py @@ -3,7 +3,7 @@ import logging -from app.config import settings +from app.config import settings, sync_checker as _defaul_sync_checker from utilities.path_mappings import path_mappings from utilities.post_processing import pp_replace, set_chmod from languages.get_languages import alpha2_from_alpha3, alpha2_from_language, alpha3_from_language, language_from_alpha3 @@ -69,6 +69,9 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u message = (f"{downloaded_language}{modifier_string} subtitles {action} from {downloaded_provider} with a score of " f"{percent_score}%.") + sync_checker = _defaul_sync_checker + logging.debug("Sync checker: %s", sync_checker) + if media_type == 'series': episode_metadata = database.execute( select(TableEpisodes.sonarrSeriesId, TableEpisodes.sonarrEpisodeId) @@ -79,13 +82,14 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u series_id = episode_metadata.sonarrSeriesId episode_id = episode_metadata.sonarrEpisodeId - from .sync import sync_subtitles - sync_subtitles(video_path=path, srt_path=downloaded_path, - forced=subtitle.language.forced, - srt_lang=downloaded_language_code2, media_type=media_type, - percent_score=percent_score, - sonarr_series_id=episode_metadata.sonarrSeriesId, - sonarr_episode_id=episode_metadata.sonarrEpisodeId) + if sync_checker(subtitle) is True: + from .sync import sync_subtitles + sync_subtitles(video_path=path, srt_path=downloaded_path, + forced=subtitle.language.forced, + srt_lang=downloaded_language_code2, media_type=media_type, + percent_score=percent_score, + sonarr_series_id=episode_metadata.sonarrSeriesId, + sonarr_episode_id=episode_metadata.sonarrEpisodeId) else: movie_metadata = database.execute( select(TableMovies.radarrId) @@ -96,12 +100,13 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u series_id = "" episode_id = movie_metadata.radarrId - from .sync import sync_subtitles - sync_subtitles(video_path=path, srt_path=downloaded_path, - forced=subtitle.language.forced, - srt_lang=downloaded_language_code2, media_type=media_type, - percent_score=percent_score, - radarr_id=movie_metadata.radarrId) + if sync_checker(subtitle) is True: + from .sync import sync_subtitles + sync_subtitles(video_path=path, srt_path=downloaded_path, + forced=subtitle.language.forced, + srt_lang=downloaded_language_code2, media_type=media_type, + percent_score=percent_score, + radarr_id=movie_metadata.radarrId) if use_postprocessing is True: command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language, downloaded_language_code2, diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx index 0d0337201..ee0dda2ce 100644 --- a/frontend/src/pages/Settings/Subtitles/index.tsx +++ b/frontend/src/pages/Settings/Subtitles/index.tsx @@ -5,6 +5,7 @@ import { CollapseBox, Layout, Message, + MultiSelector, Password, Section, Selector, @@ -23,6 +24,7 @@ import { embeddedSubtitlesParserOption, folderOptions, hiExtensionOptions, + providerOptions, } from "./options"; interface CommandOption { @@ -405,6 +407,13 @@ const SettingsSubtitlesView: FunctionComponent = () => { subtitles. + Do not actually sync the subtitles but generate a .tar.gz file to be diff --git a/frontend/src/pages/Settings/Subtitles/options.ts b/frontend/src/pages/Settings/Subtitles/options.ts index 62c4f60b2..2c57584fe 100644 --- a/frontend/src/pages/Settings/Subtitles/options.ts +++ b/frontend/src/pages/Settings/Subtitles/options.ts @@ -1,4 +1,5 @@ import { SelectorOption } from "@/components"; +import { ProviderList } from "../Providers/list"; export const hiExtensionOptions: SelectorOption[] = [ { @@ -165,3 +166,10 @@ export const colorOptions: SelectorOption[] = [ value: buildColor("dark-grey"), }, ]; + +export const providerOptions: SelectorOption[] = ProviderList.map( + (v) => ({ + label: v.key, + value: v.key, + }) +); From 3918d98791d0cdee0a6b66d173c58d63aa1f30c6 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Fri, 29 Dec 2023 23:01:20 -0500 Subject: [PATCH 25/49] no log: freeze lxml --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index fe60a1afa..7e40a4347 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ setuptools -lxml>=4.3.0 +lxml>=4.3.0, <5.0.0 numpy>=1.12.0 webrtcvad-wheels>=2.0.10 -Pillow>=9.0.0 --only-binary=Pillow \ No newline at end of file +Pillow>=9.0.0 --only-binary=Pillow From b11f8100ac81f07727b19e87da26defdbbeb55a0 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Sun, 31 Dec 2023 15:57:33 -0500 Subject: [PATCH 27/49] Fixed podnapisi search results parsing error --- libs/subliminal_patch/providers/podnapisi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/subliminal_patch/providers/podnapisi.py b/libs/subliminal_patch/providers/podnapisi.py index 2cf80a4cc..18131ff52 100644 --- a/libs/subliminal_patch/providers/podnapisi.py +++ b/libs/subliminal_patch/providers/podnapisi.py @@ -209,7 +209,7 @@ class PodnapisiProvider(_PodnapisiProvider, ProviderSubtitleArchiveMixin): break # exit if no results - if not int(xml.find('pagination/results').text): + if not xml.find('pagination/results') or not int(xml.find('pagination/results').text): logger.debug('No subtitles found') break From 902d1e62b8251c36a622f96bc40d6ef9f052997e Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Sun, 31 Dec 2023 15:59:16 -0500 Subject: [PATCH 28/49] Fixed betaseries parsing issue --- libs/subliminal_patch/providers/betaseries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/subliminal_patch/providers/betaseries.py b/libs/subliminal_patch/providers/betaseries.py index a11179406..4cd66401c 100644 --- a/libs/subliminal_patch/providers/betaseries.py +++ b/libs/subliminal_patch/providers/betaseries.py @@ -114,7 +114,7 @@ class BetaSeriesProvider(Provider): subtitles = [] if 'episode' in result and 'subtitles' in result['episode']: subs = result['episode']['subtitles'] - elif 'episodes' in result and len(result['episodes'] and 'subtitles' in result['episodes'][0]): + elif 'episodes' in result and len(result['episodes']) and 'subtitles' in result['episodes'][0]: subs = result['episodes'][0]['subtitles'] else: return [] From 549bebcc43102efaace1920bef92cc59032e2e7d Mon Sep 17 00:00:00 2001 From: Max R Date: Tue, 2 Jan 2024 20:04:14 -0800 Subject: [PATCH 29/49] Updated logging to use ISO 8601 date format --- bazarr/app/logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bazarr/app/logger.py b/bazarr/app/logger.py index b2a3eeffd..c9a1282bb 100644 --- a/bazarr/app/logger.py +++ b/bazarr/app/logger.py @@ -118,7 +118,7 @@ def configure_logging(debug=False): fh = TimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", interval=1, backupCount=7, delay=True, encoding='utf-8') f = FileHandlerFormatter('%(asctime)s|%(levelname)-8s|%(name)-32s|%(message)s|', - '%d/%m/%Y %H:%M:%S') + '%Y-%m-%d %H:%M:%S') fh.setFormatter(f) fh.setLevel(log_level) logger.addHandler(fh) From c0bbd4f150c6a0a48cba5c3d028299e0eec1d316 Mon Sep 17 00:00:00 2001 From: JayZed Date: Fri, 5 Jan 2024 21:01:45 -0500 Subject: [PATCH 30/49] Fixed some Whisper issues and added logging level selector for this specific provider --- bazarr/app/config.py | 2 + bazarr/app/get_providers.py | 1 + .../pages/Settings/Providers/components.tsx | 21 ++++++- frontend/src/pages/Settings/Providers/list.ts | 17 +++++ libs/subliminal_patch/providers/whisperai.py | 62 +++++++++++++++---- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index eebd5729b..0ef35fb3b 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -226,6 +226,8 @@ validators = [ # whisperai section Validator('whisperai.endpoint', must_exist=True, default='http://127.0.0.1:9000', is_type_of=str), Validator('whisperai.timeout', must_exist=True, default=3600, is_type_of=int, gte=1), + Validator('whisperai.loglevel', must_exist=True, default='INFO', is_type_of=str, + is_in=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']), # legendasdivx section Validator('legendasdivx.username', must_exist=True, default='', is_type_of=str, cast=str), diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py index fea4b6094..c669286c2 100644 --- a/bazarr/app/get_providers.py +++ b/bazarr/app/get_providers.py @@ -310,6 +310,7 @@ def get_providers_auth(): 'endpoint': settings.whisperai.endpoint, 'timeout': settings.whisperai.timeout, 'ffmpeg_path': _FFMPEG_BINARY, + 'loglevel': settings.whisperai.loglevel, } } diff --git a/frontend/src/pages/Settings/Providers/components.tsx b/frontend/src/pages/Settings/Providers/components.tsx index 16654c8b5..1c38ad59c 100644 --- a/frontend/src/pages/Settings/Providers/components.tsx +++ b/frontend/src/pages/Settings/Providers/components.tsx @@ -20,7 +20,15 @@ import { useRef, useState, } from "react"; -import { Card, Check, Chips, Message, Password, Text } from "../components"; +import { + Card, + Check, + Chips, + Selector as GlobalSelector, + Message, + Password, + Text, +} from "../components"; import { FormContext, FormValues, @@ -206,6 +214,7 @@ const ProviderTool: FunctionComponent = ({ info.inputs?.forEach((value) => { const key = value.key; const label = value.name ?? capitalize(value.key); + const options = value.options ?? []; switch (value.type) { case "text": @@ -236,6 +245,16 @@ const ProviderTool: FunctionComponent = ({ > ); return; + case "select": + elements.push( + + ); + return; case "chips": elements.push( = { @@ -6,12 +7,14 @@ type Input = { defaultValue?: T; name?: string; description?: string; + options?: SelectorOption[]; }; type AvailableInput = | Input | Input | Input + | Input | Input; export interface ProviderInfo { @@ -22,6 +25,14 @@ export interface ProviderInfo { inputs?: AvailableInput[]; } +export const logLevelOptions: SelectorOption[] = [ + { label: "DEBUG", value: "DEBUG" }, + { label: "INFO", value: "INFO" }, + { label: "WARNING", value: "WARNING" }, + { label: "ERROR", value: "ERROR" }, + { label: "CRITICAL", value: "CRITICAL" }, +]; + export const ProviderList: Readonly = [ { key: "addic7ed", @@ -221,6 +232,12 @@ export const ProviderList: Readonly = [ defaultValue: 3600, name: "Transcription/translation timeout in seconds", }, + { + type: "select", + key: "loglevel", + name: "Logging level", + options: logLevelOptions, + }, ], }, { diff --git a/libs/subliminal_patch/providers/whisperai.py b/libs/subliminal_patch/providers/whisperai.py index 7e1b62bbb..1cf6e5ff0 100644 --- a/libs/subliminal_patch/providers/whisperai.py +++ b/libs/subliminal_patch/providers/whisperai.py @@ -1,5 +1,7 @@ from __future__ import absolute_import import logging +import time +from datetime import timedelta from requests import Session @@ -122,6 +124,13 @@ whisper_languages = { logger = logging.getLogger(__name__) +def set_log_level(newLevel="INFO"): + newLevel = newLevel.upper() + # print(f'WhisperAI log level changing from {logging._levelToName[logger.getEffectiveLevel()]} to {newLevel}') + logger.setLevel(getattr(logging, newLevel)) + +# initialize to default above +set_log_level() @functools.lru_cache(2) def encode_audio_stream(path, ffmpeg_path, audio_stream_language=None): @@ -138,7 +147,8 @@ def encode_audio_stream(path, ffmpeg_path, audio_stream_language=None): .run(cmd=[ffmpeg_path, "-nostdin"], capture_stdout=True, capture_stderr=True) except ffmpeg.Error as e: - raise RuntimeError(f"Failed to load audio: {e.stderr.decode()}") from e + logger.warning(f"ffmpeg failed to load audio: {e.stderr.decode()}") + return None logger.debug(f"Finished encoding audio stream in {path} with no errors") @@ -161,6 +171,9 @@ def whisper_get_language_reverse(alpha3): return wl raise ValueError +def language_from_alpha3(lang): + name = Language(lang).name + return name class WhisperAISubtitle(Subtitle): '''Whisper AI Subtitle.''' @@ -198,12 +211,10 @@ class WhisperAIProvider(Provider): for lan in whisper_languages: languages.update({whisper_get_language(lan, whisper_languages[lan])}) - languages.update(set(Language.rebuild(lang, hi=True) for lang in languages)) - languages.update(set(Language.rebuild(lang, forced=True) for lang in languages)) - video_types = (Episode, Movie) - def __init__(self, endpoint=None, timeout=None, ffmpeg_path=None): + def __init__(self, endpoint=None, timeout=None, ffmpeg_path=None, loglevel=None): + set_log_level(loglevel) if not endpoint: raise ConfigurationError('Whisper Web Service Endpoint must be provided') @@ -230,12 +241,16 @@ class WhisperAIProvider(Provider): def detect_language(self, path) -> Language: out = encode_audio_stream(path, self.ffmpeg_path) + if out == None: + logger.info(f"Whisper cannot detect language of {path} because of missing/bad audio track") + return None + r = self.session.post(f"{self.endpoint}/detect-language", params={'encode': 'false'}, files={'audio_file': out}, - timeout=(5, self.timeout)) + timeout=(self.timeout, self.timeout)) - logger.info(f"Whisper detected language of {path} as {r.json()['detected_language']}") + logger.debug(f"Whisper detected language of {path} as {r.json()['detected_language']}") return whisper_get_language(r.json()["language_code"], r.json()["detected_language"]) @@ -262,6 +277,11 @@ class WhisperAIProvider(Provider): else: # We must detect the language manually detected_lang = self.detect_language(video.original_path) + if detected_lang == None: + sub.task = "error" + # tell the user what is wrong + sub.release_info = "bad/missing audio track - cannot transcribe" + return sub if detected_lang != language: sub.task = "translate" @@ -270,9 +290,11 @@ class WhisperAIProvider(Provider): if sub.task == "translate": if language.alpha3 != "eng": - logger.info(f"Translation only possible from {language} to English") + logger.debug(f"Translation only possible from {language} to English") return None - + + # tell the user what we are about to do + sub.release_info = f"{sub.task} {language_from_alpha3(sub.audio_language)} audio -> {language_from_alpha3(language.alpha3)} SRT" logger.debug(f"Whisper ({video.original_path}): {sub.audio_language} -> {language.alpha3} [TASK: {sub.task}]") return sub @@ -285,11 +307,29 @@ class WhisperAIProvider(Provider): # Invoke Whisper through the API. This may take a long time depending on the file. # TODO: This loads the entire file into memory, find a good way to stream the file in chunks - out = encode_audio_stream(subtitle.video.original_path, self.ffmpeg_path, subtitle.force_audio_stream) + out = None + if subtitle.task != "error": + out = encode_audio_stream(subtitle.video.original_path, self.ffmpeg_path, subtitle.force_audio_stream) + if out == None: + logger.info(f"Whisper cannot process {subtitle.video.original_path} because of missing/bad audio track") + subtitle.content = None + return + + if subtitle.task == "transcribe": + output_language = subtitle.audio_language + else: + output_language = "eng" + + logger.info(f'Starting WhisperAI {subtitle.task} to {language_from_alpha3(output_language)} for {subtitle.video.original_path}') + startTime = time.time() r = self.session.post(f"{self.endpoint}/asr", params={'task': subtitle.task, 'language': whisper_get_language_reverse(subtitle.audio_language), 'output': 'srt', 'encode': 'false'}, files={'audio_file': out}, - timeout=(5, self.timeout)) + timeout=(self.timeout, self.timeout)) + + endTime = time.time() + elapsedTime = timedelta(seconds=round(endTime - startTime)) + logger.info(f'Completed WhisperAI {subtitle.task} to {language_from_alpha3(output_language)} in {elapsedTime} for {subtitle.video.original_path}') subtitle.content = r.content From 0ac9ecea6e7bae6828441ac3c8eece58f68c88fa Mon Sep 17 00:00:00 2001 From: JayZed Date: Fri, 5 Jan 2024 21:43:37 -0500 Subject: [PATCH 31/49] Added additional variables for custom post processing --- bazarr/subtitles/processing.py | 4 +++- bazarr/subtitles/upload.py | 2 +- bazarr/utilities/post_processing.py | 5 ++++- frontend/src/pages/Settings/Subtitles/index.tsx | 8 ++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/bazarr/subtitles/processing.py b/bazarr/subtitles/processing.py index 34538b147..2144e9175 100644 --- a/bazarr/subtitles/processing.py +++ b/bazarr/subtitles/processing.py @@ -43,6 +43,8 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u postprocessing_cmd = settings.general.postprocessing_cmd downloaded_provider = subtitle.provider_name + uploader = subtitle.uploader + release_info = subtitle.release_info downloaded_language_code3 = _get_download_code3(subtitle) downloaded_language = language_from_alpha3(downloaded_language_code3) @@ -111,7 +113,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u if use_postprocessing is True: command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language, downloaded_language_code2, downloaded_language_code3, audio_language, audio_language_code2, audio_language_code3, - percent_score, subtitle_id, downloaded_provider, series_id, episode_id) + percent_score, subtitle_id, downloaded_provider, uploader, release_info, series_id, episode_id) if media_type == 'series': use_pp_threshold = settings.general.use_postprocessing_threshold diff --git a/bazarr/subtitles/upload.py b/bazarr/subtitles/upload.py index 998e63c21..aaeca7258 100644 --- a/bazarr/subtitles/upload.py +++ b/bazarr/subtitles/upload.py @@ -151,7 +151,7 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, aud if use_postprocessing: command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2, uploaded_language_code3, audio_language['name'], audio_language['code2'], - audio_language['code3'], 100, "1", "manual", series_id, episode_id) + audio_language['code3'], 100, "1", "manual", "user", "unknown", series_id, episode_id) postprocessing(command, path) set_chmod(subtitles_path=subtitle_path) diff --git a/bazarr/utilities/post_processing.py b/bazarr/utilities/post_processing.py index 673810241..c1e5d810d 100644 --- a/bazarr/utilities/post_processing.py +++ b/bazarr/utilities/post_processing.py @@ -16,7 +16,8 @@ def _escape(in_str): def pp_replace(pp_command, episode, subtitles, language, language_code2, language_code3, episode_language, - episode_language_code2, episode_language_code3, score, subtitle_id, provider, series_id, episode_id): + episode_language_code2, episode_language_code3, score, subtitle_id, provider, uploader, + release_info, series_id, episode_id): pp_command = re.sub(r'[\'"]?{{directory}}[\'"]?', _escape(os.path.dirname(episode)), pp_command) pp_command = re.sub(r'[\'"]?{{episode}}[\'"]?', _escape(episode), pp_command) pp_command = re.sub(r'[\'"]?{{episode_name}}[\'"]?', _escape(os.path.splitext(os.path.basename(episode))[0]), @@ -35,6 +36,8 @@ def pp_replace(pp_command, episode, subtitles, language, language_code2, languag pp_command = re.sub(r'[\'"]?{{score}}[\'"]?', _escape(str(score)), pp_command) pp_command = re.sub(r'[\'"]?{{subtitle_id}}[\'"]?', _escape(str(subtitle_id)), pp_command) pp_command = re.sub(r'[\'"]?{{provider}}[\'"]?', _escape(str(provider)), pp_command) + pp_command = re.sub(r'[\'"]?{{uploader}}[\'"]?', _escape(str(uploader)), pp_command) + pp_command = re.sub(r'[\'"]?{{release_info}}[\'"]?', _escape(str(release_info)), pp_command) pp_command = re.sub(r'[\'"]?{{series_id}}[\'"]?', _escape(str(series_id)), pp_command) pp_command = re.sub(r'[\'"]?{{episode_id}}[\'"]?', _escape(str(episode_id)), pp_command) return pp_command diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx index ee0dda2ce..51d59675e 100644 --- a/frontend/src/pages/Settings/Subtitles/index.tsx +++ b/frontend/src/pages/Settings/Subtitles/index.tsx @@ -98,6 +98,14 @@ const commandOptions: CommandOption[] = [ option: "provider", description: "Provider of the subtitle file", }, + { + option: "uploader", + description: "Uploader of the subtitle file", + }, + { + option: "release_info", + description: "Release info for the subtitle file", + }, { option: "series_id", description: "Sonarr series ID (Empty if movie)", From c17d127323cd4c62a075e3c6d26719b700e73d5a Mon Sep 17 00:00:00 2001 From: JayZed Date: Mon, 8 Jan 2024 07:35:58 -0500 Subject: [PATCH 32/49] no log: multiple cosmetic providers fixes * Made modal dialog slightly wider Made modal dialog slightly wider to avoid annoying horizontal scrolling. * Updated and sorted provider list - added and removed providers as appropriate - updated descriptions - added newlines to long descriptions to make them fit better in modal dialog without horizontal scrolling - sorted list in place as this list is exposed verbatim in UI --- README.md | 7 +- .../pages/Settings/Providers/components.tsx | 1 + frontend/src/pages/Settings/Providers/list.ts | 109 +++++++++--------- 3 files changed, 60 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 2ec370c1a..4f6f8725a 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,18 @@ If you need something that is not already part of Bazarr, feel free to create a - BSplayer - Embedded Subtitles - Gestdown.info +- GreekSubs - GreekSubtitles - HDBits.org - Hosszupuska -- LegendasDivx - Karagarga.in - Ktuvit (Get `hashed_password` using method described [here](https://github.com/XBMCil/service.subtitles.ktuvit)) +- LegendasDivx - Napiprojekt - Napisy24 - Nekur -- OpenSubtitles.org +- OpenSubtitles.com +- OpenSubtitles.org (VIP users only) - Podnapisi - RegieLive - Sous-Titres.eu @@ -79,7 +81,6 @@ If you need something that is not already part of Bazarr, feel free to create a - Subtitrari-noi.ro - subtitri.id.lv - Subtitulamos.tv -- Sucha - Supersubtitles - Titlovi - Titrari.ro diff --git a/frontend/src/pages/Settings/Providers/components.tsx b/frontend/src/pages/Settings/Providers/components.tsx index 1c38ad59c..46252a03d 100644 --- a/frontend/src/pages/Settings/Providers/components.tsx +++ b/frontend/src/pages/Settings/Providers/components.tsx @@ -314,4 +314,5 @@ const ProviderTool: FunctionComponent = ({ const ProviderModal = withModal(ProviderTool, "provider-tool", { title: "Provider", + size: "calc(50vw)", }); diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index 49d6af981..ba91cd580 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -90,7 +90,7 @@ export const ProviderList: Readonly = [ key: "bsplayer", name: "BSplayer", description: - "Provider removed from Bazarr because it was causing too much issues so it will always return no subtitles", + "Provider removed from Bazarr because it was causing too many issues.\nIt will always return no subtitles.", }, { key: "embeddedsubtitles", @@ -157,22 +157,6 @@ export const ProviderList: Readonly = [ ], }, { key: "hosszupuska", description: "Hungarian Subtitles Provider" }, - { - key: "legendasdivx", - name: "LegendasDivx", - description: "Brazilian / Portuguese Subtitles Provider", - inputs: [ - { - type: "text", - key: "username", - }, - { - type: "password", - key: "password", - }, - { type: "switch", key: "skip_wrong_fps", name: "Skip Wrong FPS" }, - ], - }, { key: "karagarga", name: "Karagarga.in", @@ -214,32 +198,23 @@ export const ProviderList: Readonly = [ }, ], }, - { key: "napiprojekt", description: "Polish Subtitles Provider" }, { - key: "whisperai", - name: "Whisper", - description: "AI Generated Subtitles powered by Whisper", + key: "legendasdivx", + name: "LegendasDivx", + description: "Brazilian / Portuguese Subtitles Provider", inputs: [ { type: "text", - key: "endpoint", - defaultValue: "http://127.0.0.1:9000", - name: "Whisper ASR Docker Endpoint", - }, - { - type: "text", - key: "timeout", - defaultValue: 3600, - name: "Transcription/translation timeout in seconds", + key: "username", }, { - type: "select", - key: "loglevel", - name: "Logging level", - options: logLevelOptions, + type: "password", + key: "password", }, + { type: "switch", key: "skip_wrong_fps", name: "Skip Wrong FPS" }, ], }, + { key: "napiprojekt", description: "Polish Subtitles Provider" }, { key: "napisy24", description: "Polish Subtitles Provider", @@ -260,6 +235,7 @@ export const ProviderList: Readonly = [ { key: "opensubtitles", name: "OpenSubtitles.org", + description: "Only works if you have VIP status", inputs: [ { type: "text", @@ -325,7 +301,7 @@ export const ProviderList: Readonly = [ { key: "regielive", name: "RegieLive", - description: "Romanian Subtitles Provider.", + description: "Romanian Subtitles Provider", }, { key: "soustitreseu", @@ -333,11 +309,6 @@ export const ProviderList: Readonly = [ description: "Mostly French Subtitles Provider", }, { key: "subdivx", description: "LATAM Spanish / Spanish Subtitles Provider" }, - { - key: "subssabbz", - name: "Subs.sab.bz", - description: "Bulgarian Subtitles Provider", - }, { key: "subf2m", name: "subf2m.co", @@ -357,16 +328,21 @@ export const ProviderList: Readonly = [ ], message: "Make sure to use a unique and credible user agent.", }, + { + key: "subssabbz", + name: "Subs.sab.bz", + description: "Bulgarian Subtitles Provider", + }, { key: "subs4free", name: "Subs4Free", - description: "Greek Subtitles Provider. Broken, may not works for some.", + description: "Greek Subtitles Provider. Broken, may not work for some.", }, { key: "subs4series", name: "Subs4Series", description: - "Greek Subtitles Provider. Requires anti-captcha provider to solve captchas for each download.", + "Greek Subtitles Provider.\nRequires anti-captcha provider to solve captchas for each download.", }, { key: "subscene", @@ -380,9 +356,9 @@ export const ProviderList: Readonly = [ key: "password", }, ], - description: "Broken, may not works for some. Use subf2m instead.", + description: "Broken, may not work for some. Use subf2m instead.", }, - { key: "subscenter" }, + { key: "subscenter", description: "Hebrew Subtitles Provider" }, { key: "subsunacs", name: "Subsunacs.net", @@ -423,17 +399,10 @@ export const ProviderList: Readonly = [ name: "Titrari.ro", description: "Mostly Romanian Subtitles Provider", }, - { - key: "tusubtitulo", - name: "Tusubtitulo.com", - description: - "Provider requested to be removed from Bazarr so it will always return no subtitles. Could potentially come back in the future with an upcoming premium account.", - // "LATAM Spanish / Spanish / English Subtitles Provider for TV Shows", - }, { key: "titulky", name: "Titulky.com", - description: "CZ/SK Subtitles Provider. Available only with VIP", + description: "CZ/SK Subtitles Provider. Available only with VIP.", inputs: [ { type: "text", @@ -450,8 +419,40 @@ export const ProviderList: Readonly = [ }, ], }, + { + key: "tusubtitulo", + name: "Tusubtitulo.com", + description: + "Provider requested to be removed from Bazarr, so it will always return no subtitles.\nCould potentially come back in the future with an upcoming premium account.", + // "LATAM Spanish / Spanish / English Subtitles Provider for TV Shows", + }, { key: "tvsubtitles", name: "TVSubtitles" }, - { key: "wizdom", description: "Wizdom.xyz Subtitles Provider." }, + { + key: "whisperai", + name: "Whisper", + description: "AI Generated Subtitles powered by Whisper", + inputs: [ + { + type: "text", + key: "endpoint", + defaultValue: "http://127.0.0.1:9000", + name: "Whisper ASR Docker Endpoint", + }, + { + type: "text", + key: "timeout", + defaultValue: 3600, + name: "Transcription/translation timeout in seconds", + }, + { + type: "select", + key: "loglevel", + name: "Logging level", + options: logLevelOptions, + }, + ], + }, + { key: "wizdom", description: "Wizdom.xyz Subtitles Provider" }, { key: "xsubs", name: "XSubs", @@ -476,6 +477,6 @@ export const ProviderList: Readonly = [ { key: "zimuku", name: "Zimuku", - description: "Chinese Subtitles Provider. Anti-captcha required", + description: "Chinese Subtitles Provider. Anti-captcha required.", }, ]; From 0807bd99b956ee3abf18acc3bec43a87fc8b1530 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Wed, 10 Jan 2024 23:04:23 -0500 Subject: [PATCH 33/49] Fixed improper closing of resources on exit --- bazarr.py | 21 ++++++++------ bazarr/app/database.py | 10 ++++++- bazarr/app/server.py | 55 +++++++++++++++--------------------- bazarr/app/signalr_client.py | 10 +++++-- bazarr/init.py | 2 ++ bazarr/main.py | 12 +++++--- requirements.txt | 1 + 7 files changed, 64 insertions(+), 47 deletions(-) diff --git a/bazarr.py b/bazarr.py index e0f214f93..f5fc76bb8 100644 --- a/bazarr.py +++ b/bazarr.py @@ -52,22 +52,27 @@ dir_name = os.path.dirname(__file__) def end_child_process(ep): try: - ep.kill() + if os.name != 'nt': + try: + ep.send_signal(signal.SIGINT) + except ProcessLookupError: + pass + else: + import win32api + import win32con + try: + win32api.GenerateConsoleCtrlEvent(win32con.CTRL_C_EVENT, ep.pid) + except KeyboardInterrupt: + pass except: - pass - -def terminate_child_process(ep): - try: ep.terminate() - except: - pass def start_bazarr(): script = [get_python_path(), "-u", os.path.normcase(os.path.join(dir_name, 'bazarr', 'main.py'))] + sys.argv[1:] ep = subprocess.Popen(script, stdout=None, stderr=None, stdin=subprocess.DEVNULL) atexit.register(end_child_process, ep=ep) - signal.signal(signal.SIGTERM, lambda signal_no, frame: terminate_child_process(ep)) + signal.signal(signal.SIGTERM, lambda signal_no, frame: end_child_process(ep)) def check_status(): diff --git a/bazarr/app/database.py b/bazarr/app/database.py index 690bda40b..c2a97987d 100644 --- a/bazarr/app/database.py +++ b/bazarr/app/database.py @@ -5,6 +5,7 @@ import json import logging import os import flask_migrate +import signal from dogpile.cache import make_region from datetime import datetime @@ -12,7 +13,7 @@ from datetime import datetime from sqlalchemy import create_engine, inspect, DateTime, ForeignKey, Integer, LargeBinary, Text, func, text, BigInteger # importing here to be indirectly imported in other modules later from sqlalchemy import update, delete, select, func # noqa W0611 -from sqlalchemy.orm import scoped_session, sessionmaker, mapped_column +from sqlalchemy.orm import scoped_session, sessionmaker, mapped_column, close_all_sessions from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.pool import NullPool @@ -74,11 +75,18 @@ session_factory = sessionmaker(bind=engine) database = scoped_session(session_factory) +def close_database(): + close_all_sessions() + engine.dispose() + + @atexit.register def _stop_worker_threads(): database.remove() +signal.signal(signal.SIGTERM, lambda signal_no, frame: close_database()) + Base = declarative_base() metadata = Base.metadata diff --git a/bazarr/app/server.py b/bazarr/app/server.py index 48db3912a..52d711fbe 100644 --- a/bazarr/app/server.py +++ b/bazarr/app/server.py @@ -13,7 +13,7 @@ from api import api_bp from .ui import ui_bp from .get_args import args from .config import settings, base_url -from .database import database +from .database import close_database from .app import create_app app = create_app() @@ -63,49 +63,40 @@ class Server: self.shutdown() def start(self): + logging.info(f'BAZARR is started and waiting for request on http://{self.server.effective_host}:' + f'{self.server.effective_port}') try: - logging.info(f'BAZARR is started and waiting for request on http://{self.server.effective_host}:' - f'{self.server.effective_port}') - try: - self.server.run() - except Exception: - pass - except KeyboardInterrupt: + self.server.run() + except (KeyboardInterrupt, SystemExit): self.shutdown() + except Exception: + pass def shutdown(self): try: - self.server.close() + stop_file = io.open(os.path.join(args.config_dir, "bazarr.stop"), "w", encoding='UTF-8') except Exception as e: - logging.error(f'BAZARR Cannot stop Waitress: {repr(e)}') + logging.error(f'BAZARR Cannot create stop file: {repr(e)}') else: - database.close() - try: - stop_file = io.open(os.path.join(args.config_dir, "bazarr.stop"), "w", encoding='UTF-8') - except Exception as e: - logging.error(f'BAZARR Cannot create stop file: {repr(e)}') - else: - logging.info('Bazarr is being shutdown...') - stop_file.write(str('')) - stop_file.close() - os._exit(0) + logging.info('Bazarr is being shutdown...') + stop_file.write(str('')) + stop_file.close() + close_database() + self.server.close() + os._exit(0) def restart(self): try: - self.server.close() + restart_file = io.open(os.path.join(args.config_dir, "bazarr.restart"), "w", encoding='UTF-8') except Exception as e: - logging.error(f'BAZARR Cannot stop Waitress: {repr(e)}') + logging.error(f'BAZARR Cannot create restart file: {repr(e)}') else: - database.close() - try: - restart_file = io.open(os.path.join(args.config_dir, "bazarr.restart"), "w", encoding='UTF-8') - except Exception as e: - logging.error(f'BAZARR Cannot create restart file: {repr(e)}') - else: - logging.info('Bazarr is being restarted...') - restart_file.write(str('')) - restart_file.close() - os._exit(0) + logging.info('Bazarr is being restarted...') + restart_file.write(str('')) + restart_file.close() + close_database() + self.server.close() + os._exit(0) webserver = Server() diff --git a/bazarr/app/signalr_client.py b/bazarr/app/signalr_client.py index 2f048eb17..b731e09e8 100644 --- a/bazarr/app/signalr_client.py +++ b/bazarr/app/signalr_client.py @@ -340,14 +340,20 @@ def consume_queue(queue): data = queue.popleft() except IndexError: pass + except (KeyboardInterrupt, SystemExit): + break else: dispatcher(data) sleep(0.1) # start both queue consuming threads -threading.Thread(target=consume_queue, args=(sonarr_queue,)).start() -threading.Thread(target=consume_queue, args=(radarr_queue,)).start() +sonarr_queue_thread = threading.Thread(target=consume_queue, args=(sonarr_queue,)) +sonarr_queue_thread.daemon = True +sonarr_queue_thread.start() +radarr_queue_thread = threading.Thread(target=consume_queue, args=(radarr_queue,)) +radarr_queue_thread.daemon = True +radarr_queue_thread.start() # instantiate proper SignalR client sonarr_signalr_client = SonarrSignalrClientLegacy() if get_sonarr_info.version().startswith(('0.', '2.', '3.')) else \ diff --git a/bazarr/init.py b/bazarr/init.py index 9db2b73ef..0a2496df0 100644 --- a/bazarr/init.py +++ b/bazarr/init.py @@ -77,6 +77,8 @@ def is_virtualenv(): # deploy requirements.txt if not args.no_update: try: + if os.name == 'nt': + import win32api, win32con # noqa E401 import lxml, numpy, webrtcvad, setuptools, PIL # noqa E401 except ImportError: try: diff --git a/bazarr/main.py b/bazarr/main.py index 970684d14..c2650aed7 100644 --- a/bazarr/main.py +++ b/bazarr/main.py @@ -1,8 +1,6 @@ # coding=utf-8 import os -import io -import logging from threading import Thread @@ -75,9 +73,15 @@ update_notifier() if not args.no_signalr: if settings.general.use_sonarr: - Thread(target=sonarr_signalr_client.start).start() + sonarr_signalr_thread = Thread(target=sonarr_signalr_client.start) + sonarr_signalr_thread.daemon = True + sonarr_signalr_thread.start() + sonarr_signalr_thread.join() if settings.general.use_radarr: - Thread(target=radarr_signalr_client.start).start() + radarr_signalr_thread = Thread(target=radarr_signalr_client.start) + radarr_signalr_thread.daemon = True + radarr_signalr_thread.start() + radarr_signalr_thread.join() if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 7e40a4347..469b8808b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ lxml>=4.3.0, <5.0.0 numpy>=1.12.0 webrtcvad-wheels>=2.0.10 Pillow>=9.0.0 --only-binary=Pillow +pywin32; platform_system == "Windows" From 0e648b5588c7d8675238b1ceb2e04a29e23d8fb1 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Wed, 10 Jan 2024 23:07:42 -0500 Subject: [PATCH 34/49] Improved subtitles synchronisation settings and added a manual sync modal --- bazarr/api/subtitles/subtitles.py | 102 ++++++-- bazarr/app/config.py | 4 + bazarr/subtitles/processing.py | 4 +- bazarr/subtitles/sync.py | 4 +- bazarr/subtitles/tools/subsyncer.py | 54 +++-- bazarr/subtitles/upload.py | 8 +- bazarr/utilities/video_analyzer.py | 129 ++++++++++- frontend/src/apis/hooks/subtitles.ts | 24 ++ frontend/src/apis/raw/subtitles.ts | 22 ++ frontend/src/components/SubtitleToolsMenu.tsx | 4 +- .../src/components/forms/SyncSubtitleForm.tsx | 183 +++++++++++++++ .../src/pages/Settings/Providers/index.tsx | 53 ++++- .../src/pages/Settings/Providers/options.ts | 12 + .../src/pages/Settings/Subtitles/index.tsx | 218 +++++++++--------- .../src/pages/Settings/Subtitles/options.ts | 30 ++- frontend/src/types/api.d.ts | 28 +++ frontend/src/types/form.d.ts | 7 + frontend/src/types/settings.d.ts | 3 + frontend/src/utilities/index.ts | 4 + libs/ffsubsync/__init__.py | 2 +- libs/ffsubsync/_version.py | 6 +- libs/ffsubsync/aligners.py | 11 +- libs/ffsubsync/ffsubsync.py | 70 ++++-- libs/ffsubsync/ffsubsync_gui.py | 6 +- libs/ffsubsync/sklearn_shim.py | 36 ++- libs/ffsubsync/speech_transformers.py | 86 ++++++- libs/ffsubsync/subtitle_parser.py | 44 +++- libs/version.txt | 2 +- 28 files changed, 931 insertions(+), 225 deletions(-) create mode 100644 frontend/src/components/forms/SyncSubtitleForm.tsx create mode 100644 frontend/src/pages/Settings/Providers/options.ts diff --git a/bazarr/api/subtitles/subtitles.py b/bazarr/api/subtitles/subtitles.py index eb021613e..a83da76eb 100644 --- a/bazarr/api/subtitles/subtitles.py +++ b/bazarr/api/subtitles/subtitles.py @@ -4,17 +4,18 @@ import os import sys import gc -from flask_restx import Resource, Namespace, reqparse +from flask_restx import Resource, Namespace, reqparse, fields, marshal from app.database import TableEpisodes, TableMovies, database, select from languages.get_languages import alpha3_from_alpha2 from utilities.path_mappings import path_mappings +from utilities.video_analyzer import subtitles_sync_references from subtitles.tools.subsyncer import SubSyncer from subtitles.tools.translate import translate_subtitles_file from subtitles.tools.mods import subtitles_apply_mods from subtitles.indexer.series import store_subtitles from subtitles.indexer.movies import store_subtitles_movie -from app.config import settings +from app.config import settings, empty_values from app.event_handler import event_stream from ..utils import authenticate @@ -25,6 +26,56 @@ api_ns_subtitles = Namespace('Subtitles', description='Apply mods/tools on exter @api_ns_subtitles.route('subtitles') class Subtitles(Resource): + get_request_parser = reqparse.RequestParser() + get_request_parser.add_argument('subtitlesPath', type=str, required=True, help='External subtitles file path') + get_request_parser.add_argument('sonarrEpisodeId', type=int, required=False, help='Sonarr Episode ID') + get_request_parser.add_argument('radarrMovieId', type=int, required=False, help='Radarr Movie ID') + + audio_tracks_data_model = api_ns_subtitles.model('audio_tracks_data_model', { + 'stream': fields.String(), + 'name': fields.String(), + 'language': fields.String(), + }) + + embedded_subtitles_data_model = api_ns_subtitles.model('embedded_subtitles_data_model', { + 'stream': fields.String(), + 'name': fields.String(), + 'language': fields.String(), + 'forced': fields.Boolean(), + 'hearing_impaired': fields.Boolean(), + }) + + external_subtitles_data_model = api_ns_subtitles.model('external_subtitles_data_model', { + 'name': fields.String(), + 'path': fields.String(), + 'language': fields.String(), + 'forced': fields.Boolean(), + 'hearing_impaired': fields.Boolean(), + }) + + get_response_model = api_ns_subtitles.model('SubtitlesGetResponse', { + 'audio_tracks': fields.Nested(audio_tracks_data_model), + 'embedded_subtitles_tracks': fields.Nested(embedded_subtitles_data_model), + 'external_subtitles_tracks': fields.Nested(external_subtitles_data_model), + }) + + @authenticate + @api_ns_subtitles.response(200, 'Success') + @api_ns_subtitles.response(401, 'Not Authenticated') + @api_ns_subtitles.doc(parser=get_request_parser) + def get(self): + """Return available audio and embedded subtitles tracks with external subtitles. Used for manual subsync + modal""" + args = self.get_request_parser.parse_args() + subtitlesPath = args.get('subtitlesPath') + episodeId = args.get('sonarrEpisodeId', None) + movieId = args.get('radarrMovieId', None) + + result = subtitles_sync_references(subtitles_path=subtitlesPath, sonarr_episode_id=episodeId, + radarr_movie_id=movieId) + + return marshal(result, self.get_response_model, envelope='data') + patch_request_parser = reqparse.RequestParser() patch_request_parser.add_argument('action', type=str, required=True, help='Action from ["sync", "translate" or mods name]') @@ -32,10 +83,20 @@ class Subtitles(Resource): patch_request_parser.add_argument('path', type=str, required=True, help='Subtitles file path') patch_request_parser.add_argument('type', type=str, required=True, help='Media type from ["episode", "movie"]') patch_request_parser.add_argument('id', type=int, required=True, help='Media ID (episodeId, radarrId)') - patch_request_parser.add_argument('forced', type=str, required=False, help='Forced subtitles from ["True", "False"]') + patch_request_parser.add_argument('forced', type=str, required=False, + help='Forced subtitles from ["True", "False"]') patch_request_parser.add_argument('hi', type=str, required=False, help='HI subtitles from ["True", "False"]') patch_request_parser.add_argument('original_format', type=str, required=False, help='Use original subtitles format from ["True", "False"]') + patch_request_parser.add_argument('reference', type=str, required=False, + help='Reference to use for sync from video file track number (a:0) or some ' + 'subtitles file path') + patch_request_parser.add_argument('max_offset_seconds', type=str, required=False, + help='Maximum offset seconds to allow') + patch_request_parser.add_argument('no_fix_framerate', type=str, required=False, + help='Don\'t try to fix framerate from ["True", "False"]') + patch_request_parser.add_argument('gss', type=str, required=False, + help='Use Golden-Section Search from ["True", "False"]') @authenticate @api_ns_subtitles.doc(parser=patch_request_parser) @@ -79,19 +140,30 @@ class Subtitles(Resource): video_path = path_mappings.path_replace_movie(metadata.path) if action == 'sync': + sync_kwargs = { + 'video_path': video_path, + 'srt_path': subtitles_path, + 'srt_lang': language, + 'reference': args.get('reference') if args.get('reference') not in empty_values else video_path, + 'max_offset_seconds': args.get('max_offset_seconds') if args.get('max_offset_seconds') not in + empty_values else str(settings.subsync.max_offset_seconds), + 'no_fix_framerate': args.get('no_fix_framerate') == 'True', + 'gss': args.get('gss') == 'True', + } + subsync = SubSyncer() - if media_type == 'episode': - subsync.sync(video_path=video_path, srt_path=subtitles_path, - srt_lang=language, media_type='series', sonarr_series_id=metadata.sonarrSeriesId, - sonarr_episode_id=id) - else: - try: - subsync.sync(video_path=video_path, srt_path=subtitles_path, - srt_lang=language, media_type='movies', radarr_id=id) - except OSError: - return 'Unable to edit subtitles file. Check logs.', 409 - del subsync - gc.collect() + try: + if media_type == 'episode': + sync_kwargs['sonarr_series_id'] = metadata.sonarrSeriesId + sync_kwargs['sonarr_episode_id'] = id + else: + sync_kwargs['radarr_id'] = id + subsync.sync(**sync_kwargs) + except OSError: + return 'Unable to edit subtitles file. Check logs.', 409 + finally: + del subsync + gc.collect() elif action == 'translate': from_language = subtitles_lang_from_filename(subtitles_path) dest_language = language diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 0ef35fb3b..d490a6a4e 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -298,6 +298,10 @@ validators = [ Validator('subsync.checker', must_exist=True, default={}, is_type_of=dict), Validator('subsync.checker.blacklisted_providers', must_exist=True, default=[], is_type_of=list), Validator('subsync.checker.blacklisted_languages', must_exist=True, default=[], is_type_of=list), + Validator('subsync.no_fix_framerate', must_exist=True, default=True, is_type_of=bool), + Validator('subsync.gss', must_exist=True, default=True, is_type_of=bool), + Validator('subsync.max_offset_seconds', must_exist=True, default=60, is_type_of=int, + is_in=[60, 120, 300, 600]), # series_scores section Validator('series_scores.hash', must_exist=True, default=359, is_type_of=int), diff --git a/bazarr/subtitles/processing.py b/bazarr/subtitles/processing.py index 2144e9175..b5c032610 100644 --- a/bazarr/subtitles/processing.py +++ b/bazarr/subtitles/processing.py @@ -88,7 +88,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u from .sync import sync_subtitles sync_subtitles(video_path=path, srt_path=downloaded_path, forced=subtitle.language.forced, - srt_lang=downloaded_language_code2, media_type=media_type, + srt_lang=downloaded_language_code2, percent_score=percent_score, sonarr_series_id=episode_metadata.sonarrSeriesId, sonarr_episode_id=episode_metadata.sonarrEpisodeId) @@ -106,7 +106,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u from .sync import sync_subtitles sync_subtitles(video_path=path, srt_path=downloaded_path, forced=subtitle.language.forced, - srt_lang=downloaded_language_code2, media_type=media_type, + srt_lang=downloaded_language_code2, percent_score=percent_score, radarr_id=movie_metadata.radarrId) diff --git a/bazarr/subtitles/sync.py b/bazarr/subtitles/sync.py index bcdf37aff..5633f73e8 100644 --- a/bazarr/subtitles/sync.py +++ b/bazarr/subtitles/sync.py @@ -8,7 +8,7 @@ from app.config import settings from subtitles.tools.subsyncer import SubSyncer -def sync_subtitles(video_path, srt_path, srt_lang, forced, media_type, percent_score, sonarr_series_id=None, +def sync_subtitles(video_path, srt_path, srt_lang, forced, percent_score, sonarr_series_id=None, sonarr_episode_id=None, radarr_id=None): if forced: logging.debug('BAZARR cannot sync forced subtitles. Skipping sync routine.') @@ -26,7 +26,7 @@ def sync_subtitles(video_path, srt_path, srt_lang, forced, media_type, percent_s if not use_subsync_threshold or (use_subsync_threshold and percent_score < float(subsync_threshold)): subsync = SubSyncer() - subsync.sync(video_path=video_path, srt_path=srt_path, srt_lang=srt_lang, media_type=media_type, + subsync.sync(video_path=video_path, srt_path=srt_path, srt_lang=srt_lang, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, radarr_id=radarr_id) del subsync gc.collect() diff --git a/bazarr/subtitles/tools/subsyncer.py b/bazarr/subtitles/tools/subsyncer.py index 30945a8d0..79bb1b0eb 100644 --- a/bazarr/subtitles/tools/subsyncer.py +++ b/bazarr/subtitles/tools/subsyncer.py @@ -30,8 +30,9 @@ class SubSyncer: self.vad = 'subs_then_webrtc' self.log_dir_path = os.path.join(args.config_dir, 'log') - def sync(self, video_path, srt_path, srt_lang, media_type, sonarr_series_id=None, sonarr_episode_id=None, - radarr_id=None): + def sync(self, video_path, srt_path, srt_lang, sonarr_series_id=None, sonarr_episode_id=None, radarr_id=None, + reference=None, max_offset_seconds=str(settings.subsync.max_offset_seconds), + no_fix_framerate=settings.subsync.no_fix_framerate, gss=settings.subsync.gss): self.reference = video_path self.srtin = srt_path self.srtout = f'{os.path.splitext(self.srtin)[0]}.synced.srt' @@ -52,20 +53,41 @@ class SubSyncer: logging.debug('BAZARR FFmpeg used is %s', ffmpeg_exe) self.ffmpeg_path = os.path.dirname(ffmpeg_exe) - unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path, '--vad', - self.vad, '--log-dir-path', self.log_dir_path, '--output-encoding', 'same'] - if settings.subsync.force_audio: - unparsed_args.append('--no-fix-framerate') - unparsed_args.append('--reference-stream') - unparsed_args.append('a:0') - if settings.subsync.debug: - unparsed_args.append('--make-test-case') - parser = make_parser() - self.args = parser.parse_args(args=unparsed_args) - if os.path.isfile(self.srtout): - os.remove(self.srtout) - logging.debug('BAZARR deleted the previous subtitles synchronization attempt file.') try: + unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path, + '--vad', self.vad, '--log-dir-path', self.log_dir_path, '--max-offset-seconds', + max_offset_seconds, '--output-encoding', 'same'] + if not settings.general.utf8_encode: + unparsed_args.append('--output-encoding') + unparsed_args.append('same') + + if no_fix_framerate: + unparsed_args.append('--no-fix-framerate') + + if gss: + unparsed_args.append('--gss') + + if reference and reference != video_path and os.path.isfile(reference): + # subtitles path provided + self.reference = reference + elif reference and isinstance(reference, str) and len(reference) == 3 and reference[:2] in ['a:', 's:']: + # audio or subtitles track id provided + unparsed_args.append('--reference-stream') + unparsed_args.append(reference) + elif settings.subsync.force_audio: + # nothing else match and force audio settings is enabled + unparsed_args.append('--reference-stream') + unparsed_args.append('a:0') + + if settings.subsync.debug: + unparsed_args.append('--make-test-case') + + parser = make_parser() + self.args = parser.parse_args(args=unparsed_args) + + if os.path.isfile(self.srtout): + os.remove(self.srtout) + logging.debug('BAZARR deleted the previous subtitles synchronization attempt file.') result = run(self.args) except Exception: logging.exception( @@ -95,7 +117,7 @@ class SubSyncer: reversed_subtitles_path=srt_path, hearing_impaired=None) - if media_type == 'series': + if sonarr_episode_id: history_log(action=5, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, result=result) else: diff --git a/bazarr/subtitles/upload.py b/bazarr/subtitles/upload.py index aaeca7258..8ad16128e 100644 --- a/bazarr/subtitles/upload.py +++ b/bazarr/subtitles/upload.py @@ -137,16 +137,16 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, aud return series_id = episode_metadata.sonarrSeriesId episode_id = episode_metadata.sonarrEpisodeId - sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type, - percent_score=100, sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced, + sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100, + sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced, sonarr_episode_id=episode_metadata.sonarrEpisodeId) else: if not movie_metadata: return series_id = "" episode_id = movie_metadata.radarrId - sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type, - percent_score=100, radarr_id=movie_metadata.radarrId, forced=forced) + sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100, + radarr_id=movie_metadata.radarrId, forced=forced) if use_postprocessing: command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2, diff --git a/bazarr/utilities/video_analyzer.py b/bazarr/utilities/video_analyzer.py index c1cde1fb3..1aad9b859 100644 --- a/bazarr/utilities/video_analyzer.py +++ b/bazarr/utilities/video_analyzer.py @@ -1,15 +1,16 @@ # coding=utf-8 - +import ast import logging +import os import pickle -from knowit.api import know, KnowitException - -from languages.custom_lang import CustomLanguage -from languages.get_languages import language_from_alpha3, alpha3_from_alpha2 +from app.config import settings from app.database import TableEpisodes, TableMovies, database, update, select +from languages.custom_lang import CustomLanguage +from languages.get_languages import language_from_alpha2, language_from_alpha3, alpha3_from_alpha2 from utilities.path_mappings import path_mappings -from app.config import settings + +from knowit.api import know, KnowitException def _handle_alpha3(detected_language: dict): @@ -107,6 +108,110 @@ def embedded_audio_reader(file, file_size, episode_file_id=None, movie_file_id=N return audio_list +def subtitles_sync_references(subtitles_path, sonarr_episode_id=None, radarr_movie_id=None): + references_dict = {'audio_tracks': [], 'embedded_subtitles_tracks': [], 'external_subtitles_tracks': []} + data = None + + if sonarr_episode_id: + media_data = database.execute( + select(TableEpisodes.path, TableEpisodes.file_size, TableEpisodes.episode_file_id, TableEpisodes.subtitles) + .where(TableEpisodes.sonarrEpisodeId == sonarr_episode_id)) \ + .first() + + if not media_data: + return references_dict + + data = parse_video_metadata(media_data.path, media_data.file_size, media_data.episode_file_id, None, + use_cache=True) + elif radarr_movie_id: + media_data = database.execute( + select(TableMovies.path, TableMovies.file_size, TableMovies.movie_file_id, TableMovies.subtitles) + .where(TableMovies.radarrId == radarr_movie_id)) \ + .first() + + if not media_data: + return references_dict + + data = parse_video_metadata(media_data.path, media_data.file_size, None, media_data.movie_file_id, + use_cache=True) + + if not data: + return references_dict + + cache_provider = None + if "ffprobe" in data and data["ffprobe"]: + cache_provider = 'ffprobe' + elif 'mediainfo' in data and data["mediainfo"]: + cache_provider = 'mediainfo' + + if cache_provider: + if 'audio' in data[cache_provider]: + track_id = 0 + for detected_language in data[cache_provider]["audio"]: + name = detected_language.get("name", "").replace("(", "").replace(")", "") + + if "language" not in detected_language: + language = 'Undefined' + else: + alpha3 = _handle_alpha3(detected_language) + language = language_from_alpha3(alpha3) + + references_dict['audio_tracks'].append({'stream': f'a:{track_id}', 'name': name, 'language': language}) + + track_id += 1 + + if 'subtitle' in data[cache_provider]: + track_id = 0 + bitmap_subs = ['dvd', 'pgs'] + for detected_language in data[cache_provider]["subtitle"]: + if any([x in detected_language.get("name", "").lower() for x in bitmap_subs]): + # skipping bitmap based subtitles + track_id += 1 + continue + + name = detected_language.get("name", "").replace("(", "").replace(")", "") + + if "language" not in detected_language: + language = 'Undefined' + else: + alpha3 = _handle_alpha3(detected_language) + language = language_from_alpha3(alpha3) + + forced = detected_language.get("forced", False) + hearing_impaired = detected_language.get("hearing_impaired", False) + + references_dict['embedded_subtitles_tracks'].append( + {'stream': f's:{track_id}', 'name': name, 'language': language, 'forced': forced, + 'hearing_impaired': hearing_impaired} + ) + + track_id += 1 + + try: + parsed_subtitles = ast.literal_eval(media_data.subtitles) + except ValueError: + pass + else: + for subtitles in parsed_subtitles: + reversed_subtitles_path = path_mappings.path_replace_reverse(subtitles_path) if sonarr_episode_id else ( + path_mappings.path_replace_reverse_movie(subtitles_path)) + if subtitles[1] and subtitles[1] != reversed_subtitles_path: + language_dict = languages_from_colon_seperated_string(subtitles[0]) + references_dict['external_subtitles_tracks'].append({ + 'name': os.path.basename(subtitles[1]), + 'path': path_mappings.path_replace(subtitles[1]) if sonarr_episode_id else + path_mappings.path_replace_reverse_movie(subtitles[1]), + 'language': language_dict['language'], + 'forced': language_dict['forced'], + 'hearing_impaired': language_dict['hi'], + }) + else: + # excluding subtitles that is going to be synced from the external subtitles list + continue + + return references_dict + + def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True): # Define default data keys value data = { @@ -195,3 +300,15 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No .values(ffprobe_cache=pickle.dumps(data, pickle.HIGHEST_PROTOCOL)) .where(TableMovies.path == path_mappings.path_replace_reverse_movie(file))) return data + + +def languages_from_colon_seperated_string(lang): + splitted_language = lang.split(':') + language = language_from_alpha2(splitted_language[0]) + forced = hi = False + if len(splitted_language) > 1: + if splitted_language[1] == 'forced': + forced = True + elif splitted_language[1] == 'hi': + hi = True + return {'language': language, 'forced': forced, 'hi': hi} diff --git a/frontend/src/apis/hooks/subtitles.ts b/frontend/src/apis/hooks/subtitles.ts index 89626d8f9..0a4417257 100644 --- a/frontend/src/apis/hooks/subtitles.ts +++ b/frontend/src/apis/hooks/subtitles.ts @@ -125,3 +125,27 @@ export function useSubtitleInfos(names: string[]) { api.subtitles.info(names) ); } + +export function useRefTracksByEpisodeId( + subtitlesPath: string, + sonarrEpisodeId: number, + isEpisode: boolean +) { + return useQuery( + [QueryKeys.Episodes, sonarrEpisodeId, QueryKeys.Subtitles, subtitlesPath], + () => api.subtitles.getRefTracksByEpisodeId(subtitlesPath, sonarrEpisodeId), + { enabled: isEpisode } + ); +} + +export function useRefTracksByMovieId( + subtitlesPath: string, + radarrMovieId: number, + isMovie: boolean +) { + return useQuery( + [QueryKeys.Movies, radarrMovieId, QueryKeys.Subtitles, subtitlesPath], + () => api.subtitles.getRefTracksByMovieId(subtitlesPath, radarrMovieId), + { enabled: isMovie } + ); +} diff --git a/frontend/src/apis/raw/subtitles.ts b/frontend/src/apis/raw/subtitles.ts index d31f897a7..b3d75eb70 100644 --- a/frontend/src/apis/raw/subtitles.ts +++ b/frontend/src/apis/raw/subtitles.ts @@ -5,6 +5,28 @@ class SubtitlesApi extends BaseApi { super("/subtitles"); } + async getRefTracksByEpisodeId( + subtitlesPath: string, + sonarrEpisodeId: number + ) { + const response = await this.get>("", { + subtitlesPath, + sonarrEpisodeId, + }); + return response.data; + } + + async getRefTracksByMovieId( + subtitlesPath: string, + radarrMovieId?: number | undefined + ) { + const response = await this.get>("", { + subtitlesPath, + radarrMovieId, + }); + return response.data; + } + async info(names: string[]) { const response = await this.get>(`/info`, { filenames: names, diff --git a/frontend/src/components/SubtitleToolsMenu.tsx b/frontend/src/components/SubtitleToolsMenu.tsx index 953d748d5..ba44e94aa 100644 --- a/frontend/src/components/SubtitleToolsMenu.tsx +++ b/frontend/src/components/SubtitleToolsMenu.tsx @@ -25,6 +25,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core"; import { FunctionComponent, ReactElement, useCallback, useMemo } from "react"; +import { SyncSubtitleModal } from "./forms/SyncSubtitleForm"; export interface ToolOptions { key: string; @@ -41,7 +42,8 @@ export function useTools() { { key: "sync", icon: faPlay, - name: "Sync", + name: "Sync...", + modal: SyncSubtitleModal, }, { key: "remove_HI", diff --git a/frontend/src/components/forms/SyncSubtitleForm.tsx b/frontend/src/components/forms/SyncSubtitleForm.tsx new file mode 100644 index 000000000..349058f63 --- /dev/null +++ b/frontend/src/components/forms/SyncSubtitleForm.tsx @@ -0,0 +1,183 @@ +/* eslint-disable camelcase */ + +import { + useRefTracksByEpisodeId, + useRefTracksByMovieId, + useSubtitleAction, +} from "@/apis/hooks"; +import { useModals, withModal } from "@/modules/modals"; +import { task } from "@/modules/task"; +import { syncMaxOffsetSecondsOptions } from "@/pages/Settings/Subtitles/options"; +import { toPython } from "@/utilities"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { FunctionComponent } from "react"; +import { Selector, SelectorOption } from "../inputs"; + +const TaskName = "Syncing Subtitle"; + +function useReferencedSubtitles( + mediaType: "episode" | "movie", + mediaId: number, + subtitlesPath: string +) { + // We cannot call hooks conditionally, we rely on useQuery "enabled" option to do only the required API call + const episodeData = useRefTracksByEpisodeId( + subtitlesPath, + mediaId, + mediaType === "episode" + ); + const movieData = useRefTracksByMovieId( + subtitlesPath, + mediaId, + mediaType === "movie" + ); + + const mediaData = mediaType === "episode" ? episodeData : movieData; + + const subtitles: { group: string; value: string; label: string }[] = []; + + if (!mediaData.data) { + return []; + } else { + if (mediaData.data.audio_tracks.length > 0) { + mediaData.data.audio_tracks.forEach((item) => { + subtitles.push({ + group: "Embedded audio tracks", + value: item.stream, + label: `${item.name || item.language} (${item.stream})`, + }); + }); + } + + if (mediaData.data.embedded_subtitles_tracks.length > 0) { + mediaData.data.embedded_subtitles_tracks.forEach((item) => { + subtitles.push({ + group: "Embedded subtitles tracks", + value: item.stream, + label: `${item.name || item.language} (${item.stream})`, + }); + }); + } + + if (mediaData.data.external_subtitles_tracks.length > 0) { + mediaData.data.external_subtitles_tracks.forEach((item) => { + if (item) { + subtitles.push({ + group: "External Subtitles files", + value: item.path, + label: item.name, + }); + } + }); + } + + return subtitles; + } +} + +interface Props { + selections: FormType.ModifySubtitle[]; + onSubmit?: VoidFunction; +} + +interface FormValues { + reference?: string; + maxOffsetSeconds?: string; + noFixFramerate: boolean; + gss: boolean; +} + +const SyncSubtitleForm: FunctionComponent = ({ + selections, + onSubmit, +}) => { + if (selections.length === 0) { + throw new Error("You need to select at least 1 media to sync"); + } + + const { mutateAsync } = useSubtitleAction(); + const modals = useModals(); + + const mediaType = selections[0].type; + const mediaId = selections[0].id; + const subtitlesPath = selections[0].path; + + const subtitles: SelectorOption[] = useReferencedSubtitles( + mediaType, + mediaId, + subtitlesPath + ); + + const form = useForm({ + initialValues: { + noFixFramerate: false, + gss: false, + }, + }); + + return ( +
{ + selections.forEach((s) => { + const form: FormType.ModifySubtitle = { + ...s, + reference: parameters.reference, + max_offset_seconds: parameters.maxOffsetSeconds, + no_fix_framerate: toPython(parameters.noFixFramerate), + gss: toPython(parameters.gss), + }; + + task.create(s.path, TaskName, mutateAsync, { action: "sync", form }); + }); + + onSubmit?.(); + modals.closeSelf(); + })} + > + + } + > + {selections.length} subtitles selected + + + + + + + + +
+ ); +}; + +export const SyncSubtitleModal = withModal(SyncSubtitleForm, "sync-subtitle", { + title: "Sync Subtitle Options", + size: "lg", +}); + +export default SyncSubtitleForm; diff --git a/frontend/src/pages/Settings/Providers/index.tsx b/frontend/src/pages/Settings/Providers/index.tsx index 4d18f4d1c..8a2a85a67 100644 --- a/frontend/src/pages/Settings/Providers/index.tsx +++ b/frontend/src/pages/Settings/Providers/index.tsx @@ -1,5 +1,15 @@ +import { antiCaptchaOption } from "@/pages/Settings/Providers/options"; +import { Anchor } from "@mantine/core"; import { FunctionComponent } from "react"; -import { Layout, Section } from "../components"; +import { + CollapseBox, + Layout, + Message, + Password, + Section, + Selector, + Text, +} from "../components"; import { ProviderView } from "./components"; const SettingsProvidersView: FunctionComponent = () => { @@ -8,6 +18,47 @@ const SettingsProvidersView: FunctionComponent = () => {
+
+ (v === undefined ? "None" : v) }} + options={antiCaptchaOption} + > + + value === "anti-captcha"} + > + + + Anti-Captcha.com + + Link to subscribe + + value === "death-by-captcha"} + > + + + + DeathByCaptcha.com + + Link to subscribe + +
); }; diff --git a/frontend/src/pages/Settings/Providers/options.ts b/frontend/src/pages/Settings/Providers/options.ts new file mode 100644 index 000000000..63227ca76 --- /dev/null +++ b/frontend/src/pages/Settings/Providers/options.ts @@ -0,0 +1,12 @@ +import { SelectorOption } from "@/components"; + +export const antiCaptchaOption: SelectorOption[] = [ + { + label: "Anti-Captcha", + value: "anti-captcha", + }, + { + label: "Death by Captcha", + value: "death-by-captcha", + }, +]; diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx index 51d59675e..9f77234ba 100644 --- a/frontend/src/pages/Settings/Subtitles/index.tsx +++ b/frontend/src/pages/Settings/Subtitles/index.tsx @@ -1,4 +1,4 @@ -import { Anchor, Code, Table } from "@mantine/core"; +import { Code, Table } from "@mantine/core"; import { FunctionComponent } from "react"; import { Check, @@ -6,7 +6,6 @@ import { Layout, Message, MultiSelector, - Password, Section, Selector, Slider, @@ -19,12 +18,12 @@ import { import { adaptiveSearchingDelayOption, adaptiveSearchingDeltaOption, - antiCaptchaOption, colorOptions, embeddedSubtitlesParserOption, folderOptions, hiExtensionOptions, providerOptions, + syncMaxOffsetSecondsOptions, } from "./options"; interface CommandOption { @@ -128,7 +127,7 @@ const commandOptionElements: JSX.Element[] = commandOptions.map((op, idx) => ( const SettingsSubtitlesView: FunctionComponent = () => { return ( -
+
{ settingKey="settings-general-subfolder_custom" > + + + What file extension to use when saving hearing-impaired subtitles to + disk (e.g., video.en.sdh.srt). + +
+
+ + + Use embedded subtitles in media files when determining missing ones. + + + (v === undefined ? "ffprobe" : v), + }} + options={embeddedSubtitlesParserOption} + > + Embedded subtitles video parser + + + Ignores PGS Subtitles in Embedded Subtitles detection. + + + + Ignores VobSub Subtitles in Embedded Subtitles detection. + + + + Ignores ASS Subtitles in Embedded Subtitles detection. + + + + Hide embedded subtitles for languages that are not currently + desired. + + +
+
{ subtitles. - +
+
+ - What file extension to use when saving hearing-impaired subtitles to - disk (e.g., video.en.sdh.srt). + Re-encode downloaded Subtitles to UTF8. Should be left enabled in most + case.
-
- (v === undefined ? "None" : v) }} - options={antiCaptchaOption} - > - Choose the anti-captcha provider you want to use - value === "anti-captcha"} - > - - Anti-Captcha.com - - - - value === "death-by-captcha"} - > - - DeathByCaptcha.com - - - +
+ + + + Must be 4 digit octal
@@ -258,52 +289,6 @@ const SettingsSubtitlesView: FunctionComponent = () => { Search multiple providers at once (Don't choose this on low powered devices) - - - Use embedded subtitles in media files when determining missing ones. - - - - - Ignores PGS Subtitles in Embedded Subtitles detection. - - - - Ignores VobSub Subtitles in Embedded Subtitles detection. - - - - Ignores ASS Subtitles in Embedded Subtitles detection. - - - - Hide embedded subtitles for languages that are not currently - desired. - - (v === undefined ? "ffprobe" : v), - }} - options={embeddedSubtitlesParserOption} - > - Embedded subtitles video parser - { search results scores.
-
- - - Re-encode downloaded Subtitles to UTF8. Should be left enabled in most - case. - +
{ Reverses the punctuation in right-to-left subtitles for problematic playback devices. - - - - Must be 4 digit octal - +
+
{ Use the audio track as reference for syncing, instead of using the embedded subtitle. + + + If specified, subsync will not attempt to correct a framerate mismatch + between reference and subtitles. + + + + If specified, use golden-section search to try to find the optimal + framerate ratio between video and subtitles. + + + + The max allowed offset seconds for any subtitle segment. + { +
+
[] = [ }, ]; -export const antiCaptchaOption: SelectorOption[] = [ - { - label: "Anti-Captcha", - value: "anti-captcha", - }, - { - label: "Death by Captcha", - value: "death-by-captcha", - }, -]; - export const embeddedSubtitlesParserOption: SelectorOption[] = [ { label: "ffprobe (faster)", @@ -173,3 +162,22 @@ export const providerOptions: SelectorOption[] = ProviderList.map( value: v.key, }) ); + +export const syncMaxOffsetSecondsOptions: SelectorOption[] = [ + { + label: "60", + value: 60, + }, + { + label: "120", + value: 120, + }, + { + label: "300", + value: 300, + }, + { + label: "600", + value: 600, + }, +]; diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index d714b5149..069be3029 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -51,6 +51,28 @@ interface Subtitle { path: string | null | undefined; // TODO: FIX ME!!!!!! } +interface AudioTrack { + stream: string; + name: string; + language: string; +} + +interface SubtitleTrack { + stream: string; + name: string; + language: string; + forced: boolean; + hearing_impaired: boolean; +} + +interface ExternalSubtitle { + name: string; + path: string; + language: string; + forced: boolean; + hearing_impaired: boolean; +} + interface PathType { path: string; } @@ -149,6 +171,12 @@ declare namespace Item { season: number; episode: number; }; + + type RefTracks = { + audio_tracks: AudioTrack[]; + embedded_subtitles_tracks: SubtitleTrack[]; + external_subtitles_tracks: ExternalSubtitle[]; + }; } declare namespace Wanted { diff --git a/frontend/src/types/form.d.ts b/frontend/src/types/form.d.ts index 6019a3fa0..81b86be96 100644 --- a/frontend/src/types/form.d.ts +++ b/frontend/src/types/form.d.ts @@ -41,6 +41,13 @@ declare namespace FormType { type: "episode" | "movie"; language: string; path: string; + forced?: PythonBoolean; + hi?: PythonBoolean; + original_format?: PythonBoolean; + reference?: string; + max_offset_seconds?: string; + no_fix_framerate?: PythonBoolean; + gss?: PythonBoolean; } interface DownloadSeries { diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts index 89cb42a6d..d88489a0e 100644 --- a/frontend/src/types/settings.d.ts +++ b/frontend/src/types/settings.d.ts @@ -114,6 +114,9 @@ declare namespace Settings { subsync_movie_threshold: number; debug: boolean; force_audio: boolean; + max_offset_seconds: number; + no_fix_framerate: boolean; + gss: boolean; } interface Analytic { diff --git a/frontend/src/utilities/index.ts b/frontend/src/utilities/index.ts index 8fa53a60b..549660722 100644 --- a/frontend/src/utilities/index.ts +++ b/frontend/src/utilities/index.ts @@ -59,6 +59,10 @@ export function filterSubtitleBy( } } +export function toPython(value: boolean): PythonBoolean { + return value ? "True" : "False"; +} + export * from "./env"; export * from "./hooks"; export * from "./validate"; diff --git a/libs/ffsubsync/__init__.py b/libs/ffsubsync/__init__.py index 0ad6c1236..a97907205 100644 --- a/libs/ffsubsync/__init__.py +++ b/libs/ffsubsync/__init__.py @@ -14,7 +14,7 @@ try: datefmt="[%X]", handlers=[RichHandler(console=Console(file=sys.stderr))], ) -except ImportError: +except: # noqa: E722 logging.basicConfig(stream=sys.stderr, level=logging.INFO) from .version import __version__ # noqa diff --git a/libs/ffsubsync/_version.py b/libs/ffsubsync/_version.py index 7215e42bb..a39e32836 100644 --- a/libs/ffsubsync/_version.py +++ b/libs/ffsubsync/_version.py @@ -8,11 +8,11 @@ import json version_json = ''' { - "date": "2022-01-07T20:35:34-0800", + "date": "2023-04-20T11:25:58+0100", "dirty": false, "error": null, - "full-revisionid": "9ae15d825b24b3445112683bbb7b2e4a9d3ecb8f", - "version": "0.4.20" + "full-revisionid": "0953aa240101a7aa235438496f796ef5f8d69d5b", + "version": "0.4.25" } ''' # END VERSION_JSON diff --git a/libs/ffsubsync/aligners.py b/libs/ffsubsync/aligners.py index f02243dd2..28b7bcf9d 100644 --- a/libs/ffsubsync/aligners.py +++ b/libs/ffsubsync/aligners.py @@ -34,13 +34,16 @@ class FFTAligner(TransformerMixin): convolve = np.copy(convolve) if self.max_offset_samples is None: return convolve - offset_to_index = lambda offset: len(convolve) - 1 + offset - len(substring) - convolve[: offset_to_index(-self.max_offset_samples)] = float("-inf") - convolve[offset_to_index(self.max_offset_samples) :] = float("-inf") + + def _offset_to_index(offset): + return len(convolve) - 1 + offset - len(substring) + + convolve[: _offset_to_index(-self.max_offset_samples)] = float("-inf") + convolve[_offset_to_index(self.max_offset_samples) :] = float("-inf") return convolve def _compute_argmax(self, convolve: np.ndarray, substring: np.ndarray) -> None: - best_idx = np.argmax(convolve) + best_idx = int(np.argmax(convolve)) self.best_offset_ = len(convolve) - 1 - best_idx - len(substring) self.best_score_ = convolve[best_idx] diff --git a/libs/ffsubsync/ffsubsync.py b/libs/ffsubsync/ffsubsync.py index 6fc8f2a20..9a808a29b 100755 --- a/libs/ffsubsync/ffsubsync.py +++ b/libs/ffsubsync/ffsubsync.py @@ -202,10 +202,7 @@ def try_sync( if args.output_encoding != "same": out_subs = out_subs.set_encoding(args.output_encoding) suppress_output_thresh = args.suppress_output_if_offset_less_than - if suppress_output_thresh is None or ( - scale_step.scale_factor == 1.0 - and offset_seconds >= suppress_output_thresh - ): + if offset_seconds >= (suppress_output_thresh or float("-inf")): logger.info("writing output to {}".format(srtout or "stdout")) out_subs.write_file(srtout) else: @@ -216,11 +213,10 @@ def try_sync( ) except FailedToFindAlignmentException as e: sync_was_successful = False - logger.error(e) + logger.error(str(e)) except Exception as e: exc = e sync_was_successful = False - logger.error(e) else: result["offset_seconds"] = offset_seconds result["framerate_scale_factor"] = scale_step.scale_factor @@ -362,23 +358,29 @@ def validate_args(args: argparse.Namespace) -> None: ) if not args.srtin: raise ValueError( - "need to specify input srt if --overwrite-input is specified since we cannot overwrite stdin" + "need to specify input srt if --overwrite-input " + "is specified since we cannot overwrite stdin" ) if args.srtout is not None: raise ValueError( - "overwrite input set but output file specified; refusing to run in case this was not intended" + "overwrite input set but output file specified; " + "refusing to run in case this was not intended" ) if args.extract_subs_from_stream is not None: if args.make_test_case: raise ValueError("test case is for sync and not subtitle extraction") if args.srtin: raise ValueError( - "stream specified for reference subtitle extraction; -i flag for sync input not allowed" + "stream specified for reference subtitle extraction; " + "-i flag for sync input not allowed" ) def validate_file_permissions(args: argparse.Namespace) -> None: - error_string_template = "unable to {action} {file}; try ensuring file exists and has correct permissions" + error_string_template = ( + "unable to {action} {file}; " + "try ensuring file exists and has correct permissions" + ) if args.reference is not None and not os.access(args.reference, os.R_OK): raise ValueError( error_string_template.format(action="read reference", file=args.reference) @@ -506,27 +508,27 @@ def run( try: sync_was_successful = _run_impl(args, result) result["sync_was_successful"] = sync_was_successful + return result finally: - if log_handler is None or log_path is None: - return result - try: + if log_handler is not None and log_path is not None: log_handler.close() logger.removeHandler(log_handler) if args.make_test_case: result["retval"] += make_test_case( args, _npy_savename(args), sync_was_successful ) - finally: if args.log_dir_path is None or not os.path.isdir(args.log_dir_path): os.remove(log_path) - return result def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None: parser.add_argument( "reference", nargs="?", - help="Reference (video, subtitles, or a numpy array with VAD speech) to which to synchronize input subtitles.", + help=( + "Reference (video, subtitles, or a numpy array with VAD speech) " + "to which to synchronize input subtitles." + ), ) parser.add_argument( "-i", "--srtin", nargs="*", help="Input subtitles file (default=stdin)." @@ -554,11 +556,13 @@ def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None: "--reference-track", "--reftrack", default=None, - help="Which stream/track in the video file to use as reference, " - "formatted according to ffmpeg conventions. For example, 0:s:0 " - "uses the first subtitle track; 0:a:3 would use the third audio track. " - "You can also drop the leading `0:`; i.e. use s:0 or a:3, respectively. " - "Example: `ffs ref.mkv -i in.srt -o out.srt --reference-stream s:2`", + help=( + "Which stream/track in the video file to use as reference, " + "formatted according to ffmpeg conventions. For example, 0:s:0 " + "uses the first subtitle track; 0:a:3 would use the third audio track. " + "You can also drop the leading `0:`; i.e. use s:0 or a:3, respectively. " + "Example: `ffs ref.mkv -i in.srt -o out.srt --reference-stream s:2`" + ), ) @@ -574,7 +578,10 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--overwrite-input", action="store_true", - help="If specified, will overwrite the input srt instead of writing the output to a new file.", + help=( + "If specified, will overwrite the input srt " + "instead of writing the output to a new file." + ), ) parser.add_argument( "--encoding", @@ -642,7 +649,14 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None: ) parser.add_argument( "--vad", - choices=["subs_then_webrtc", "webrtc", "subs_then_auditok", "auditok"], + choices=[ + "subs_then_webrtc", + "webrtc", + "subs_then_auditok", + "auditok", + "subs_then_silero", + "silero", + ], default=None, help="Which voice activity detector to use for speech extraction " "(if using video / audio as a reference, default={}).".format(DEFAULT_VAD), @@ -680,7 +694,10 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--log-dir-path", default=None, - help="If provided, will save log file ffsubsync.log to this path (must be an existing directory).", + help=( + "If provided, will save log file ffsubsync.log to this path " + "(must be an existing directory)." + ), ) parser.add_argument( "--gss", @@ -688,6 +705,11 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None: help="If specified, use golden-section search to try to find" "the optimal framerate ratio between video and subtitles.", ) + parser.add_argument( + "--strict", + action="store_true", + help="If specified, refuse to parse srt files with formatting issues.", + ) parser.add_argument("--vlc-mode", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--gui-mode", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--skip-sync", action="store_true", help=argparse.SUPPRESS) diff --git a/libs/ffsubsync/ffsubsync_gui.py b/libs/ffsubsync/ffsubsync_gui.py index 1bdb45031..4ec851eec 100755 --- a/libs/ffsubsync/ffsubsync_gui.py +++ b/libs/ffsubsync/ffsubsync_gui.py @@ -64,7 +64,11 @@ _menu = [ def make_parser(): description = DESCRIPTION if update_available(): - description += '\nUpdate available! Please go to "File" -> "Download latest release" to update FFsubsync.' + description += ( + "\nUpdate available! Please go to " + '"File" -> "Download latest release"' + " to update FFsubsync." + ) parser = GooeyParser(description=description) main_group = parser.add_argument_group("Basic") main_group.add_argument( diff --git a/libs/ffsubsync/sklearn_shim.py b/libs/ffsubsync/sklearn_shim.py index ac79e4f3c..c691852a1 100644 --- a/libs/ffsubsync/sklearn_shim.py +++ b/libs/ffsubsync/sklearn_shim.py @@ -4,7 +4,37 @@ This module borrows and adapts `Pipeline` from `sklearn.pipeline` and `TransformerMixin` from `sklearn.base` in the scikit-learn framework (commit hash d205638475ca542dc46862652e3bb0be663a8eac) to be precise). Both are BSD licensed and allow for this sort of thing; attribution -is given as a comment above each class. +is given as a comment above each class. License reproduced below: + +BSD 3-Clause License + +Copyright (c) 2007-2022 The scikit-learn developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ from collections import defaultdict from itertools import islice @@ -14,7 +44,7 @@ from typing_extensions import Protocol class TransformerProtocol(Protocol): fit: Callable[..., "TransformerProtocol"] - transform: Callable[["TransformerProtocol", Any], Any] + transform: Callable[[Any], Any] # Author: Gael Varoquaux @@ -176,7 +206,7 @@ class Pipeline: ) step, param = pname.split("__", 1) fit_params_steps[step][param] = pval - for (step_idx, name, transformer) in self._iter( + for step_idx, name, transformer in self._iter( with_final=False, filter_passthrough=False ): if transformer is None or transformer == "passthrough": diff --git a/libs/ffsubsync/speech_transformers.py b/libs/ffsubsync/speech_transformers.py index 33b54db6a..72ca23e30 100644 --- a/libs/ffsubsync/speech_transformers.py +++ b/libs/ffsubsync/speech_transformers.py @@ -1,17 +1,24 @@ # -*- coding: utf-8 -*- +import os from contextlib import contextmanager import logging import io import subprocess import sys from datetime import timedelta -from typing import cast, Callable, Dict, Optional, Union +from typing import cast, Callable, Dict, List, Optional, Union import ffmpeg import numpy as np import tqdm -from ffsubsync.constants import * +from ffsubsync.constants import ( + DEFAULT_ENCODING, + DEFAULT_MAX_SUBTITLE_SECONDS, + DEFAULT_SCALE_FACTOR, + DEFAULT_START_SECONDS, + SAMPLE_RATE, +) from ffsubsync.ffmpeg_utils import ffmpeg_bin_path, subprocess_args from ffsubsync.generic_subtitles import GenericSubtitle from ffsubsync.sklearn_shim import TransformerMixin @@ -144,7 +151,7 @@ def _make_webrtcvad_detector( asegment[start * bytes_per_frame : stop * bytes_per_frame], sample_rate=frame_rate, ) - except: + except Exception: is_speech = False failures += 1 # webrtcvad has low recall on mode 3, so treat non-speech as "not sure" @@ -154,6 +161,49 @@ def _make_webrtcvad_detector( return _detect +def _make_silero_detector( + sample_rate: int, frame_rate: int, non_speech_label: float +) -> Callable[[bytes], np.ndarray]: + import torch + + window_duration = 1.0 / sample_rate # duration in seconds + frames_per_window = int(window_duration * frame_rate + 0.5) + bytes_per_frame = 1 + + model, _ = torch.hub.load( + repo_or_dir="snakers4/silero-vad", + model="silero_vad", + force_reload=False, + onnx=False, + ) + + exception_logged = False + + def _detect(asegment) -> np.ndarray: + asegment = np.frombuffer(asegment, np.int16).astype(np.float32) / (1 << 15) + asegment = torch.FloatTensor(asegment) + media_bstring = [] + failures = 0 + for start in range(0, len(asegment) // bytes_per_frame, frames_per_window): + stop = min(start + frames_per_window, len(asegment)) + try: + speech_prob = model( + asegment[start * bytes_per_frame : stop * bytes_per_frame], + frame_rate, + ).item() + except Exception: + nonlocal exception_logged + if not exception_logged: + exception_logged = True + logger.exception("exception occurred during speech detection") + speech_prob = 0.0 + failures += 1 + media_bstring.append(1.0 - (1.0 - speech_prob) * (1.0 - non_speech_label)) + return np.array(media_bstring) + + return _detect + + class ComputeSpeechFrameBoundariesMixin: def __init__(self) -> None: self.start_frame_: Optional[int] = None @@ -170,8 +220,8 @@ class ComputeSpeechFrameBoundariesMixin: ) -> "ComputeSpeechFrameBoundariesMixin": nz = np.nonzero(speech_frames > 0.5)[0] if len(nz) > 0: - self.start_frame_ = np.min(nz) - self.end_frame_ = np.max(nz) + self.start_frame_ = int(np.min(nz)) + self.end_frame_ = int(np.max(nz)) return self @@ -287,9 +337,13 @@ class VideoSpeechTransformer(TransformerMixin): detector = _make_auditok_detector( self.sample_rate, self.frame_rate, self._non_speech_label ) + elif "silero" in self.vad: + detector = _make_silero_detector( + self.sample_rate, self.frame_rate, self._non_speech_label + ) else: raise ValueError("unknown vad: %s" % self.vad) - media_bstring = [] + media_bstring: List[np.ndarray] = [] ffmpeg_args = [ ffmpeg_bin_path( "ffmpeg", self.gui_mode, ffmpeg_resources_path=self.ffmpeg_path @@ -324,10 +378,7 @@ class VideoSpeechTransformer(TransformerMixin): windows_per_buffer = 10000 simple_progress = 0.0 - @contextmanager - def redirect_stderr(enter_result=None): - yield enter_result - + redirect_stderr = None tqdm_extra_args = {} should_print_redirected_stderr = self.gui_mode if self.gui_mode: @@ -337,6 +388,13 @@ class VideoSpeechTransformer(TransformerMixin): tqdm_extra_args["file"] = sys.stdout except ImportError: should_print_redirected_stderr = False + if redirect_stderr is None: + + @contextmanager + def redirect_stderr(enter_result=None): + yield enter_result + + assert redirect_stderr is not None pbar_output = io.StringIO() with redirect_stderr(pbar_output): with tqdm.tqdm( @@ -363,13 +421,17 @@ class VideoSpeechTransformer(TransformerMixin): assert self.gui_mode # no need to flush since we pass -u to do unbuffered output for gui mode print(pbar_output.read()) - in_bytes = np.frombuffer(in_bytes, np.uint8) + if "silero" not in self.vad: + in_bytes = np.frombuffer(in_bytes, np.uint8) media_bstring.append(detector(in_bytes)) + process.wait() if len(media_bstring) == 0: raise ValueError( - "Unable to detect speech. Perhaps try specifying a different stream / track, or a different vad." + "Unable to detect speech. " + "Perhaps try specifying a different stream / track, or a different vad." ) self.video_speech_results_ = np.concatenate(media_bstring) + logger.info("total of speech segments: %s", np.sum(self.video_speech_results_)) return self def transform(self, *_) -> np.ndarray: diff --git a/libs/ffsubsync/subtitle_parser.py b/libs/ffsubsync/subtitle_parser.py index ea5e6657c..b42d9bb9e 100755 --- a/libs/ffsubsync/subtitle_parser.py +++ b/libs/ffsubsync/subtitle_parser.py @@ -1,17 +1,29 @@ # -*- coding: utf-8 -*- from datetime import timedelta import logging -from typing import Any, Optional +from typing import Any, cast, List, Optional try: - import cchardet as chardet -except ImportError: - import chardet # type: ignore + import cchardet +except: # noqa: E722 + cchardet = None +try: + import chardet +except: # noqa: E722 + chardet = None +try: + import charset_normalizer +except: # noqa: E722 + charset_normalizer = None import pysubs2 from ffsubsync.sklearn_shim import TransformerMixin import srt -from ffsubsync.constants import * +from ffsubsync.constants import ( + DEFAULT_ENCODING, + DEFAULT_MAX_SUBTITLE_SECONDS, + DEFAULT_START_SECONDS, +) from ffsubsync.file_utils import open_file from ffsubsync.generic_subtitles import GenericSubtitle, GenericSubtitlesFile, SubsMixin @@ -61,6 +73,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin): max_subtitle_seconds: Optional[int] = None, start_seconds: int = 0, skip_ssa_info: bool = False, + strict: bool = False, ) -> None: super(self.__class__, self).__init__() self.sub_format: str = fmt @@ -72,6 +85,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin): self.start_seconds: int = start_seconds # FIXME: hack to get tests to pass; remove self._skip_ssa_info: bool = skip_ssa_info + self._strict: bool = strict def fit(self, fname: str, *_) -> "GenericSubtitleParser": if self.caching and self.fit_fname == ("" if fname is None else fname): @@ -80,15 +94,28 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin): with open_file(fname, "rb") as f: subs = f.read() if self.encoding == "infer": - encodings_to_try = (chardet.detect(subs)["encoding"],) - self.detected_encoding_ = encodings_to_try[0] + for chardet_lib in (cchardet, charset_normalizer, chardet): + if chardet_lib is not None: + try: + detected_encoding = cast( + Optional[str], chardet_lib.detect(subs)["encoding"] + ) + except: # noqa: E722 + continue + if detected_encoding is not None: + self.detected_encoding_ = detected_encoding + encodings_to_try = (detected_encoding,) + break + assert self.detected_encoding_ is not None logger.info("detected encoding: %s" % self.detected_encoding_) exc = None for encoding in encodings_to_try: try: decoded_subs = subs.decode(encoding, errors="replace").strip() if self.sub_format == "srt": - parsed_subs = srt.parse(decoded_subs) + parsed_subs = srt.parse( + decoded_subs, ignore_errors=not self._strict + ) elif self.sub_format in ("ass", "ssa", "sub"): parsed_subs = pysubs2.SSAFile.from_string(decoded_subs) else: @@ -144,4 +171,5 @@ def make_subtitle_parser( max_subtitle_seconds=max_subtitle_seconds, start_seconds=start_seconds, skip_ssa_info=kwargs.get("skip_ssa_info", False), + strict=kwargs.get("strict", False), ) diff --git a/libs/version.txt b/libs/version.txt index ac120f6b4..6d73509c2 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -10,7 +10,7 @@ deep-translator==1.9.1 dogpile.cache==1.1.8 dynaconf==3.1.12 fese==0.1.2 -ffsubsync==0.4.20 +ffsubsync==0.4.25 Flask-Compress==1.13 # modified to import brotli only if required flask-cors==3.0.10 flask-migrate==4.0.4 From 783e6b38ea5be62ebe9c001a9736a19486b269e6 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Fri, 12 Jan 2024 06:41:51 -0500 Subject: [PATCH 35/49] Removed aRGENTeaM provider that have been recently closed. #2352 --- README.md | 1 - bazarr/app/get_providers.py | 2 +- frontend/src/pages/Settings/Providers/list.ts | 1 - libs/subliminal_patch/providers/argenteam.py | 193 ------- .../test_download_subtitle.yaml | 546 ------------------ .../test_list_subtitles_episode.yaml | 129 ----- ...test_list_subtitles_episode_with_tvdb.yaml | 174 ------ .../test_list_subtitles_movie.yaml | 366 ------------ tests/subliminal_patch/test_argenteam.py | 142 ----- tests/subliminal_patch/test_core.py | 30 +- 10 files changed, 16 insertions(+), 1568 deletions(-) delete mode 100644 libs/subliminal_patch/providers/argenteam.py delete mode 100644 tests/subliminal_patch/cassettes/test_argenteam/test_download_subtitle.yaml delete mode 100644 tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode.yaml delete mode 100644 tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode_with_tvdb.yaml delete mode 100644 tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_movie.yaml delete mode 100644 tests/subliminal_patch/test_argenteam.py diff --git a/README.md b/README.md index 4f6f8725a..c244d7b47 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ If you need something that is not already part of Bazarr, feel free to create a ## Supported subtitles providers: - Addic7ed -- Argenteam - Assrt - BetaSeries - BSplayer diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py index c669286c2..415340b19 100644 --- a/bazarr/app/get_providers.py +++ b/bazarr/app/get_providers.py @@ -122,7 +122,7 @@ def provider_throttle_map(): PROVIDERS_FORCED_OFF = ["addic7ed", "tvsubtitles", "legendasdivx", "napiprojekt", "shooter", - "hosszupuska", "supersubtitles", "titlovi", "argenteam", "assrt", "subscene"] + "hosszupuska", "supersubtitles", "titlovi", "assrt", "subscene"] throttle_count = {} diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index ba91cd580..b70b989ce 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -63,7 +63,6 @@ export const ProviderList: Readonly = [ }, ], }, - { key: "argenteam", description: "LATAM Spanish Subtitles Provider" }, { key: "assrt", description: "Chinese Subtitles Provider", diff --git a/libs/subliminal_patch/providers/argenteam.py b/libs/subliminal_patch/providers/argenteam.py deleted file mode 100644 index 4826a7f9b..000000000 --- a/libs/subliminal_patch/providers/argenteam.py +++ /dev/null @@ -1,193 +0,0 @@ -# coding=utf-8 -from __future__ import absolute_import - -from json import JSONDecodeError -import logging -import os -import urllib.parse - -from requests import Session -from subliminal import Episode -from subliminal import Movie -from subliminal_patch.providers import Provider -from subliminal_patch.providers.mixins import ProviderSubtitleArchiveMixin -from subliminal_patch.providers.utils import get_archive_from_bytes -from subliminal_patch.providers.utils import get_subtitle_from_archive -from subliminal_patch.providers.utils import update_matches -from subliminal_patch.subtitle import Subtitle -from subzero.language import Language - -BASE_URL = "https://argenteam.net" -API_URL = f"{BASE_URL}/api/v1" - -logger = logging.getLogger(__name__) - - -class ArgenteamSubtitle(Subtitle): - provider_name = "argenteam" - hearing_impaired_verifiable = False - - def __init__(self, language, page_link, download_link, release_info, matches): - super(ArgenteamSubtitle, self).__init__(language, page_link=page_link) - - self._found_matches = matches - - self.page_link = page_link - self.download_link = download_link - self.release_info = release_info - - @property - def id(self): - return self.download_link - - def get_matches(self, video): - update_matches(self._found_matches, video, self.release_info) - - return self._found_matches - - -class ArgenteamProvider(Provider, ProviderSubtitleArchiveMixin): - provider_name = "argenteam" - - languages = {Language("spa", "MX")} - video_types = (Episode, Movie) - subtitle_class = ArgenteamSubtitle - - _default_lang = Language("spa", "MX") - - def __init__(self): - self.session = Session() - - def initialize(self): - self.session.headers.update( - {"User-Agent": os.environ.get("SZ_USER_AGENT", "Sub-Zero/2")} - ) - - def terminate(self): - self.session.close() - - def query(self, video): - is_episode = isinstance(video, Episode) - imdb_id = video.series_imdb_id if is_episode else video.imdb_id - - if not imdb_id: - logger.debug("%s doesn't have IMDB ID. Can't search") - return [] - - if is_episode: - argenteam_ids = self._search_ids( - imdb_id, season=video.season, episode=video.episode - ) - else: - argenteam_ids = self._search_ids(imdb_id) - - if not argenteam_ids: - logger.debug("No IDs found") - return [] - - return self._parse_subtitles(argenteam_ids, is_episode) - - def _parse_subtitles(self, ids, is_episode=True): - movie_kind = "episode" if is_episode else "movie" - - subtitles = [] - - for aid in ids: - response = self.session.get( - f"{API_URL}/{movie_kind}", params={"id": aid}, timeout=10 - ) - response.raise_for_status() - - try: - content = response.json() - except JSONDecodeError: - continue - - if not content or not content.get("releases"): - continue - - for r in content["releases"]: - for s in r["subtitles"]: - page_link = f"{BASE_URL}/{movie_kind}/{aid}" - - release_info = self._combine_release_info(r, s) - - logger.debug("Got release info: %s", release_info) - - download_link = s["uri"].replace("http://", "https://") - - # Already matched within query - if is_episode: - matches = {"series", "title", "season", "episode", "imdb_id", "year"} - else: - matches = {"title", "year", "imdb_id"} - - subtitles.append( - ArgenteamSubtitle( - self._default_lang, - page_link, - download_link, - release_info, - matches, - ) - ) - - return subtitles - - def list_subtitles(self, video, languages): - return self.query(video) - - def download_subtitle(self, subtitle): - r = self.session.get(subtitle.download_link, timeout=10) - r.raise_for_status() - - archive = get_archive_from_bytes(r.content) - subtitle.content = get_subtitle_from_archive(archive) - - def _search_ids(self, identifier, **kwargs): - """ - :param identifier: imdb_id or title (without year) - """ - identifier = identifier.lstrip("tt") - - query = identifier - if kwargs.get("season") and kwargs.get("episode"): - query = f"{identifier} S{kwargs['season']:02}E{kwargs['episode']:02}" - - logger.debug("Searching ID for %s", query) - - r = self.session.get(f"{API_URL}/search", params={"q": query}, timeout=10) - r.raise_for_status() - - try: - results = r.json() - except JSONDecodeError: - return [] - - if not results.get("results"): - return [] - - match_ids = [result["id"] for result in results["results"]] - logger.debug("Found matching IDs: %s", match_ids) - - return match_ids - - def _combine_release_info(self, release_dict, subtitle_dict): - releases = [ - urllib.parse.unquote(subtitle_dict.get("uri", "Unknown").split("/")[-1]) - ] - - combine = [ - release_dict.get(key) - for key in ("source", "codec", "tags") - if release_dict.get(key) - ] - - if combine: - r_info = ".".join(combine) - if release_dict.get("team"): - r_info += f"-{release_dict['team']}" - - releases.append(r_info) - - return "\n".join(releases) diff --git a/tests/subliminal_patch/cassettes/test_argenteam/test_download_subtitle.yaml b/tests/subliminal_patch/cassettes/test_argenteam/test_download_subtitle.yaml deleted file mode 100644 index 01d73f5e9..000000000 --- a/tests/subliminal_patch/cassettes/test_argenteam/test_download_subtitle.yaml +++ /dev/null @@ -1,546 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/search?q=Breaking+Bad+S01E01 - response: - body: - string: !!binary | - H4sIAAAAAAAAAyxSvU4cMRB+lZErkJbTkTRoO1AiFJRIKBQUiGLWnuPm8HqW8fiiC+JhrqSgQHmE - fbHIvussfTPffD9+dUq5RMuuf3h1HFz/dblcXnTO2CK53l0p4TOnJ7jCACdflsuLU4CHu+X59+X5 - I5zBLUcx1znbTXWcJs4SyHUul3FE3bne3WM0Urhfs1EHpAglwQpHyQKctpSNnzCIwkuZP0b2AoHq - yKQN4jr3EwdRNFGuaIRrGZg0SQcvhQAhzB9Y19aygzVC5iCgFKkSAwKlTPMnKiDgNO+NvWSgbCUw - JqN8vPgjZWMrJkAJLuNQXgrpS6EF3KIiTBJIIVbWyrTiVPdGymA4Djy/JzCm1KhMccBNvT1SYISN - aMJQlVQ04hYDaXPqxa8pL+Ay0Djvcwfze4Qd5AKUJ8nYwd3zLpLCyWVKCNclpQ6+EYY/IuG0g5IQ - xrIhbVFkqv5XopvqvGESKBuCR9UaPtXz4GWcFM+2lKwFh2rzhy9RMkyiQFe462pC8z6BV8bUcjxU - 2cGNLuDk9+JmAb/YLFKTARvZUoskMHnOTEaA86fkDrykJuZJMcGqkP5tV7cSSzIMhxoP4U2o8z5y - 5gyelAbFCFHaQP0RTXBJldE4lVqzSV3CamuFI0fGBdzWdE0Ctp4xQ+AV+xINA2WYMHIgT6mmoRTR - 8/yvcQJFSIW2tZrsy6A8MiVrTR3c90edft4nT3q0BpNKmv9lO/7fIPngvdnkgAvXOR7D4HrnOjdJ - NtL6fnvsnIlhdP1552S1ymSuX779BwAA//8DAOpgiQCaAwAA - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743ae9fcd7523-SCL - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:43 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=PxlJNR0AxqyB8FPFuHChYEXuwVNJ9Fy9z23lPUnvuHiZY9aRqeJ22Rs2o1lg2c7n6KA0E9h2xU0tcT%2FgzTO0J17LLKT3FFWqeBB161WjmjrZg9FZc%2FtVuJ4YGvDToYPqI7uMzTVKZYBWI095"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/episode?id=30008 - response: - body: - string: !!binary | - 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 - AA== - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743b0fc487523-SCL - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:44 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=wAS4IfAlvPbIWOaiVRMMsvQyHjHeWJHg4DXEppWEOIWVyUcfyHACr9s0g5RBj%2Fxx6QDVNq3tP9xwho%2BocKR%2BjjHi%2B72%2B67nzX80tDqz7h6qXT9BT%2FLK1WuIsY%2BUsSXWCzxIwjw4bTFYx1U2T"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/subtitles/23940/Breaking.Bad.%282008%29.S01E01-Pilot.DVDRip.XviD-ORPHEUS - response: - body: - string: '' - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743b348877523-SCL - Connection: - - keep-alive - Content-Length: - - '0' - Date: - - Tue, 30 Nov 2021 21:45:44 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=3rN8IWuQQ1NCihTEeSmgpHtZRkKEwab5k36GYfg7oPgpo9NNZ3d0POAg4yeDdAg8fKkwpnitmaSVFIeOkV3tFCuSFM%2BbA0M4vOAXlmKTd5YBrtbsvG58QoR%2FAvvY1ZBlhiylJo8cR8qt3JkJ"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - location: - - https://subs.argenteam.net/Breaking.Bad(101)(2008)-aRGENTeaM-16601.zip - status: - code: 302 - message: Found -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://subs.argenteam.net/Breaking.Bad(101)(2008)-aRGENTeaM-16601.zip - response: - body: - string: !!binary | - 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 - Gl2gf4bbDznQcDl8K7xc5FXbfH3Pabsc63nFdgJnk3Dbpq6HqOu0K8rluWeiud7Lc2YuX631At6C - Jjjba8e4XygZzHJ3GNpzdcpTRVAMsDmJwUuCIXyYOTnIK6LlOafQCn/JGCE2ma5gcmu7nHnFtTxf - l2ZCZL6U5AnE/A7n+4f9AQcFX1b39q9nr963zcvHj/75z38+4yuQ922zebZt92D5/wFQSwcIloU3 - BfVDAABwowAAUEsDBAoAAAAAAPOUnUAAAAAAAAAAAAAAAAAJABAAX19NQUNPU1gvVVgMAMlunU/J - bp1P9QEUAFBLAwQUAAgACADSlJ1AAAAAAAAAAAAAAAAAPAAQAF9fTUFDT1NYLy5fQnJlYWtpbmcu - QmFkLlMwMUUwMS5QaWxvdC5EVkRSaXAuWHZpRC1PUlBIRVVTLnNydFVYDACUbp1PjG6dT/UBFABj - YBVjZ2BiYPBNTFbwD1aIUIACkBgDJxAbAfFkIAbxjzIQBRxDQoKgTJCOGUCsi6aEESEumpyfq5dY - UJCTqldYmliUmFeSmZfKUKhvYGBgaG2SZplilmqRbO2WWZSall9hXZNflK6Xm1+VmZOTqJcGEWQA - AFBLBwilfDPjdAAAAMUAAABQSwECFQMUAAgACADSlJ1AloU3BfVDAABwowAAMQAMAAAAAAAAAABA - pIEAAAAAQnJlYWtpbmcuQmFkLlMwMUUwMS5QaWxvdC5EVkRSaXAuWHZpRC1PUlBIRVVTLnNydFVY - CACUbp1PjG6dT1BLAQIVAwoAAAAAAPOUnUAAAAAAAAAAAAAAAAAJAAwAAAAAAAAAAED9QWREAABf - X01BQ09TWC9VWAgAyW6dT8lunU9QSwECFQMUAAgACADSlJ1ApXwz43QAAADFAAAAPAAMAAAAAAAA - AABApIGbRAAAX19NQUNPU1gvLl9CcmVha2luZy5CYWQuUzAxRTAxLlBpbG90LkRWRFJpcC5YdmlE - LU9SUEhFVVMuc3J0VVgIAJRunU+Mbp1PUEsFBgAAAAADAAMAJAEAAIlFAAAAAA== - headers: - Accept-Ranges: - - bytes - Age: - - '703' - CF-Cache-Status: - - HIT - CF-RAY: - - 6b6743b72ac3e51a-ARI - Cache-Control: - - max-age=14400 - Connection: - - keep-alive - Content-Length: - - '18115' - Content-Type: - - application/zip - Date: - - Tue, 30 Nov 2021 21:45:44 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=Clmze%2FTQasv7IHNXRzD09cj0q3UVcXbLCJrgz5RB4CFS7HhylBeuaQpCnZeRmeJg%2FyAC1I63ekzBZc465j1AmEfvz2jNFh8myAxmYiOpHqYa7sNy2CmTGIW5RLdpRwxLFdy%2BLHV67W45YXWkYkjgrRQ%3D"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Vary: - - Accept-Encoding - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - etag: - - '"4f9d6e2f-46c3"' - last-modified: - - Sun, 29 Apr 2012 16:37:03 GMT - status: - code: 200 - message: OK -version: 1 diff --git a/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode.yaml b/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode.yaml deleted file mode 100644 index 1fec411d6..000000000 --- a/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode.yaml +++ /dev/null @@ -1,129 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/search?q=Breaking+Bad+S01E01 - response: - body: - string: !!binary | - H4sIAAAAAAAAAyxSvU4cMRB+lZErkJbTkTRoO1AiFJRIKBQUiGLWnuPm8HqW8fiiC+JhrqSgQHmE - fbHIvussfTPffD9+dUq5RMuuf3h1HFz/dblcXnTO2CK53l0p4TOnJ7jCACdflsuLU4CHu+X59+X5 - I5zBLUcx1znbTXWcJs4SyHUul3FE3bne3WM0Urhfs1EHpAglwQpHyQKctpSNnzCIwkuZP0b2AoHq - yKQN4jr3EwdRNFGuaIRrGZg0SQcvhQAhzB9Y19aygzVC5iCgFKkSAwKlTPMnKiDgNO+NvWSgbCUw - JqN8vPgjZWMrJkAJLuNQXgrpS6EF3KIiTBJIIVbWyrTiVPdGymA4Djy/JzCm1KhMccBNvT1SYISN - aMJQlVQ04hYDaXPqxa8pL+Ay0Djvcwfze4Qd5AKUJ8nYwd3zLpLCyWVKCNclpQ6+EYY/IuG0g5IQ - xrIhbVFkqv5XopvqvGESKBuCR9UaPtXz4GWcFM+2lKwFh2rzhy9RMkyiQFe462pC8z6BV8bUcjxU - 2cGNLuDk9+JmAb/YLFKTARvZUoskMHnOTEaA86fkDrykJuZJMcGqkP5tV7cSSzIMhxoP4U2o8z5y - 5gyelAbFCFHaQP0RTXBJldE4lVqzSV3CamuFI0fGBdzWdE0Ctp4xQ+AV+xINA2WYMHIgT6mmoRTR - 8/yvcQJFSIW2tZrsy6A8MiVrTR3c90edft4nT3q0BpNKmv9lO/7fIPngvdnkgAvXOR7D4HrnOjdJ - NtL6fnvsnIlhdP1552S1ymSuX779BwAA//8DAOpgiQCaAwAA - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743a65fbde532-ARI - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:42 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=fINpgSr6RpM33TGdh1m%2BF74xZy7MmkNtBwLKyP5cXcseIaB2FzprBhWzr3VG2jBUQR%2BBoMDNt2U51nL1BtJls0u13HhErbwC4j3mMevtJJlNJQ7BOq5kD9bVUrxVekFn24slQYBEMRQpFPyc"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/episode?id=30008 - response: - body: - string: !!binary | - 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 - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743aa4ff6e532-ARI - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:43 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=6889nScfx6LNh5a9UuL4xYY7q%2F46Je690M1FC3gyzpHpFkd56IT%2B4tnMmn2I8CLaCc97OCGESa89i9lc4%2F48VHaCkseJxlb2XURNVbEprhTMeEccV4cikl8gVK35irBC%2FhEw4dw63QyW9%2FHI"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -version: 1 diff --git a/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode_with_tvdb.yaml b/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode_with_tvdb.yaml deleted file mode 100644 index 5a6bb52d7..000000000 --- a/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_episode_with_tvdb.yaml +++ /dev/null @@ -1,174 +0,0 @@ -interactions: - - request: - body: null - headers: - Accept: - - "*/*" - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/search?q=Severance+S01E01 - response: - body: - string: !!binary | - H4sIAAAAAAAAA3yQvWocQRCEX6XoyIaR2TsJy2xmLGMlcmCFQkHfTp93dLPTq+6ZOxahF3PqFzNr - jH8CGSrogiror57IxFuuTv3dE6VI/eb84vLtZaCaahbq6VaOYlwGwattt92+Bu5uu83HbnOPM3xS - jfgsJ8f7nbaKa8mZAtVlXqsyJ9coFMjbNLEt1NMN2wHiYB+kxBQVMxsjpyjGhlYgjy3NiqEt6jAZ - mlhUhwtGhssaj4rHluz7N/uaBp6kVIGUagJvqMY7flAsqzmmyJjFXAvnNxQoTXFHPVGgWb2Krfdz - +IXenXcXmz/oH9hrFnzR4bDCb979C/97mf8TX0uxBVfCR7EARhSu45npCVyrWpElYNCyNy3VMSZH - XDea2StOoxTwKi3LpM0xcM7IzWSNTtjxcEDVn7VRJ6l6KtA9/no94IZTkRfh7wNVrZyp3wbS/d6l - Ut89/wAAAP//AwBurbIoGgIAAA== - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6e0db09d7bdfe51e-ARI - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Mon, 21 Feb 2022 05:49:11 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=cx3Z59tbyYQ5rXEfX2FssZh1WPCFloUN9QX%2Bj0enSws3CCLaRPWdo9hH4vcDFE8NIoEgQuXnkROhPZUpfCZyagPBMgR54Q2rI%2FaXuilqP1o%2BYOS6KCwwS4qsA7qp6ffle7mcoBB27A56ETGB"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400 - status: - code: 200 - message: OK - - request: - body: null - headers: - Accept: - - "*/*" - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/episode?id=134767 - response: - body: - string: !!binary | - H4sIAAAAAAAAA7TVX2/bNhAA8K9CEHCfHIJ/RJESEAzu7CRA0y1ou/RhyANFnmwtMulSVJqsyBfb - a79YIW9esy6eJ2x+Egjc6Y768ahPuHG4ZCJTuZridOcqXPq+bae4A9MFj0s2xb5fVxBxiSnDU5ya - 1AIu8XkIDv0AHzs0q0Kf0AW0LZ7irl+vTXzAJX5t4i2CDpnOgneNC2hjokFt4yCaiHqP4EPfbAKy - /UPoUATbQ3ShQx2glUEdDOEuoA99Ez//FpeNNWvwCRD4FAF1PUrRVOaXgB6GxV3jDNpA7II3LcFT - HKEF00GHy58/4S700Q5tv1+8xFNsgwOLS3zB82zYE5g1LvGrH8+bYWWWHS6x4nQzbKj5dchjRKLz - ITWFGMGn31/bxwaXeG2WHlL53X067aMvq9SsSqdyygoJUNS2yKTmQjOtuSyyOq+tVTZzObhCvHD+ - 9C3cQTTeAnlL2YIyMpQm7xcvyYrn2cnQ1osUT1cpbSZiNuFnE36WorG3EMkfz7q5JzasJ2Km6YSf - Ge9D7y0Mab37mlWQaGK1JGuYiBlXBd0XkMI2QPwt4M+6q8ZDC5uV8YmEuJyIGeNK743v2vDRrgCS - We3CM5VT/HgzxdA2/nb4oDfD+am2J2y7fJz+S7nzy4uzr3KM6qd0GclG0Umpciut1TKXGaVOisJJ - ldW1lkZSavKKciUpfZZuW3trN3R4MjR2HDt+wE7vt6hNul0F05qdhBgLx/4TnNwHx1n+FE4TNQoO - lNBWUFooxQWjGStEJmlVA3OF1EZVVNJcAnsWblt7ByePB1ewQ0M3Bk7TkRMq/i+5q8X3P13O3i2+ - 0UMX8zdPbk1K6ChCrjNWVZw6q5yEXFR1XjsrClFklPOszjMhRa7qZwkv5m++Zdx1eRRKfWgGVb5f - x7TtBvyyb/wORyo2EvOYY4jm139xZKMcC8aoVqZyrla20oYLzitqjdWi0prxCsBoKPQ/jOLs3fXV - AHkyvyTz+ZUkjMzSOnRkfk1ev7om90cdUy0O3a/52H/dqKNw8Nd48/gFAAD//wMA0OOUr70JAAA= - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6e0db0a07fbbe51e-ARI - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Mon, 21 Feb 2022 05:49:11 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=g5d54GFqOV5jObpa5K3eUGRTqV2aiKnMhZPzvSi38fpQ%2FiL3hPjNbooaZAHsfoXbgF%2F8mF9rGm2vw%2BGqQdJM2tHpWIJ5Vhwxaoi4dMMhlc8%2FQAR5QLBIQpkR9FEUuXHu4uXE%2BerMI6g4ko%2FU"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400 - status: - code: 200 - message: OK - - request: - body: null - headers: - Accept: - - "*/*" - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/episode?id=103041 - response: - body: - string: !!binary | - H4sIAAAAAAAAAwAAAP//3JbZbqNIFIZfBZXkvnJwLdSGFI1YTKfb8e7YSUa5KKCIkW2IAGeZdt59 - hJ3FnUmmx9M3My0hIcQ5nP/89dUpvoE0BjaCBFqoCarbOAQ2YEgIC1qgCUqtyjwDNmqCbL0KdQFs - ABFogiqtlhrYYKxvdaGySNfB69VKFQ/ABic6Kx4MX6tbXTQNZcRaVfOjIr8zVFXlRaYfmkaUZ0mR - Z1VpzNPSiFWxMG5UWRl3c50Zqr7y7GGVr0sjUsulsVwXug5dGaGKFkaVb9Pm+UpX+V1m5InhqbJa - amOUR4um0VVppk3QBIVealXqEti/fwNlvi6iWvZMh6P0BjRBlMc6Aja4x6zut9JqBWwwccf1g7ou - gQ3qztI/6iwCodF16zd5Ueis2n10XaTABit1nenK/u2+Ol4XmR1W6dxWhOA4kVQKpAUmitI4JkrR - SCgrTqiQjLGIUPIpzo536s1avTmGqA2ROWu7o/TGrKUdTdzxp6o4nlfVTYM4DRw0cFAVKlrowny6 - J+m9GeWrBnEEbOBAZVm+ziJdp63j1yxpFqoIr82VbhAHcwQ/CqjypwDweNUEeplmi/2GdYwXdqu1 - SdKl3ryj/oWMt32Yq8XthkBiSYYJ2kCLe64LGZKBEMShGLoBEQFFnieDtsSb+XEQnHb8c6/rBN0p - 8y58TsmlyztjPBwPB4Ovk01rK7Fch1ss91XWhpV2q6WKa53Vq2tmumq9RLY44Uy29uU3sMAQiQaW - T50c7XWyxealky0/66wCtqBSiserx+a/ZIxjePPKmaCHcca4CkPEEFdRHDKZYEkQRJiRkFoxljKM - udQqSj7grC7+y8D2XjNb4oSE0LI4ZRuPusSVtG0R6VFGoY+4gyGUwpcWl4zWxE17zmzaHk4C96x3 - 2e1MnS51+nw0uOxMhw45fYe4gxa/2554/d7r+iMo9gHAJjI+HwCA1EqohKqEWoxKxRCFSKKY8yhG - HMEYScwEIvQDALbVn007r03b6fuPQHCY0SffGd2bhB/tMnbgLtMxp1HCsFYR0VDymCc6sghPokir - RCRKMsipZh+Y/AbRk7PTs2fLHcfDJjRr5Ue9Sfir2f4GbmRaB8GNRcRljBRFMaWMxRRSRCVnhElF - YSKYplYYIvVD33eY/8+Nd/2/myt+u+v0/H/67/Jzk3arZDdjd2V3BzumBGMk4MbhkGEOA4oxdSlG - 0GU+YQT6nGHp8mAzP+6zIe8P+m33xLPafTrgzmB2TqyL2dRzLzqO88Mx6y7XI/XwvR1074xtj7pp - zzl9NQQjts8iNdlfWPw5V7YFzLMT39xpqw2iR89CthYxiBCjEmGx8TDkAlvEgpbnMcykF3DkUzcg - qI25Wx9Fw8+zL50OHgwt6+K0Sy798dfxoEfOppezaX/8pfuuR1ePfwIAAP//AwAUxeXr2QsAAA== - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6e0db0aea83ce51e-ARI - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Mon, 21 Feb 2022 05:49:13 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=AdOHU5vn5DOJvLGv0lIJUFfzCd10UMoFR0Rw5PsWx9O0mFdhpyALZD4%2BAS020DGyUIebvektdDb4Yt5BGySvi9xEMeGdOPE48fqr3Xc%2Bw1auefrQ3GWmS9R9AALLnKa4VeSORN%2Fwpc3h5OSy"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400 - status: - code: 200 - message: OK -version: 1 diff --git a/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_movie.yaml b/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_movie.yaml deleted file mode 100644 index 99d9a88bb..000000000 --- a/tests/subliminal_patch/cassettes/test_argenteam/test_list_subtitles_movie.yaml +++ /dev/null @@ -1,366 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/search?q=Dune - response: - body: - string: !!binary | - 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 - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743805800751b-SCL - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:36 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=Yn6t4SwaaAd9gFu7CdcUfuG7sL4OIk%2BUlzt%2FiSOj00efVFPV5%2Botf%2BLwl8egR0g5tIU8V33OlhnF9TKWWImtVl8yPo80jTtytM7ELcnayyMkjEyj%2BCgZMBN2ptAqHyimW3e%2FNkBpWaeaJVuP"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/movie?id=324 - response: - body: - string: !!binary | - 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 - AAA= - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743828b81751b-SCL - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:36 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=6sWVI63HrWSWdlINe3fSYawBVQfavfKNksSfSbBSIW6Fh8YSxwqMlDHb5EP37r1WrL0w5i01ybpYeOJ1zG84rRBtfe6J1y550wu9PNeZbzB8vmwhwSHhXz19bBr23yYqCXR03wsghed1mTOt"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/movie?id=43318 - response: - body: - string: !!binary | - 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 - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743847e35751b-SCL - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:36 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=roopZ7rq4zoUh8HmwGGxVKeqTkLo4oTjKQais8ptS3gkHgwlMMTqBYkDaxwKTpKxO35BOLVkkmPEafRs1dWLLO9P10SkErIx04SdNuBn8mopoL858lvHHYUM75ZhPDx%2BO5k%2FJaAv%2Bm0dy3w5"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/movie?id=24827 - response: - body: - string: !!binary | - H4sIAAAAAAAAA2xUy24jRwz8FYLAAgnQGY3eowFysC07wK4c+JE1jGR9oLopqe2e7kk/BMiWP2aP - e9hDsJ+gHwt6lA1iIMdhNVnFGpIvqBXWg1E1mAqMOhrGGufJMvzQn1WjH1GgtiuH9csbNIcbtcQa - y7Ka9qsBCtwxeaxzlkBPUds11pNiLNAnG3XDWPeHU4FkIntLUW/5t38qokDpko1+hzV+vD1Bga0L - kT3WuImxDXWvR37NNjI1heXY8xxc8pJDT7ot+9DjwbCi6Wg64dFE9mdlSeVqsppNZqNyyVKq4rFd - o0ClPcvocuE5bbWCxc7KDQqkHA1Y44edYbgkuSC5MWQF3Gm/1lYTXJIKbAVceLKSgyQ4sVYHfBUY - UtNQp/7cAhtQHA5ffNTSQWvIciRQDCfe05MOAqSzTmrlIFKz1IcvFlrnIfsqwBB43rJnqwgaUp7h - hhoyxuW0x+QJkiXYan4kaL1bsTx8pRo+4R1b5Q+fgcw6abaZsUk7MPzoAuwgeuIMG4J1Yu8JAtlI - Iut9rzekBPyZGNrk9UpLyk/ZQLI6++tgB9YFCHREFIOhAFFbzUtDoSiKTwjH5j03Lr5p/MwZUtnL - BakdvOcQtCSRXXJLViyzBcp1FQ/fvGLLIaeFBBv2DVlFKn9LZyUvtYfgDDV5GmCjHykIIEnLjkmR - BwKTnoFMBh1syR++2a61kIAa1zWsUg4sODo4iZ614iAgko0uq2JaUvFGLITEh78ItJUmhe5XfC/Z - sVxRMmAMrxMDZXTLPmofGNhCstBwOHylkJ/YTAGtU+w5QHP4vNbS5aGglp4zlKz2WWPU3LTZeDbA - oSWpXVEUKNCzYQocsP7jBY97kOf5bn6j226VFMsc0Nt7FJh3Bmv81f1yc5U/aR2OKxf0c86bliVc - nmbEec825rIPAtlo+3SkSF5jjawGT3Wvt19pw/s8q8XxQhRH4iLT/TS8uCxoq/fTYTksq0lZ7c8m - 81l/Phyczaez8Ww2mFxUo+qkGs/Phxfz8WS03/x8ev/x98V4fDe8vDk9/XA7WNyMpvP76fXV9fvb - s/H1+b6Hrw95yZbdCfqvqP+/Dv++7PVn49Gk16l9N6iy3neD2RvF333pDhDW1XhY9V8fXh9e/wYA - AP//AwAQgSiXGgUAAA== - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743871ab0751b-SCL - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:37 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=f1%2FtVUaDLDW9XLjFghRHPaon1nuMq2Umv6OSR%2F5TGwYh2Pd4H1RGDiql3jFX05VN7oSywM%2FNyLKtDscx%2BXhXlHdLOmB%2BOILMvGUMfsjWE3wAXN%2BCOrR7ELOK%2F3tGj6Qk8jYjUX48q9NsD8j2"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - Sub-Zero/2 - method: GET - uri: https://argenteam.net/api/v1/movie?id=132088 - response: - body: - string: !!binary | - H4sIAAAAAAAAAwAAAP//vFXbbtw2EP0VgsAGDqBoddvVBQiCTRzXaXNDcyta+GFEjrSMpaFCUls7 - gT8mj37uJ/jHCmrt2GnqpC7SPkkEz5yZOXM0+sCV5FWcJlFRBNwp1yGv+O5IWLHnYBx7Rsh2kiiJ - b/OAK2o0rz5cg/OAXta84nG8jLK45AE/RjC88vEBN+AUtbwqwjTgZiSneuRVvFgEHDqHhsCpDb48 - 5+YBF3okZ455xV+RcijZCwcObcAeAIGEgO2P1II5DthTbX6H44A9IqmAB3zQ1qHhFV87N9hqPgfT - IjmEPiR0c4NWj0agnQu9QWPnUV1ngM1yIdO0yNIijmpMlyLGZS5qLBfh26HlAZfKoHDaE+8iKcte - q65DwnHjmwd/ZXnFX6peu/XZKbIHa+igRxewn7FGIYDtoWlHqylgvyJJOAZ+EnA79j1Mja6MgUNl - A4YdGzogdMAkdkyiVWicDliDo9RMIuuANdCrTgHbB3OoiZA8TiJbg0DWIqEBoTR5yd6NKIEhsR5I - 2/P4B2BhetWWrZxBJdFf2WE8O50w70b0lWA/oAGpDROeBdjZqXXgGfBo6LQDoc7+oC2pZQYtmg1M - BGgHFAoCNhJc3Pfg0CiwbDCq9+ezj5ZtoFPawkVlLXRwpIAdMwd9rc5OactgdAtMwADvPRD6oVON - EmB8jNAkFJKYwvDIIUmcLjZKQsgedkyOvqHH6DTbeWZ92CMLIG4HHiWhB/YjWqsEsJ2/zuu25+zY - Wr3V28y1tgF7DmN3qdzOl4P31B22QAwuByo0TdpZryq9n4QxKEZ/ND6LQdJ9bdBf2JEJsBCwAY1m - g9HkNLPINmjOPhJD2ozYOW39bL1Ezvg+JPoXtZ2+L51auKftNNAOrS9qAxMBMDGidUoTmCmZpkZN - VSE54/OfTwimRqT2dODnpX21MJmnG1sweM9L4o97Bnuk7dA9txmmVtZQKwfkJpNdenrS4xxqUKyB - GezOLXVFKqEg5AE32CFYtLz67QPffsa84m8e3p8WhkTBK76fLDMecP+984o/XT168cofofVfZ55E - Aw+4Ve99YBYu2A8+1mljkNyWdzSKV7yHltBV947c3dFQVTu1rmqsJZZLKLK6iXOEJCuwWUaYyHxZ - Yp76S5HE2S1Jd/16DP3uC33O8M3D+6Gv7M5U0C1n7vrtNEtXs2Rvluw5A+IQTXj+bNRRKHQ/S1dF - NEv2gEiPJNCHjfIyqgwNmLoNe5ylqyTPo+sATntAkX4B+JR3rQg7HNZALtSmnaWrOMm/goeuG5Da - UdEFfJHnET85CDh2ig69lAd+udXT/+Kqsn+/lj8h58UyStL5pJ//u4TPCMNZUngtZ0n5SclJ1zsX - 851+F7wqFmkRnxycBP/OHnFUXPVHGRY38kdSJxLTqJG4hCRPiygSSdNETVbnUVkuyqxImqaM48/9 - MSX9PwxSZN8ySHSzgSc381MRfT+DZP/AIJOwN3fIUbJcXOuQJF5edUgS3XCF5NgUCdYLEZUC6xRl - Vogig2UTJ2VaJlG5gKxe5unnFpmyhvtPVr/47u7sPg53d58vwjhcuV7bcPd1+OSn16Ev/D+0T5l+ - wz754ob75Xr/NOAO1xo6uACnefz9zLP4unm8jlvJrzPPwcmfAAAA//8DAF+Mars8CwAA - headers: - CF-Cache-Status: - - DYNAMIC - CF-RAY: - - 6b6743963ac0751b-SCL - Connection: - - keep-alive - Content-Encoding: - - gzip - Content-Type: - - application/json;charset=UTF-8 - Date: - - Tue, 30 Nov 2021 21:45:39 GMT - Expect-CT: - - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" - NEL: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=OOTMjfi961ChESCSv1lx8pV7e4fOzfcUwY%2FGNUVjmQb7GZ5jDSRK51fRZwZlAxGzIXWonIGdvrRtnVF4dvJ%2FbANIAd0LPcbDR1w2JbzE8Z%2B%2BvHg7waks8MDGnggzHmjXyUTPHAySBa8Vo0DM"}],"group":"cf-nel","max_age":604800}' - Server: - - cloudflare - Transfer-Encoding: - - chunked - alt-svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400, h3-28=":443"; ma=86400, h3-27=":443"; - ma=86400 - status: - code: 200 - message: OK -version: 1 diff --git a/tests/subliminal_patch/test_argenteam.py b/tests/subliminal_patch/test_argenteam.py deleted file mode 100644 index 93e9cf6ac..000000000 --- a/tests/subliminal_patch/test_argenteam.py +++ /dev/null @@ -1,142 +0,0 @@ -# -*- coding: utf-8 -*- - -import pytest -import os -from subliminal_patch.providers.argenteam import ArgenteamProvider -from subliminal_patch.providers.argenteam import ArgenteamSubtitle -from subliminal_patch.core import Episode -from subzero.language import Language - - -@pytest.mark.parametrize( - "imdb_id,expected_id", [("tt0028950", 62790), ("tt0054407", 102006)] -) -def test_search_ids_movie(imdb_id, expected_id): - with ArgenteamProvider() as provider: - ids = provider._search_ids(imdb_id) - assert ids[0] == expected_id - - -def test_search_ids_tv_show(): - with ArgenteamProvider() as provider: - ids = provider._search_ids("tt0306414", season=1, episode=1) - assert ids[0] == 10075 - - -def test_parse_subtitles_episode(): - with ArgenteamProvider() as provider: - assert len(provider._parse_subtitles([10075])) > 1 - - -def test_parse_subtitles_movie(): - with ArgenteamProvider() as provider: - assert len(provider._parse_subtitles([61], is_episode=False)) > 3 - - -def test_get_matches_episode(episodes): - episode = episodes["breaking_bad_s01e01"] - subtitle = ArgenteamSubtitle( - Language.fromalpha2("es"), - None, - "https://argenteam.net/subtitles/24002/Breaking.Bad.%282008%29.S01E01-Pilot.BluRay.x264.720p-REWARD", - "Breaking.Bad.(2008).S01E01-Pilot.BluRay.x264.720p-REWARD\nBluRay x264 720p", - {"series", "title", "season", "episode", "imdb_id"}, - ) - matches = subtitle.get_matches(episode) - assert matches == { - "title", - "season", - "episode", - "imdb_id", - "source", - "video_codec", - "resolution", - "edition", - "streaming_service", - "release_group", - "series", - "year", - } - - -def test_get_matches_movie(movies): - movie = movies["dune"] - subtitle = ArgenteamSubtitle( - Language.fromalpha2("es"), - None, - "https://argenteam.net/subtitles/86024/Dune.Part.One.%282021%29.WEB.H264.1080p-NAISU", - "WEB H264 1080p", - {"title", "year", "imdb_id"}, - ) - matches = subtitle.get_matches(movie) - assert matches == { - "title", - "year", - "imdb_id", - "source", - "resolution", - "edition", - "video_codec", - "streaming_service", - } - - -def test_list_subtitles_movie(movies): - item = movies["dune"] - with ArgenteamProvider() as provider: - subtitles = provider.list_subtitles(item, {Language("spa", "MX")}) - - for expected in ( - "https://argenteam.net/subtitles/86023/Dune.Part.One.%282021%29.WEB.H264.720p-NAISU", - "https://argenteam.net/subtitles/86024/Dune.Part.One.%282021%29.WEB.H264.1080p-NAISU", - "https://argenteam.net/subtitles/86025/Dune.Part.One.%282021%29.WEB.x265.2160p-NAISU", - ): - assert any(expected == sub.download_link for sub in subtitles) - - -def test_list_subtitles_movie_no_imdb(movies): - item = movies["dune"] - item.imdb_id = None - with ArgenteamProvider() as provider: - assert not provider.list_subtitles(item, {Language("spa", "MX")}) - - -def test_list_subtitles_movie_not_found(movies): - item = movies["dune"] - item.imdb_id = "tt29318321832" - with ArgenteamProvider() as provider: - assert not provider.list_subtitles(item, {Language("spa", "MX")}) - - -def test_list_subtitles_episode(episodes): - item = episodes["breaking_bad_s01e01"] - with ArgenteamProvider() as provider: - subtitles = provider.list_subtitles(item, {Language("spa", "MX")}) - - for expected in ( - "https://argenteam.net/subtitles/24002/Breaking.Bad.%282008%29.S01E01-Pilot.BluRay.x264.720p-REWARD", - "https://argenteam.net/subtitles/23940/Breaking.Bad.%282008%29.S01E01-Pilot.DVDRip.XviD-ORPHEUS", - ): - assert any(expected == sub.download_link for sub in subtitles) - - -def test_list_subtitles_episode_no_imdb_id(episodes): - item = episodes["breaking_bad_s01e01"] - item.series_imdb_id = None - with ArgenteamProvider() as provider: - assert not provider.list_subtitles(item, {Language("spa", "MX")}) - - -def test_list_subtitles_episode_not_found(episodes): - item = episodes["breaking_bad_s01e01"] - item.series_imdb_id = "tt29318321832" - with ArgenteamProvider() as provider: - assert not provider.list_subtitles(item, {Language("spa", "MX")}) - - -def test_download_subtitle(episodes): - item = episodes["breaking_bad_s01e01"] - with ArgenteamProvider() as provider: - subtitles = provider.list_subtitles(item, {Language("spa", "MX")}) - provider.download_subtitle(subtitles[0]) - assert subtitles[0].is_valid() diff --git a/tests/subliminal_patch/test_core.py b/tests/subliminal_patch/test_core.py index fadf4e493..b4d283493 100644 --- a/tests/subliminal_patch/test_core.py +++ b/tests/subliminal_patch/test_core.py @@ -23,7 +23,7 @@ def test_scan_video_episode(tmpdir): @pytest.fixture def pool_instance(): - yield core.SZProviderPool({"argenteam"}, {}) + yield core.SZProviderPool({"subdivx"}, {}) def test_pool_update_w_nothing(pool_instance): @@ -33,43 +33,43 @@ def test_pool_update_w_nothing(pool_instance): def test_pool_update_w_multiple_providers(pool_instance): - assert pool_instance.providers == {"argenteam"} - pool_instance.update({"argenteam", "subdivx", "subf2m"}, {}, [], {}) - assert pool_instance.providers == {"argenteam", "subdivx", "subf2m"} + assert pool_instance.providers == {"subdivx"} + pool_instance.update({"subdivx", "subf2m"}, {}, [], {}) + assert pool_instance.providers == {"subdivx", "subf2m"} def test_pool_update_discarded_providers(pool_instance): - assert pool_instance.providers == {"argenteam"} + assert pool_instance.providers == {"subdivx"} # Provider was discarded internally - pool_instance.discarded_providers = {"argenteam"} + pool_instance.discarded_providers = {"subdivx"} - assert pool_instance.discarded_providers == {"argenteam"} + assert pool_instance.discarded_providers == {"subdivx"} # Provider is set to be used again - pool_instance.update({"subdivx", "argenteam"}, {}, [], {}) + pool_instance.update({"subdivx", "subf2m"}, {}, [], {}) - assert pool_instance.providers == {"argenteam", "subdivx"} + assert pool_instance.providers == {"subf2m", "subdivx"} # Provider should disappear from discarded providers assert pool_instance.discarded_providers == set() def test_pool_update_discarded_providers_2(pool_instance): - assert pool_instance.providers == {"argenteam"} + assert pool_instance.providers == {"subdivx"} # Provider was discarded internally - pool_instance.discarded_providers = {"argenteam"} + pool_instance.discarded_providers = {"subdivx"} - assert pool_instance.discarded_providers == {"argenteam"} + assert pool_instance.discarded_providers == {"subdivx"} # Provider is not set to be used again - pool_instance.update({"subdivx"}, {}, [], {}) + pool_instance.update({"subf2m"}, {}, [], {}) - assert pool_instance.providers == {"subdivx"} + assert pool_instance.providers == {"subf2m"} # Provider should not disappear from discarded providers - assert pool_instance.discarded_providers == {"argenteam"} + assert pool_instance.discarded_providers == {"subdivx"} def test_language_equals_init(): From e4ebc64ca95b4a18b7a1909a5cef517c0715d74d Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Fri, 12 Jan 2024 16:45:37 -0500 Subject: [PATCH 36/49] Fixed issue introduced while merging the new sync feature. #2356 --- bazarr/subtitles/sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bazarr/subtitles/sync.py b/bazarr/subtitles/sync.py index 5633f73e8..35f62bc46 100644 --- a/bazarr/subtitles/sync.py +++ b/bazarr/subtitles/sync.py @@ -17,7 +17,7 @@ def sync_subtitles(video_path, srt_path, srt_lang, forced, percent_score, sonarr else: logging.debug(f'BAZARR automatic syncing is enabled in settings. We\'ll try to sync this ' f'subtitles: {srt_path}.') - if media_type == 'series': + if sonarr_episode_id: use_subsync_threshold = settings.subsync.use_subsync_threshold subsync_threshold = settings.subsync.subsync_threshold else: From 828ac34074c35778ac47ce7df78c362def60de77 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Sat, 13 Jan 2024 16:58:13 -0500 Subject: [PATCH 37/49] Fixed titlovi provider not being properly throttled and resulting in account locking. #2062 --- bazarr/app/get_providers.py | 3 +++ libs/subliminal_patch/providers/titlovi.py | 14 ++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py index 415340b19..2b6d5f5f7 100644 --- a/bazarr/app/get_providers.py +++ b/bazarr/app/get_providers.py @@ -98,6 +98,9 @@ def provider_throttle_map(): 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(), diff --git a/libs/subliminal_patch/providers/titlovi.py b/libs/subliminal_patch/providers/titlovi.py index 42c224bce..88782522c 100644 --- a/libs/subliminal_patch/providers/titlovi.py +++ b/libs/subliminal_patch/providers/titlovi.py @@ -7,8 +7,6 @@ import re from datetime import datetime, timedelta import dateutil.parser -import rarfile - from zipfile import ZipFile, is_zipfile from rarfile import RarFile, is_rarfile from babelfish import language_converters, Script @@ -20,6 +18,7 @@ from subliminal_patch.providers.mixins import ProviderSubtitleArchiveMixin from subliminal_patch.subtitle import Subtitle, guess_matches from subliminal_patch.utils import sanitize, fix_inconsistent_naming as _fix_inconsistent_naming from subliminal.exceptions import ProviderError, AuthenticationError, ConfigurationError +from subliminal_patch.exceptions import TooManyRequests from subliminal.score import get_equivalent_release_groups from subliminal.utils import sanitize_release_group from subliminal.video import Episode, Movie @@ -190,7 +189,11 @@ class TitloviProvider(Provider, ProviderSubtitleArchiveMixin): @region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME) def get_result(self, search_url, search_params): - return self.session.get(search_url, params=search_params) + resp = self.session.get(search_url, params=search_params) + if resp.status_code == request_codes.too_many_requests: + raise TooManyRequests('Too many requests') + else: + return resp def query(self, languages, title, season=None, episode=None, year=None, imdb_id=None, video=None): search_params = dict() @@ -235,7 +238,8 @@ class TitloviProvider(Provider, ProviderSubtitleArchiveMixin): resp_json = response.json() if resp_json['SubtitleResults']: query_results.extend(resp_json['SubtitleResults']) - + except TooManyRequests: + raise except Exception as e: logger.error(e) @@ -295,6 +299,8 @@ class TitloviProvider(Provider, ProviderSubtitleArchiveMixin): def download_subtitle(self, subtitle): r = self.session.get(subtitle.download_link, timeout=10) + if r.status_code == request_codes.too_many_requests: + raise TooManyRequests('Too many requests') r.raise_for_status() # open the archive From e17865ad535da06caddb25cfff30961cecae20f1 Mon Sep 17 00:00:00 2001 From: JayZed Date: Tue, 23 Jan 2024 19:57:27 -0500 Subject: [PATCH 38/49] Fixed improper passing of hi and forced flags when searching a subtitles manually. #2350 --- frontend/src/pages/Wanted/Movies/index.tsx | 6 +++--- frontend/src/pages/Wanted/Series/index.tsx | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Wanted/Movies/index.tsx b/frontend/src/pages/Wanted/Movies/index.tsx index 102a41139..663a2fa57 100644 --- a/frontend/src/pages/Wanted/Movies/index.tsx +++ b/frontend/src/pages/Wanted/Movies/index.tsx @@ -34,7 +34,7 @@ const WantedMoviesView: FunctionComponent = () => { accessor: "missing_subtitles", Cell: ({ row, value }) => { const wanted = row.original; - const { hearing_impaired: hi, radarrId } = wanted; + const { radarrId } = wanted; const { download } = useMovieSubtitleModification(); @@ -55,8 +55,8 @@ const WantedMoviesView: FunctionComponent = () => { radarrId, form: { language: item.code2, - hi, - forced: false, + hi: item.hi, + forced: item.forced, }, } ); diff --git a/frontend/src/pages/Wanted/Series/index.tsx b/frontend/src/pages/Wanted/Series/index.tsx index 5496a8530..863f17dfd 100644 --- a/frontend/src/pages/Wanted/Series/index.tsx +++ b/frontend/src/pages/Wanted/Series/index.tsx @@ -48,7 +48,6 @@ const WantedSeriesView: FunctionComponent = () => { accessor: "missing_subtitles", Cell: ({ row, value }) => { const wanted = row.original; - const hi = wanted.hearing_impaired; const seriesId = wanted.sonarrSeriesId; const episodeId = wanted.sonarrEpisodeId; @@ -72,8 +71,8 @@ const WantedSeriesView: FunctionComponent = () => { episodeId, form: { language: item.code2, - hi, - forced: false, + hi: item.hi, + forced: item.forced, }, } ); From 345408d692b6cd6917a0a0db786c1b0af3ed809c Mon Sep 17 00:00:00 2001 From: JayZed Date: Tue, 23 Jan 2024 19:59:09 -0500 Subject: [PATCH 39/49] Fixed wizdom provider to handle Zip files with more than one (up to two entries). #2351 --- libs/subliminal_patch/providers/wizdom.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/libs/subliminal_patch/providers/wizdom.py b/libs/subliminal_patch/providers/wizdom.py index 12666f541..4091b7ad8 100644 --- a/libs/subliminal_patch/providers/wizdom.py +++ b/libs/subliminal_patch/providers/wizdom.py @@ -213,7 +213,12 @@ class WizdomProvider(Provider): with zipfile.ZipFile(io.BytesIO(r.content)) as zf: # remove some filenames from the namelist namelist = [n for n in zf.namelist() if os.path.splitext(n)[1] in ['.srt', '.sub']] - if len(namelist) > 1: - raise ProviderError('More than one file to unzip') - - subtitle.content = fix_line_ending(zf.read(namelist[0])) + if len(namelist) > 0: + subtitle.content = fix_line_ending(zf.read(namelist[0])) + # this provider sometimes returns both utf-8 and windows-1255 encodings of the same text in one zip file + if len(namelist) > 1: + # check if the first one we downloaded is good + valid = subtitle.is_valid() + if not valid: + # in case we can't use the first one, return the second one and hope for the best + subtitle.content = fix_line_ending(zf.read(namelist[1])) From c45a2ac43cad30ab6aa45f7cfd90afba16cdbdd3 Mon Sep 17 00:00:00 2001 From: Lawrence A <3211473+lawadr@users.noreply.github.com> Date: Wed, 24 Jan 2024 01:06:46 +0000 Subject: [PATCH 40/49] Fixed single blacklist item deletion issue that would remove all the blacklisted subtitles from the same provider at once. --- bazarr/radarr/blacklist.py | 2 +- bazarr/sonarr/blacklist.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bazarr/radarr/blacklist.py b/bazarr/radarr/blacklist.py index f42f8172f..bd291399a 100644 --- a/bazarr/radarr/blacklist.py +++ b/bazarr/radarr/blacklist.py @@ -29,7 +29,7 @@ def blacklist_log_movie(radarr_id, provider, subs_id, language): def blacklist_delete_movie(provider, subs_id): database.execute( delete(TableBlacklistMovie) - .where((TableBlacklistMovie.provider == provider) and (TableBlacklistMovie.subs_id == subs_id))) + .where((TableBlacklistMovie.provider == provider) & (TableBlacklistMovie.subs_id == subs_id))) event_stream(type='movie-blacklist', action='delete') diff --git a/bazarr/sonarr/blacklist.py b/bazarr/sonarr/blacklist.py index 2b063d4d4..a72649434 100644 --- a/bazarr/sonarr/blacklist.py +++ b/bazarr/sonarr/blacklist.py @@ -30,7 +30,7 @@ def blacklist_log(sonarr_series_id, sonarr_episode_id, provider, subs_id, langua def blacklist_delete(provider, subs_id): database.execute( delete(TableBlacklist) - .where((TableBlacklist.provider == provider) and (TableBlacklist.subs_id == subs_id))) + .where((TableBlacklist.provider == provider) & (TableBlacklist.subs_id == subs_id))) event_stream(type='episode-blacklist', action='delete') From 3922d52d5cfa6fd3da2aca2f9f2e5bda61091dd4 Mon Sep 17 00:00:00 2001 From: Lawrence A <3211473+lawadr@users.noreply.github.com> Date: Wed, 24 Jan 2024 21:50:05 +0000 Subject: [PATCH 41/49] Fixed improper API call on removal of all blacklist items --- bazarr/api/episodes/blacklist.py | 4 ++-- bazarr/api/movies/blacklist.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bazarr/api/episodes/blacklist.py b/bazarr/api/episodes/blacklist.py index 8c5ffc0c0..d429f232f 100644 --- a/bazarr/api/episodes/blacklist.py +++ b/bazarr/api/episodes/blacklist.py @@ -129,8 +129,8 @@ class EpisodesBlacklist(Resource): delete_request_parser = reqparse.RequestParser() delete_request_parser.add_argument('all', type=str, required=False, help='Empty episodes subtitles blacklist') - delete_request_parser.add_argument('provider', type=str, required=True, help='Provider name') - delete_request_parser.add_argument('subs_id', type=str, required=True, help='Subtitles ID') + delete_request_parser.add_argument('provider', type=str, required=False, help='Provider name') + delete_request_parser.add_argument('subs_id', type=str, required=False, help='Subtitles ID') @authenticate @api_ns_episodes_blacklist.doc(parser=delete_request_parser) diff --git a/bazarr/api/movies/blacklist.py b/bazarr/api/movies/blacklist.py index 60c069597..266027fab 100644 --- a/bazarr/api/movies/blacklist.py +++ b/bazarr/api/movies/blacklist.py @@ -122,8 +122,8 @@ class MoviesBlacklist(Resource): delete_request_parser = reqparse.RequestParser() delete_request_parser.add_argument('all', type=str, required=False, help='Empty movies subtitles blacklist') - delete_request_parser.add_argument('provider', type=str, required=True, help='Provider name') - delete_request_parser.add_argument('subs_id', type=str, required=True, help='Subtitles ID') + delete_request_parser.add_argument('provider', type=str, required=False, help='Provider name') + delete_request_parser.add_argument('subs_id', type=str, required=False, help='Subtitles ID') @authenticate @api_ns_movies_blacklist.doc(parser=delete_request_parser) From 990448b06e6df9f2728d98a9a14df5f2b378b00a Mon Sep 17 00:00:00 2001 From: JayZed Date: Wed, 24 Jan 2024 18:07:15 -0500 Subject: [PATCH 42/49] Added Sync Optimization for Movies, Series and Episodes to ignore unmonitored items --- bazarr/app/config.py | 3 + bazarr/constants.py | 3 + bazarr/radarr/sync/movies.py | 67 ++++++++++++++++--- bazarr/radarr/sync/parser.py | 41 +++--------- bazarr/sonarr/sync/episodes.py | 54 ++++++++++++++- bazarr/sonarr/sync/parser.py | 3 +- bazarr/sonarr/sync/series.py | 48 +++++++++++++ bazarr/utilities/video_analyzer.py | 5 ++ .../src/pages/Settings/Scheduler/index.tsx | 44 ++++++++++++ 9 files changed, 222 insertions(+), 46 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index d490a6a4e..d1967cc38 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -161,6 +161,8 @@ validators = [ Validator('sonarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool), Validator('sonarr.exclude_season_zero', must_exist=True, default=False, is_type_of=bool), Validator('sonarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool), + Validator('sonarr.sync_only_monitored_series', must_exist=True, default=False, is_type_of=bool), + Validator('sonarr.sync_only_monitored_episodes', must_exist=True, default=False, is_type_of=bool), # radarr section Validator('radarr.ip', must_exist=True, default='127.0.0.1', is_type_of=str), @@ -180,6 +182,7 @@ validators = [ Validator('radarr.excluded_tags', must_exist=True, default=[], is_type_of=list), Validator('radarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool), Validator('radarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool), + Validator('radarr.sync_only_monitored_movies', must_exist=True, default=False, is_type_of=bool), # proxy section Validator('proxy.type', must_exist=True, default=None, is_type_of=(NoneType, str), diff --git a/bazarr/constants.py b/bazarr/constants.py index a746e7628..4f8af9614 100644 --- a/bazarr/constants.py +++ b/bazarr/constants.py @@ -8,3 +8,6 @@ headers = {"User-Agent": os.environ["SZ_USER_AGENT"]} # hearing-impaired detection regex hi_regex = re.compile(r'[*¶♫♪].{3,}[*¶♫♪]|[\[\(\{].{3,}[\]\)\}](? 20480 or - get_movie_file_size_from_db(movie['movieFile']['path']) > 20480)] + (movie['movieFile']['size'] > MINIMUM_VIDEO_SIZE or + get_movie_file_size_from_db(movie['movieFile']['path']) > MINIMUM_VIDEO_SIZE)] - # Remove old movies from DB + # Remove movies from DB that either no longer exist in Radarr or exist and Radarr says do not have a movie file movies_to_delete = list(set(current_movies_id_db) - set(current_movies_radarr)) - + movies_deleted = [] if len(movies_to_delete): try: database.execute(delete(TableMovies).where(TableMovies.tmdbId.in_(movies_to_delete))) @@ -117,11 +135,19 @@ def update_movies(send_event=True): logging.error(f"BAZARR cannot delete movies because of {e}") else: for removed_movie in movies_to_delete: + movies_deleted.append(removed_movie['title']) if send_event: event_stream(type='movie', action='delete', payload=removed_movie) - # Build new and updated movies + # Add new movies and update movies that Radarr says have media files + # Any new movies added to Radarr that don't have media files yet will not be added to DB movies_count = len(movies) + sync_monitored = settings.radarr.sync_only_monitored_movies + if sync_monitored: + skipped_count = 0 + files_missing = 0 + movies_added = [] + movies_updated = [] for i, movie in enumerate(movies): if send_event: show_progress(id='movies_progress', @@ -129,12 +155,22 @@ def update_movies(send_event=True): name=movie['title'], value=i, count=movies_count) - + # Only movies that Radarr says have files downloaded will be kept up to date in the DB if movie['hasFile'] is True: if 'movieFile' in movie: - if (movie['movieFile']['size'] > 20480 or - get_movie_file_size_from_db(movie['movieFile']['path']) > 20480): - # Add movies in radarr to current movies list + if sync_monitored: + if get_movie_monitored_status(movie['tmdbId']) != movie['monitored']: + # monitored status is not the same as our DB + trace(f"{i}: (Monitor Status Mismatch) {movie['title']}") + elif not movie['monitored']: + trace(f"{i}: (Skipped Unmonitored) {movie['title']}") + skipped_count += 1 + continue + + if (movie['movieFile']['size'] > MINIMUM_VIDEO_SIZE or + get_movie_file_size_from_db(movie['movieFile']['path']) > MINIMUM_VIDEO_SIZE): + # Add/update movies from Radarr that have a movie file to current movies list + trace(f"{i}: (Processing) {movie['title']}") if str(movie['tmdbId']) in current_movies_id_db: parsed_movie = movieParser(movie, action='update', tags_dict=tagsDict, @@ -142,16 +178,29 @@ def update_movies(send_event=True): audio_profiles=audio_profiles) if not any([parsed_movie.items() <= x for x in current_movies_db_kv]): update_movie(parsed_movie, send_event) + movies_updated.append(parsed_movie['title']) else: parsed_movie = movieParser(movie, action='insert', tags_dict=tagsDict, movie_default_profile=movie_default_profile, audio_profiles=audio_profiles) add_movie(parsed_movie, send_event) + movies_added.append(parsed_movie['title']) + else: + trace(f"{i}: (Skipped File Missing) {movie['title']}") + files_missing += 1 if send_event: hide_progress(id='movies_progress') + trace(f"Skipped {files_missing} file missing movies out of {i}") + if sync_monitored: + trace(f"Skipped {skipped_count} unmonitored movies out of {i}") + trace(f"Processed {i - files_missing - skipped_count} movies out of {i} " + + f"with {len(movies_added)} added, {len(movies_updated)} updated and {len(movies_deleted)} deleted") + else: + trace(f"Processed {i - files_missing} movies out of {i} with {len(movies_added)} added and {len(movies_updated)} updated") + logging.debug('BAZARR All movies synced from Radarr into database.') diff --git a/bazarr/radarr/sync/parser.py b/bazarr/radarr/sync/parser.py index 0d7e915ee..598d824c5 100644 --- a/bazarr/radarr/sync/parser.py +++ b/bazarr/radarr/sync/parser.py @@ -13,12 +13,6 @@ from .converter import RadarrFormatAudioCodec, RadarrFormatVideoCodec def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles): if 'movieFile' in movie: - # Detect file separator - if movie['path'][0] == "/": - separator = "/" - else: - separator = "\\" - try: overview = str(movie['overview']) except Exception: @@ -120,10 +114,9 @@ def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles) tags = [d['label'] for d in tags_dict if d['id'] in movie['tags']] - if action == 'update': - return {'radarrId': int(movie["id"]), + parsed_movie = {'radarrId': int(movie["id"]), 'title': movie["title"], - 'path': movie["path"] + separator + movie['movieFile']['relativePath'], + 'path': os.path.join(movie["path"], movie['movieFile']['relativePath']), 'tmdbId': str(movie["tmdbId"]), 'poster': poster, 'fanart': fanart, @@ -142,30 +135,12 @@ def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles) 'movie_file_id': int(movie['movieFile']['id']), 'tags': str(tags), 'file_size': movie['movieFile']['size']} - else: - return {'radarrId': int(movie["id"]), - 'title': movie["title"], - 'path': movie["path"] + separator + movie['movieFile']['relativePath'], - 'tmdbId': str(movie["tmdbId"]), - 'subtitles': '[]', - 'overview': overview, - 'poster': poster, - 'fanart': fanart, - 'audio_language': str(audio_language), - 'sceneName': sceneName, - 'monitored': str(bool(movie['monitored'])), - 'sortTitle': movie['sortTitle'], - 'year': str(movie['year']), - 'alternativeTitles': alternativeTitles, - 'format': format, - 'resolution': resolution, - 'video_codec': videoCodec, - 'audio_codec': audioCodec, - 'imdbId': imdbId, - 'movie_file_id': int(movie['movieFile']['id']), - 'tags': str(tags), - 'profileId': movie_default_profile, - 'file_size': movie['movieFile']['size']} + + if action == 'insert': + parsed_movie['subtitles'] = '[]' + parsed_movie['profileId'] = movie_default_profile + + return parsed_movie def profile_id_to_language(id, profiles): diff --git a/bazarr/sonarr/sync/episodes.py b/bazarr/sonarr/sync/episodes.py index 7894d9061..fc4b568ee 100644 --- a/bazarr/sonarr/sync/episodes.py +++ b/bazarr/sonarr/sync/episodes.py @@ -2,10 +2,11 @@ import os import logging +from constants import MINIMUM_VIDEO_SIZE from sqlalchemy.exc import IntegrityError -from app.database import database, TableEpisodes, delete, update, insert, select +from app.database import database, TableShows, TableEpisodes, delete, update, insert, select from app.config import settings from utilities.path_mappings import path_mappings from subtitles.indexer.series import store_subtitles, series_full_scan_subtitles @@ -16,14 +17,29 @@ from sonarr.info import get_sonarr_info, url_sonarr from .parser import episodeParser from .utils import get_episodes_from_sonarr_api, get_episodesFiles_from_sonarr_api +# map between booleans and strings in DB +bool_map = {"True": True, "False": False} +FEATURE_PREFIX = "SYNC_EPISODES " +def trace(message): + if settings.general.debug: + logging.debug(FEATURE_PREFIX + message) + +def get_episodes_monitored_table(series_id): + episodes_monitored = database.execute( + select(TableEpisodes.episode_file_id, TableEpisodes.monitored) + .where(TableEpisodes.sonarrSeriesId == series_id))\ + .all() + episode_dict = dict((x, y) for x, y in episodes_monitored) + return episode_dict + def update_all_episodes(): series_full_scan_subtitles() logging.info('BAZARR All existing episode subtitles indexed from disk.') def sync_episodes(series_id, send_event=True): - logging.debug('BAZARR Starting episodes sync from Sonarr.') + logging.debug(f'BAZARR Starting episodes sync from Sonarr for series ID {series_id}.') apikey_sonarr = settings.sonarr.apikey # Get current episodes id in DB @@ -58,16 +74,42 @@ def sync_episodes(series_id, send_event=True): if item: episode['episodeFile'] = item[0] + + sync_monitored = settings.sonarr.sync_only_monitored_series and settings.sonarr.sync_only_monitored_episodes + if sync_monitored: + episodes_monitored = get_episodes_monitored_table(series_id) + skipped_count = 0 + for episode in episodes: if 'hasFile' in episode: if episode['hasFile'] is True: if 'episodeFile' in episode: + # monitored_status_db = get_episodes_monitored_status(episode['episodeFileId']) + if sync_monitored: + try: + monitored_status_db = bool_map[episodes_monitored[episode['episodeFileId']]] + except KeyError: + monitored_status_db = None + + if monitored_status_db is None: + # not in db, might need to add, if we have a file on disk + pass + elif monitored_status_db != episode['monitored']: + # monitored status changed and we don't know about it until now + trace(f"(Monitor Status Mismatch) {episode['title']}") + # pass + elif not episode['monitored']: + # Add unmonitored episode in sonarr to current episode list, otherwise it will be deleted from db + current_episodes_sonarr.append(episode['id']) + skipped_count += 1 + continue + try: bazarr_file_size = \ os.path.getsize(path_mappings.path_replace(episode['episodeFile']['path'])) except OSError: bazarr_file_size = 0 - if episode['episodeFile']['size'] > 20480 or bazarr_file_size > 20480: + if episode['episodeFile']['size'] > MINIMUM_VIDEO_SIZE or bazarr_file_size > MINIMUM_VIDEO_SIZE: # Add episodes in sonarr to current episode list current_episodes_sonarr.append(episode['id']) @@ -80,6 +122,12 @@ def sync_episodes(series_id, send_event=True): episodes_to_add.append(episodeParser(episode)) else: return + + if sync_monitored: + # try to avoid unnecessary database calls + if settings.general.debug: + series_title = database.execute(select(TableShows.title).where(TableShows.sonarrSeriesId == series_id)).first()[0] + trace(f"Skipped {skipped_count} unmonitored episodes out of {len(episodes)} for {series_title}") # Remove old episodes from DB episodes_to_delete = list(set(current_episodes_id_db_list) - set(current_episodes_sonarr)) diff --git a/bazarr/sonarr/sync/parser.py b/bazarr/sonarr/sync/parser.py index ad3fae852..d8fce1697 100644 --- a/bazarr/sonarr/sync/parser.py +++ b/bazarr/sonarr/sync/parser.py @@ -4,6 +4,7 @@ import os from app.config import settings from app.database import TableShows, database, select +from constants import MINIMUM_VIDEO_SIZE from utilities.path_mappings import path_mappings from utilities.video_analyzer import embedded_audio_reader from sonarr.info import get_sonarr_info @@ -92,7 +93,7 @@ def episodeParser(episode): bazarr_file_size = os.path.getsize(path_mappings.path_replace(episode['episodeFile']['path'])) except OSError: bazarr_file_size = 0 - if episode['episodeFile']['size'] > 20480 or bazarr_file_size > 20480: + if episode['episodeFile']['size'] > MINIMUM_VIDEO_SIZE or bazarr_file_size > MINIMUM_VIDEO_SIZE: if 'sceneName' in episode['episodeFile']: sceneName = episode['episodeFile']['sceneName'] else: diff --git a/bazarr/sonarr/sync/series.py b/bazarr/sonarr/sync/series.py index 41eb4ee35..47bf4d59d 100644 --- a/bazarr/sonarr/sync/series.py +++ b/bazarr/sonarr/sync/series.py @@ -16,6 +16,20 @@ from .episodes import sync_episodes from .parser import seriesParser from .utils import get_profile_list, get_tags, get_series_from_sonarr_api +# map between booleans and strings in DB +bool_map = {"True": True, "False": False} + +FEATURE_PREFIX = "SYNC_SERIES " +def trace(message): + if settings.general.debug: + logging.debug(FEATURE_PREFIX + message) + +def get_series_monitored_table(): + series_monitored = database.execute( + select(TableShows.tvdbId, TableShows.monitored))\ + .all() + series_dict = dict((x, y) for x, y in series_monitored) + return series_dict def update_series(send_event=True): check_sonarr_rootfolder() @@ -55,6 +69,12 @@ def update_series(send_event=True): current_shows_sonarr = [] series_count = len(series) + sync_monitored = settings.sonarr.sync_only_monitored_series + if sync_monitored: + series_monitored = get_series_monitored_table() + skipped_count = 0 + trace(f"Starting sync for {series_count} shows") + for i, show in enumerate(series): if send_event: show_progress(id='series_progress', @@ -63,6 +83,26 @@ def update_series(send_event=True): value=i, count=series_count) + if sync_monitored: + try: + monitored_status_db = bool_map[series_monitored[show['tvdbId']]] + except KeyError: + monitored_status_db = None + if monitored_status_db is None: + # not in db, need to add + pass + elif monitored_status_db != show['monitored']: + # monitored status changed and we don't know about it until now + trace(f"{i}: (Monitor Status Mismatch) {show['title']}") + # pass + elif not show['monitored']: + # Add unmonitored series in sonarr to current series list, otherwise it will be deleted from db + trace(f"{i}: (Skipped Unmonitored) {show['title']}") + current_shows_sonarr.append(show['id']) + skipped_count += 1 + continue + + trace(f"{i}: (Processing) {show['title']}") # Add shows in Sonarr to current shows list current_shows_sonarr.append(show['id']) @@ -76,6 +116,7 @@ def update_series(send_event=True): .filter_by(**updated_series))\ .first(): try: + trace(f"Updating {show['title']}") database.execute( update(TableShows) .values(updated_series) @@ -92,6 +133,7 @@ def update_series(send_event=True): audio_profiles=audio_profiles) try: + trace(f"Inserting {show['title']}") database.execute( insert(TableShows) .values(added_series)) @@ -110,6 +152,10 @@ def update_series(send_event=True): removed_series = list(set(current_shows_db) - set(current_shows_sonarr)) for series in removed_series: + # try to avoid unnecessary database calls + if settings.general.debug: + series_title = database.execute(select(TableShows.title).where(TableShows.sonarrSeriesId == series)).first()[0] + trace(f"Deleting {series_title}") database.execute( delete(TableShows) .where(TableShows.sonarrSeriesId == series)) @@ -120,6 +166,8 @@ def update_series(send_event=True): if send_event: hide_progress(id='series_progress') + if sync_monitored: + trace(f"skipped {skipped_count} unmonitored series out of {i}") logging.debug('BAZARR All series synced from Sonarr into database.') diff --git a/bazarr/utilities/video_analyzer.py b/bazarr/utilities/video_analyzer.py index 1aad9b859..bd4cac011 100644 --- a/bazarr/utilities/video_analyzer.py +++ b/bazarr/utilities/video_analyzer.py @@ -266,6 +266,11 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No elif embedded_subs_parser == 'mediainfo': mediainfo_path = get_binary("mediainfo") + # see if file exists (perhaps offline) + if not os.path.exists(file): + logging.error(f'Video file "{file}" cannot be found for analysis') + return None + # if we have ffprobe available if ffprobe_path: try: diff --git a/frontend/src/pages/Settings/Scheduler/index.tsx b/frontend/src/pages/Settings/Scheduler/index.tsx index 3bd6da91a..a6cd2ca74 100644 --- a/frontend/src/pages/Settings/Scheduler/index.tsx +++ b/frontend/src/pages/Settings/Scheduler/index.tsx @@ -35,11 +35,55 @@ const SettingsSchedulerView: FunctionComponent = () => { options={seriesSyncOptions} settingKey="settings-sonarr-series_sync" > + + + + If enabled, only series with a monitored status in Sonarr will be + synced. If you make changes to a specific unmonitored Sonarr series + and you want Bazarr to know about those changes, simply toggle the + monitored status back on in Sonarr and Bazarr will sync any changes. + + + + + + + If enabled, only episodes with a monitored status in Sonarr will + be synced. If you make changes to a specific unmonitored Sonarr + episode (or season) and you want Bazarr to know about those + changes, simply toggle the monitored status back on in Sonarr and + Bazarr will sync any changes. This setting is especially helpful + for long running TV series with many seasons and many episodes, + but that are still actively producing new episodes (e.g. Saturday + Night Live). + + + + + + + If enabled, only movies with a monitored status in Radarr will be + synced. If you make changes to a specific unmonitored Radarr movie + and you want Bazarr to know about those changes, simply toggle the + monitored status back on in Radarr and Bazarr will sync any changes. + +
Date: Fri, 26 Jan 2024 11:09:50 -0500 Subject: [PATCH 43/49] Fixed unhandled FileNotFoundError during restore process --- bazarr/utilities/backup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bazarr/utilities/backup.py b/bazarr/utilities/backup.py index 314200a02..8088a50a2 100644 --- a/bazarr/utilities/backup.py +++ b/bazarr/utilities/backup.py @@ -151,6 +151,8 @@ def restore_from_backup(): try: os.remove(restore_config_path) + except FileNotFoundError: + pass except OSError: logging.exception(f'Unable to delete {dest_config_path}') From deef13d11b9310384d71cb0a4af230a524541359 Mon Sep 17 00:00:00 2001 From: Vitiko Date: Sun, 28 Jan 2024 17:18:48 -0400 Subject: [PATCH 44/49] Add Argenteam Dump Provider --- frontend/src/pages/Settings/Providers/list.ts | 5 + .../providers/argenteamdump.py | 96 +++++++++++++++++++ tests/subliminal_patch/test_argenteam_dump.py | 22 +++++ 3 files changed, 123 insertions(+) create mode 100644 libs/subliminal_patch/providers/argenteamdump.py create mode 100644 tests/subliminal_patch/test_argenteam_dump.py diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index b70b989ce..f4f6dc471 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -63,6 +63,11 @@ export const ProviderList: Readonly = [ }, ], }, + { + key: "argenteam_dump", + name: "Argenteam Dump", + description: "Subtitles dump of the now extinct Argenteam", + }, { key: "assrt", description: "Chinese Subtitles Provider", diff --git a/libs/subliminal_patch/providers/argenteamdump.py b/libs/subliminal_patch/providers/argenteamdump.py new file mode 100644 index 000000000..127f5a6d9 --- /dev/null +++ b/libs/subliminal_patch/providers/argenteamdump.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +import logging + +from requests import Session +from subliminal_patch.core import Episode +from subliminal_patch.core import Movie +from subliminal_patch.providers import Provider +from subliminal_patch.providers.utils import get_archive_from_bytes +from subliminal_patch.providers.utils import get_subtitle_from_archive +from subliminal_patch.providers.utils import update_matches +from subliminal_patch.subtitle import Subtitle +from subzero.language import Language + +logger = logging.getLogger(__name__) + + +class ArgenteamSubtitle(Subtitle): + provider_name = "argenteam_dump" + hash_verifiable = False + hearing_impaired_verifiable = True + + def __init__(self, language, rel_path, release_info, matches=None): + super().__init__(language, hearing_impaired=language.hi) + self.release_info = release_info + self.rel_path = rel_path + self._matches = matches or set() + + def get_matches(self, video): + update_matches(self._matches, video, self.release_info) + return self._matches + + @property + def id(self): + return f"{self.provider_name}_{self.rel_path}" + + +_BASE_URL = "https://argt.caretas.club" + + +class ArgenteamDumpProvider(Provider): + provider_name = "argenteam_dump" + + video_types = (Movie, Episode) + subtitle_class = ArgenteamSubtitle + + languages = {Language("spa", "MX")} + _language = Language("spa", "MX") + + def __init__(self) -> None: + self._session = Session() + self._session.headers.update({"User-Agent": "Bazarr"}) + + def initialize(self): + pass + + def terminate(self): + self._session.close() + + def list_subtitles(self, video, languages): + episode = None + if isinstance(video, Movie): + params = {"query": video.title} + matches = {"title"} + endpoint = f"{_BASE_URL}/search/movies/" + else: + params = { + "query": video.series, + "season": video.season, + "episode": video.episode, + } + matches = {"tvdb_id", "imdb_id", "series", "title", "episode", "season"} + endpoint = f"{_BASE_URL}/search/episodes/" + + response = self._session.get(endpoint, params=params) + response.raise_for_status() + items = response.json() + if not items: + return [] + + subs = [] + for item in items: + subs.append( + ArgenteamSubtitle( + self._language, item["rel_path"], item["release_info"], matches + ) + ) + + return subs + + def download_subtitle(self, subtitle): + response = self._session.get( + f"{_BASE_URL}/download/", params={"rel_path": subtitle.rel_path} + ) + response.raise_for_status() + archive = get_archive_from_bytes(response.content) + subtitle.content = get_subtitle_from_archive(archive) diff --git a/tests/subliminal_patch/test_argenteam_dump.py b/tests/subliminal_patch/test_argenteam_dump.py new file mode 100644 index 000000000..15b784510 --- /dev/null +++ b/tests/subliminal_patch/test_argenteam_dump.py @@ -0,0 +1,22 @@ +import pytest +from subliminal_patch.providers.argenteamdump import ArgenteamDumpProvider +from subzero.language import Language + + +def test_list_subtitles_movies(movies): + languages = {Language("spa", "MX")} + with ArgenteamDumpProvider() as provider: + subtitles = provider.list_subtitles(movies["man_of_steel"], languages) + assert subtitles + + provider.download_subtitle(subtitles[0]) + assert subtitles[0].is_valid() + +def test_list_subtitles_episodes(episodes): + languages = {Language("spa", "MX")} + with ArgenteamDumpProvider() as provider: + subtitles = provider.list_subtitles(episodes["got_s03e10"], languages) + assert subtitles + + provider.download_subtitle(subtitles[0]) + assert subtitles[0].is_valid() From cdf7296dd41fe6fb46d7abe49c684e03680a278c Mon Sep 17 00:00:00 2001 From: Vitiko Date: Sun, 28 Jan 2024 17:21:48 -0400 Subject: [PATCH 45/49] Embedded Subtitles Provider: handle KeyError for cached paths --- libs/subliminal_patch/providers/embeddedsubtitles.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/subliminal_patch/providers/embeddedsubtitles.py b/libs/subliminal_patch/providers/embeddedsubtitles.py index 2a690331f..045347563 100644 --- a/libs/subliminal_patch/providers/embeddedsubtitles.py +++ b/libs/subliminal_patch/providers/embeddedsubtitles.py @@ -190,7 +190,11 @@ class EmbeddedSubtitlesProvider(Provider): @blacklist_on(ExtractionError) def download_subtitle(self, subtitle: EmbeddedSubtitle): - path = self._get_subtitle_path(subtitle) + try: + path = self._get_subtitle_path(subtitle) + except KeyError: # TODO: add MustGetBlacklisted support + logger.error("Couldn't get subtitle path") + return None modifiers = _type_modifiers.get(subtitle.stream.codec_name) or set() logger.debug("Found modifiers for %s type: %s", subtitle.stream, modifiers) From 938f6df386c0f50568f7a6c04ed4ae58e440af04 Mon Sep 17 00:00:00 2001 From: JayZed Date: Sun, 28 Jan 2024 21:09:37 -0500 Subject: [PATCH 46/49] Added separate values to whisperai provider for connection and read timeouts Added "response" key with default value of 5 to pair with existing "timeout" key which has a default of 3600 sec. Both values can be updated in WhisperAI settings dialog by user. More information about these timeouts here: https://reqbin.com/code/python/3zdpeao1/python-requests-timeout-example --- bazarr/app/config.py | 1 + bazarr/app/get_providers.py | 1 + frontend/src/pages/Settings/Providers/list.ts | 6 ++++++ libs/subliminal_patch/providers/whisperai.py | 12 ++++++++---- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/bazarr/app/config.py b/bazarr/app/config.py index d1967cc38..560069f40 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -228,6 +228,7 @@ validators = [ # whisperai section Validator('whisperai.endpoint', must_exist=True, default='http://127.0.0.1:9000', is_type_of=str), + Validator('whisperai.response', must_exist=True, default=5, is_type_of=int, gte=1), Validator('whisperai.timeout', must_exist=True, default=3600, is_type_of=int, gte=1), Validator('whisperai.loglevel', must_exist=True, default='INFO', is_type_of=str, is_in=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']), diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py index 2b6d5f5f7..278b38288 100644 --- a/bazarr/app/get_providers.py +++ b/bazarr/app/get_providers.py @@ -311,6 +311,7 @@ def get_providers_auth(): }, 'whisperai': { 'endpoint': settings.whisperai.endpoint, + 'response': settings.whisperai.response, 'timeout': settings.whisperai.timeout, 'ffmpeg_path': _FFMPEG_BINARY, 'loglevel': settings.whisperai.loglevel, diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index f4f6dc471..b811a852a 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -442,6 +442,12 @@ export const ProviderList: Readonly = [ defaultValue: "http://127.0.0.1:9000", name: "Whisper ASR Docker Endpoint", }, + { + type: "text", + key: "response", + defaultValue: 5, + name: "Connection/response timeout in seconds", + }, { type: "text", key: "timeout", diff --git a/libs/subliminal_patch/providers/whisperai.py b/libs/subliminal_patch/providers/whisperai.py index 1cf6e5ff0..33421f201 100644 --- a/libs/subliminal_patch/providers/whisperai.py +++ b/libs/subliminal_patch/providers/whisperai.py @@ -213,18 +213,22 @@ class WhisperAIProvider(Provider): video_types = (Episode, Movie) - def __init__(self, endpoint=None, timeout=None, ffmpeg_path=None, loglevel=None): + def __init__(self, endpoint=None, response=None, timeout=None, ffmpeg_path=None, loglevel=None): set_log_level(loglevel) if not endpoint: raise ConfigurationError('Whisper Web Service Endpoint must be provided') + if not response: + raise ConfigurationError('Whisper Web Service Connection/response timeout must be provided') + if not timeout: - raise ConfigurationError('Whisper Web Service Timeout must be provided') + raise ConfigurationError('Whisper Web Service Transcription/translation timeout must be provided') if not ffmpeg_path: raise ConfigurationError("ffmpeg path must be provided") self.endpoint = endpoint + self.response = int(response) self.timeout = int(timeout) self.session = None self.ffmpeg_path = ffmpeg_path @@ -248,7 +252,7 @@ class WhisperAIProvider(Provider): r = self.session.post(f"{self.endpoint}/detect-language", params={'encode': 'false'}, files={'audio_file': out}, - timeout=(self.timeout, self.timeout)) + timeout=(self.response, self.timeout)) logger.debug(f"Whisper detected language of {path} as {r.json()['detected_language']}") @@ -326,7 +330,7 @@ class WhisperAIProvider(Provider): r = self.session.post(f"{self.endpoint}/asr", params={'task': subtitle.task, 'language': whisper_get_language_reverse(subtitle.audio_language), 'output': 'srt', 'encode': 'false'}, files={'audio_file': out}, - timeout=(self.timeout, self.timeout)) + timeout=(self.response, self.timeout)) endTime = time.time() elapsedTime = timedelta(seconds=round(endTime - startTime)) From e6b9b327f2723f3d2cc24c9ef0acb256e970f2da Mon Sep 17 00:00:00 2001 From: JayZed Date: Mon, 29 Jan 2024 22:54:12 -0500 Subject: [PATCH 47/49] Fixed movies indexing issue The removed_movie variable is not an actual movie object but simply a tmdbid, so it can't be indexed. --- bazarr/radarr/sync/movies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bazarr/radarr/sync/movies.py b/bazarr/radarr/sync/movies.py index 6227a478c..3c184857b 100644 --- a/bazarr/radarr/sync/movies.py +++ b/bazarr/radarr/sync/movies.py @@ -135,7 +135,7 @@ def update_movies(send_event=True): logging.error(f"BAZARR cannot delete movies because of {e}") else: for removed_movie in movies_to_delete: - movies_deleted.append(removed_movie['title']) + movies_deleted.append(removed_movie) if send_event: event_stream(type='movie', action='delete', payload=removed_movie) From fb660a0e6ea4d45c9cab0a49008d4e65d31b355c Mon Sep 17 00:00:00 2001 From: Lawrence A <3211473+lawadr@users.noreply.github.com> Date: Fri, 2 Feb 2024 18:29:33 +0000 Subject: [PATCH 48/49] Fixed debug logging of opensubtitlescom provider --- .../providers/opensubtitlescom.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/libs/subliminal_patch/providers/opensubtitlescom.py b/libs/subliminal_patch/providers/opensubtitlescom.py index 615bc8cf1..2e6ab6a0c 100644 --- a/libs/subliminal_patch/providers/opensubtitlescom.py +++ b/libs/subliminal_patch/providers/opensubtitlescom.py @@ -228,7 +228,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): title_id = None parameters = {'query': title.lower()} - logging.debug(f'Searching using this title: {title}') + logger.debug(f'Searching using this title: {title}') results = self.retry( lambda: self.checked( @@ -259,7 +259,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): continue if title_id: - logging.debug(f'Found this title ID: {title_id}') + logger.debug(f'Found this title ID: {title_id}') return self.sanitize_external_ids(title_id) if not title_id: @@ -269,7 +269,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): self.video = video if self.use_hash: file_hash = self.video.hashes.get('opensubtitlescom') - logging.debug(f'Searching using this hash: {hash}') + logger.debug(f'Searching using this hash: {file_hash}') else: file_hash = None @@ -294,7 +294,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): langs_list = sorted(list(set([to_opensubtitlescom(lang.basename).lower() for lang in languages]))) langs = ','.join(langs_list) - logging.debug(f'Searching for those languages: {langs}') + logger.debug(f'Searching for those languages: {langs}') # query the server if isinstance(self.video, Episode): @@ -344,18 +344,18 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): else: # not forced result['data'] = [x for x in result['data'] if not x['attributes']['foreign_parts_only']] - logging.debug(f"Query returned {len(result['data'])} subtitles") + logger.debug(f"Query returned {len(result['data'])} subtitles") if len(result['data']): for item in result['data']: # ignore AI translated subtitles if 'ai_translated' in item['attributes'] and item['attributes']['ai_translated']: - logging.debug("Skipping AI translated subtitles") + logger.debug("Skipping AI translated subtitles") continue # ignore machine translated subtitles if 'machine_translated' in item['attributes'] and item['attributes']['machine_translated']: - logging.debug("Skipping machine translated subtitles") + logger.debug("Skipping machine translated subtitles") continue if 'season_number' in item['attributes']['feature_details']: @@ -440,7 +440,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): @staticmethod def reset_token(): - logging.debug('Authentication failed: clearing cache and attempting to login.') + logger.debug('Authentication failed: clearing cache and attempting to login.') region.delete("oscom_token") return @@ -470,7 +470,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): except (ConnectionError, Timeout, ReadTimeout): raise ServiceUnavailable(f'Unknown Error, empty response: {response.status_code}: {response}') except Exception: - logging.exception('Unhandled exception raised.') + logger.exception('Unhandled exception raised.') raise ProviderError('Unhandled exception raised. Check log.') else: status_code = response.status_code @@ -519,7 +519,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): log_request_response(response) raise TooManyRequests() elif status_code == 500: - logging.debug("Server side exception raised while downloading from opensubtitles.com website. They " + logger.debug("Server side exception raised while downloading from opensubtitles.com website. They " "should mitigate this soon.") return None elif status_code == 502: @@ -543,10 +543,10 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): if validate_content: if not hasattr(response, 'content'): - logging.error('Download link returned no content attribute.') + logger.error('Download link returned no content attribute.') return False elif not response.content: - logging.error(f'This download link returned empty content: {response.url}') + logger.error(f'This download link returned empty content: {response.url}') return False return response @@ -566,13 +566,13 @@ def log_request_response(response, non_standard=True): redacted_response_body['token'] = redacted_response_body['token'][:-8] + 8 * 'x' if non_standard: - logging.debug("opensubtitlescom returned a non standard response. Logging request/response for debugging " + logger.debug("opensubtitlescom returned a non standard response. Logging request/response for debugging " "purpose.") else: - logging.debug("opensubtitlescom returned a standard response. Logging request/response for debugging purpose.") - logging.debug(f"Request URL: {response.request.url}") - logging.debug(f"Request Headers: {redacted_request_headers}") - logging.debug(f"Request Body: {json.dumps(redacted_request_body)}") - logging.debug(f"Response Status Code: {response.status_code}") - logging.debug(f"Response Headers: {response.headers}") - logging.debug(f"Response Body: {json.dumps(redacted_response_body)}") + logger.debug("opensubtitlescom returned a standard response. Logging request/response for debugging purpose.") + logger.debug(f"Request URL: {response.request.url}") + logger.debug(f"Request Headers: {redacted_request_headers}") + logger.debug(f"Request Body: {json.dumps(redacted_request_body)}") + logger.debug(f"Response Status Code: {response.status_code}") + logger.debug(f"Response Headers: {response.headers}") + logger.debug(f"Response Body: {json.dumps(redacted_response_body)}") From 4029c9f712b7718eca89c5dd2e4d29c3f5b2762e Mon Sep 17 00:00:00 2001 From: Lawrence A <3211473+lawadr@users.noreply.github.com> Date: Fri, 2 Feb 2024 21:24:28 +0000 Subject: [PATCH 49/49] Fixed forced subtitles download loop Searching for the best forced subtitles for a given language was resulting in all forced subtitles for that language being downloaded in descending score order until the minimum score was reached. Not only did this burn through any download limits imposed by providers, it left poor quality subtitles downloaded (which could later be automatically upgraded to the first choice). This change uses the string conversion of Language objects instead of their basenames when working out when to stop downloading subtitles, as this takes into account the forced flag while still ignoring the hearing impaired flag. --- libs/subliminal_patch/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/subliminal_patch/core.py b/libs/subliminal_patch/core.py index 59cfd22a3..3c7e00479 100644 --- a/libs/subliminal_patch/core.py +++ b/libs/subliminal_patch/core.py @@ -574,12 +574,12 @@ class SZProviderPool(ProviderPool): break # stop when all languages are downloaded - if set(s.language.basename for s in downloaded_subtitles) == languages: + if set(str(s.language) for s in downloaded_subtitles) == languages: logger.debug('All languages downloaded') break # check downloaded languages - if subtitle.language in set(s.language.basename for s in downloaded_subtitles): + if subtitle.language in set(str(s.language) for s in downloaded_subtitles): logger.debug('%r: Skipping subtitle: already downloaded', subtitle.language) continue