__init__.py 56.2 KB
Newer Older
echel0n's avatar
echel0n committed
1
# Author: echel0n <[email protected]>
echel0n's avatar
echel0n committed
2
# URL: https://sickrage.ca
echel0n's avatar
echel0n committed
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#
# 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/>.

19
from __future__ import unicode_literals
20

21
import base64
22
import ctypes
echel0n's avatar
echel0n committed
23
import datetime
24
25
26
import io
import os
import platform
27
28
import random
import re
29
import shutil
30
31
import socket
import stat
32
import string
echel0n's avatar
echel0n committed
33
import sys
34
import tempfile
35
36
37
38
import time
import traceback
import urlparse
import uuid
39
import webbrowser
40
import zipfile
41
from collections import OrderedDict
42
from contextlib import contextmanager
43

echel0n's avatar
echel0n committed
44
import rarfile
echel0n's avatar
echel0n committed
45
import requests
46
import six
47
from bs4 import BeautifulSoup
48
49

import sickrage
50
from sickrage.core.common import Quality, SKIPPED, WANTED, FAILED, UNAIRED
51
from sickrage.core.exceptions import MultipleShowObjectsException
52

53

54
55
56
def safe_getattr(object, name, default=None):
    try:
        return getattr(object, name, default)
57
    except:
58
59
60
        return default


61
def try_int(value, default=0):
62
63
    try:
        return int(value)
64
65
    except Exception:
        return default
66
67


68
def readFileBuffered(filename, reverse=False):
69
    blocksize = (1 << 15)
70
    with io.open(filename, 'r', encoding='utf-8') as fh:
71
        if reverse:
72
            fh.seek(0, os.SEEK_END)
73
74
        pos = fh.tell()
        while True:
75

76
            if reverse:
77
                chunksize = min(blocksize, pos)
78
79
                pos -= chunksize
            else:
80
                chunksize = max(blocksize, pos)
81
                pos += chunksize
82

83
            fh.seek(pos, os.SEEK_SET)
84
85
86
            data = fh.read(chunksize)
            if not data:
                break
87
            yield data
88
            del data
89

90

91
def argToBool(x):
92
93
94
    """
    convert argument of unknown type to a bool:
    """
95
96

    if isinstance(x, six.string_types):
97
98
99
100
101
102
        if x.lower() in ("0", "false", "f", "no", "n", "off"):
            return False
        elif x.lower() in ("1", "true", "t", "yes", "y", "on"):
            return True
        raise ValueError("failed to cast as boolean")

103
104
    return bool(x)

105

106
107
def auto_type(s):
    for fn in (int, float, argToBool):
108
109
110
111
112
        try:
            return fn(s)
        except ValueError:
            pass

113
    return (s, '')[s.lower() == "none"]
114
115


116
117
118
119
def fixGlob(path):
    path = re.sub(r'\[', '[[]', path)
    return re.sub(r'(?<!\[)\]', '[]]', path)

120

121
def indentXML(elem, level=0):
122
    """
123
    Does our pretty printing, makes Matt very happy
124
    """
125
    i = "\n" + level * "  "
126
127
128
129
130
131
    if len(elem):
        if not elem.text or not elem.text.strip():
            elem.text = i + "  "
        if not elem.tail or not elem.tail.strip():
            elem.tail = i
        for elem in elem:
132
            indentXML(elem, level + 1)
133
134
135
136
137
138
139
140
141
142
143
144
145
        if not elem.tail or not elem.tail.strip():
            elem.tail = i
    else:
        if level and (not elem.tail or not elem.tail.strip()):
            elem.tail = i


def remove_extension(name):
    """
    Remove download or media extension from name (if any)
    """

    if name and "." in name:
146
        base_name, sep, extension = name.rpartition('.')
147
        if base_name and extension.lower() in ['nzb', 'torrent'] + sickrage.app.config.allowed_video_file_exts:
148
149
150
151
152
153
154
155
156
            name = base_name

    return name


def remove_non_release_groups(name):
    """
    Remove non release groups from name
    """
157
158
159
    if not name:
        return name

