diff --git a/README.md b/README.md index 8d6c6b7b3..c244d7b47 100644 --- a/README.md +++ b/README.md @@ -48,22 +48,23 @@ If you need something that is not already part of Bazarr, feel free to create a ## Supported subtitles providers: - Addic7ed -- Argenteam - Assrt - BetaSeries - 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 +80,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 @@ -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 diff --git a/bazarr.py b/bazarr.py index c85aee411..f5fc76bb8 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]: @@ -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/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) 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/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(_): diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 92f0192c3..560069f40 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']), @@ -122,8 +126,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), @@ -151,14 +155,14 @@ 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), 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), @@ -174,23 +178,24 @@ 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), + 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), 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 +203,14 @@ 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), + 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), - 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 +223,60 @@ 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.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']), # 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 +286,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), @@ -290,6 +299,13 @@ 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), + 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), @@ -323,8 +339,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), ] @@ -399,7 +415,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, []] @@ -408,8 +426,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 @@ -519,7 +535,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 @@ -533,7 +549,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', @@ -627,7 +643,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] @@ -774,3 +793,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/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/get_providers.py b/bazarr/app/get_providers.py index 28a8b1aeb..278b38288 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(), @@ -122,7 +125,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 = {} @@ -240,6 +243,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': { @@ -307,8 +311,10 @@ 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/bazarr/app/logger.py b/bazarr/app/logger.py index abf832aa9..c9a1282bb 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) @@ -88,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) @@ -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) diff --git a/bazarr/app/scheduler.py b/bazarr/app/scheduler.py index 8abdab14e..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,11 +306,19 @@ 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') + except JobLookupError: + pass 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/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/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) 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/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') 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/subtitles/processing.py b/bazarr/subtitles/processing.py index 9325d1904..b5c032610 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 @@ -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) @@ -69,6 +71,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 +84,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, + 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,17 +102,18 @@ 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, + 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, 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/sync.py b/bazarr/subtitles/sync.py index bcdf37aff..35f62bc46 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.') @@ -17,7 +17,7 @@ def sync_subtitles(video_path, srt_path, srt_lang, forced, media_type, percent_s 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: @@ -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 cee72dd5d..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] - 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 998e63c21..8ad16128e 100644 --- a/bazarr/subtitles/upload.py +++ b/bazarr/subtitles/upload.py @@ -137,21 +137,21 @@ 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, 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/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}') 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 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/bazarr/utilities/video_analyzer.py b/bazarr/utilities/video_analyzer.py index c1cde1fb3..bd4cac011 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.database import TableEpisodes, TableMovies, database, update, select -from utilities.path_mappings import path_mappings 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 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 = { @@ -161,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: @@ -195,3 +305,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/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. 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/components.tsx b/frontend/src/pages/Settings/Providers/components.tsx index 16654c8b5..46252a03d 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( = ({ const ProviderModal = withModal(ProviderTool, "provider-tool", { title: "Provider", + size: "calc(50vw)", }); 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/list.ts b/frontend/src/pages/Settings/Providers/list.ts index 0f1375756..b811a852a 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -1,3 +1,4 @@ +import { SelectorOption } from "@/components"; import { ReactText } from "react"; type Input = { @@ -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", @@ -52,7 +63,11 @@ export const ProviderList: Readonly = [ }, ], }, - { key: "argenteam", description: "LATAM Spanish Subtitles Provider" }, + { + key: "argenteam_dump", + name: "Argenteam Dump", + description: "Subtitles dump of the now extinct Argenteam", + }, { key: "assrt", description: "Chinese Subtitles Provider", @@ -79,7 +94,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", @@ -146,22 +161,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", @@ -203,26 +202,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", + key: "username", }, { - type: "text", - key: "timeout", - defaultValue: 3600, - name: "Transcription/translation timeout in seconds", + 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", @@ -243,6 +239,7 @@ export const ProviderList: Readonly = [ { key: "opensubtitles", name: "OpenSubtitles.org", + description: "Only works if you have VIP status", inputs: [ { type: "text", @@ -286,6 +283,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", + }, ], }, { @@ -303,7 +305,7 @@ export const ProviderList: Readonly = [ { key: "regielive", name: "RegieLive", - description: "Romanian Subtitles Provider.", + description: "Romanian Subtitles Provider", }, { key: "soustitreseu", @@ -311,11 +313,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", @@ -335,16 +332,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", @@ -358,9 +360,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", @@ -401,17 +403,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", @@ -428,8 +423,46 @@ 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: "response", + defaultValue: 5, + name: "Connection/response timeout in seconds", + }, + { + 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", @@ -454,6 +487,6 @@ export const ProviderList: Readonly = [ { key: "zimuku", name: "Zimuku", - description: "Chinese Subtitles Provider. Anti-captcha required", + description: "Chinese Subtitles Provider. Anti-captcha required.", }, ]; 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/Scheduler/index.tsx b/frontend/src/pages/Settings/Scheduler/index.tsx index e2a236197..a6cd2ca74 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,63 @@ const SettingsSchedulerView: FunctionComponent = () => {
- + + + + 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. + +
@@ -88,7 +124,7 @@ const SettingsSchedulerView: FunctionComponent = () => { @@ -144,7 +180,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/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx index 0d0337201..9f77234ba 100644 --- a/frontend/src/pages/Settings/Subtitles/index.tsx +++ b/frontend/src/pages/Settings/Subtitles/index.tsx @@ -1,11 +1,11 @@ -import { Anchor, Code, Table } from "@mantine/core"; +import { Code, Table } from "@mantine/core"; import { FunctionComponent } from "react"; import { Check, CollapseBox, Layout, Message, - Password, + MultiSelector, Section, Selector, Slider, @@ -18,11 +18,12 @@ import { import { adaptiveSearchingDelayOption, adaptiveSearchingDeltaOption, - antiCaptchaOption, colorOptions, embeddedSubtitlesParserOption, folderOptions, hiExtensionOptions, + providerOptions, + syncMaxOffsetSecondsOptions, } from "./options"; interface CommandOption { @@ -96,6 +97,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)", @@ -118,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
@@ -248,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. + { subtitles. + Do not actually sync the subtitles but generate a .tar.gz file to be @@ -426,6 +439,8 @@ const SettingsSubtitlesView: FunctionComponent = () => { +
+
[] = [ { @@ -30,17 +31,6 @@ export const folderOptions: SelectorOption[] = [ }, ]; -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)", @@ -165,3 +155,29 @@ export const colorOptions: SelectorOption[] = [ value: buildColor("dark-grey"), }, ]; + +export const providerOptions: SelectorOption[] = ProviderList.map( + (v) => ({ + label: v.key, + 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/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) => { 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, }, } ); 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 55112c873..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 { @@ -144,7 +147,6 @@ declare namespace Settings { full_update_hour: number; only_monitored: boolean; series_sync: number; - episodes_sync: number; excluded_tags: string[]; excluded_series_types: SonarrSeriesType[]; } 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/_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,17 +44,220 @@ 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 AppriseLocale: + """ + A wrapper class to gettext so that we can manipulate multiple lanaguages + on the fly if required. + + """ + + # Define our translation domain + _domain = 'apprise' + + # 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) + + # Define our default encoding + _default_encoding = 'utf-8' + + # The function to assign `_` by default + _fn = 'gettext' + + # The language we should fall back to if all else fails + _default_language = 'en' + + def __init__(self, language=None): + """ + Initializes our object, if a language is specified, then we + initialize ourselves to that, otherwise we use whatever we detect + from the local operating system. If all else fails, we resort to the + defined default_language. + + """ + + # Cache previously loaded translations + self._gtobjs = {} + + # 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 + + # 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[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) + + except FileNotFoundError: + # The translation directory does not exist + logger.debug( + 'Could not load translation path: %s', + join(self._locale_dir, lang)) + + # 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, mapto=_fn): + """ + The syntax works as: + with at.lang_at('fr'): + # apprise works as though the french language has been + # defined. afterwards, the language falls back to whatever + # it was. + """ + + if GETTEXT_LOADED is False: + # Do nothing + yield None + + # we're done + return + + # Tidy the language + lang = AppriseLocale.detect_language(lang, detect_fallback=False) + 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 getattr(self._gtobjs[lang], mapto) + + return + + @property + def gettext(self): + """ + Return the current language gettext() function + + 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) + """ + # We want to only use the 2 character version of this language + # hence en_CA becomes en, en_US becomes en. + if not isinstance(lang, str): + if detect_fallback is False: + # 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: + lang = locale.windows_locale[ + windll.GetUserDefaultUILanguage()] + + # Our detected windows language + return lang[0:2].lower() + + except (TypeError, KeyError): + # Fallback to posix detection + pass + + # Built in locale library check + try: + # Acquire our locale + lang = locale.getlocale()[0] + + 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 + # receiving this error should check their environment + # variables. + logger.warning( + 'Language detection failure / {}'.format(str(e))) + 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: @@ -77,7 +275,7 @@ class LazyTranslation: super().__init__(*args, **kwargs) def __str__(self): - return gettext.gettext(self.text) + return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text # Lazy translation handling @@ -86,140 +284,3 @@ def gettext_lazy(text): A dummy function that can be referenced """ return LazyTranslation(text=text) - - -class AppriseLocale: - """ - A wrapper class to gettext so that we can manipulate multiple lanaguages - on the fly if required. - - """ - - def __init__(self, language=None): - """ - Initializes our object, if a language is specified, then we - initialize ourselves to that, otherwise we use whatever we detect - from the local operating system. If all else fails, we resort to the - defined default_language. - - """ - - # Cache previously loaded translations - self._gtobjs = {} - - # Get our language - self.lang = AppriseLocale.detect_language(language) - - if GETTEXT_LOADED is False: - # We're done - return - - if self.lang: - # Load our gettext object and install our language - try: - self._gtobjs[self.lang] = gettext.translation( - DOMAIN, localedir=LOCALE_DIR, languages=[self.lang]) - - # Install our language - self._gtobjs[self.lang].install() - - except IOError: - # This occurs if we can't access/load our translations - pass - - @contextlib.contextmanager - def lang_at(self, lang): - """ - The syntax works as: - with at.lang_at('fr'): - # apprise works as though the french language has been - # defined. afterwards, the language falls back to whatever - # it was. - """ - - if GETTEXT_LOADED is False: - # yield - yield - - # 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() - - # Yield - yield - - except (IOError, KeyError): - # This occurs if we can't access/load our translations - # Yield reguardless - yield - - finally: - # Fall back to our previous language - if lang != self.lang and lang in self._gtobjs: - # Install our language - self._gtobjs[self.lang].install() - - return - - @staticmethod - def detect_language(lang=None, detect_fallback=True): - """ - 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. - if not isinstance(lang, str): - if detect_fallback is False: - # no detection enabled; we're done - return None - - if hasattr(ctypes, 'windll'): - windll = ctypes.windll.kernel32 - try: - lang = locale.windows_locale[ - windll.GetUserDefaultUILanguage()] - - # Our detected windows language - return lang[0:2].lower() - - except (TypeError, KeyError): - # Fallback to posix detection - pass - - try: - # Detect language - lang = locale.getdefaultlocale()[0] - - except ValueError 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 - # receiving this error should check their environment - # variables. - logger.warning( - '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() 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'] - # if we reach here, we successfully loaded our data - servers.append(plugin) + # + # 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) + + # 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 925d178f0..0236722fd 100644 Binary files a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo and b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo differ diff --git a/libs/apprise/logger.py b/libs/apprise/logger.py index 005a3e0d7..6a594ec60 100644 --- a/libs/apprise/logger.py +++ b/libs/apprise/logger.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/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 attach: + 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:] + + 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)): + # Prepare JSON Object + payload = { + 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 + for key, value in self.payload_extras.items(): - # Apply any/all payload over-rides defined - payload.update(self.payload_extras) + 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) + + if short_url is None: + self.short_url = self.template_args['short_url']['default'] 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 - - # Store our sender - self.sender = sender + 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 + if self.supplemental_url_title: + payload['url_title'] = self.supplemental_url_title - # 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.notify_format == NotifyFormat.HTML: + # https://pushover.net/api#html + payload['html'] = 1 - if self.supplemental_url: - payload['url'] = self.supplemental_url - if self.supplemental_url_title: - payload['url_title'] = self.supplemental_url_title + elif self.notify_format == NotifyFormat.MARKDOWN: + payload['message'] = convert_between( + NotifyFormat.MARKDOWN, NotifyFormat.HTML, body) + payload['html'] = 1 - 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.priority == PushoverPriority.EMERGENCY: - payload.update({'retry': self.retry, 'expire': self.expire}) - - 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 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 + 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 - - 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/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/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/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 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/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/libs/subliminal_patch/providers/betaseries.py b/libs/subliminal_patch/providers/betaseries.py index ea4d248e8..4cd66401c 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 [] 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) diff --git a/libs/subliminal_patch/providers/opensubtitlescom.py b/libs/subliminal_patch/providers/opensubtitlescom.py index 8576dc2e0..2e6ab6a0c 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): @@ -227,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( @@ -258,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: @@ -268,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 @@ -293,14 +294,15 @@ 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): 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), @@ -341,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']: @@ -437,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 @@ -467,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 @@ -516,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: @@ -540,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 @@ -563,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)}") diff --git a/libs/subliminal_patch/providers/podnapisi.py b/libs/subliminal_patch/providers/podnapisi.py index f567f25ac..18131ff52 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,14 +202,14 @@ 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) 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 @@ -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 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) 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 diff --git a/libs/subliminal_patch/providers/whisperai.py b/libs/subliminal_patch/providers/whisperai.py index 7e1b62bbb..33421f201 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,22 +211,24 @@ 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, 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 @@ -230,12 +245,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.response, 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 +281,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 +294,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 +311,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.response, 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 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])) diff --git a/libs/subliminal_patch/providers/zimuku.py b/libs/subliminal_patch/providers/zimuku.py index 99bfb3d6c..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 @@ -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 @@ -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( diff --git a/libs/version.txt b/libs/version.txt index ced825452..6d73509c2 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 @@ -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 @@ -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 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 diff --git a/requirements.txt b/requirements.txt index fe60a1afa..469b8808b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ 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 +pywin32; platform_system == "Windows" 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_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() 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"} - - # Provider was discarded internally - pool_instance.discarded_providers = {"argenteam"} - - assert pool_instance.discarded_providers == {"argenteam"} - - # Provider is not set to be used again - pool_instance.update({"subdivx"}, {}, [], {}) - assert pool_instance.providers == {"subdivx"} + # Provider was discarded internally + pool_instance.discarded_providers = {"subdivx"} + + assert pool_instance.discarded_providers == {"subdivx"} + + # Provider is not set to be used again + pool_instance.update({"subf2m"}, {}, [], {}) + + 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():