#!/usr/bin/python3 -OO
# Copyright 2007-2019 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 . assembler - threaded assembly / decoding of files
"""
import os
import queue
import logging
import re
from threading import Thread
from time import sleep
import hashlib
import sabnzbd
from sabnzbd . misc import get_all_passwords
from sabnzbd . filesystem import get_filepath , sanitize_filename , \
set_permissions , long_path , clip_path , has_win_device , diskspace , \
get_filename , get_ext
from sabnzbd . constants import Status , GIGI
import sabnzbd . cfg as cfg
from sabnzbd . articlecache import ArticleCache
from sabnzbd . postproc import PostProcessor
import sabnzbd . downloader
import sabnzbd . par2file as par2file
import sabnzbd . utils . rarfile as rarfile
from sabnzbd . rating import Rating
class Assembler ( Thread ) :
do = None # Link to the instance of this method
def __init__ ( self ) :
Thread . __init__ ( self )
self . queue = queue . Queue ( )
Assembler . do = self
def stop ( self ) :
self . process ( None )
def process ( self , job ) :
self . queue . put ( job )
def run ( self ) :
while 1 :
job = self . queue . get ( )
if not job :
logging . info ( " Shutting down " )
break
nzo , nzf = job
if nzf :
# Check if enough disk space is free, if not pause downloader and send email
if diskspace ( force = True ) [ ' download_dir ' ] [ 1 ] < ( cfg . download_free . get_float ( ) + nzf . bytes ) / GIGI :
# Only warn and email once
if not sabnzbd . downloader . Downloader . do . paused :
logging . warning ( T ( ' Too little diskspace forcing PAUSE ' ) )
# Pause downloader, but don't save, since the disk is almost full!
sabnzbd . downloader . Downloader . do . pause ( )
sabnzbd . emailer . diskfull ( )
# Abort all direct unpackers, just to be sure
sabnzbd . directunpacker . abort_all ( )
# Prepare filename
nzo . verify_nzf_filename ( nzf )
nzf . filename = sanitize_filename ( nzf . filename )
filepath = get_filepath ( long_path ( cfg . download_dir . get_path ( ) ) , nzo , nzf . filename )
nzf . filename = get_filename ( filepath )
if filepath :
logging . info ( ' Decoding %s %s ' , filepath , nzf . type )
try :
filepath = self . assemble ( nzf , filepath )
except IOError as err :
# If job was deleted or in active post-processing, ignore error
if not nzo . deleted and not nzo . is_gone ( ) and not nzo . pp_active :
# 28 == disk full => pause downloader
if err . errno == 28 :
logging . error ( T ( ' Disk full! Forcing Pause ' ) )
else :
logging . error ( T ( ' Disk error on creating file %s ' ) , clip_path ( filepath ) )
# Log traceback
logging . info ( ' Traceback: ' , exc_info = True )
# Pause without saving
sabnzbd . downloader . Downloader . do . pause ( )
continue
except :
logging . error ( T ( ' Fatal error in Assembler ' ) , exc_info = True )
break
# Clean-up admin data
nzf . remove_admin ( )
# Do rar-related processing
if rarfile . is_rarfile ( filepath ) :
# Encryption and unwanted extension detection
rar_encrypted , unwanted_file = check_encrypted_and_unwanted_files ( nzo , filepath )
if rar_encrypted :
if cfg . pause_on_pwrar ( ) == 1 :
logging . warning ( remove_warning_label ( T ( ' WARNING: Paused job " %s " because of encrypted RAR file (if supplied, all passwords were tried) ' ) ) , nzo . final_name )
nzo . pause ( )
else :
logging . warning ( remove_warning_label ( T ( ' WARNING: Aborted job " %s " because of encrypted RAR file (if supplied, all passwords were tried) ' ) ) , nzo . final_name )
nzo . fail_msg = T ( ' Aborted, encryption detected ' )
sabnzbd . nzbqueue . NzbQueue . do . end_job ( nzo )
if unwanted_file :
logging . warning ( remove_warning_label ( T ( ' WARNING: In " %s " unwanted extension in RAR file. Unwanted file is %s ' ) ) , nzo . final_name , unwanted_file )
logging . debug ( T ( ' Unwanted extension is in rar file %s ' ) , filepath )
if cfg . action_on_unwanted_extensions ( ) == 1 and nzo . unwanted_ext == 0 :
logging . debug ( ' Unwanted extension ... pausing ' )
nzo . unwanted_ext = 1
nzo . pause ( )
if cfg . action_on_unwanted_extensions ( ) == 2 :
logging . debug ( ' Unwanted extension ... aborting ' )
nzo . fail_msg = T ( ' Aborted, unwanted extension detected ' )
sabnzbd . nzbqueue . NzbQueue . do . end_job ( nzo )
# Add to direct unpack
nzo . add_to_direct_unpacker ( nzf )
elif par2file . is_parfile ( filepath ) :
# Parse par2 files, cloaked or not
nzo . handle_par2 ( nzf , filepath )
filter , reason = nzo_filtered_by_rating ( nzo )
if filter == 1 :
logging . warning ( remove_warning_label ( T ( ' WARNING: Paused job " %s " because of rating ( %s ) ' ) ) , nzo . final_name , reason )
nzo . pause ( )
elif filter == 2 :
logging . warning ( remove_warning_label ( T ( ' WARNING: Aborted job " %s " because of rating ( %s ) ' ) ) , nzo . final_name , reason )
nzo . fail_msg = T ( ' Aborted, rating filter matched ( %s ) ' ) % reason
sabnzbd . nzbqueue . NzbQueue . do . end_job ( nzo )
else :
sabnzbd . nzbqueue . NzbQueue . do . remove ( nzo . nzo_id , add_to_history = False , cleanup = False )
PostProcessor . do . process ( nzo )
def assemble ( self , nzf , path ) :
""" Assemble a NZF from its table of articles """
md5 = hashlib . md5 ( )
fout = open ( path , ' ab ' )
decodetable = nzf . decodetable
for articlenum in sorted ( decodetable ) :
# Break if deleted during writing
if nzf . nzo . status is Status . DELETED :
break
# Sleep to allow decoder/assembler switching
article = decodetable [ articlenum ]
data = ArticleCache . do . load_article ( article )
if not data :
logging . info ( T ( ' %s missing ' ) , article )
else :
# yenc data already decoded, flush it out
fout . write ( data )
md5 . update ( data )
fout . flush ( )
fout . close ( )
set_permissions ( path )
nzf . md5sum = md5 . digest ( )
return path
def file_has_articles ( nzf ) :
""" Do a quick check to see if any articles are present for this file.
Destructive : only to be used to differentiate between unknown encoding and no articles .
"""
has = False
decodetable = nzf . decodetable
for articlenum in decodetable :
sleep ( 0.01 )
article = decodetable [ articlenum ]
data = ArticleCache . do . load_article ( article )
if data :
has = True
return has
RE_SUBS = re . compile ( r ' \ W+sub|subs|subpack|subtitle|subtitles(?![a-z]) ' , re . I )
SAFE_EXTS = ( ' .mkv ' , ' .mp4 ' , ' .avi ' , ' .wmv ' , ' .mpg ' , ' .webm ' )
def is_cloaked ( nzo , path , names ) :
""" Return True if this is likely to be a cloaked encrypted post """
fname = os . path . splitext ( get_filename ( path . lower ( ) ) ) [ 0 ]
for name in names :
name = get_filename ( name . lower ( ) )
name , ext = os . path . splitext ( name )
if ext == ' .rar ' and fname . startswith ( name ) and ( len ( fname ) - len ( name ) ) < 8 and len ( names ) < 3 and not RE_SUBS . search ( fname ) :
# Only warn once
if nzo . encrypted == 0 :
logging . warning ( T ( ' Job " %s " is probably encrypted due to RAR with same name inside this RAR ' ) , nzo . final_name )
nzo . encrypted = 1
return True
elif ' password ' in name and ext not in SAFE_EXTS :
# Only warn once
if nzo . encrypted == 0 :
logging . warning ( T ( ' Job " %s " is probably encrypted: " password " in filename " %s " ' ) , nzo . final_name , name )
nzo . encrypted = 1
return True
return False
def check_encrypted_and_unwanted_files ( nzo , filepath ) :
""" Combines check for unwanted and encrypted files to save on CPU and IO """
encrypted = False
unwanted = None
if ( cfg . unwanted_extensions ( ) and cfg . action_on_unwanted_extensions ( ) ) or ( nzo . encrypted == 0 and cfg . pause_on_pwrar ( ) ) :
# These checks should not break the assembler
try :
# Rarfile freezes on Windows special names, so don't try those!
if sabnzbd . WIN32 and has_win_device ( filepath ) :
return encrypted , unwanted
# Is it even a rarfile?
if rarfile . is_rarfile ( filepath ) :
# Open the rar
rarfile . UNRAR_TOOL = sabnzbd . newsunpack . RAR_COMMAND
zf = rarfile . RarFile ( filepath , all_names = True )
# Check for encryption
if nzo . encrypted == 0 and cfg . pause_on_pwrar ( ) and ( zf . needs_password ( ) or is_cloaked ( nzo , filepath , zf . namelist ( ) ) ) :
# Load all passwords
passwords = get_all_passwords ( nzo )
# Cloaked job?
if is_cloaked ( nzo , filepath , zf . namelist ( ) ) :
encrypted = True
elif not passwords :
# Only error when no password was set
nzo . encrypted = 1
encrypted = True
else :
# Lets test if any of the password work
password_hit = False
for password in passwords :
if password :
logging . info ( ' Trying password " %s " on job " %s " ' , password , nzo . final_name )
try :
zf . setpassword ( password )
except rarfile . Error :
# On weird passwords the setpassword() will fail
# but the actual rartest() will work
pass
try :
zf . testrar ( )
password_hit = password
break
except rarfile . RarCRCError :
# On CRC error we can continue!
password_hit = password
break
except Exception as e :
# Did we start from the right volume?
if ' need to start extraction from a previous volume ' in e :
return encrypted , unwanted
# This one failed
pass
# Did any work?
if password_hit :
# We always trust the user's input
if not nzo . password :
nzo . password = password_hit
# Don't check other files
logging . info ( ' Password " %s " matches for job " %s " ' , password_hit , nzo . final_name )
nzo . encrypted = - 1
encrypted = False
else :
# Encrypted and none of them worked
nzo . encrypted = 1
encrypted = True
# Check for unwanted extensions
if cfg . unwanted_extensions ( ) and cfg . action_on_unwanted_extensions ( ) :
for somefile in zf . namelist ( ) :
logging . debug ( ' File contains: %s ' , somefile )
if get_ext ( somefile ) . replace ( ' . ' , ' ' ) . lower ( ) in cfg . unwanted_extensions ( ) :
logging . debug ( ' Unwanted file %s ' , somefile )
unwanted = somefile
zf . close ( )
del zf
except :
logging . info ( ' Error during inspection of RAR-file %s ' , filepath )
logging . debug ( ' Traceback: ' , exc_info = True )
return encrypted , unwanted
def nzo_filtered_by_rating ( nzo ) :
if Rating . do and cfg . rating_enable ( ) and cfg . rating_filter_enable ( ) and ( nzo . rating_filtered < 2 ) :
rating = Rating . do . get_rating_by_nzo ( nzo . nzo_id )
if rating is not None :
nzo . rating_filtered = 1
reason = rating_filtered ( rating , nzo . filename . lower ( ) , True )
if reason is not None :
return 2 , reason
reason = rating_filtered ( rating , nzo . filename . lower ( ) , False )
if reason is not None :
return 1 , reason
return 0 , " "
def rating_filtered ( rating , filename , abort ) :
def check_keyword ( keyword ) :
clean_keyword = keyword . strip ( ) . lower ( )
return ( len ( clean_keyword ) > 0 ) and ( clean_keyword in filename )
audio = cfg . rating_filter_abort_audio ( ) if abort else cfg . rating_filter_pause_audio ( )
video = cfg . rating_filter_abort_video ( ) if abort else cfg . rating_filter_pause_video ( )
spam = cfg . rating_filter_abort_spam ( ) if abort else cfg . rating_filter_pause_spam ( )
spam_confirm = cfg . rating_filter_abort_spam_confirm ( ) if abort else cfg . rating_filter_pause_spam_confirm ( )
encrypted = cfg . rating_filter_abort_encrypted ( ) if abort else cfg . rating_filter_pause_encrypted ( )
encrypted_confirm = cfg . rating_filter_abort_encrypted_confirm ( ) if abort else cfg . rating_filter_pause_encrypted_confirm ( )
downvoted = cfg . rating_filter_abort_downvoted ( ) if abort else cfg . rating_filter_pause_downvoted ( )
keywords = cfg . rating_filter_abort_keywords ( ) if abort else cfg . rating_filter_pause_keywords ( )
if ( video > 0 ) and ( rating . avg_video > 0 ) and ( rating . avg_video < = video ) :
return T ( ' video ' )
if ( audio > 0 ) and ( rating . avg_audio > 0 ) and ( rating . avg_audio < = audio ) :
return T ( ' audio ' )
if ( spam and ( ( rating . avg_spam_cnt > 0 ) or rating . avg_encrypted_confirm ) ) or ( spam_confirm and rating . avg_spam_confirm ) :
return T ( ' spam ' )
if ( encrypted and ( ( rating . avg_encrypted_cnt > 0 ) or rating . avg_encrypted_confirm ) ) or ( encrypted_confirm and rating . avg_encrypted_confirm ) :
return T ( ' passworded ' )
if downvoted and ( rating . avg_vote_up < rating . avg_vote_down ) :
return T ( ' downvoted ' )
if any ( check_keyword ( k ) for k in keywords . split ( ' , ' ) ) :
return T ( ' keywords ' )
return None
def remove_warning_label ( msg ) :
""" Standardize errors by removing obsolete
" WARNING: " part in all languages """
if ' : ' in msg :
return msg . split ( ' : ' ) [ 1 ] . strip ( )
return msg