160
161
162
163
    # Do not remove all [....] suffixes, or it will break anime releases ## Need to verify this is true now
    # Check your database for funky release_names and add them here, to improve failed handling, archiving, and history.
    # select release_name from tv_episodes WHERE LENGTH(release_name);
    # [eSc], [SSG], [GWC] are valid release groups for non-anime
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
    removeWordsList = OrderedDict([
        (r'^\[www\.Cpasbien\.pe\] ', 'searchre'),
        (r'^\[www\.Cpasbien\.com\] ', 'searchre'),
        (r'^\[ www\.Cpasbien\.pw \] ', 'searchre'),
        (r'^\.www\.Cpasbien\.pw', 'searchre'),
        (r'^\[www\.newpct1\.com\]', 'searchre'),
        (r'^\[ www\.Cpasbien\.com \] ', 'searchre'),
        (r'^\{ www\.SceneTime\.com \} - ', 'searchre'),
        (r'^\]\.\[www\.tensiontorrent.com\] - ', 'searchre'),
        (r'^\]\.\[ www\.tensiontorrent.com \] - ', 'searchre'),
        (r'^\[ www\.TorrentDay\.com \] - ', 'searchre'),
        (r'^www\.Torrenting\.com\.-\.', 'searchre'),
        (r'\[rartv\]$', 'searchre'),
        (r'\[rarbg\]$', 'searchre'),
        (r'\.\[eztv\]$', 'searchre'),
        (r'\[eztv\]$', 'searchre'),
        (r'\[ettv\]$', 'searchre'),
        (r'\[cttv\]$', 'searchre'),
        (r'\.\[vtv\]$', 'searchre'),
        (r'\[vtv\]$', 'searchre'),
        (r'\[EtHD\]$', 'searchre'),
        (r'\[GloDLS\]$', 'searchre'),
        (r'\[silv4\]$', 'searchre'),
        (r'\[Seedbox\]$', 'searchre'),
        (r'\[PublicHD\]$', 'searchre'),
        (r'\.\[PublicHD\]$', 'searchre'),
        (r'\.\[NO.RAR\]$', 'searchre'),
        (r'\[NO.RAR\]$', 'searchre'),
        (r'-\=\{SPARROW\}\=-$', 'searchre'),
        (r'\=\{SPARR$', 'searchre'),
        (r'\.\[720P\]\[HEVC\]$', 'searchre'),
        (r'\[AndroidTwoU\]$', 'searchre'),
        (r'\[brassetv\]$', 'searchre'),
        (r'\[Talamasca32\]$', 'searchre'),
        (r'\(musicbolt\.com\)$', 'searchre'),
        (r'\.\(NLsub\)$', 'searchre'),
        (r'\(NLsub\)$', 'searchre'),
        (r'\.\[BT\]$', 'searchre'),
        (r' \[1044\]$', 'searchre'),
        (r'\.RiPSaLoT$', 'searchre'),
        (r'\.GiuseppeTnT$', 'searchre'),
        (r'\.Renc$', 'searchre'),
        (r'\.gz$', 'searchre'),
        (r'\.English$', 'searchre'),
        (r'\.German$', 'searchre'),
        (r'\.\.Italian$', 'searchre'),
        (r'\.Italian$', 'searchre'),
        (r'(?<![57])\.1$', 'searchre'),
        (r'-NZBGEEK$', 'searchre'),
        (r'-Siklopentan$', 'searchre'),
        (r'-Chamele0n$', 'searchre'),
        (r'-Obfuscated$', 'searchre'),
        (r'-BUYMORE$', 'searchre'),
        (r'-\[SpastikusTV\]$', 'searchre'),
        (r'-RP$', 'searchre'),
        (r'-20-40$', 'searchre'),
        (r'\.\[www\.usabit\.com\]$', 'searchre'),
        (r'\[NO-RAR\] - \[ www\.torrentday\.com \]$', 'searchre'),
        (r'- \[ www\.torrentday\.com \]$', 'searchre'),
        (r'- \{ www\.SceneTime\.com \}$', 'searchre'),
        (r'-Scrambled$', 'searchre')
    ])
226
227

    _name = name
228
    for remove_string, remove_type in six.iteritems(removeWordsList):
229
230
231
232
233
        if remove_type == 'search':
            _name = _name.replace(remove_string, '')
        elif remove_type == 'searchre':
            _name = re.sub(r'(?i)' + remove_string, '', _name)

