__init__.py 17.7 KB
Newer Older
echel0n's avatar
echel0n committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# ##############################################################################
#  Author: echel0n <[email protected]>
#  URL: https://sickrage.ca/
#  Git: https://git.sickrage.ca/SiCKRAGE/sickrage.git
#  -
#  This file is part of SiCKRAGE.
#  -
#  SiCKRAGE is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#  -
#  SiCKRAGE is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#  -
#  You should have received a copy of the GNU General Public License
#  along with SiCKRAGE.  If not, see <http://www.gnu.org/licenses/>.
# ##############################################################################
echel0n's avatar
echel0n committed
21

echel0n's avatar
echel0n committed
22
__version__ = "10.0.65"
echel0n's avatar
echel0n committed
23
__install_type__ = ""
24

25
26
27
28
29
30
import sys

# sickrage requires python 3.6+
if sys.version_info < (3, 6, 0):
    sys.exit("Sorry, SiCKRAGE requires Python 3.6+")

31
import argparse
echel0n's avatar
echel0n committed
32
import atexit
33
import gettext
34
import multiprocessing
35
import os
36
import pathlib
37
import re
38
import site
39
import subprocess
40
import threading
echel0n's avatar
echel0n committed
41
import time
42
import traceback
43
44
import pkg_resources

45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# pywin32 for windows service
try:
    import win32api
    import win32serviceutil
    import win32evtlogutil
    import win32event
    import win32service
    import win32ts
    import servicemanager
    from win32com.shell import shell, shellcon
except ImportError:
    if __install_type__ == 'windows':
        sys.exit("Sorry, SiCKRAGE requires Python module PyWin32.")

from signal import SIGTERM

61
app = None
echel0n's avatar
echel0n committed
62

63
64
MAIN_DIR = os.path.abspath(os.path.realpath(os.path.expanduser(os.path.dirname(os.path.dirname(__file__)))))
PROG_DIR = os.path.abspath(os.path.realpath(os.path.expanduser(os.path.dirname(__file__))))
65
LOCALE_DIR = os.path.join(PROG_DIR, 'locale')
echel0n's avatar
echel0n committed
66
CHANGELOG_FILE = os.path.join(MAIN_DIR, 'CHANGELOG.md')
echel0n's avatar
echel0n committed
67
REQS_FILE = os.path.join(MAIN_DIR, 'requirements.txt')
echel0n's avatar
echel0n committed
68
CHECKSUM_FILE = os.path.join(PROG_DIR, 'checksums.md5')
69
AUTO_PROCESS_TV_CFG_FILE = os.path.join(*[PROG_DIR, 'autoProcessTV', 'autoProcessTV.cfg'])
70

71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# add sickrage libs path to python system path
LIBS_DIR = os.path.join(PROG_DIR, 'libs')
if not (LIBS_DIR in sys.path) and not getattr(sys, 'frozen', False):
    sys.path, remainder = sys.path[:1], sys.path[1:]
    site.addsitedir(LIBS_DIR)
    sys.path.extend(remainder)

# set system default language
gettext.install('messages', LOCALE_DIR, codeset='UTF-8', names=["ngettext"])

if __install_type__ == 'windows':
    class SiCKRAGEService(win32serviceutil.ServiceFramework):
        _svc_name_ = "SiCKRAGE"
        _svc_display_name_ = "SiCKRAGE"
        _svc_description_ = (
            "Automated video library manager for TV shows. "
            'Set to "automatic" to start the service at system startup. '
            "You may need to login with a real user account when you need "
            "access to network shares."
        )

        if hasattr(sys, "frozen"):
            _exe_name_ = "SiCKRAGE.exe"

        def __init__(self, args):
            win32serviceutil.ServiceFramework.__init__(self, args)
            self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)

        def SvcDoRun(self):
            msg = "SiCKRAGE-service %s" % __version__
            self.Logger(servicemanager.PYS_SERVICE_STARTED, msg + " has started")
            start()
            self.Logger(servicemanager.PYS_SERVICE_STOPPED, msg + " has stopped")

        def SvcStop(self):
            if app:
                app.shutdown()

            self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
            win32event.SetEvent(self.hWaitStop)

        def Logger(self, state, msg):
            win32evtlogutil.ReportEvent(
                self._svc_display_name_, state, 0, servicemanager.EVENTLOG_INFORMATION_TYPE, (self._svc_name_, msg)
            )

