YTSync 0.0.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yt2jf/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .main import start # noqa: F401
yt2jf/bot.py ADDED
@@ -0,0 +1,436 @@
1
+ # noinspection PyUnresolvedReferences
2
+ """Module for TelegramAPI.
3
+
4
+ >>> Bot
5
+
6
+ """
7
+
8
+ import logging
9
+ import secrets
10
+ import sys
11
+ import time
12
+ from datetime import datetime
13
+ from enum import StrEnum
14
+ from typing import Dict, List
15
+
16
+ import requests
17
+ from yt_dlp.utils import DownloadError
18
+
19
+ from yt2jf.config import env
20
+ from yt2jf.exceptions import BotInUse, BotTokenInvalid, BotWebhookConflict
21
+ from yt2jf.settings import Audio, Chat, Document, PhotoFragment, Text, Video, Voice
22
+ from yt2jf.word_match import word_match
23
+ from yt2jf.youtube import queue_download
24
+
25
+ BASE_URL = f"https://api.telegram.org/bot{env.bot_token}"
26
+ LOGGER = logging.getLogger("uvicorn.default")
27
+
28
+
29
+ class RequestMethods(StrEnum):
30
+ """Allowed request methods.
31
+
32
+ >>> RequestMethods
33
+
34
+ """
35
+
36
+ GET = "GET"
37
+ POST = "POST"
38
+
39
+
40
+ def intro() -> str:
41
+ """Returns a welcome message as a string.
42
+
43
+ Returns:
44
+ str:
45
+ """
46
+ return (
47
+ "\nTo start, send a link to YT music playlist in the following format:\n\n"
48
+ "- /id: <playlist id>\n- /url: <playlist url>\n"
49
+ )
50
+
51
+
52
+ def _make_request(
53
+ url: str,
54
+ payload: dict,
55
+ files: dict = None,
56
+ method: RequestMethods = RequestMethods.POST,
57
+ ) -> requests.Response:
58
+ """Makes a post request with a ``connect timeout`` of 5 seconds and ``read timeout`` of 60.
59
+
60
+ Args:
61
+ url: URL to submit the request.
62
+ payload: Payload received, to extract information from.
63
+ files: Take filename as an optional argument.
64
+
65
+ Returns:
66
+ Response:
67
+ Response class.
68
+ """
69
+ if method == RequestMethods.GET:
70
+ response = requests.get(url=url, data=payload, files=files, timeout=(2, 3))
71
+ elif method == RequestMethods.POST:
72
+ response = requests.post(url=url, data=payload, files=files, timeout=(2, 3))
73
+ else:
74
+ raise ValueError("Invalid request method received: '%s'", method)
75
+ if not response.ok:
76
+ LOGGER.debug(payload)
77
+ LOGGER.debug(files)
78
+ LOGGER.warning("Called by: '%s'", sys._getframe(1).f_code.co_name) # noqa
79
+ LOGGER.error(response.json())
80
+ return response
81
+
82
+
83
+ def reply_to(
84
+ chat: Chat,
85
+ response: str,
86
+ parse_mode: str | None = "markdown",
87
+ retry: bool = False,
88
+ ) -> requests.Response:
89
+ """Generates a payload to reply to a message received.
90
+
91
+ Args:
92
+ chat: Required section of the payload as Chat object.
93
+ response: Message to be sent to the user.
94
+ parse_mode: Parse mode. Defaults to ``markdown``
95
+ retry: Retry reply in case reply failed because of parsing.
96
+
97
+ Returns:
98
+ Response:
99
+ Response class.
100
+ """
101
+ result = _make_request(
102
+ url=BASE_URL + "/sendMessage",
103
+ payload={
104
+ "chat_id": chat.id,
105
+ "reply_to_message_id": chat.message_id,
106
+ "text": response,
107
+ "parse_mode": parse_mode,
108
+ },
109
+ )
110
+ # Retry with response as plain text
111
+ if result.status_code == 400 and parse_mode and not retry:
112
+ LOGGER.warning("Retrying response as plain text with no parsing")
113
+ reply_to(chat, response, None, True)
114
+ return result
115
+
116
+
117
+ def send_message(
118
+ chat_id: int,
119
+ response: str,
120
+ parse_mode: str | None = "markdown",
121
+ retry: bool = False,
122
+ ) -> requests.Response:
123
+ """Generates a payload to reply to a message received.
124
+
125
+ Args:
126
+ chat_id: Chat ID.
127
+ response: Message to be sent to the user.
128
+ parse_mode: Parse mode. Defaults to ``markdown``
129
+ retry: Retry reply in case reply failed because of parsing.
130
+
131
+ Returns:
132
+ Response:
133
+ Response class.
134
+ """
135
+ result = _make_request(
136
+ url=BASE_URL + "/sendMessage",
137
+ payload={"chat_id": chat_id, "text": response, "parse_mode": parse_mode},
138
+ )
139
+ # Retry with response as plain text
140
+ if result.status_code == 400 and parse_mode and not retry:
141
+ LOGGER.warning("Retrying response as plain text with no parsing")
142
+ send_message(chat_id=chat_id, response=response, parse_mode=None, retry=True)
143
+ return result
144
+
145
+
146
+ def poll_for_messages(offset: int) -> None | int:
147
+ """Polls ``api.telegram.org`` for new messages.
148
+
149
+ Args:
150
+ offset: Offset in messages to poll.
151
+
152
+ Raises:
153
+ BotInUse:
154
+ - When a new polling is initiated using the same token.
155
+ ConnectionError:
156
+ - If unable to connect to the endpoint.
157
+
158
+ See Also:
159
+ Swaps ``offset`` value during every iteration to avoid reprocessing messages.
160
+ """
161
+ response = _make_request(
162
+ url=BASE_URL + "/getUpdates",
163
+ payload={"offset": offset, "timeout": 60},
164
+ method=RequestMethods.GET,
165
+ )
166
+ if response.ok:
167
+ results = response.json().get("result", [])
168
+ if not results:
169
+ return None
170
+
171
+ last_update_id = offset
172
+ for result in results:
173
+ if payload := result.get("message"):
174
+ process_request(payload)
175
+ else:
176
+ LOGGER.error("Received empty payload!!")
177
+ last_update_id = result["update_id"]
178
+
179
+ return last_update_id + 1
180
+
181
+ # Handle errors
182
+ error_data = response.json()
183
+ err_desc = error_data.get("description", "")
184
+
185
+ if response.status_code == 409:
186
+ if "webhook" in err_desc.lower():
187
+ raise BotWebhookConflict(err_desc)
188
+ raise BotInUse(err_desc)
189
+ if response.status_code == 401:
190
+ raise BotTokenInvalid(error_data)
191
+ raise ConnectionError(error_data)
192
+
193
+
194
+ def process_request(payload: Dict[str, int | dict]) -> None:
195
+ """Processes the request via Telegram messages.
196
+
197
+ Args:
198
+ payload: Payload as received.
199
+ """
200
+ LOGGER.debug(payload)
201
+ chat = Chat(**{**payload, **payload["chat"], **payload["from"]})
202
+ if not authenticate(chat):
203
+ LOGGER.warning(payload)
204
+ return
205
+ if not verify_timeout(chat):
206
+ LOGGER.warning(payload)
207
+ return
208
+ if payload.get("text"):
209
+ chat.message_type = "text"
210
+ process_text(chat, Text(**payload))
211
+ elif payload.get("voice"):
212
+ chat.message_type = "voice"
213
+ process_voice(chat, Voice(**payload["voice"]))
214
+ elif payload.get("document"):
215
+ chat.message_type = "document"
216
+ process_document(chat, Document(**payload["document"]))
217
+ elif payload.get("video"):
218
+ chat.message_type = "video"
219
+ process_video(chat, Video(**payload["video"]))
220
+ elif payload.get("audio"):
221
+ chat.message_type = "audio"
222
+ process_audio(chat, Audio(**payload["audio"]))
223
+ elif payload.get("photo"):
224
+ # Matches for compressed images
225
+ chat.message_type = "photo"
226
+ process_photo(chat, [PhotoFragment(**d) for d in payload["photo"]])
227
+ else:
228
+ reply_to(chat, "Payload type is not allowed.")
229
+
230
+
231
+ def username_is_valid(username: str) -> bool:
232
+ """Compares username and returns True if username is allowed."""
233
+ for user in env.bot_users:
234
+ if secrets.compare_digest(user, username):
235
+ return True
236
+ return False
237
+
238
+
239
+ def authenticate(chat: Chat) -> bool:
240
+ """Authenticates the user with ``userId`` and ``userName``.
241
+
242
+ Args:
243
+ chat: Required section of the payload as Chat object.
244
+
245
+ Returns:
246
+ bool:
247
+ Returns a boolean to indicate whether the user is authenticated.
248
+ """
249
+ if chat.is_bot:
250
+ LOGGER.error("Bot request from %s", chat.username)
251
+ send_message(
252
+ chat_id=chat.id,
253
+ response=f"Sorry {chat.first_name}! I can't process requests from bots.",
254
+ )
255
+ return False
256
+ if chat.id not in env.bot_chat_ids or not username_is_valid(username=chat.username):
257
+ LOGGER.error("Unauthorized chatID [%d] or userName [%s]", chat.id, chat.username)
258
+ send_message(chat_id=chat.id, response=f"401 Unauthorized user: ({chat.username})")
259
+ return False
260
+ return True
261
+
262
+
263
+ def verify_timeout(chat: Chat) -> bool:
264
+ """Verifies whether the message was received in the past 60 seconds.
265
+
266
+ Args:
267
+ chat: Required section of the payload as Chat object.
268
+
269
+ Returns:
270
+ bool:
271
+ True or False flag to indicate if the request timed out.
272
+ """
273
+ if int(time.time()) - chat.date < 60:
274
+ return True
275
+ request_time = time.strftime("%m-%d-%Y %H:%M:%S", time.localtime(chat.date))
276
+ LOGGER.warning("Request timed out [%s] for %s", request_time, chat.username)
277
+ reply_to(
278
+ chat,
279
+ f"Request timed out\nRequested: {request_time}\n"
280
+ f"Processed: {time.strftime('%m-%d-%Y %H:%M:%S', time.localtime(time.time()))}",
281
+ )
282
+ return False
283
+
284
+
285
+ def process_photo(chat: Chat, data_class: List[PhotoFragment]) -> None:
286
+ """Processes a photo input.
287
+
288
+ Args:
289
+ chat: Required section of the payload as Chat object.
290
+ data_class: Required section of the payload as Voice object.
291
+ """
292
+ LOGGER.info(data_class)
293
+ reply_to(
294
+ chat,
295
+ "Image fragments are not supported. If you're sending a compressed image, "
296
+ "please try sending it without compression.",
297
+ )
298
+
299
+
300
+ def process_audio(chat: Chat, data_class: Audio) -> None:
301
+ """Processes an audio input.
302
+
303
+ Args:
304
+ chat: Required section of the payload as Chat object.
305
+ data_class: Required section of the payload as Voice object.
306
+ """
307
+ process_document(chat, data_class)
308
+
309
+
310
+ def process_video(chat: Chat, data_class: Video) -> None:
311
+ """Processes a video input.
312
+
313
+ Args:
314
+ chat: Required section of the payload as Chat object.
315
+ data_class: Required section of the payload as Voice object.
316
+ """
317
+ process_document(chat, data_class)
318
+
319
+
320
+ def process_voice(chat: Chat, data_class: Voice) -> None:
321
+ """Processes the audio file in payload received after checking for authentication.
322
+
323
+ Args:
324
+ chat: Required section of the payload as Chat object.
325
+ data_class: Required section of the payload as Voice object.
326
+ """
327
+ reply_to(chat, "Audio inputs are not supported at the moment. Please try text input.")
328
+
329
+
330
+ def process_document(chat: Chat, data_class: Document | Audio | Video) -> None:
331
+ """Processes the document in payload received after checking for authentication.
332
+
333
+ Args:
334
+ chat: Required section of the payload as Chat object.
335
+ data_class: Required section of the payload as Document object.
336
+ """
337
+ reply_to(chat, "Document inputs are not supported at the moment. Please try text input.")
338
+
339
+
340
+ def process_text(chat: Chat, data_class: Text) -> None:
341
+ """Processes the text in payload received after checking for authentication.
342
+
343
+ Args:
344
+ chat: Required section of the payload as Chat object.
345
+ data_class: Required section of the payload as Text object.
346
+ """
347
+ if data_class.text:
348
+ data_class.text = data_class.text.strip()
349
+ else:
350
+ send_message(chat_id=chat.id, response="Un-processable payload")
351
+ return
352
+ data_class.text = data_class.text.replace("override", "").replace("OVERRIDE", "")
353
+ text_lower = data_class.text.lower().lstrip("/")
354
+ if word_match(
355
+ phrase=text_lower,
356
+ match_list=(
357
+ "hey",
358
+ "hola",
359
+ "what's up",
360
+ "ssup",
361
+ "whats up",
362
+ "hello",
363
+ "hi",
364
+ "howdy",
365
+ "hey",
366
+ "chao",
367
+ "hiya",
368
+ "aloha",
369
+ ),
370
+ strict=True,
371
+ ):
372
+ reply_to(
373
+ chat,
374
+ intro(),
375
+ )
376
+ return
377
+ if text_lower == "start":
378
+ send_message(chat.id, intro())
379
+ return
380
+ if text_lower == "help":
381
+ send_message(
382
+ chat_id=chat.id,
383
+ response="Use '/id' or '/url' followed by the identifier.",
384
+ )
385
+ return
386
+ if text_lower == "test":
387
+ reply_to(chat, f"Test message received at - {datetime.now().strftime('%c')}")
388
+ return
389
+ executor(data_class.text, chat)
390
+
391
+
392
+ def executor(command: str, chat: Chat) -> None:
393
+ """Executes the command via offline communicator.
394
+
395
+ Args:
396
+ command: Command to be executed.
397
+ chat: Required section of the payload as Chat object.
398
+ """
399
+ LOGGER.info("Request: %s", command)
400
+ # TODO: Replace with reply_to
401
+ kwargs = dict(chat_id=chat.id, callback=send_message)
402
+ if command.startswith("/id"):
403
+ if playlist_id := command.replace("/id", "").strip():
404
+ kwargs["playlist_id"] = playlist_id
405
+ else:
406
+ reply_to(chat, "Invalid entry, a playlist id is required followed by /id")
407
+ return
408
+ elif command.startswith("/url"):
409
+ if playlist_url := command.replace("/url", "").strip():
410
+ kwargs["playlist_url"] = playlist_url
411
+ else:
412
+ reply_to(chat, "Invalid entry, a playlist url is required followed by /url")
413
+ return
414
+ else:
415
+ process_response(
416
+ f"Invalid command received: {command}\n\nEither use '/id' or '/url' followed by the identifier.",
417
+ chat,
418
+ )
419
+ return
420
+ try:
421
+ name = queue_download(**kwargs)
422
+ response = f"Download queued for {name!r}"
423
+ except (ValueError, AssertionError, DownloadError) as error:
424
+ response = error.__str__()
425
+ LOGGER.info("Response: %s", response)
426
+ process_response(response, chat)
427
+
428
+
429
+ def process_response(response: str, chat: Chat) -> None:
430
+ """Processes the response via Telegram API.
431
+
432
+ Args:
433
+ response: Response from yt2jf.
434
+ chat: Required section of the payload as Chat object.
435
+ """
436
+ send_message(chat.id, response, None)
yt2jf/config.py ADDED
@@ -0,0 +1,80 @@
1
+ import math
2
+ import os
3
+ import pathlib
4
+ import socket
5
+ import warnings
6
+ from ipaddress import IPv4Address
7
+ from multiprocessing import current_process
8
+ from typing import Any, Dict, List
9
+
10
+ from pydantic import (
11
+ DirectoryPath,
12
+ Field,
13
+ FilePath,
14
+ HttpUrl,
15
+ NewPath,
16
+ PositiveFloat,
17
+ PositiveInt,
18
+ )
19
+
20
+ from yt2jf.pydantic_config import PydanticEnvConfig
21
+
22
+ SECRETS_PATH = os.environ.get("SECRETS_PATH") or os.environ.get("secrets_path") or ".env"
23
+ LOGICAL_CORES = os.cpu_count() or 2
24
+ PHYSICAL_CORES = math.ceil(LOGICAL_CORES / 2)
25
+
26
+
27
+ class EnvConfig(PydanticEnvConfig):
28
+ """Configuration values for the project.
29
+
30
+ >>> EnvConfig
31
+
32
+ """
33
+
34
+ host: str = socket.gethostbyname("localhost")
35
+ port: PositiveInt = 4483
36
+ log_config: FilePath | Dict[str, Any] | None = None
37
+
38
+ # Applies to both rsync and telegram polling
39
+ max_retries: PositiveInt | PositiveFloat = Field(10, le=30, ge=1)
40
+ backoff_factor: PositiveInt | PositiveFloat = Field(3, le=10, ge=1)
41
+
42
+ # Concurrency
43
+ max_listeners: PositiveInt = Field(PHYSICAL_CORES, le=LOGICAL_CORES, ge=1)
44
+ max_transfers: PositiveInt = Field(LOGICAL_CORES, le=LOGICAL_CORES * 2, ge=1)
45
+
46
+ # Data
47
+ data_dir: NewPath | DirectoryPath = pathlib.Path("data")
48
+
49
+ # Telegram config
50
+ bot_token: str
51
+ bot_chat_ids: List[int]
52
+ bot_users: List[str]
53
+ poll_interval: PositiveInt | PositiveFloat = Field(2, le=5, ge=1)
54
+
55
+ # Remote config
56
+ remote_host: str | None = None
57
+ remote_user: str | None = None
58
+ remote_path: str | None = None
59
+ delete_after_sync: bool = True
60
+
61
+ # Telegram Webhook specific
62
+ bot_webhook: HttpUrl | None = None
63
+ bot_webhook_ip: IPv4Address | None = None
64
+ bot_endpoint: str = Field("/telegram-webhook", pattern=r"^\/")
65
+ bot_secret: str | None = Field(None, pattern="^[A-Za-z0-9_-]{1,256}$")
66
+ bot_certificate: FilePath | None = None
67
+
68
+ class Config:
69
+ """Environment variables configuration."""
70
+
71
+ vault_table = "yt2jf"
72
+ env_file = SECRETS_PATH
73
+ extra = "ignore"
74
+
75
+
76
+ env = EnvConfig()
77
+
78
+ if not all((env.remote_host, env.remote_path, env.remote_user)) and current_process().name == "MainProcess":
79
+ warnings.warn("No remote connections have been setup, all downloaded media will be stored locally.")
80
+ env.data_dir.mkdir(exist_ok=True)
yt2jf/exceptions.py ADDED
@@ -0,0 +1,40 @@
1
+ import requests
2
+
3
+ EgressErrors = (
4
+ ConnectionError,
5
+ TimeoutError,
6
+ requests.RequestException,
7
+ requests.Timeout,
8
+ )
9
+
10
+
11
+ class BotError(Exception):
12
+ """Custom base exception for Telegram Bot.
13
+
14
+ >>> BotError
15
+
16
+ """
17
+
18
+
19
+ class BotWebhookConflict(BotError):
20
+ """Error for conflict with webhook and getUpdates API call.
21
+
22
+ >>> BotWebhookConflict
23
+
24
+ """
25
+
26
+
27
+ class BotInUse(BotError):
28
+ """Error indicate bot token is being used else where.
29
+
30
+ >>> BotInUse
31
+
32
+ """
33
+
34
+
35
+ class BotTokenInvalid(BotError):
36
+ """Error indicate bot token is invalid.
37
+
38
+ >>> BotTokenInvalid
39
+
40
+ """
yt2jf/main.py ADDED
@@ -0,0 +1,79 @@
1
+ import asyncio
2
+ import logging
3
+ import pathlib
4
+ from contextlib import asynccontextmanager
5
+
6
+ import uvicorn
7
+ from fastapi import FastAPI
8
+ from fastapi.routing import APIRoute
9
+
10
+ from yt2jf.config import env
11
+ from yt2jf.poll import run_polling, shutdown_event
12
+ from yt2jf.routes import (
13
+ ACTIVE_TASKS,
14
+ api_delete_webhook,
15
+ api_get_webhook,
16
+ api_set_webhook,
17
+ telegram_webhook,
18
+ )
19
+ from yt2jf.version import __version__
20
+
21
+ LOGGER = logging.getLogger("uvicorn.default")
22
+
23
+
24
+ @asynccontextmanager
25
+ async def lifespan(_: FastAPI):
26
+ """Simple startup function to add anything that has to be triggered when Jarvis API starts up."""
27
+ # noinspection HttpUrlsUsage
28
+ LOGGER.info("Hosting at http://%s:%s", env.host, env.port)
29
+ bg_task = None
30
+ if not env.bot_webhook:
31
+ LOGGER.info("Polling for incoming messages...")
32
+ bg_task = asyncio.create_task(run_polling())
33
+ ACTIVE_TASKS["poll"] = bg_task
34
+ yield
35
+ if bg_task:
36
+ bg_task.cancel()
37
+ shutdown_event()
38
+ LOGGER.info("Shutting down API server.")
39
+
40
+
41
+ routes = [
42
+ APIRoute(
43
+ endpoint=telegram_webhook,
44
+ methods=["POST"],
45
+ path=env.bot_endpoint, # No enum
46
+ include_in_schema=False,
47
+ ),
48
+ APIRoute(
49
+ endpoint=api_get_webhook,
50
+ methods=["GET"],
51
+ path="/get-webhook",
52
+ ),
53
+ APIRoute(
54
+ endpoint=api_set_webhook,
55
+ methods=["POST"],
56
+ path="/set-webhook",
57
+ ),
58
+ APIRoute(
59
+ endpoint=api_delete_webhook,
60
+ methods=["DELETE"],
61
+ path="/delete-webhook",
62
+ ),
63
+ ]
64
+
65
+ app = FastAPI(title="YT2JF", version=__version__, lifespan=lifespan, routes=routes)
66
+
67
+
68
+ def start():
69
+ """Start the Jarvis API server using Uvicorn."""
70
+ module_name = pathlib.Path(__file__)
71
+ kwargs = dict(
72
+ host=env.host,
73
+ port=env.port,
74
+ app=f"{module_name.parent.stem}.main:app",
75
+ workers=1,
76
+ )
77
+ if env.log_config:
78
+ kwargs["log_config"] = env.log_config
79
+ uvicorn.run(**kwargs)
yt2jf/poll.py ADDED
@@ -0,0 +1,59 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ import requests.exceptions
5
+
6
+ from yt2jf.bot import poll_for_messages
7
+ from yt2jf.config import env
8
+ from yt2jf.exceptions import BotInUse, BotTokenInvalid, BotWebhookConflict, EgressErrors
9
+ from yt2jf.youtube import controllers, process_pool
10
+
11
+ LOGGER = logging.getLogger("uvicorn.default")
12
+
13
+
14
+ def shutdown_event():
15
+ """Shuts down all the threads and gracefully terminates the processes."""
16
+ process_pool.shutdown(wait=True)
17
+ for controller in controllers:
18
+ LOGGER.info("Shutting down controller for: %s", controller.name)
19
+ try:
20
+ result = controller.future.result()
21
+ except Exception as exc:
22
+ LOGGER.error("Controller failed for %s: %s", controller.name, exc)
23
+ else:
24
+ LOGGER.info("Controller completed for %s: %s", controller.name, result)
25
+
26
+
27
+ async def run_polling():
28
+ """Starts up all the threads and gracefully terminates the processes."""
29
+ offset = 0
30
+ failed_connections = 0
31
+ while True:
32
+ try:
33
+ await asyncio.sleep(env.poll_interval)
34
+ if offset_id := poll_for_messages(offset):
35
+ offset = offset_id
36
+ except EgressErrors as error:
37
+ if isinstance(error, requests.exceptions.ReadTimeout):
38
+ continue
39
+ LOGGER.error(error)
40
+ failed_connections += 1
41
+ if failed_connections > env.max_retries:
42
+ LOGGER.critical("ATTENTION::Couldn't recover from connection error. Restarting current process.")
43
+ delay = failed_connections * env.backoff_factor
44
+ LOGGER.info("Restarting in %d seconds.", delay)
45
+ await asyncio.sleep(delay) # Simple backoff wait
46
+ except (
47
+ asyncio.CancelledError,
48
+ BotWebhookConflict,
49
+ BotInUse,
50
+ BotTokenInvalid,
51
+ KeyboardInterrupt,
52
+ Exception,
53
+ ) as error:
54
+ if isinstance(error, asyncio.CancelledError):
55
+ LOGGER.info("Shutting down all threads and gracefully terminated.")
56
+ else:
57
+ LOGGER.error(error)
58
+ shutdown_event()
59
+ break