234
    return _name
235
236
237


def replaceExtension(filename, newExt):
238
    """
239
240
241
242
243
244
245
246
247
248
    >>> replaceExtension('foo.avi', 'mkv')
    'foo.mkv'
    >>> replaceExtension('.vimrc', 'arglebargle')
    '.vimrc'
    >>> replaceExtension('a.b.c', 'd')
    'a.b.d'
    >>> replaceExtension('', 'a')
    ''
    >>> replaceExtension('foo.bar', '')
    'foo.'
249
    """
250
251
252
253
254
255
256
    sepFile = filename.rpartition(".")
    if sepFile[0] == "":
        return filename
    else:
        return sepFile[0] + "." + newExt


echel0n's avatar
echel0n committed
257
def is_torrent_or_nzb_file(filename):
258
    """
echel0n's avatar
echel0n committed
259
260
261
    Check if the provided ``filename`` is a NZB file or a torrent file, based on its extension.
    :param filename: The filename to check
    :return: ``True`` if the ``filename`` is a NZB file or a torrent file, ``False`` otherwise
262
    """
263

echel0n's avatar
echel0n committed
264
265
266
267
    if not isinstance(filename, six.string_types):
        return False

    return filename.rpartition('.')[2].lower() in ['nzb', 'torrent']
268

269

echel0n's avatar
echel0n committed
270
def is_sync_file(filename):
271
272
273
274
275
276
    """
    Returns true if filename is a syncfile, indicating filesystem may be in flux

    :param filename: Filename to check
    :return: True if this file is a syncfile, False otherwise
    """
277

278
    extension = filename.rpartition(".")[2].lower()
279
    # if extension == '!sync' or extension == 'lftp-pget-status' or extension == 'part' or extension == 'bts':
280
    syncfiles = sickrage.app.config.sync_files
281
    if extension in syncfiles.split(",") or filename.startswith('.syncthing'):
282
283
284
285
286
        return True
    else:
        return False


echel0n's avatar
echel0n committed
287
def is_media_file(filename):
288
289
290
291
292
293
    """
    Check if named file may contain media

    :param filename: Filename to check
    :return: True if this is a known media file, False if not
    """
294

295
    # ignore samples
Dustyn Gibson's avatar
Dustyn Gibson committed
296
    if re.search(r'(^|[\W_])(?<!shomin.)(sample\d*)[\W_]', filename, re.I):
297
298
        return False

299
    # ignore RARBG release intro
300
    if re.search(r'^RARBG\.(\w+\.)?(mp4|avi|txt)$', filename, re.I):
301
302
        return False

303
304
305
306
307
308
309
310
311
    # ignore MAC OS's retarded "resource fork" files
    if filename.startswith('._'):
        return False

    sepFile = filename.rpartition(".")

    if re.search('extras?$', sepFile[0], re.I):
        return False

312
    return sepFile[-1].lower() in sickrage.app.config.allowed_video_file_exts
313

echel0n's avatar
echel0n committed
314

echel0n's avatar
echel0n committed
315
def is_rar_file(filename):
316
317
318
319
320
321
    """
    Check if file is a RAR file, or part of a RAR set

    :param filename: Filename to check
    :return: True if this is RAR/Part file, False if not
    """
322

Dustyn Gibson's avatar
Dustyn Gibson committed
323
    archive_regex = r'(?P<file>^(?P<base>(?:(?!\.part\d+\.rar$).)*)\.(?:(?:part0*1\.)?rar)$)'
echel0n's avatar
echel0n committed
324
325
326
327
328
329
    ret = re.search(archive_regex, filename) is not None
    try:
        if ret and os.path.exists(filename) and os.path.isfile(filename):
            ret = rarfile.is_rarfile(filename)
    except (IOError, OSError):
        pass
330

echel0n's avatar
echel0n committed
331
    return ret
332
333
334


def sanitizeFileName(name):
335
    """
336
337
338
339
340
341
342
343
    >>> sanitizeFileName('a/b/c')
    'a-b-c'
    >>> sanitizeFileName('abc')
    'abc'
    >>> sanitizeFileName('a"b')
    'ab'
    >>> sanitizeFileName('.a.b..')
    'a.b'
344
    """