117

echel0n's avatar
echel0n committed
118
119
class Daemon(object):
    """
echel0n's avatar
echel0n committed
120
    Usage: subclass the Daemon class
echel0n's avatar
echel0n committed
121
122
    """

echel0n's avatar
echel0n committed
123
    def __init__(self, pidfile, working_dir="/"):
echel0n's avatar
echel0n committed
124
125
126
127
        self.stdin = getattr(os, 'devnull', '/dev/null')
        self.stdout = getattr(os, 'devnull', '/dev/null')
        self.stderr = getattr(os, 'devnull', '/dev/null')
        self.pidfile = pidfile
echel0n's avatar
echel0n committed
128
        self.working_dir = working_dir
129
        self.pid = None
echel0n's avatar
echel0n committed
130
131
132
133
134
135
136
137
138
139
140

    def daemonize(self):
        """
        do the UNIX double-fork magic, see Stevens' "Advanced
        Programming in the UNIX Environment" for details (ISBN 0201563177)
        http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16
        """
        try:
            pid = os.fork()
            if pid > 0:
                # exit first parent
141
                os._exit(0)
142
        except OSError as e:
echel0n's avatar
echel0n committed
143
144
145
146
            sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
            sys.exit(1)

        os.setsid()
147
        os.umask(0)
echel0n's avatar
echel0n committed
148
149
150
151
152
153

        # do second fork
        try:
            pid = os.fork()
            if pid > 0:
                # exit from second parent
154
                os._exit(0)
155
        except OSError as e:
echel0n's avatar
echel0n committed
156
157
158
159
            sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
            sys.exit(1)

        # redirect standard file descriptors
echel0n's avatar
echel0n committed
160
161
162
        sys.stdin = sys.__stdin__
        sys.stdout = sys.__stdout__
        sys.stderr = sys.__stderr__
echel0n's avatar
echel0n committed
163
164
        sys.stdout.flush()
        sys.stderr.flush()
165
166
        si = open(self.stdin, 'r')
        so = open(self.stdout, 'a+')
167
        se = open(self.stderr, 'a+')
echel0n's avatar
echel0n committed
168
169
170
171
172
173
        os.dup2(si.fileno(), sys.stdin.fileno())
        os.dup2(so.fileno(), sys.stdout.fileno())
        os.dup2(se.fileno(), sys.stderr.fileno())

        # write pidfile
        atexit.register(self.delpid)
174
        self.pid = os.getpid()
175
        open(self.pidfile, 'w+').write("%s\n" % self.pid)
echel0n's avatar
echel0n committed
176
177
178
179
180
181
182
183
184
185
186

    def delpid(self):
        if os.path.exists(self.pidfile):
            os.remove(self.pidfile)

    def start(self):
        """
        Start the daemon
        """
        # Check for a pidfile to see if the daemon already runs
        try:
187
            pf = open(self.pidfile, 'r')
echel0n's avatar
echel0n committed
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
            pid = int(pf.read().strip())
            pf.close()
        except IOError:
            pid = None

        if pid:
            message = "pidfile %s already exist. Daemon already running?\n"
            sys.stderr.write(message % self.pidfile)
            sys.exit(1)

        # Start the daemon
        self.daemonize()

    def stop(self):
        """
        Stop the daemon
        """

        # Get the pid from the pidfile
        try:
208
            pf = open(self.pidfile, 'r')
