You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
450 lines
16 KiB
450 lines
16 KiB
#!/usr/bin/python -OO
|
|
# Copyright 2008-2017 The SABnzbd-Team <team@sabnzbd.org>
|
|
#
|
|
# This program 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 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program 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 this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
|
|
"""
|
|
sabnzbd.dirscanner - Scanner for Watched Folder
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import logging
|
|
import zipfile
|
|
import gzip
|
|
import bz2
|
|
import threading
|
|
|
|
import sabnzbd
|
|
from sabnzbd.constants import SCAN_FILE_NAME, VALID_ARCHIVES
|
|
import sabnzbd.utils.rarfile as rarfile
|
|
from sabnzbd.decorators import NzbQueueLocker
|
|
from sabnzbd.encoding import ubtou, platform_encode
|
|
from sabnzbd.newsunpack import is_sevenfile, SevenZip
|
|
import sabnzbd.nzbstuff as nzbstuff
|
|
import sabnzbd.filesystem
|
|
import sabnzbd.config as config
|
|
import sabnzbd.cfg as cfg
|
|
|
|
def name_to_cat(fname, cat=None):
|
|
""" Retrieve category from file name, but only if "cat" is None. """
|
|
if cat is None and fname.startswith('{{'):
|
|
n = fname.find('}}')
|
|
if n > 2:
|
|
cat = fname[2:n].strip()
|
|
fname = fname[n + 2:].strip()
|
|
logging.debug('Job %s has category %s', fname, cat)
|
|
|
|
return fname, cat
|
|
|
|
|
|
def CompareStat(tup1, tup2):
|
|
""" Test equality of two stat-tuples, content-related parts only """
|
|
if tup1.st_ino != tup2.st_ino:
|
|
return False
|
|
if tup1.st_size != tup2.st_size:
|
|
return False
|
|
if tup1.st_mtime != tup2.st_mtime:
|
|
return False
|
|
if tup1.st_ctime != tup2.st_ctime:
|
|
return False
|
|
return True
|
|
|
|
|
|
def is_archive(path):
|
|
""" Check if file in path is an ZIP, RAR or 7z file
|
|
:param path: path to file
|
|
:return: (zf, status, expected_extension)
|
|
status: -1==Error/Retry, 0==OK, 1==Ignore
|
|
"""
|
|
if zipfile.is_zipfile(path):
|
|
try:
|
|
zf = zipfile.ZipFile(path)
|
|
return 0, zf, '.zip'
|
|
except:
|
|
logging.info(T('Cannot read %s'), path, exc_info=True)
|
|
return -1, None, ''
|
|
elif rarfile.is_rarfile(path):
|
|
try:
|
|
# Set path to tool to open it
|
|
rarfile.UNRAR_TOOL = sabnzbd.newsunpack.RAR_COMMAND
|
|
zf = rarfile.RarFile(path)
|
|
return 0, zf, '.rar'
|
|
except:
|
|
logging.info(T('Cannot read %s'), path, exc_info=True)
|
|
return -1, None, ''
|
|
elif is_sevenfile(path):
|
|
try:
|
|
zf = SevenZip(path)
|
|
return 0, zf, '.7z'
|
|
except:
|
|
logging.info(T('Cannot read %s'), path, exc_info=True)
|
|
return -1, None, ''
|
|
else:
|
|
logging.info('Archive %s is not a real archive!', os.path.basename(path))
|
|
return 1, None, ''
|
|
|
|
|
|
@NzbQueueLocker
|
|
def ProcessArchiveFile(filename, path, pp=None, script=None, cat=None, catdir=None, keep=False,
|
|
priority=None, url='', nzbname=None, password=None, nzo_id=None):
|
|
""" Analyse ZIP file and create job(s).
|
|
Accepts ZIP files with ONLY nzb/nfo/folder files in it.
|
|
returns (status, nzo_ids)
|
|
status: -1==Error/Retry, 0==OK, 1==Ignore
|
|
"""
|
|
nzo_ids = []
|
|
if catdir is None:
|
|
catdir = cat
|
|
|
|
filename, cat = name_to_cat(filename, catdir)
|
|
|
|
status, zf, extension = is_archive(path)
|
|
|
|
if status != 0:
|
|
return status, []
|
|
|
|
status = 1
|
|
names = zf.namelist()
|
|
nzbcount = 0
|
|
for name in names:
|
|
name = name.lower()
|
|
if name.endswith('.nzb'):
|
|
status = 0
|
|
nzbcount += 1
|
|
|
|
if status == 0:
|
|
if nzbcount != 1:
|
|
nzbname = None
|
|
for name in names:
|
|
if name.lower().endswith('.nzb'):
|
|
try:
|
|
data = zf.read(name)
|
|
except:
|
|
logging.error(T('Cannot read %s'), name, exc_info=True)
|
|
zf.close()
|
|
return -1, []
|
|
name = os.path.basename(name)
|
|
if data:
|
|
nzo = None
|
|
try:
|
|
nzo = nzbstuff.NzbObject(name, pp, script, data, cat=cat, url=url,
|
|
priority=priority, nzbname=nzbname)
|
|
if not nzo.password:
|
|
nzo.password = password
|
|
# except (TypeError, ValueError) as e:
|
|
# # Duplicate or empty, ignore
|
|
# pass
|
|
except:
|
|
# Something else is wrong, show error
|
|
logging.error(T('Error while adding %s, removing'), name, exc_info=True)
|
|
|
|
if nzo:
|
|
if nzo_id:
|
|
# Re-use existing nzo_id, when a "future" job gets it payload
|
|
sabnzbd.nzbqueue.NzbQueue.do.remove(nzo_id, add_to_history=False)
|
|
nzo.nzo_id = nzo_id
|
|
nzo_id = None
|
|
nzo_ids.append(sabnzbd.nzbqueue.NzbQueue.do.add(nzo))
|
|
nzo.update_rating()
|
|
zf.close()
|
|
try:
|
|
if not keep:
|
|
misc.remove_file(path)
|
|
except:
|
|
logging.error(T('Error removing %s'), sabnzbd.filesystem.clip_path(path))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
status = 1
|
|
else:
|
|
zf.close()
|
|
status = 1
|
|
|
|
return status, nzo_ids
|
|
|
|
|
|
@NzbQueueLocker
|
|
def ProcessSingleFile(filename, path, pp=None, script=None, cat=None, catdir=None, keep=False,
|
|
priority=None, nzbname=None, reuse=False, nzo_info=None, dup_check=True, url='',
|
|
password=None, nzo_id=None):
|
|
""" Analyze file and create a job from it
|
|
Supports NZB, NZB.BZ2, NZB.GZ and GZ.NZB-in-disguise
|
|
returns (status, nzo_ids)
|
|
status: -2==Error/retry, -1==Error, 0==OK, 1==OK-but-ignorecannot-delete
|
|
"""
|
|
nzo_ids = []
|
|
if catdir is None:
|
|
catdir = cat
|
|
|
|
try:
|
|
f = open(path, 'rb')
|
|
check_bytes = f.read(2)
|
|
f.close()
|
|
|
|
if check_bytes == b'\x1f\x8b':
|
|
# gzip file or gzip in disguise
|
|
name = filename.replace('.nzb.gz', '.nzb')
|
|
f = gzip.GzipFile(path, 'rb')
|
|
data = ubtou(f.read())
|
|
elif check_bytes == b'BZ':
|
|
# bz2 file or bz2 in disguise
|
|
name = filename.replace('.nzb.bz2', '.nzb')
|
|
f = bz2.BZ2File(path, 'rb')
|
|
data = ubtou(f.read())
|
|
else:
|
|
name = filename
|
|
f = open(path, 'r')
|
|
data = f.read()
|
|
f.close()
|
|
except:
|
|
logging.warning(T('Cannot read %s'), sabnzbd.filesystem.clip_path(path))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
return -2, nzo_ids
|
|
|
|
if name:
|
|
name, cat = name_to_cat(name, catdir)
|
|
# The name is used as the name of the folder, so sanitize it using folder specific santization
|
|
if not nzbname:
|
|
# Prevent embedded password from being damaged by sanitize and trimming
|
|
nzbname = os.path.split(name)[1]
|
|
|
|
try:
|
|
nzo = nzbstuff.NzbObject(name, pp, script, data, cat=cat, priority=priority, nzbname=nzbname,
|
|
nzo_info=nzo_info, url=url, reuse=reuse, dup_check=dup_check)
|
|
if not nzo.password:
|
|
nzo.password = password
|
|
# except TypeError:
|
|
# # Duplicate, ignore
|
|
# if nzo_id:
|
|
# sabnzbd.nzbqueue.NzbQueue.do.remove(nzo_id, add_to_history=False)
|
|
# nzo = None
|
|
# except ValueError:
|
|
# # Empty, but correct file
|
|
# return -1, nzo_ids
|
|
except:
|
|
if data.find("<nzb") >= 0 and data.find("</nzb") < 0:
|
|
# Looks like an incomplete file, retry
|
|
return -2, nzo_ids
|
|
else:
|
|
# Something else is wrong, show error
|
|
logging.error(T('Error while adding %s, removing'), name, exc_info=True)
|
|
return -1, nzo_ids
|
|
|
|
if nzo:
|
|
if nzo_id:
|
|
# Re-use existing nzo_id, when a "future" job gets it payload
|
|
sabnzbd.nzbqueue.NzbQueue.do.remove(nzo_id, add_to_history=False)
|
|
nzo.nzo_id = nzo_id
|
|
nzo_ids.append(sabnzbd.nzbqueue.NzbQueue.do.add(nzo, quiet=reuse))
|
|
nzo.update_rating()
|
|
try:
|
|
if not keep:
|
|
misc.remove_file(path)
|
|
except:
|
|
logging.error(T('Error removing %s'), sabnzbd.filesystem.clip_path(path))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
return 1, nzo_ids
|
|
|
|
return 0, nzo_ids
|
|
|
|
|
|
def CleanList(list, folder, files):
|
|
""" Remove elements of "list" not found in "files" """
|
|
for path in sorted(list.keys()):
|
|
fld, name = os.path.split(path)
|
|
if fld == folder:
|
|
present = False
|
|
for name in files:
|
|
if os.path.join(folder, name) == path:
|
|
present = True
|
|
break
|
|
if not present:
|
|
del list[path]
|
|
|
|
|
|
class DirScanner(threading.Thread):
|
|
""" Thread that periodically scans a given directory and picks up any
|
|
valid NZB, NZB.GZ ZIP-with-only-NZB and even NZB.GZ named as .NZB
|
|
Candidates which turned out wrong, will be remembered and skipped in
|
|
subsequent scans, unless changed.
|
|
"""
|
|
do = None # Access to instance of DirScanner
|
|
|
|
def __init__(self):
|
|
threading.Thread.__init__(self)
|
|
|
|
self.newdir()
|
|
try:
|
|
dir, self.ignored, self.suspected = sabnzbd.load_admin(SCAN_FILE_NAME)
|
|
if dir != self.dirscan_dir:
|
|
self.ignored = {}
|
|
self.suspected = {}
|
|
except:
|
|
self.ignored = {} # Will hold all unusable files and the
|
|
# successfully processed ones that cannot be deleted
|
|
self.suspected = {} # Will hold name/attributes of suspected candidates
|
|
|
|
self.shutdown = False
|
|
self.error_reported = False # Prevents mulitple reporting of missing watched folder
|
|
self.dirscan_dir = cfg.dirscan_dir.get_path()
|
|
self.dirscan_speed = cfg.dirscan_speed()
|
|
self.busy = False
|
|
self.trigger = False
|
|
cfg.dirscan_dir.callback(self.newdir)
|
|
cfg.dirscan_speed.callback(self.newspeed)
|
|
DirScanner.do = self
|
|
|
|
def newdir(self):
|
|
""" We're notified of a dir change """
|
|
self.ignored = {}
|
|
self.suspected = {}
|
|
self.dirscan_dir = cfg.dirscan_dir.get_path()
|
|
self.dirscan_speed = cfg.dirscan_speed()
|
|
|
|
def newspeed(self):
|
|
""" We're notified of a scan speed change """
|
|
self.dirscan_speed = cfg.dirscan_speed()
|
|
self.trigger = True
|
|
|
|
def stop(self):
|
|
""" Stop the dir scanner """
|
|
logging.info('Dirscanner shutting down')
|
|
self.shutdown = True
|
|
|
|
def save(self):
|
|
""" Save dir scanner bookkeeping """
|
|
sabnzbd.save_admin((self.dirscan_dir, self.ignored, self.suspected), SCAN_FILE_NAME)
|
|
|
|
def run(self):
|
|
""" Start the scanner """
|
|
logging.info('Dirscanner starting up')
|
|
self.shutdown = False
|
|
|
|
while not self.shutdown:
|
|
# Use variable scan delay
|
|
x = max(self.dirscan_speed, 1)
|
|
while (x > 0) and not self.shutdown and not self.trigger:
|
|
time.sleep(1.0)
|
|
x = x - 1
|
|
|
|
self.trigger = False
|
|
if self.dirscan_speed and not self.shutdown:
|
|
self.scan()
|
|
|
|
def scan(self):
|
|
""" Do one scan of the watched folder """
|
|
def run_dir(folder, catdir):
|
|
try:
|
|
files = os.listdir(folder)
|
|
except:
|
|
if not self.error_reported and not catdir:
|
|
logging.error(T('Cannot read Watched Folder %s'), sabnzbd.filesystem.clip_path(folder))
|
|
self.error_reported = True
|
|
files = []
|
|
|
|
for filename in files:
|
|
path = os.path.join(folder, platform_encode(filename))
|
|
if os.path.isdir(path) or path in self.ignored or filename[0] == '.':
|
|
continue
|
|
|
|
ext = os.path.splitext(path)[1].lower()
|
|
candidate = ext in ('.nzb', '.gz', '.bz2') or ext in VALID_ARCHIVES
|
|
if candidate:
|
|
try:
|
|
stat_tuple = os.stat(path)
|
|
except:
|
|
continue
|
|
else:
|
|
self.ignored[path] = 1
|
|
|
|
if path in self.suspected:
|
|
if CompareStat(self.suspected[path], stat_tuple):
|
|
# Suspected file still has the same attributes
|
|
continue
|
|
else:
|
|
del self.suspected[path]
|
|
|
|
if candidate and stat_tuple.st_size > 0:
|
|
logging.info('Trying to import %s', path)
|
|
|
|
# Wait until the attributes are stable for 1 second
|
|
# but give up after 3 sec
|
|
stable = False
|
|
for n in range(3):
|
|
time.sleep(1.0)
|
|
try:
|
|
stat_tuple_tmp = os.stat(path)
|
|
except:
|
|
continue
|
|
if CompareStat(stat_tuple, stat_tuple_tmp):
|
|
stable = True
|
|
break
|
|
else:
|
|
stat_tuple = stat_tuple_tmp
|
|
|
|
if not stable:
|
|
continue
|
|
|
|
# Handle archive files, but only when containing just NZB files
|
|
if ext in VALID_ARCHIVES:
|
|
res, nzo_ids = ProcessArchiveFile(filename, path, catdir=catdir, url=path)
|
|
if res == -1:
|
|
self.suspected[path] = stat_tuple
|
|
elif res == 0:
|
|
self.error_reported = False
|
|
else:
|
|
self.ignored[path] = 1
|
|
|
|
# Handle .nzb, .nzb.gz or gzip-disguised-as-nzb or .bz2
|
|
elif ext == '.nzb' or filename.lower().endswith('.nzb.gz') or filename.lower().endswith('.nzb.bz2'):
|
|
res, nzo_id = ProcessSingleFile(filename, path, catdir=catdir, url=path)
|
|
if res < 0:
|
|
self.suspected[path] = stat_tuple
|
|
elif res == 0:
|
|
self.error_reported = False
|
|
else:
|
|
self.ignored[path] = 1
|
|
|
|
else:
|
|
self.ignored[path] = 1
|
|
|
|
CleanList(self.ignored, folder, files)
|
|
CleanList(self.suspected, folder, files)
|
|
|
|
if not self.busy:
|
|
self.busy = True
|
|
dirscan_dir = self.dirscan_dir
|
|
if dirscan_dir and not sabnzbd.PAUSED_ALL:
|
|
run_dir(dirscan_dir, None)
|
|
|
|
try:
|
|
list = os.listdir(dirscan_dir)
|
|
except:
|
|
if not self.error_reported:
|
|
logging.error(T('Cannot read Watched Folder %s'), sabnzbd.filesystem.clip_path(dirscan_dir))
|
|
self.error_reported = True
|
|
list = []
|
|
|
|
cats = config.get_categories()
|
|
for dd in list:
|
|
dpath = os.path.join(dirscan_dir, platform_encode(dd))
|
|
if os.path.isdir(dpath) and dd.lower() in cats:
|
|
run_dir(dpath, dd.lower())
|
|
self.busy = False
|
|
|
|
|
|
def dirscan():
|
|
""" Wrapper required for scheduler """
|
|
logging.info('Scheduled or manual watched folder scan')
|
|
DirScanner.do.scan()
|
|
|