345
346
347
348

    # remove bad chars from the filename
    name = re.sub(r'[\\/\*]', '-', name)
    name = re.sub(r'[:"<>|?]', '', name)
349
    name = re.sub(r'\u2122', '', name)  # Trade Mark Sign
350
351
352
353
354
355
356

    # remove leading/trailing periods and spaces
    name = name.strip(' .')

    return name


357
def remove_file_failed(failed_file):
358
359
360
361
    """
    Remove file from filesystem

    """
362

363
    try:
364
        os.remove(failed_file)
Dustyn Gibson's avatar
Dustyn Gibson committed
365
    except Exception:
366
367
        pass

368

369
def findCertainShow(indexerid, return_show_object=True):
370
371
    """
    Find a show by indexer ID in the show list
372

373
374
375
376
    :param showList: List of shows to search in (needle)
    :param indexerid: Show to look for
    :return: result list
    """
377

378
    if not indexerid:
379
        return None
380

381
    indexer_ids = [indexerid] if not isinstance(indexerid, list) else indexerid
382
    results = [show for show in sickrage.app.showlist if show.indexerid in indexer_ids]
383
384
385

    if not results:
        return None
386
387

    if len(results) == 1:
388
389
390
391
        if return_show_object:
            return results[0]
        else:
            return True
392
393

    raise MultipleShowObjectsException()
394

395

396
def makeDir(path):
397
398
399
400
401
402
    """
    Make a directory on the filesystem

    :param path: directory to make
    :return: True if success, False if failure
    """
403

404
    if not os.path.isdir(path):
405
        try:
406
            os.makedirs(path)
407
            sickrage.app.notifier_providers['synoindex'].addFolder(path)
408
409
410
411
        except OSError:
            return False
    return True

412

413
def list_media_files(path):
414
415
416
417
418
419
    """
    Get a list of files possibly containing media in a path

    :param path: Path to check for files
    :return: list of files
    """
420

421
    if not dir or not os.path.isdir(path):
422
423
424
        return []

    files = []
425
426
    for curFile in os.listdir(path):
        fullCurFile = os.path.join(path, curFile)
427
428

        # if it's a folder do it recursively
429
        if os.path.isdir(fullCurFile) and not curFile.startswith('.') and not curFile == 'Extras':
430
            files += list_media_files(fullCurFile)
431

echel0n's avatar
echel0n committed
432
        elif is_media_file(curFile):
433
434
435
436
437
438
            files.append(fullCurFile)

    return files


def copyFile(srcFile, destFile):
439
440
441
442
443
444
    """
    Copy a file from source to destination

    :param srcFile: Path of source file
    :param destFile: Path of destination file
    """
445

446
    try:
447
        shutil.copyfile(srcFile, destFile)
echel0n's avatar
echel0n committed
448
    except Exception as e:
echel0n's avatar
echel0n committed
449
        sickrage.app.log.warning(str(e))
echel0n's avatar
echel0n committed
450
451
452
453
454
    else:
        try:
            shutil.copymode(srcFile, destFile)
        except OSError:
            pass
455
456
457


def moveFile(srcFile, destFile):
458
459
460
461
462
463
    """
    Move a file from source to destination

    :param srcFile: Path of source file
    :param destFile: Path of destination file
    """
464

465
    try:
466
        shutil.move(srcFile, destFile)
467
        fixSetGroupID(destFile)
echel0n's avatar
echel0n committed
468
    except OSError:
echel0n's avatar
echel0n committed
469
470
        copyFile(srcFile, destFile)
        os.unlink(srcFile)
471

472

473
def link(src, dst):
474
475
476
477
478
479
480
    """
    Create a file link from source to destination.
    TODO: Make this unicode proof

    :param src: Source file
    :param dst: Destination file
    """
481

482
    if os.name == 'nt':
echel0n's avatar
echel0n committed
483
        if ctypes.windll.kernel32.CreateHardLinkW(ctypes.c_wchar_p(dst), ctypes.c_wchar_p(src), None) == 0:
Dustyn Gibson's avatar
Dustyn Gibson committed
484
            raise ctypes.WinError()
485
    else:
486
        os.link(src, dst)
487
488
489