echel0n's avatar
echel0n committed
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
            pid = int(pf.read().strip())
            pf.close()
        except IOError:
            pid = None

        if not pid:
            message = "pidfile %s does not exist. Daemon not running?\n"
            sys.stderr.write(message % self.pidfile)
            return  # not an error in a restart

        # Try killing the daemon process
        try:
            while 1:
                os.kill(pid, SIGTERM)
                time.sleep(0.1)
224
        except OSError as err:
echel0n's avatar
echel0n committed
225
226
227
228
229
230
231
            err = str(err)
            if err.find("No such process") > 0:
                self.delpid()
            else:
                sys.exit(1)


echel0n's avatar
echel0n committed
232
233
234
235
236
237
238
239
240
241
242
def version():
    # Return the version number
    return __version__


def install_type():
    # Return the install type
    if not __install_type__ and os.path.isdir(os.path.join(MAIN_DIR, '.git')):
        return 'git'
    else:
        return __install_type__ or 'source'
243
244


echel0n's avatar
echel0n committed
245
246
247
248
def changelog():
    # Return contents of CHANGELOG.md
    with open(CHANGELOG_FILE) as f:
        return f.read()
249
250


251
252
def check_requirements():
    if os.path.exists(REQS_FILE):
253
254
255
        with open(REQS_FILE) as f:
            for line in f.readlines():
                try:
256
257
258
259
260
261
262
263
264
265
                    req_name, req_version = line.strip().split('==', 1)
                    if 'python_version' in req_version:
                        m = re.search('(\d+.\d+.\d+).*(\d+.\d+)', req_version)
                        req_version = m.group(1)
                        python_version = m.group(2)
                        python_version_major = int(python_version.split('.')[0])
                        python_version_minor = int(python_version.split('.')[1])
                        if sys.version_info.major == python_version_major and sys.version_info.minor != python_version_minor:
                            continue

266
267
268
269
270
271
272
273
                    if not pkg_resources.get_distribution(req_name).version == req_version:
                        print('Updating requirement {} to {}'.format(req_name, req_version))
                        subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-deps", "--no-cache-dir", line.strip()])
                except pkg_resources.DistributionNotFound:
                    print('Installing requirement {}'.format(line.strip()))
                    subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-deps", "--no-cache-dir", line.strip()])
                except ValueError:
                    continue
274

275

echel0n's avatar
echel0n committed
276
def verify_checksums(remove_unverified=False):
echel0n's avatar
echel0n committed
277
    valid_files = []
echel0n's avatar
echel0n committed
278
    exempt_files = [pathlib.Path(__file__), pathlib.Path(CHECKSUM_FILE), pathlib.Path(AUTO_PROCESS_TV_CFG_FILE)]
echel0n's avatar
echel0n committed
279
280
281
282
283
284

    if not os.path.exists(CHECKSUM_FILE):
        return

    with open(CHECKSUM_FILE, "rb") as fp:
        for line in fp.readlines():
285
286
            file, checksum = line.decode().strip().split(' = ')
            full_filename = pathlib.Path(MAIN_DIR).joinpath(file)
echel0n's avatar
echel0n committed
287
288
289
290
            valid_files.append(full_filename)

    for root, dirs, files in os.walk(PROG_DIR):
        for file in files:
291
            full_filename = pathlib.Path(root).joinpath(file)
292

293
            if full_filename in exempt_files or full_filename.suffix == '.pyc':
294
295
296
                continue

            if full_filename not in valid_files and PROG_DIR in str(full_filename):
echel0n's avatar
echel0n committed
297
                try:
echel0n's avatar
echel0n committed
298
299
                    if remove_unverified:
                        print('Found unverified file {}, removed!'.format(full_filename))
300
                        full_filename.unlink()
echel0n's avatar
echel0n committed
301
                    else:
echel0n's avatar
echel0n committed
302
                        print('Found unverified file {}, you should delete this file manually!'.format(full_filename))
echel0n's avatar
echel0n committed
303
                except OSError:
echel0n's avatar
echel0n committed
304
                    print('Unable to delete unverified filename {} during checksum verification, you should delete this file manually!'.format(full_filename))
