diff --git a/bazarr.py b/bazarr.py index f5fc76bb8..dedf1aea1 100644 --- a/bazarr.py +++ b/bazarr.py @@ -6,10 +6,13 @@ import signal import subprocess import sys import time -import atexit from bazarr.app.get_args import args +from bazarr.literals import * +def exit_program(status_code): + print(f'Bazarr exited with status code {status_code}.') + raise SystemExit(status_code) def check_python_version(): python_version = platform.python_version_tuple() @@ -19,7 +22,7 @@ def check_python_version(): if int(python_version[0]) < minimum_py3_tuple[0]: print("Python " + minimum_py3_str + " or greater required. " "Current version is " + platform.python_version() + ". Please upgrade Python.") - sys.exit(1) + exit_program(EXIT_PYTHON_UPGRADE_NEEDED) elif int(python_version[0]) == 3 and int(python_version[1]) > 11: print("Python version greater than 3.11.x is unsupported. Current version is " + platform.python_version() + ". Keep in mind that even if it works, you're on your own.") @@ -27,7 +30,7 @@ def check_python_version(): (int(python_version[0]) != minimum_py3_tuple[0]): print("Python " + minimum_py3_str + " or greater required. " "Current version is " + platform.python_version() + ". Please upgrade Python.") - sys.exit(1) + exit_program(EXIT_PYTHON_UPGRADE_NEEDED) def get_python_path(): @@ -49,55 +52,77 @@ check_python_version() dir_name = os.path.dirname(__file__) +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) + print(f"Bazarr starting child process with PID {ep.pid}...") + return ep + + +def terminate_child(): + print(f"Terminating child process with PID {child_process.pid}") + child_process.terminate() -def end_child_process(ep): + +def get_stop_status_code(input_file): try: - if os.name != 'nt': - try: - ep.send_signal(signal.SIGINT) - except ProcessLookupError: - pass - else: - import win32api - import win32con + with open(input_file,'r') as file: + # read status code from file, if it exists + line = file.readline() try: - win32api.GenerateConsoleCtrlEvent(win32con.CTRL_C_EVENT, ep.pid) - except KeyboardInterrupt: - pass + status_code = int(line) + except (ValueError, TypeError): + status_code = EXIT_NORMAL + file.close() except: - ep.terminate() - - -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: end_child_process(ep)) + status_code = EXIT_NORMAL + return status_code def check_status(): + global child_process if os.path.exists(stopfile): + status_code = get_stop_status_code(stopfile) try: + print(f"Deleting stop file...") os.remove(stopfile) - except Exception: + except Exception as e: print('Unable to delete stop file.') finally: - print('Bazarr exited.') - sys.exit(0) + terminate_child() + exit_program(status_code) if os.path.exists(restartfile): try: + print(f"Deleting restart file...") os.remove(restartfile) except Exception: print('Unable to delete restart file.') - else: - print("Bazarr is restarting...") - start_bazarr() + finally: + terminate_child() + print(f"Bazarr is restarting...") + child_process = start_bazarr() + + +def interrupt_handler(signum, frame): + # catch and ignore keyboard interrupt Ctrl-C + # the child process Server object will catch SIGINT and perform an orderly shutdown + global interrupted + if not interrupted: + # ignore user hammering Ctrl-C; we heard you the first time! + interrupted = True + print('Handling keyboard interrupt...') + else: + print(f"Stop doing that! I heard you the first time!") if __name__ == '__main__': - restartfile = os.path.join(args.config_dir, 'bazarr.restart') - stopfile = os.path.join(args.config_dir, 'bazarr.stop') + interrupted = False + signal.signal(signal.SIGINT, interrupt_handler) + restartfile = os.path.join(args.config_dir, FILE_RESTART) + stopfile = os.path.join(args.config_dir, FILE_STOP) + os.environ[ENV_STOPFILE] = stopfile + os.environ[ENV_RESTARTFILE] = restartfile # Cleanup leftover files try: @@ -111,18 +136,14 @@ if __name__ == '__main__': pass # Initial start of main bazarr process - print("Bazarr starting...") - start_bazarr() + child_process = start_bazarr() - # Keep the script running forever until stop is requested through term or keyboard interrupt + # Keep the script running forever until stop is requested through term, special files or keyboard interrupt while True: check_status() try: - if sys.platform.startswith('win'): - time.sleep(5) - else: - os.wait() - time.sleep(1) + time.sleep(5) except (KeyboardInterrupt, SystemExit, ChildProcessError): - print('Bazarr exited.') - sys.exit(0) + # this code should never be reached, if signal handling is working properly + print(f'Bazarr exited main script file via keyboard interrupt.') + exit_program(EXIT_INTERRUPT) diff --git a/bazarr/api/system/logs.py b/bazarr/api/system/logs.py index 58225f375..7293f44c2 100644 --- a/bazarr/api/system/logs.py +++ b/bazarr/api/system/logs.py @@ -1,14 +1,15 @@ # coding=utf-8 import io -import os import re from flask_restx import Resource, Namespace, fields, marshal + from app.config import settings from app.logger import empty_log from app.get_args import args +from utilities.central import get_log_file_path from ..utils import authenticate api_ns_system_logs = Namespace('System Logs', description='List log file entries or empty log file') @@ -54,7 +55,7 @@ class SystemLogs(Resource): include = include.casefold() exclude = exclude.casefold() - with io.open(os.path.join(args.config_dir, 'log', 'bazarr.log'), encoding='UTF-8') as file: + with io.open(get_log_file_path(), encoding='UTF-8') as file: raw_lines = file.read() lines = raw_lines.split('|\n') for line in lines: diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 3fa92bc94..e6629105e 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -7,6 +7,8 @@ import logging import re from urllib.parse import quote_plus +from literals import EXIT_VALIDATION_ERROR +from utilities.central import stop_bazarr from subliminal.cache import region from dynaconf import Dynaconf, Validator as OriginalValidator from dynaconf.loaders.yaml_loader import write @@ -410,8 +412,9 @@ while failed_validator: settings[current_validator_details.names[0]] = current_validator_details.default else: logging.critical(f"Value for {current_validator_details.names[0]} doesn't pass validation and there's no " - f"default value. This issue must be reported. Bazarr won't works until it's been fixed.") - os._exit(0) + f"default value. This issue must be reported to and fixed by the development team. " + f"Bazarr won't work until it's been fixed.") + stop_bazarr(EXIT_VALIDATION_ERROR) def write_config(): diff --git a/bazarr/app/logger.py b/bazarr/app/logger.py index a65c24e14..0b8de2435 100644 --- a/bazarr/app/logger.py +++ b/bazarr/app/logger.py @@ -8,6 +8,7 @@ import platform import warnings from logging.handlers import TimedRotatingFileHandler +from utilities.central import get_log_file_path from pytz_deprecation_shim import PytzUsageWarning from .get_args import args @@ -61,16 +62,19 @@ class UnwantedWaitressMessageFilter(logging.Filter): if settings.general.debug is 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"'] + + 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"' ], + + "Exception when servicing %r", + [], ] wanted = True @@ -79,7 +83,7 @@ class UnwantedWaitressMessageFilter(logging.Filter): if record.msg == unwantedMessages[i]: exceptionTuple = record.exc_info if exceptionTuple is not None: - if str(exceptionTuple[1]) in unwantedMessages[i+1]: + if len(unwantedMessages[i+1]) == 0 or str(exceptionTuple[1]) in unwantedMessages[i+1]: wanted = False break @@ -112,10 +116,10 @@ def configure_logging(debug=False): # File Logging global fh if sys.version_info >= (3, 9): - fh = PatchedTimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", + fh = PatchedTimedRotatingFileHandler(get_log_file_path(), when="midnight", interval=1, backupCount=7, delay=True, encoding='utf-8') else: - fh = TimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", interval=1, + fh = TimedRotatingFileHandler(get_log_file_path(), when="midnight", interval=1, backupCount=7, delay=True, encoding='utf-8') f = FileHandlerFormatter('%(asctime)s|%(levelname)-8s|%(name)-32s|%(message)s|', '%Y-%m-%d %H:%M:%S') diff --git a/bazarr/app/server.py b/bazarr/app/server.py index 52d711fbe..d56e1205b 100644 --- a/bazarr/app/server.py +++ b/bazarr/app/server.py @@ -1,10 +1,11 @@ # coding=utf-8 +import signal import warnings import logging -import os -import io import errno +from literals import EXIT_INTERRUPT, EXIT_NORMAL +from utilities.central import restart_bazarr, stop_bazarr from waitress.server import create_server from time import sleep @@ -37,6 +38,7 @@ class Server: self.connected = False self.address = str(settings.general.ip) self.port = int(args.port) if args.port else int(settings.general.port) + self.interrupted = False while not self.connected: sleep(0.1) @@ -62,9 +64,17 @@ class Server: logging.exception("BAZARR cannot start because of unhandled exception.") self.shutdown() + def interrupt_handler(self, signum, frame): + # print('Server signal interrupt handler called with signal', signum) + if not self.interrupted: + # ignore user hammering Ctrl-C; we heard you the first time! + self.interrupted = True + self.shutdown(EXIT_INTERRUPT) + def start(self): logging.info(f'BAZARR is started and waiting for request on http://{self.server.effective_host}:' f'{self.server.effective_port}') + signal.signal(signal.SIGINT, self.interrupt_handler) try: self.server.run() except (KeyboardInterrupt, SystemExit): @@ -72,31 +82,19 @@ class Server: except Exception: pass - def shutdown(self): - 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() - close_database() - self.server.close() - os._exit(0) + def close_all(self): + print(f"Closing database...") + close_database() + print(f"Closing webserver...") + self.server.close() + + def shutdown(self, status=EXIT_NORMAL): + self.close_all() + stop_bazarr(status, False) def restart(self): - 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() - close_database() - self.server.close() - os._exit(0) + self.close_all() + restart_bazarr() webserver = Server() diff --git a/bazarr/app/ui.py b/bazarr/app/ui.py index 229768fc4..b45433552 100644 --- a/bazarr/app/ui.py +++ b/bazarr/app/ui.py @@ -9,9 +9,11 @@ from functools import wraps from urllib.parse import unquote from constants import headers +from literals import FILE_LOG from sonarr.info import url_api_sonarr from radarr.info import url_api_radarr from utilities.helper import check_credentials +from utilities.central import get_log_file_path from .config import settings, base_url from .database import System @@ -98,9 +100,9 @@ def catch_all(path): @check_login -@ui_bp.route('/bazarr.log') +@ui_bp.route('/' + FILE_LOG) def download_log(): - return send_file(os.path.join(args.config_dir, 'log', 'bazarr.log'), max_age=0, as_attachment=True) + return send_file(get_log_file_path(), max_age=0, as_attachment=True) @check_login diff --git a/bazarr/init.py b/bazarr/init.py index 0a2496df0..98b7d3129 100644 --- a/bazarr/init.py +++ b/bazarr/init.py @@ -1,7 +1,6 @@ # coding=utf-8 import os -import io import sys import subprocess import subliminal @@ -20,6 +19,9 @@ from utilities.backup import restore_from_backup from app.database import init_db +from literals import * +from utilities.central import make_bazarr_dir, restart_bazarr, stop_bazarr + # set start time global variable as epoch global startTime startTime = time.time() @@ -37,20 +39,15 @@ if not os.path.exists(args.config_dir): os.mkdir(os.path.join(args.config_dir)) except OSError: print("BAZARR The configuration directory doesn't exist and Bazarr cannot create it (permission issue?).") - exit(2) - -if not os.path.exists(os.path.join(args.config_dir, 'config')): - os.mkdir(os.path.join(args.config_dir, 'config')) -if not os.path.exists(os.path.join(args.config_dir, 'db')): - os.mkdir(os.path.join(args.config_dir, 'db')) -if not os.path.exists(os.path.join(args.config_dir, 'log')): - os.mkdir(os.path.join(args.config_dir, 'log')) -if not os.path.exists(os.path.join(args.config_dir, 'cache')): - os.mkdir(os.path.join(args.config_dir, 'cache')) -if not os.path.exists(os.path.join(args.config_dir, 'backup')): - os.mkdir(os.path.join(args.config_dir, 'backup')) -if not os.path.exists(os.path.join(args.config_dir, 'restore')): - os.mkdir(os.path.join(args.config_dir, 'restore')) + stop_bazarr(EXIT_CONFIG_CREATE_ERROR) + +os.environ[ENV_BAZARR_ROOT_DIR] = os.path.join(args.config_dir) +make_bazarr_dir(DIR_BACKUP) +make_bazarr_dir(DIR_CACHE) +make_bazarr_dir(DIR_CONFIG) +make_bazarr_dir(DIR_DB) +make_bazarr_dir(DIR_LOG) +make_bazarr_dir(DIR_RESTORE) # set subliminal_patch hearing-impaired extension to use when naming subtitles os.environ["SZ_HI_EXTENSION"] = settings.general.hi_extension @@ -99,19 +96,11 @@ if not args.no_update: subprocess.check_output(pip_command, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: logging.exception(f'BAZARR requirements.txt installation result: {e.stdout}') - os._exit(1) + os._exit(EXIT_REQUIREMENTS_ERROR) else: logging.info('BAZARR requirements installed.') - 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) + restart_bazarr() # change default base_url to '' settings.general.base_url = settings.general.base_url.rstrip('/') diff --git a/bazarr/literals.py b/bazarr/literals.py new file mode 100644 index 000000000..9a5e7eb9a --- /dev/null +++ b/bazarr/literals.py @@ -0,0 +1,30 @@ +# coding=utf-8 + +# only primitive types can be specified here +# for other derived values, use constants.py + +# bazarr environment variable names +ENV_STOPFILE = 'STOPFILE' +ENV_RESTARTFILE = 'RESTARTFILE' +ENV_BAZARR_ROOT_DIR = 'BAZARR_ROOT' + +# bazarr subdirectories +DIR_BACKUP = 'backup' +DIR_CACHE = 'cache' +DIR_CONFIG = 'config' +DIR_DB = 'db' +DIR_LOG = 'log' +DIR_RESTORE = 'restore' + +# bazarr special files +FILE_LOG = 'bazarr.log' +FILE_RESTART = 'bazarr.restart' +FILE_STOP = 'bazarr.stop' + +# bazarr exit codes +EXIT_NORMAL = 0 +EXIT_INTERRUPT = -100 +EXIT_VALIDATION_ERROR = -101 +EXIT_CONFIG_CREATE_ERROR = -102 +EXIT_PYTHON_UPGRADE_NEEDED = -103 +EXIT_REQUIREMENTS_ERROR = -104 diff --git a/bazarr/main.py b/bazarr/main.py index a2a734be1..15af61e97 100644 --- a/bazarr/main.py +++ b/bazarr/main.py @@ -44,16 +44,8 @@ from app.server import webserver, app # noqa E402 from app.announcements import get_announcements_to_file # noqa E402 if args.create_db_revision: - 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: - create_db_revision(app) - logging.info('Bazarr is being shutdown...') - stop_file.write(str('')) - stop_file.close() - os._exit(0) + create_db_revision(app) + stop_bazarr(EXIT_NORMAL) else: migrate_db(app) diff --git a/bazarr/utilities/backup.py b/bazarr/utilities/backup.py index 8088a50a2..136a959b1 100644 --- a/bazarr/utilities/backup.py +++ b/bazarr/utilities/backup.py @@ -1,7 +1,6 @@ # coding=utf-8 import os -import io import sqlite3 import shutil import logging @@ -12,6 +11,7 @@ from glob import glob from app.get_args import args from app.config import settings +from utilities.central import restart_bazarr def get_backup_path(): @@ -133,16 +133,7 @@ def restore_from_backup(): logging.exception(f'Unable to delete {dest_database_path}') logging.info('Backup restored successfully. Bazarr will restart.') - - 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('') - restart_file.close() - os._exit(0) + restart_bazarr() elif os.path.isfile(restore_config_path) or os.path.isfile(restore_database_path): logging.debug('Cannot restore a partial backup. You must have both config and database.') else: diff --git a/bazarr/utilities/central.py b/bazarr/utilities/central.py new file mode 100644 index 000000000..3a0ed8378 --- /dev/null +++ b/bazarr/utilities/central.py @@ -0,0 +1,49 @@ +# coding=utf-8 + +# only methods can be specified here that do not cause other moudules to be loaded +# for other methods that use settings, etc., use utilities/helper.py + +import logging +import os +from pathlib import Path +from literals import * + +def get_bazarr_dir(sub_dir): + path = os.path.join(os.environ[ENV_BAZARR_ROOT_DIR], sub_dir) + return path + +def make_bazarr_dir(sub_dir): + path = get_bazarr_dir(sub_dir) + if not os.path.exists(path): + os.mkdir(path) + +def get_log_file_path(): + path = os.path.join(get_bazarr_dir(DIR_LOG), FILE_LOG) + return path + +def get_stop_file_path(): + return os.environ[ENV_STOPFILE] + +def get_restart_file_path(): + return os.environ[ENV_RESTARTFILE] + +def stop_bazarr(status_code=EXIT_NORMAL, exit_main=True): + try: + with open(get_stop_file_path(),'w', encoding='UTF-8') as file: + # write out status code for final exit + file.write(f'{status_code}\n') + file.close() + except Exception as e: + logging.error(f'BAZARR Cannot create stop file: {repr(e)}') + logging.info('Bazarr is being shutdown...') + if exit_main: + raise SystemExit(status_code) + +def restart_bazarr(): + try: + Path(get_restart_file_path()).touch() + except Exception as e: + logging.error(f'BAZARR Cannot create restart file: {repr(e)}') + logging.info('Bazarr is being restarted...') + raise SystemExit(EXIT_NORMAL) + \ No newline at end of file