def hardlinkFile(srcFile, destFile):
490
491
492
493
494
495
    """
    Create a hard-link (inside filesystem link) between source and destination

    :param srcFile: Source file
    :param destFile: Destination file
    """
496

497
    try:
498
        link(srcFile, destFile)
499
        fixSetGroupID(destFile)
500
    except Exception as e:
501
        sickrage.app.log.warning("Failed to create hardlink of %s at %s. Error: %r. Copying instead"
502
                                 % (srcFile, destFile, e))
503
504
505
506
        copyFile(srcFile, destFile)


def symlink(src, dst):
507
508
509
510
511
512
    """
    Create a soft/symlink between source and destination

    :param src: Source file
    :param dst: Destination file
    """
513

514
    if os.name == 'nt':
echel0n's avatar
echel0n committed
515
        if ctypes.windll.kernel32.CreateSymbolicLinkW(ctypes.c_wchar_p(dst), ctypes.c_wchar_p(src),
516
                                                      1 if os.path.isdir(src) else 0) in [0, 1280]:
Dustyn Gibson's avatar
Dustyn Gibson committed
517
            raise ctypes.WinError()
518
    else:
519
        os.symlink(src, dst)
520
521
522


def moveAndSymlinkFile(srcFile, destFile):
523
524
525
526
527
528
529
    """
    Move a file from source to destination, then create a symlink back from destination from source. If this fails, copy
    the file from source to destination

    :param srcFile: Source file
    :param destFile: Destination file
    """
530

531
    try:
532
        shutil.move(srcFile, destFile)
533
        fixSetGroupID(destFile)
534
        symlink(destFile, srcFile)
535
    except Exception as e:
536
        sickrage.app.log.warning("Failed to create symlink of %s at %s. Error: %r. Copying instead"
537
                                 % (srcFile, destFile, e))
538
539
540
541
542
543
544
545
546
        copyFile(srcFile, destFile)


def make_dirs(path):
    """
    Creates any folders that are missing and assigns them the permissions of their
    parents
    """

547
    sickrage.app.log.debug("Checking if the path [{}] already exists".format(path))
548

549
    if not os.path.isdir(path):
550
551
552
        # Windows, create all missing folders
        if os.name == 'nt' or os.name == 'ce':
            try:
553
                sickrage.app.log.debug("Folder %s didn't exist, creating it" % path)
554
                os.makedirs(path)
555
            except (OSError, IOError) as e:
556
                sickrage.app.log.error("Failed creating %s : %r" % (path, e))
557
558
559
560
561
562
563
564
565
566
567
568
                return False

        # not Windows, create all missing folders and set permissions
        else:
            sofar = ''
            folder_list = path.split(os.path.sep)

            # look through each subfolder and make sure they all exist
            for cur_folder in folder_list:
                sofar += cur_folder + os.path.sep

                # if it exists then just keep walking down the line
569
                if os.path.isdir(sofar):
570
571
572
                    continue

                try:
573
                    sickrage.app.log.debug("Folder %s didn't exist, creating it" % sofar)
574
                    os.mkdir(sofar)
575
                    # use normpath to remove end separator, otherwise checks permissions against itself
576
                    chmodAsParent(os.path.normpath(sofar))
577
                    # do the library update for synoindex
578
                    sickrage.app.notifier_providers['synoindex'].addFolder(sofar)
579
                except (OSError, IOError) as e:
580
                    sickrage.app.log.error("Failed creating %s : %r" % (sofar, e))
581
582
583
584
585
586
587
588
589
                    return False

    return True


def delete_empty_folders(check_empty_dir, keep_dir=None):
    """
    Walks backwards up the path and deletes any empty folders found.

590
591
    :param check_empty_dir: The path to clean (absolute path to a folder)
    :param keep_dir: Clean until this path is reached
592
593
594
595
596
    """

    # treat check_empty_dir as empty when it only contains these items
    ignore_items = []

597
    sickrage.app.log.info("Trying to clean any empty folders under " + check_empty_dir)
598
599

    # as long as the folder exists and doesn't contain any files, delete it
600
601
602
    try:
        while os.path.isdir(check_empty_dir) and check_empty_dir != keep_dir:
            check_files = os.listdir(check_empty_dir)
603

604
605
            if not check_files or (len(check_files) <= len(ignore_items)
                                   and all([check_file in ignore_items for check_file in check_files])):