echel0n's avatar
echel0n committed
305
306


307
308
309
310
311
312
313
314
315
316
317
318
319
def handle_windows_service():
    if hasattr(sys, "frozen") and win32ts.ProcessIdToSessionId(win32api.GetCurrentProcessId()) == 0:
        servicemanager.Initialize()
        servicemanager.PrepareToHostSingle(SiCKRAGEService)
        servicemanager.StartServiceCtrlDispatcher()
        return True

    if len(sys.argv) > 1 and sys.argv[1] in ("install", "update", "remove", "start", "stop", "restart", "debug"):
        win32serviceutil.HandleCommandLine(SiCKRAGEService)
        del sys.argv[1]
        return True


320
def main():
321
    multiprocessing.freeze_support()
322
323

    # set thread name
324
    threading.current_thread().name = 'MAIN'
325
326
327
328

    # fix threading time bug
    time.strptime("2012", "%Y")

329
330
331
332
333
334
    if __install_type__ == 'windows':
        if not handle_windows_service():
            start()
    else:
        start()

335

336
337
338
339
340
341
342
def start():
    global app

    parser = argparse.ArgumentParser(
        prog='sickrage',
        description='Automated video library manager for TV shows'
    )
343

echel0n's avatar
echel0n committed
344
345
    parser.add_argument('-v', '--version',
                        action='version',
echel0n's avatar
echel0n committed
346
                        version=version())
echel0n's avatar
echel0n committed
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
    parser.add_argument('-d', '--daemon',
                        action='store_true',
                        help='Run as a daemon (*NIX ONLY)')
    parser.add_argument('-q', '--quiet',
                        action='store_true',
                        help='Disables logging to CONSOLE')
    parser.add_argument('-p', '--port',
                        default=0,
                        type=int,
                        help='Override default/configured port to listen on')
    parser.add_argument('-H', '--host',
                        default='',
                        help='Override default/configured host to listen on')
    parser.add_argument('--dev',
                        action='store_true',
                        help='Enable developer mode')
    parser.add_argument('--debug',
                        action='store_true',
                        help='Enable debugging')
    parser.add_argument('--datadir',
                        default=os.path.abspath(os.path.join(os.path.expanduser("~"), '.sickrage')),
                        help='Overrides data folder for database, config, cache and logs (specify full path)')
    parser.add_argument('--config',
                        default='config.ini',
                        help='Overrides config filename (specify full path and filename if outside datadir path)')
    parser.add_argument('--pidfile',
                        default='sickrage.pid',
                        help='Creates a PID file (specify full path and filename if outside datadir path)')
    parser.add_argument('--no_clean',
                        action='store_true',
                        help='Suppress cleanup of files not present in checksum.md5')
    parser.add_argument('--nolaunch',
                        action='store_true',
                        help='Suppress launching web browser on startup')
    parser.add_argument('--disable_updates',
                        action='store_true',
                        help='Disable application updates')
    parser.add_argument('--web_root',
                        default='',
                        type=str,
                        help='Overrides URL web root')
    parser.add_argument('--db_type',
                        default='sqlite',
                        help='Database type: sqlite or mysql')
    parser.add_argument('--db_prefix',
                        default='sickrage',
                        help='Database prefix you want prepended to database table names')
    parser.add_argument('--db_host',
                        default='localhost',
                        help='Database hostname (not used for sqlite)')
    parser.add_argument('--db_port',
                        default='3306',
                        help='Database port number (not used for sqlite)')
    parser.add_argument('--db_username',
                        default='sickrage',
                        help='Database username (not used for sqlite)')
    parser.add_argument('--db_password',
                        default='sickrage',
                        help='Database password (not used for sqlite)')

    # Parse startup args
    args = parser.parse_args()

410
411
412
    # check requirements
    # if install_type() not in ['windows', 'synology', 'docker', 'qnap', 'readynas', 'pip']:
    #     check_requirements()
413

echel0n's avatar
echel0n committed
414
    # verify file checksums, remove unverified files