606

607
608
                try:
                    # directory is empty or contains only ignore_items
609
                    sickrage.app.log.info("Deleting empty folder: " + check_empty_dir)
610
                    shutil.rmtree(check_empty_dir)
611
612

                    # do the library update for synoindex
613
                    sickrage.app.notifier_providers['synoindex'].deleteFolder(check_empty_dir)
614
                except OSError as e:
615
                    sickrage.app.log.warning("Unable to delete %s. Error: %r" % (check_empty_dir, repr(e)))
616
617
618
619
620
621
                    raise StopIteration
                check_empty_dir = os.path.dirname(check_empty_dir)
            else:
                raise StopIteration
    except StopIteration:
        pass
622

623

624
def fileBitFilter(mode):
625
626
627
628
629
630
    """
    Strip special filesystem bits from file

    :param mode: mode to check and strip
    :return: required mode for media file
    """
631

632
633
634
635
636
637
638
639
    for bit in [stat.S_IXUSR, stat.S_IXGRP, stat.S_IXOTH, stat.S_ISUID, stat.S_ISGID]:
        if mode & bit:
            mode -= bit

    return mode


def chmodAsParent(childPath):
640
641
642
643
644
645
    """
    Retain permissions of parent for childs
    (Does not work for Windows hosts)

    :param childPath: Child Path to change permissions to sync from parent
    """
646

647
648
649
    if os.name == 'nt' or os.name == 'ce':
        return

650
    parentPath = os.path.dirname(childPath)
651
652

    if not parentPath:
echel0n's avatar
echel0n committed
653
        sickrage.app.log.debug("No parent path provided in " + childPath + ", unable to get permissions from it")
654
655
        return

656
    childPath = os.path.join(parentPath, os.path.basename(childPath))
657

658
    parentPathStat = os.stat(parentPath)
659
660
    parentMode = stat.S_IMODE(parentPathStat[stat.ST_MODE])

661
    childPathStat = os.stat(childPath)
662
663
    childPath_mode = stat.S_IMODE(childPathStat[stat.ST_MODE])

664
    if os.path.isfile(childPath):
665
666
667
668
669
670
671
672
        childMode = fileBitFilter(parentMode)
    else:
        childMode = parentMode

    if childPath_mode == childMode:
        return

    childPath_owner = childPathStat.st_uid
echel0n's avatar
echel0n committed
673
    user_id = os.geteuid()
674
675

    if user_id != 0 and user_id != childPath_owner:
echel0n's avatar
echel0n committed
676
        sickrage.app.log.debug("Not running as root or owner of " + childPath + ", not trying to set permissions")
677
678
679
        return

    try:
680
        os.chmod(childPath, childMode)
echel0n's avatar
echel0n committed
681
682
        sickrage.app.log.debug(
            "Setting permissions for %s to %o as parent directory has %o" % (childPath, childMode, parentMode))
683
    except OSError:
684
        sickrage.app.log.debug("Failed to set permission for %s to %o" % (childPath, childMode))
685
686
687


def fixSetGroupID(childPath):
688
689
690
691
692
693
    """
    Inherid SGID from parent
    (does not work on Windows hosts)

    :param childPath: Path to inherit SGID permissions from parent
    """
694

695
696
697
    if os.name == 'nt' or os.name == 'ce':
        return

698
699
    parentPath = os.path.dirname(childPath)
    parentStat = os.stat(parentPath)
700
701
    parentMode = stat.S_IMODE(parentStat[stat.ST_MODE])

702
    childPath = os.path.join(parentPath, os.path.basename(childPath))
703

704
705
    if parentMode & stat.S_ISGID:
        parentGID = parentStat[stat.ST_GID]
706
        childStat = os.stat(childPath)
707
708
709
710
711
712
        childGID = childStat[stat.ST_GID]

        if childGID == parentGID:
            return

        childPath_owner = childStat.st_uid
echel0n's avatar
echel0n committed
713
        user_id = os.geteuid()
714
715

        if user_id != 0 and user_id != childPath_owner:
echel0n's avatar
echel0n committed
716
717
            sickrage.app.log.debug(
                "Not running as root or owner of {}, not trying to set the set-group-ID".format(childPath))
718
719
720
            return

        try:
721
            os.chown(childPath, -1, parentGID)  # @UndefinedVariable - only available on UNIX
echel0n's avatar
echel0n committed
722
            sickrage.app.log.debug("Respecting the set-group-ID bit on the parent directory for {}".format(childPath))
723
        except OSError:
echel0n's avatar
echel0n committed
724
725
            sickrage.app.log.error("Failed to respect the set-group-ID bit on the parent directory for {} (setting "
                                   "group ID {})".format(childPath, parentGID))
726
727


728
def sanitizeSceneName(name, anime=False):
729
730
    """
    Takes a show name and returns the "scenified" version of it.
Gaëtan Muller's avatar
Gaëtan Muller committed
731

732
733
    :param anime: Some show have a ' in their name(Kuroko's Basketball) and is needed for search.
    :return: A string containing the scene version of the show name given.
734
735
    """

736
    if not name:
737
        return ''
738

739
    bad_chars = ',:()!?\u2019'
740
    if not anime:
741
        bad_chars += "'"
742

743
744
    # strip out any bad chars
    for x in bad_chars:
745
        name = name.replace(x, "")
746

747
748
    # tidy up stuff that doesn't belong in scene names
    name = name.replace("- ", ".").replace(" ", ".").replace("&", "and").replace('/', '.')
Dustyn Gibson's avatar
Dustyn Gibson committed
749
    name = re.sub(r"\.\.*", ".", name)
750

751
752
753
754
    if name.endswith('.'):
        name = name[:-1]

    return name
755
756
757


def create_https_certificates(ssl_cert, ssl_key):
758
759
760
    """This function takes a domain name as a parameter and then creates a certificate and key with the
    domain name(replacing dots by underscores), finally signing the certificate using specified CA and
    returns the path of key and cert files. If you are yet to generate a CA then check the top comments"""
761

762
763
764
    try:
        import OpenSSL
    except ImportError:
echel0n's avatar
echel0n committed
765
766
        sickrage.app.log.warning("OpenSSL not available, please install for better requests validation: "
                                 "`https://pyopenssl.readthedocs.org/en/latest/install.html`")
767
768
        return False

769
770
771
772
773
774
    # Check happens if the certificate and key pair already exists for a domain
    if not os.path.exists(ssl_key) and os.path.exists(ssl_cert):
        # Serial Generation - Serial number must be unique for each certificate,
        serial = int(time.time())

        # Create the CA Certificate
775
776
        cakey = OpenSSL.crypto.PKey().generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
        careq = OpenSSL.crypto.X509()
777
778
779
780
781
        careq.get_subject().CN = "Certificate Authority"
        careq.set_pubkey(cakey)
        careq.sign(cakey, "sha1")

        # Sign the CA Certificate
782
        cacert = OpenSSL.crypto.X509()
783
784
785
786
787
788
789
790
791
        cacert.set_serial_number(serial)
        cacert.gmtime_adj_notBefore(0)
        cacert.gmtime_adj_notAfter(365 * 24 * 60 * 60)
        cacert.set_issuer(careq.get_subject())
        cacert.set_subject(careq.get_subject())
        cacert.set_pubkey(careq.get_pubkey())
        cacert.sign(cakey, "sha1")

        # Generate self-signed certificate
792
793
794
        key = OpenSSL.crypto.PKey()
        key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
        cert = OpenSSL.crypto.X509()
795
796
797
798
799
800
801
802
803
804
805
806
807
        cert.get_subject().CN = "SiCKRAGE"
        cert.gmtime_adj_notBefore(0)
        cert.gmtime_adj_notAfter(365 * 24 * 60 * 60)
        cert.set_serial_number(serial)
        cert.set_issuer(cacert.get_subject())
        cert.set_pubkey(key)
        cert.sign(cakey, "sha1")

        # Save the key and certificate to disk
        try:
            # pylint: disable=E1101
            # Module has no member
            with io.open(ssl_key, 'w') as keyout:
808
                keyout.write(OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key))
809
            with io.open(ssl_cert, 'w') as certout:
810
                certout.write(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert))
811
        except Exception:
echel0n's avatar
echel0n committed
812
            sickrage.app.log.warning("Error creating SSL key and certificate")
813
            return False