415
    # verify_checksums(remove_unverified=not args.no_clean)
echel0n's avatar
echel0n committed
416

417
    try:
418
419
        from sickrage.core import Core
        app = Core()
420
    except ImportError:
421
        sys.exit("Sorry, SiCKRAGE requirements need to be installed.")
422

423
    try:
echel0n's avatar
echel0n committed
424
        app.quiet = args.quiet
425
        app.web_host = args.host
426
        app.web_port = int(args.port)
427
        app.web_root = args.web_root.lstrip('/').rstrip('/')
428
        app.no_launch = args.nolaunch
429
        app.disable_updates = args.disable_updates
430
        app.developer = args.dev
431
432
433
434
435
436
        app.db_type = args.db_type
        app.db_prefix = args.db_prefix
        app.db_host = args.db_host
        app.db_port = args.db_port
        app.db_username = args.db_username
        app.db_password = args.db_password
437
438
439
440
        app.debug = args.debug
        app.data_dir = os.path.abspath(os.path.realpath(os.path.expanduser(args.datadir)))
        app.cache_dir = os.path.abspath(os.path.realpath(os.path.join(app.data_dir, 'cache')))
        app.config_file = args.config
echel0n's avatar
echel0n committed
441
442
        daemonize = (False, args.daemon)[not sys.platform == 'win32']
        pid_file = args.pidfile
443

444
445
        if not os.path.isabs(app.config_file):
            app.config_file = os.path.join(app.data_dir, app.config_file)
446

447
        if not os.path.isabs(pid_file):
echel0n's avatar
echel0n committed
448
            pid_file = os.path.join(app.data_dir, pid_file)
449
450

        # add sickrage module to python system path
451
        if not (PROG_DIR in sys.path) and not getattr(sys, 'frozen', False):
452
453
454
455
            sys.path, remainder = sys.path[:1], sys.path[1:]
            site.addsitedir(PROG_DIR)
            sys.path.extend(remainder)

456
        # Make sure that we can create the data dir
457
        if not os.access(app.data_dir, os.F_OK):
458
            try:
459
                os.makedirs(app.data_dir, 0o744)
460
            except os.error:
461
                sys.exit("Unable to create data directory '" + app.data_dir + "'")
462
463

        # Make sure we can write to the data dir
464
465
        if not os.access(app.data_dir, os.W_OK):
            sys.exit("Data directory must be writeable '" + app.data_dir + "'")
466

467
        # Make sure that we can create the cache dir
468
        if not os.access(app.cache_dir, os.F_OK):
469
            try:
470
                os.makedirs(app.cache_dir, 0o744)
471
            except os.error:
472
                sys.exit("Unable to create cache directory '" + app.cache_dir + "'")
473
474

        # Make sure we can write to the cache dir
475
476
        if not os.access(app.cache_dir, os.W_OK):
            sys.exit("Cache directory must be writeable '" + app.cache_dir + "'")
477

echel0n's avatar
echel0n committed
478
        # daemonize if requested
echel0n's avatar
echel0n committed
479
        if daemonize:
480
            app.no_launch = True
echel0n's avatar
echel0n committed
481
            app.quiet = True
echel0n's avatar
echel0n committed
482
            app.daemon = Daemon(pid_file, app.data_dir)
483
            app.daemon.daemonize()
484
            app.pid = app.daemon.pid
echel0n's avatar
echel0n committed
485

486
        app.start()
echel0n's avatar
echel0n committed
487
    except (SystemExit, KeyboardInterrupt):
488
489
        if app:
            app.shutdown()
echel0n's avatar
echel0n committed
490
491
    except Exception as e:
        try:
492
            # attempt to send exception to sentry
echel0n's avatar
echel0n committed
493
494
495
496
497
            import sentry_sdk
            sentry_sdk.capture_exception(e)
        except ImportError:
            pass

498
        traceback.print_exc()
499

500

echel0n's avatar
echel0n committed
501
502
if __name__ == '__main__':
    main()