814
815
816

    return True

817

818
def get_lan_ip():
echel0n's avatar
echel0n committed
819
    """Return IP of system."""
820
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
echel0n's avatar
echel0n committed
821
    s.connect(('8.8.8.8', 1))
822
    return s.getsockname()[0]
823

824

825
826
827
828
def anon_url(*url):
    """
    Return a URL string consisting of the Anonymous redirect URL and an arbitrary number of values appended.
    """
829

echel0n's avatar
echel0n committed
830
    url = ''.join(map(unicode, url))
831
832
833
834
835

    # Handle URL's containing https or http, previously only handled http
    uri_pattern = ur'^https?://'
    unicode_uri_pattern = re.compile(uri_pattern, re.UNICODE)
    if not re.search(unicode_uri_pattern, url):
836
837
        url = 'http://' + url

838
    return '{}{}'.format(sickrage.app.config.anon_redirect, url)
839

840

841
842
843
844
845
846
847
848
def full_sanitizeSceneName(name):
    return re.sub('[. -]', ' ', sanitizeSceneName(name)).lower().lstrip()


def is_hidden_folder(folder):
    """
    Returns True if folder is hidden.
    On Linux based systems hidden folders start with . (dot)
849
    :param folder: Full path of folder to check
850
    """
851

852
    def is_hidden(filepath):
853
        name = os.path.basename(os.path.abspath(filepath))
854
855
856
857
        return name.startswith('.') or has_hidden_attribute(filepath)

    def has_hidden_attribute(filepath):
        try:
858
            attrs = ctypes.windll.kernel32.GetFileAttributesW(filepath)
859
860
861
862
863
            assert attrs != -1
            result = bool(attrs & 2)
        except (AttributeError, AssertionError):
            result = False
        return result
Gaëtan Muller's avatar
Gaëtan Muller committed
864

865
    if os.path.isdir(folder):
866
867
868
869
870
871
872
873
874
875
        if is_hidden(folder):
            return True

    return False


def real_path(path):
    """
    Returns: the canonicalized absolute pathname. The resulting path will have no symbolic link, '/./' or '/../' components.
    """
876
    return os.path.normpath(os.path.normcase(os.path.realpath(path)))
877

878

echel0n's avatar
echel0n committed
879
def extract_zipfile(archive, targetDir):
880
    """
881
882
883
    Unzip a file to a directory

    :param archive: The file name for the archive with a full path
884
    """
885

886
    try:
887
888
        if not os.path.exists(targetDir):
            os.mkdir(targetDir)
889

890
        zip_file = zipfile.ZipFile(archive, 'r', allowZip64=True)
891
        for member in zip_file.namelist():
892
            filename = os.path.basename(member)
893
894
895
896
897
898
            # skip directories
            if not filename:
                continue

            # copy file (taken from zipfile's extract)
            source = zip_file.open(member)
899
            target = io.open(os.path.join(targetDir, filename), "wb")
900
            shutil.copyfileobj(source, target)
901
902
903
904
905
            source.close()
            target.close()
        zip_file.close()
        return True
    except Exception as e:
906
        sickrage.app.log.error("Zip extraction error: %r " % repr(e))
907
908
909
        return False


echel0n's avatar
echel0n committed
910
def create_zipfile(fileList, archive, arcname=None):
911
912
913
914
915
916
917
918
    """
    Store the config file as a ZIP

    :param fileList: List of files to store
    :param archive: ZIP file name
    :param arcname: Archive path
    :return: True on success, False on failure
    """
919

920
    try:
921
922
923
        with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as z:
            for f in list(set(fileList)):
                z.write(f, os.path.relpath(f, arcname))
924
925
        return True
    except Exception as e:
echel0n's avatar
echel0n committed
926
        sickrage.app.log.error("Zip creation error: {} ".format(e))
927
928
929
        return False


930
def restoreConfigZip(archive, targetDir, restore_database=True, restore_config=True, restore_cache=True):
931
    """
932
    Restores a backup ZIP file back in place
933
934
935
936
937

    :param archive: ZIP filename
    :param targetDir: Directory to restore to
    :return: True on success, False on failure
    """
938

939
    try:
940
941
        if not os.path.exists(targetDir):
            os.mkdir(targetDir)