From 53aed799a1c86b2276be189d6c249d6a23cbbc08 Mon Sep 17 00:00:00 2001 From: ShyPike Date: Sun, 26 Jun 2011 12:39:05 +0200 Subject: [PATCH] Add universal Growl support. - Windows: through GNTP library - OSX: through Growl library for local server and GTNP for remote server - Unix/Linux: through GNTP library For Ubuntu there's support for NotifyOSD. Made Growl class texts translatable. --- INSTALL.txt | 16 +- SABnzbd.py | 8 +- gntp/__init__.py | 443 ++++++++++++++++++++++++ gntp/notifier.py | 130 +++++++ interfaces/Classic/templates/config_email.tmpl | 30 +- interfaces/Plush/templates/config_email.tmpl | 50 +++ interfaces/Plush/templates/config_switches.tmpl | 2 +- interfaces/smpl/templates/config_email.tmpl | 33 +- interfaces/smpl/templates/main.tmpl | 10 + package.py | 1 + sabnzbd/__init__.py | 2 + sabnzbd/cfg.py | 5 + sabnzbd/downloader.py | 6 +- sabnzbd/growler.py | 223 ++++++++++++ sabnzbd/interface.py | 19 +- sabnzbd/misc.py | 11 + sabnzbd/newzbin.py | 4 +- sabnzbd/nzbqueue.py | 4 +- sabnzbd/osxmenu.py | 6 +- sabnzbd/postproc.py | 10 +- sabnzbd/skintext.py | 3 +- sabnzbd/utils/osx.py | 65 ---- 22 files changed, 986 insertions(+), 95 deletions(-) create mode 100644 gntp/__init__.py create mode 100644 gntp/notifier.py create mode 100644 sabnzbd/growler.py delete mode 100644 sabnzbd/utils/osx.py diff --git a/INSTALL.txt b/INSTALL.txt index 7534a16..76c0257 100644 --- a/INSTALL.txt +++ b/INSTALL.txt @@ -39,9 +39,14 @@ Start the SABnzbd.exe program. Within 5-10 seconds your web browser will start and show the user interface. Use the "Help" button in the web-interface to be directed to the Help Wiki. +------------------------------------------------------------------------------- +3) INSTALL pre-built OSX binaries +------------------------------------------------------------------------------- +Download teh DMG file, mount and drag the SABnzbd icon to Programs. +Just like you do with so many apps. ------------------------------------------------------------------------------- -3) INSTALL with only sources +4) INSTALL with only sources ------------------------------------------------------------------------------- You need to have Python installed and some modules. @@ -55,7 +60,7 @@ Windows Python-2.7.latest Essential modules - cheetah-2.0.1+ http://www.cheetahtemplate.org/ + cheetah-2.0.1+ http://www.cheetahtemplate.org/ (or use "pypm install cheetah") yenc module >= 0.3 http://sabnzbd.sourceforge.net/yenc-0.3.tar.gz http://sabnzbd.sourceforge.net/yenc-0.3-w32fixed.zip (Win32-only) par2cmdline >= 0.4 http://parchive.sourceforge.net/ @@ -65,6 +70,7 @@ Optional modules unrar >= 3.90+ http://www.rarlab.com/rar_add.htm unzip >= 5.52 http://www.info-zip.org/ gnu gettext http://www.gnu.org/software/gettext/ + gntp https://github.com/kfdm/gntp/ (or use "pypm install gntp") Optional modules Windows pyopenssl >= 0.11 http://pypi.python.org/pypi/pyOpenSSL @@ -91,7 +97,7 @@ Use the "Help" button in the web-interface to be directed to the Help Wiki. ------------------------------------------------------------------------------- -4) TROUBLESHOOTING +5) TROUBLESHOOTING ------------------------------------------------------------------------------- Your browser may start up with just an error page. @@ -109,7 +115,7 @@ This will show a black window where logging information will be shown. This may help you solve problems easier. ------------------------------------------------------------------------------- -5) MORE INFORMATION +6) MORE INFORMATION ------------------------------------------------------------------------------- Visit the WIKI site: @@ -117,7 +123,7 @@ Visit the WIKI site: ------------------------------------------------------------------------------- -6) CREDITS +7) CREDITS ------------------------------------------------------------------------------- Serveral parts of SABnzbd were built by other people, illustrating the diff --git a/SABnzbd.py b/SABnzbd.py index 8d139f3..9af88f4 100755 --- a/SABnzbd.py +++ b/SABnzbd.py @@ -78,7 +78,7 @@ import sabnzbd.config as config import sabnzbd.cfg import sabnzbd.downloader from sabnzbd.encoding import unicoder, latin1 -from sabnzbd.utils import osx +import sabnzbd.growler as growler from threading import Thread @@ -1387,7 +1387,8 @@ def main(): if sabnzbd.FOUNDATION: import sabnzbd.osxmenu sabnzbd.osxmenu.notify("SAB_Launched", None) - osx.sendGrowlMsg('SABnzbd %s' % (sabnzbd.__version__),"http://%s:%s/sabnzbd" % (browserhost, cherryport),osx.NOTIFICATION['startup']) + growler.send_notification('SABnzbd %s' % (sabnzbd.__version__), + "http://%s:%s/sabnzbd" % (browserhost, cherryport), 'startup') # Now's the time to check for a new version check_latest_version() autorestarted = False @@ -1527,8 +1528,7 @@ def main(): if getattr(sys, 'frozen', None) == 'macosx_app': AppHelper.stopEventLoop() else: - if sabnzbd.DARWIN: - osx.sendGrowlMsg('SABnzbd',T('SABnzbd shutdown finished'),osx.NOTIFICATION['startup']) + growler.send_notification('SABnzbd',T('SABnzbd shutdown finished'), 'startup') os._exit(0) diff --git a/gntp/__init__.py b/gntp/__init__.py new file mode 100644 index 0000000..40c0775 --- /dev/null +++ b/gntp/__init__.py @@ -0,0 +1,443 @@ +import re +import hashlib +import time +import platform + +__version__ = '0.4' + +class BaseError(Exception): + pass + +class ParseError(BaseError): + def gntp_error(self): + error = GNTPError(errorcode=500,errordesc='Error parsing the message') + return error.encode() + +class AuthError(BaseError): + def gntp_error(self): + error = GNTPError(errorcode=400,errordesc='Error with authorization') + return error.encode() + +class UnsupportedError(BaseError): + def gntp_error(self): + error = GNTPError(errorcode=500,errordesc='Currently unsupported by gntp.py') + return error.encode() + +class _GNTPBase(object): + info = { + 'version':'1.0', + 'messagetype':None, + 'encryptionAlgorithmID':None + } + _requiredHeaders = [] + headers = {} + resources = {} + def add_origin_info(self): + self.add_header('Origin-Machine-Name',platform.node()) + self.add_header('Origin-Software-Name','gntp.py') + self.add_header('Origin-Software-Version',__version__) + self.add_header('Origin-Platform-Name',platform.system()) + self.add_header('Origin-Platform-Version',platform.platform()) + def __str__(self): + return self.encode() + def _parse_info(self,data): + ''' + Parse the first line of a GNTP message to get security and other info values + @param data: GNTP Message + @return: GNTP Message information in a dictionary + ''' + #GNTP/ [:][ :.] + match = re.match('GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)'+ + ' (?P[A-Z0-9]+(:(?P[A-F0-9]+))?) ?'+ + '((?P[A-Z0-9]+):(?P[A-F0-9]+).(?P[A-F0-9]+))?\r\n', data,re.IGNORECASE) + + if not match: + raise ParseError('ERROR_PARSING_INFO_LINE') + + info = match.groupdict() + if info['encryptionAlgorithmID'] == 'NONE': + info['encryptionAlgorithmID'] = None + + return info + def set_password(self,password,encryptAlgo='MD5'): + ''' + Set a password for a GNTP Message + @param password: Null to clear password + @param encryptAlgo: Supports MD5,SHA1,SHA256,SHA512 + @todo: Support other hash functions + ''' + hash = { + 'MD5': hashlib.md5, + 'SHA1': hashlib.sha1, + 'SHA256': hashlib.sha256, + 'SHA512': hashlib.sha512, + } + + self.password = password + self.encryptAlgo = encryptAlgo.upper() + if not password: + self.info['encryptionAlgorithmID'] = None + self.info['keyHashAlgorithm'] = None; + return + if not self.encryptAlgo in hash.keys(): + raise UnsupportedError('INVALID HASH "%s"'%self.encryptAlgo) + + hashfunction = hash.get(self.encryptAlgo) + + password = password.encode('utf8') + seed = time.ctime() + salt = hashfunction(seed).hexdigest() + saltHash = hashfunction(seed).digest() + keyBasis = password+saltHash + key = hashfunction(keyBasis).digest() + keyHash = hashfunction(key).hexdigest() + + self.info['keyHashAlgorithmID'] = self.encryptAlgo + self.info['keyHash'] = keyHash.upper() + self.info['salt'] = salt.upper() + def _decode_hex(self,value): + ''' + Helper function to decode hex string to `proper` hex string + @param value: Value to decode + @return: Hex string + ''' + result = '' + for i in range(0,len(value),2): + tmp = int(value[i:i+2],16) + result += chr(tmp) + return result + def _decode_binary(self,rawIdentifier,identifier): + rawIdentifier += '\r\n\r\n' + dataLength = int(identifier['Length']) + pointerStart = self.raw.find(rawIdentifier)+len(rawIdentifier) + pointerEnd = pointerStart + dataLength + data = self.raw[pointerStart:pointerEnd] + if not len(data) == dataLength: + raise ParseError('INVALID_DATA_LENGTH Expected: %s Recieved %s'%(dataLength,len(data))) + return data + def _validate_password(self,password): + ''' + Validate GNTP Message against stored password + ''' + self.password = password + if password == None: raise Exception() + keyHash = self.info.get('keyHash',None) + if keyHash is None and self.password is None: + return True + if keyHash is None: + raise AuthError('Invalid keyHash') + if self.password is None: + raise AuthError('Missing password') + + password = self.password.encode('utf8') + saltHash = self._decode_hex(self.info['salt']) + + keyBasis = password+saltHash + key = hashlib.md5(keyBasis).digest() + keyHash = hashlib.md5(key).hexdigest() + + if not keyHash.upper() == self.info['keyHash'].upper(): + raise AuthError('Invalid Hash') + return True + def validate(self): + ''' + Verify required headers + ''' + for header in self._requiredHeaders: + if not self.headers.get(header,False): + raise ParseError('Missing Notification Header: '+header) + + def _format_info(self): + ''' + Generate info line for GNTP Message + @return: Info line string + ''' + info = u'GNTP/%s %s'%( + self.info.get('version'), + self.info.get('messagetype'), + ) + if self.info.get('encryptionAlgorithmID',None): + info += ' %s:%s'%( + self.info.get('encryptionAlgorithmID'), + self.info.get('ivValue'), + ) + else: + info+=' NONE' + + if self.info.get('keyHashAlgorithmID',None): + info += ' %s:%s.%s'%( + self.info.get('keyHashAlgorithmID'), + self.info.get('keyHash'), + self.info.get('salt') + ) + + return info + def _parse_dict(self,data): + ''' + Helper function to parse blocks of GNTP headers into a dictionary + @param data: + @return: Dictionary of headers + ''' + dict = {} + for line in data.split('\r\n'): + match = re.match('([\w-]+):(.+)', line) + if not match: continue + + key = match.group(1).strip() + val = match.group(2).strip() + dict[key] = val + return dict + def add_header(self,key,value): + if isinstance(value, unicode): + self.headers[key] = value + else: + self.headers[key] = unicode('%s'%value,'utf8','replace') + def decode(self,data,password=None): + ''' + Decode GNTP Message + @param data: + ''' + self.password = password + self.raw = data + parts = self.raw.split('\r\n\r\n') + self.info = self._parse_info(data) + self.headers = self._parse_dict(parts[0]) + def encode(self): + ''' + Encode a GNTP Message + @return: GNTP Message ready to be sent + ''' + self.validate() + EOL = u'\r\n' + + message = self._format_info() + EOL + #Headers + for k,v in self.headers.iteritems(): + message += u'%s: %s%s'%(k,v,EOL) + + message += EOL + return message.encode('utf8') +class GNTPRegister(_GNTPBase): + ''' + GNTP Registration Message + ''' + notifications = [] + _requiredHeaders = [ + 'Application-Name', + 'Notifications-Count' + ] + _requiredNotificationHeaders = ['Notification-Name'] + def __init__(self,data=None,password=None): + ''' + @param data: (Optional) See decode() + @param password: (Optional) Password to use while encoding/decoding messages + ''' + self.info['messagetype'] = 'REGISTER' + + if data: + self.decode(data,password) + else: + self.set_password(password) + self.add_header('Application-Name', 'pygntp') + self.add_header('Notifications-Count', 0) + self.add_origin_info() + def validate(self): + ''' + Validate required headers and validate notification headers + ''' + for header in self._requiredHeaders: + if not self.headers.get(header,False): + raise ParseError('Missing Registration Header: '+header) + for notice in self.notifications: + for header in self._requiredNotificationHeaders: + if not notice.get(header,False): + raise ParseError('Missing Notification Header: '+header) + def decode(self,data,password): + ''' + Decode existing GNTP Registration message + @param data: Message to decode. + ''' + self.raw = data + parts = self.raw.split('\r\n\r\n') + self.info = self._parse_info(data) + self._validate_password(password) + self.headers = self._parse_dict(parts[0]) + + for i,part in enumerate(parts): + if i==0: continue #Skip Header + if part.strip()=='': continue + notice = self._parse_dict(part) + if notice.get('Notification-Name',False): + self.notifications.append(notice) + elif notice.get('Identifier',False): + notice['Data'] = self._decode_binary(part,notice) + #open('register.png','wblol').write(notice['Data']) + self.resources[ notice.get('Identifier') ] = notice + + def add_notification(self,name,enabled=True): + ''' + Add new Notification to Registration message + @param name: Notification Name + @param enabled: Default Notification to Enabled + ''' + notice = {} + notice['Notification-Name'] = u'%s'%name + notice['Notification-Enabled'] = u'%s'%enabled + + self.notifications.append(notice) + self.add_header('Notifications-Count', len(self.notifications)) + def encode(self): + ''' + Encode a GNTP Registration Message + @return: GNTP Registration Message ready to be sent + ''' + self.validate() + EOL = u'\r\n' + + message = self._format_info() + EOL + #Headers + for k,v in self.headers.iteritems(): + message += u'%s: %s%s'%(k,v,EOL) + + #Notifications + if len(self.notifications)>0: + for notice in self.notifications: + message += EOL + for k,v in notice.iteritems(): + message += u'%s: %s%s'%(k,v,EOL) + + message += EOL + return message + +class GNTPNotice(_GNTPBase): + ''' + GNTP Notification Message + ''' + _requiredHeaders = [ + 'Application-Name', + 'Notification-Name', + 'Notification-Title' + ] + def __init__(self,data=None,app=None,name=None,title=None,password=None): + ''' + + @param data: (Optional) See decode() + @param app: (Optional) Set Application-Name + @param name: (Optional) Set Notification-Name + @param title: (Optional) Set Notification Title + @param password: (Optional) Password to use while encoding/decoding messages + ''' + self.info['messagetype'] = 'NOTIFY' + + if data: + self.decode(data,password) + else: + self.set_password(password) + if app: + self.add_header('Application-Name', app) + if name: + self.add_header('Notification-Name', name) + if title: + self.add_header('Notification-Title', title) + self.add_origin_info() + def decode(self,data,password): + ''' + Decode existing GNTP Notification message + @param data: Message to decode. + ''' + self.raw = data + parts = self.raw.split('\r\n\r\n') + self.info = self._parse_info(data) + self._validate_password(password) + self.headers = self._parse_dict(parts[0]) + + for i,part in enumerate(parts): + if i==0: continue #Skip Header + if part.strip()=='': continue + notice = self._parse_dict(part) + if notice.get('Identifier',False): + notice['Data'] = self._decode_binary(part,notice) + #open('notice.png','wblol').write(notice['Data']) + self.resources[ notice.get('Identifier') ] = notice + def encode(self): + ''' + Encode a GNTP Notification Message + @return: GNTP Notification Message ready to be sent + ''' + self.validate() + EOL = u'\r\n' + + message = self._format_info() + EOL + #Headers + for k,v in self.headers.iteritems(): + message += u'%s: %s%s'%(k,v,EOL) + + message += EOL + return message.encode('utf8') + +class GNTPSubscribe(_GNTPBase): + def __init__(self,data=None,password=None): + self.info['messagetype'] = 'SUBSCRIBE' + self._requiredHeaders = [ + 'Subscriber-ID', + 'Subscriber-Name', + ] + if data: + self.decode(data,password) + else: + self.set_password(password) + self.add_origin_info() + +class GNTPOK(_GNTPBase): + _requiredHeaders = ['Response-Action'] + def __init__(self,data=None,action=None): + ''' + @param data: (Optional) See _GNTPResponse.decode() + @param action: (Optional) Set type of action the OK Response is for + ''' + self.info['messagetype'] = '-OK' + if data: + self.decode(data) + if action: + self.add_header('Response-Action', action) + self.add_origin_info() + +class GNTPError(_GNTPBase): + _requiredHeaders = ['Error-Code','Error-Description'] + def __init__(self,data=None,errorcode=None,errordesc=None): + ''' + @param data: (Optional) See _GNTPResponse.decode() + @param errorcode: (Optional) Error code + @param errordesc: (Optional) Error Description + ''' + self.info['messagetype'] = '-ERROR' + if data: + self.decode(data) + if errorcode: + self.add_header('Error-Code', errorcode) + self.add_header('Error-Description', errordesc) + self.add_origin_info() + def error(self): + return self.headers['Error-Code'],self.headers['Error-Description'] + +def parse_gntp(data,password=None): + ''' + Attempt to parse a message as a GNTP message + @param data: Message to be parsed + @param password: Optional password to be used to verify the message + ''' + match = re.match('GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)',data,re.IGNORECASE) + if not match: + raise ParseError('INVALID_GNTP_INFO') + info = match.groupdict() + if info['messagetype'] == 'REGISTER': + return GNTPRegister(data,password=password) + elif info['messagetype'] == 'NOTIFY': + return GNTPNotice(data,password=password) + elif info['messagetype'] == 'SUBSCRIBE': + return GNTPSubscribe(data,password=password) + elif info['messagetype'] == '-OK': + return GNTPOK(data) + elif info['messagetype'] == '-ERROR': + return GNTPError(data) + raise ParseError('INVALID_GNTP_MESSAGE') diff --git a/gntp/notifier.py b/gntp/notifier.py new file mode 100644 index 0000000..f1719d1 --- /dev/null +++ b/gntp/notifier.py @@ -0,0 +1,130 @@ +""" +A Python module that uses GNTP to post messages +Mostly mirrors the Growl.py file that comes with Mac Growl +http://code.google.com/p/growl/source/browse/Bindings/python/Growl.py +""" +import gntp +import socket +import logging + +logger = logging.getLogger(__name__) + +class GrowlNotifier(object): + applicationName = 'Python GNTP' + notifications = [] + defaultNotifications = [] + applicationIcon = None + passwordHash = 'MD5' + + #GNTP Specific + password = None + hostname = 'localhost' + port = 23053 + + def __init__(self, applicationName=None, notifications=None, defaultNotifications=None, applicationIcon=None, hostname=None, password=None, port=None): + if applicationName: + self.applicationName = applicationName + assert self.applicationName, 'An application name is required.' + + if notifications: + self.notifications = list(notifications) + assert self.notifications, 'A sequence of one or more notification names is required.' + + if defaultNotifications is not None: + self.defaultNotifications = list(defaultNotifications) + elif not self.defaultNotifications: + self.defaultNotifications = list(self.notifications) + + if applicationIcon is not None: + self.applicationIcon = self._checkIcon(applicationIcon) + elif self.applicationIcon is not None: + self.applicationIcon = self._checkIcon(self.applicationIcon) + + #GNTP Specific + if password: + self.password = password + + if hostname: + self.hostname = hostname + assert self.hostname, 'Requires valid hostname' + + if port: + self.port = int(port) + assert isinstance(self.port,int), 'Requires valid port' + + def _checkIcon(self, data): + ''' + Check the icon to see if it's valid + @param data: + @todo Consider checking for a valid URL + ''' + return data + + def register(self): + ''' + Send GNTP Registration + ''' + logger.info('Sending registration to %s:%s',self.hostname,self.port) + register = gntp.GNTPRegister() + register.add_header('Application-Name',self.applicationName) + for notification in self.notifications: + enabled = notification in self.defaultNotifications + register.add_notification(notification,enabled) + if self.applicationIcon: + register.add_header('Application-Icon',self.applicationIcon) + if self.password: + register.set_password(self.password,self.passwordHash) + response = self.send('register',register.encode()) + if isinstance(response,gntp.GNTPOK): return True + logger.debug('Invalid response %s',response.error()) + return response.error() + + def notify(self, noteType, title, description, icon=None, sticky=False, priority=None): + ''' + Send a GNTP notifications + ''' + logger.info('Sending notification [%s] to %s:%s',noteType,self.hostname,self.port) + assert noteType in self.notifications + notice = gntp.GNTPNotice() + notice.add_header('Application-Name',self.applicationName) + notice.add_header('Notification-Name',noteType) + notice.add_header('Notification-Title',title) + if self.password: + notice.set_password(self.password,self.passwordHash) + if sticky: + notice.add_header('Notification-Sticky',sticky) + if priority: + notice.add_header('Notification-Priority',priority) + if icon: + notice.add_header('Notification-Icon',self._checkIcon(icon)) + if description: + notice.add_header('Notification-Text',description) + response = self.send('notify',notice.encode()) + if isinstance(response,gntp.GNTPOK): return True + logger.debug('Invalid response %s',response.error()) + return response.error() + def subscribe(self,id,name,port): + sub = gntp.GNTPSubscribe() + sub.add_header('Subscriber-ID',id) + sub.add_header('Subscriber-Name',name) + sub.add_header('Subscriber-Port',port) + if self.password: + sub.set_password(self.password,self.passwordHash) + response = self.send('subscribe',sub.encode()) + if isinstance(response,gntp.GNTPOK): return True + logger.debug('Invalid response %s',response.error()) + return response.error() + def send(self,type,data): + ''' + Send the GNTP Packet + ''' + logger.debug('To : %s:%s <%s>\n%s',self.hostname,self.port,type,data) + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect((self.hostname,self.port)) + s.send(data.encode('utf-8', 'replace')) + response = gntp.parse_gntp(s.recv(1024)) + s.close() + + logger.debug('From : %s:%s <%s>\n%s',self.hostname,self.port,response.__class__,response) + return response diff --git a/interfaces/Classic/templates/config_email.tmpl b/interfaces/Classic/templates/config_email.tmpl index 0d60f5f..c17cffb 100644 --- a/interfaces/Classic/templates/config_email.tmpl +++ b/interfaces/Classic/templates/config_email.tmpl @@ -52,11 +52,39 @@ $T('explain-email_account')
$T('opt-email_pwd'):
$T('explain-email_pwd')
- + + +
+ $T('growlSettings') + +
+ $T('explain-ntfosd_enable') +
+
+ + +
+ $T('explain-growl_enable') +
+
+ $T('opt-growl_server'):
+ $T('explain-growl_server')
+ +
+
+ $T('opt-growl_password'):
+ $T('explain-growl_password')
+ +
+ + + +

   +

diff --git a/interfaces/Plush/templates/config_email.tmpl b/interfaces/Plush/templates/config_email.tmpl index 1180ef6..bb65678 100644 --- a/interfaces/Plush/templates/config_email.tmpl +++ b/interfaces/Plush/templates/config_email.tmpl @@ -111,6 +111,54 @@ + +
+
+

$T('growlSettings')

+
+
+ +
+ /> + +
+ + +
+ /> + +
+
+ + +
+
+ + +
+ +
+
+ +
    $T('emailResult') = $lastmail diff --git a/interfaces/Plush/templates/config_switches.tmpl b/interfaces/Plush/templates/config_switches.tmpl index 63fbffd..b52cef0 100644 --- a/interfaces/Plush/templates/config_switches.tmpl +++ b/interfaces/Plush/templates/config_switches.tmpl @@ -35,7 +35,7 @@
- +
diff --git a/interfaces/smpl/templates/config_email.tmpl b/interfaces/smpl/templates/config_email.tmpl index 1ff2b29..4bd8257 100644 --- a/interfaces/smpl/templates/config_email.tmpl +++ b/interfaces/smpl/templates/config_email.tmpl @@ -67,13 +67,44 @@
+ + +
+ $T('growlSettings') +
+ + +
+ + + +
+ + + + $T('explain-growl_server') +
+ + + + $T('explain-growl_password') +
+ + +
+


-Test E-Mail +$T('link-testEmail') +$T('testNotify')     $T('emailResult') = $lastmail diff --git a/interfaces/smpl/templates/main.tmpl b/interfaces/smpl/templates/main.tmpl index f8027c0..59094da 100644 --- a/interfaces/smpl/templates/main.tmpl +++ b/interfaces/smpl/templates/main.tmpl @@ -740,6 +740,16 @@ function lrb(url, extra, refresh) d.addErrback(handleServerError); } + function testnotification() + { + d = doSimpleXMLHttpRequest('config/email/testnotification?session='+session); + d.addCallback(function (d) + { + alert("$T('smpl-notesent')"); + }); + d.addErrback(handleServerError); + } + function unblock_server(host, port) { d = doSimpleXMLHttpRequest('connections/unblock_server?server='+host+':'+port+'&session='+session); diff --git a/package.py b/package.py index 7baee3d..8fbe6e7 100755 --- a/package.py +++ b/package.py @@ -307,6 +307,7 @@ data_files = [ 'COPYRIGHT.txt', 'ISSUES.txt', 'nzb.ico', + 'sabnzbd.ico', 'Sample-PostProc.cmd', 'Sample-PostProc.sh', 'PKG-INFO', diff --git a/sabnzbd/__init__.py b/sabnzbd/__init__.py index 9d90c2f..2b57a77 100644 --- a/sabnzbd/__init__.py +++ b/sabnzbd/__init__.py @@ -227,6 +227,8 @@ def initialize(pause_downloader = False, clean_up = False, evalSched=False, repa cfg.bandwidth_limit.callback(guard_speedlimit) cfg.top_only.callback(guard_top_only) cfg.pause_on_post_processing.callback(guard_pause_on_pp) + cfg.growl_server.callback(sabnzbd.growler.change_value) + cfg.growl_password.callback(sabnzbd.growler.change_value) cfg.quotum_size.callback(guard_quotum_size) cfg.quotum_day.callback(guard_quotum_dp) cfg.quotum_period.callback(guard_quotum_dp) diff --git a/sabnzbd/cfg.py b/sabnzbd/cfg.py index d8f15b5..a0bf1aa 100644 --- a/sabnzbd/cfg.py +++ b/sabnzbd/cfg.py @@ -212,6 +212,11 @@ nzb_key = OptionStr('misc', 'nzb_key', create_api_key()) disable_key = OptionBool('misc', 'disable_api_key', False) api_warnings = OptionBool('misc', 'api_warnings', True) +growl_server = OptionStr('growl', 'growl_server') +growl_password = OptionPassword('growl', 'growl_password') +growl_enable = OptionBool('growl', 'growl_enable', True) +ntfosd_enable = OptionBool('growl', 'ntfosd_enable', True) + quotum_size = OptionStr('misc', 'quotum_size') quotum_day = OptionStr('misc', 'quotum_day') quotum_resume = OptionBool('misc', 'quotum_resume', False) diff --git a/sabnzbd/downloader.py b/sabnzbd/downloader.py index 7fa1ac0..92ba12d 100644 --- a/sabnzbd/downloader.py +++ b/sabnzbd/downloader.py @@ -30,7 +30,7 @@ import sabnzbd from sabnzbd.decorators import synchronized, synchronized_CV, CV from sabnzbd.decoder import Decoder from sabnzbd.newswrapper import NewsWrapper, request_server_info -from sabnzbd.utils import osx +import sabnzbd.growler as growler from sabnzbd.constants import * import sabnzbd.config as config import sabnzbd.cfg as cfg @@ -201,7 +201,7 @@ class Downloader(Thread): if not self.paused: self.paused = True logging.info("Pausing") - osx.sendGrowlMsg("SABnzbd",T('Paused'),osx.NOTIFICATION['download']) + growler.send_notification("SABnzbd", T('Paused'), 'download') if self.is_paused(): BPSMeter.do.reset() if cfg.autodisconnect(): @@ -747,7 +747,7 @@ class Downloader(Thread): def stop(self): self.shutdown = True - osx.sendGrowlMsg("SABnzbd",T('Shutting down'),osx.NOTIFICATION['startup']) + growler.send_notification("SABnzbd",T('Shutting down'), 'startup') def stop(): diff --git a/sabnzbd/growler.py b/sabnzbd/growler.py new file mode 100644 index 0000000..4e2186f --- /dev/null +++ b/sabnzbd/growler.py @@ -0,0 +1,223 @@ +#!/usr/bin/python -OO +# Copyright 2008-2011 The SABnzbd-Team +# +# 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.growler - Send notifications to Growl +""" +#------------------------------------------------------------------------------ + +import os.path +import logging +import socket + +import sabnzbd +import sabnzbd.cfg +from sabnzbd.encoding import unicoder, latin1 +from gntp import GNTPRegister +from gntp.notifier import GrowlNotifier +try: + import Growl + _HAVE_OSX_GROWL = True +except ImportError: + _HAVE_OSX_GROWL = False +try: + import pynotify + _HAVE_NTFOSD = True +except ImportError: + _HAVE_NTFOSD = False + +#------------------------------------------------------------------------------ +# Define translatable message table +TT = lambda x:x +_NOTIFICATION = { + 'startup' : TT('Startup/Shutdown'), #: Message class for Growl server + 'download' : TT('Added NZB'), #: Message class for Growl server + 'pp' : TT('Post-processing started'), #: Message class for Growl server + 'complete' : TT('Job finished'), #: Message class for Growl server + 'other' : TT('Other Messages') #: Message class for Growl server +} +_KEYS = ('startup', 'download', 'pp', 'complete', 'other') + +#------------------------------------------------------------------------------ +# Setup platform dependent Growl support +# +_GROWL_ICON = None # Platform-dependant icon path +_GROWL = None # Instance of the Notifier after registration +_GROWL_REG = False # Succesful registration + + +#------------------------------------------------------------------------------ +def get_icon(): + icon = os.path.join(sabnzbd.DIR_PROG, 'sabnzbd.ico') + if not os.path.isfile(icon): + icon = None + return icon + + +#------------------------------------------------------------------------------ +def change_value(): + """ Signal that we should register with a new Growl server + """ + global _GROWL_REG + _GROWL_REG = False + + +#------------------------------------------------------------------------------ +def have_growl(): + """ Return if any Growl support is present + """ + return True + +def have_ntfosd(): + """ Return if any PyNotify support is present + """ + return bool(_HAVE_NTFOSD) + + +#------------------------------------------------------------------------------ +def send_notification(title , msg, gtype): + """ Send Notification message + """ + if have_growl(): + send_growl(title, msg, gtype) + if have_ntfosd(): + send_notify_osd(title, msg) + + +#------------------------------------------------------------------------------ +def register_growl(): + """ Register this app with Growl + """ + host, port = sabnzbd.misc.split_host(sabnzbd.cfg.growl_server()) + + if host: + sys_name = '@' + sabnzbd.misc.hostname().lower() + else: + sys_name = '' + + # Clean up persistent data in GNTP to make re-registration work + GNTPRegister.notifications = [] + GNTPRegister.headers = {} + + growler = GrowlNotifier( + applicationName = 'SABnzbd%s' % sys_name, + applicationIcon = get_icon(), + notifications = [Tx(_NOTIFICATION[key]) for key in _KEYS], + hostname = host or None, + port = port or 23053, + password = sabnzbd.cfg.growl_password() or None + ) + + try: + ret = growler.register() + if ret is None or isinstance(ret, bool): + logging.info('Registered with Growl') + ret = growler + else: + logging.debug('Cannot register with Growl %s', ret) + del growler + ret = None + except socket.error, err: + logging.debug('Cannot register with Growl %s', err) + del growler + ret = None + return ret + + +#------------------------------------------------------------------------------ +def send_growl(title , msg, gtype): + """ Send Growl message + """ + global _GROWL, _GROWL_REG + if not sabnzbd.cfg.growl_enable(): + return + + if _HAVE_OSX_GROWL and not sabnzbd.cfg.growl_server(): + send_local_growl(title, msg, gtype) + return + + for n in (0, 1): + if not _GROWL_REG: _GROWL = None + _GROWL = _GROWL or register_growl() + if _GROWL: + assert isinstance(_GROWL, GrowlNotifier) + _GROWL_REG = True + logging.debug('Send to Growl: %s %s %s', gtype, latin1(title), latin1(msg)) + try: + ret = _GROWL.notify( + noteType = Tx(_NOTIFICATION.get(gtype, 'other')), + title = title, + description = unicoder(msg), + #icon = options.icon, + #sticky = options.sticky, + #priority = options.priority + ) + if ret is None or isinstance(ret, bool): + return + elif ret[0] == '401': + _GROWL = False + else: + logging.debug('Growl error %s', ret) + return + except socket.error, err: + logging.debug('Growl error %s', err) + return + else: + return + + +#------------------------------------------------------------------------------ +# Local OSX Growl support +# +if _HAVE_OSX_GROWL: + + if os.path.isfile('sabnzbdplus.icns'): + _OSX_ICON = Growl.Image.imageFromPath('sabnzbdplus.icns') + elif os.path.isfile('osx/resources/sabnzbdplus.icns'): + _OSX_ICON = Growl.Image.imageFromPath('osx/resources/sabnzbdplus.icns') + else: + _OSX_ICON = Growl.Image.imageWithIconForApplication('Terminal') + + def send_local_growl(title , msg, gtype): + """ Send to local Growl server, OSX-only """ + notes = [Tx(_NOTIFICATION[key]) for key in _KEYS] + growler = Growl.GrowlNotifier( + applicationName = 'SABnzbd', + applicationIcon = _OSX_ICON, + notifications = notes, + defaultNotifications = notes + ) + growler.register() + growler.notify(Tx(_NOTIFICATION.get(gtype, 'other')), title, msg) + + +#------------------------------------------------------------------------------ +# Ubuntu NotifyOSD Support +# +if _HAVE_NTFOSD: + _NTFOSD = False + def send_notify_osd(title, message): + """ Send a message to NotifyOSD + """ + global _NTFOSD + if sabnzbd.cfg.ntfosd_enable(): + icon = os.path.join(sabnzbd.DIR_PROG, 'sabnzbd.ico') + _NTFOSD = _NTFOSD or pynotify.init('icon-summary-body') + if _NTFOSD: + logging.info('Send to NotifyOSD: %s / %s', latin1(title), latin1(message)) + note = pynotify.Notification(title, message, icon) + note.show() diff --git a/sabnzbd/interface.py b/sabnzbd/interface.py index 3978eb7..77c6711 100644 --- a/sabnzbd/interface.py +++ b/sabnzbd/interface.py @@ -51,6 +51,7 @@ from sabnzbd.downloader import Downloader from sabnzbd.nzbqueue import NzbQueue import sabnzbd.wizard from sabnzbd.utils.servertests import test_nntp_server_dict +from sabnzbd.growler import send_notification from sabnzbd.constants import * from sabnzbd.lang import list_languages, set_language @@ -2451,6 +2452,7 @@ LIST_EMAIL = ( 'email_server', 'email_to', 'email_from', 'email_account', 'email_pwd', 'email_dir', 'email_rss' ) +LIST_GROWL = ('growl_enable', 'growl_server', 'growl_password', 'ntfosd_enable') class ConfigEmail(object): def __init__(self, web_dir, root, prim): @@ -2469,10 +2471,13 @@ class ConfigEmail(object): conf['my_home'] = sabnzbd.DIR_HOME conf['my_lcldata'] = sabnzbd.DIR_LCLDATA conf['lastmail'] = self.__lastmail - + conf['have_growl'] = sabnzbd.growler.have_growl() + conf['have_ntfosd'] = sabnzbd.growler.have_ntfosd() for kw in LIST_EMAIL: conf[kw] = config.get_config('misc', kw).get_string() + for kw in LIST_GROWL: + conf[kw] = config.get_config('growl', kw).get_string() template = Template(file=os.path.join(self.__web_dir, 'config_email.tmpl'), filter=FILTER, searchList=[conf], compilerSettings=DIRECTIVES) @@ -2487,6 +2492,10 @@ class ConfigEmail(object): msg = config.get_config('misc', kw).set(platform_encode(kwargs.get(kw))) if msg: return badParameterResponse(T('Incorrect value for %s: %s') % (kw, unicoder(msg))) + for kw in LIST_GROWL: + msg = config.get_config('growl', kw).set(platform_encode(kwargs.get(kw))) + if msg: + return badParameterResponse(T('Incorrect value for %s: %s') % (kw, unicoder(msg))) config.save_config() self.__lastmail = None @@ -2507,6 +2516,14 @@ class ConfigEmail(object): str(123*MEBI), pack, 'my_script', 'Line 1\nLine 2\nLine 3\nd\xe8ja vu\n', 0) raise dcRaiser(self.__root, kwargs) + @cherrypy.expose + def testnotification(self, **kwargs): + msg = check_session(kwargs) + if msg: return msg + logging.info("Sending test notification") + send_notification('SABNzbd', T('Test Notification'), 'other') + raise dcRaiser(self.__root, kwargs) + def rss_history(url, limit=50, search=None): url = url.replace('rss','') diff --git a/sabnzbd/misc.py b/sabnzbd/misc.py index 64df0ad..6e7fa2e 100644 --- a/sabnzbd/misc.py +++ b/sabnzbd/misc.py @@ -661,6 +661,17 @@ def split_host(srv): #------------------------------------------------------------------------------ +def hostname(): + """ Return host's pretty name """ + if sabnzbd.WIN32: + return os.environ.get('computername', 'unknown') + try: + return os.uname()[1] + except: + return 'unknown' + + +#------------------------------------------------------------------------------ def check_mount(path): """ Return False if volume isn't mounted on Linux or OSX """ diff --git a/sabnzbd/newzbin.py b/sabnzbd/newzbin.py index 0932395..7ea3c4d 100644 --- a/sabnzbd/newzbin.py +++ b/sabnzbd/newzbin.py @@ -41,7 +41,7 @@ from sabnzbd.misc import cat_to_opts, sanitize_foldername, bad_fetch, cat_conver from sabnzbd.encoding import name_fixer import sabnzbd.newswrapper import sabnzbd.cfg as cfg -from sabnzbd.utils import osx +import sabnzbd.growler as growler ################################################################################ @@ -135,7 +135,7 @@ class MSGIDGrabber(Thread): bad_fetch(nzo, msgid, msg=nzo_info, retry=True) msgid = None - osx.sendGrowlMsg(T('NZB added to queue'),filename,osx.NOTIFICATION['download']) + growler.send_notification(T('NZB added to queue'), filename, 'download') # Keep some distance between the grabs sleeper(5) diff --git a/sabnzbd/nzbqueue.py b/sabnzbd/nzbqueue.py index c217ce1..4cedf47 100644 --- a/sabnzbd/nzbqueue.py +++ b/sabnzbd/nzbqueue.py @@ -39,7 +39,7 @@ import sabnzbd.cfg as cfg from sabnzbd.articlecache import ArticleCache import sabnzbd.downloader from sabnzbd.assembler import Assembler, file_has_articles -from sabnzbd.utils import osx +import sabnzbd.growler as growler from sabnzbd.encoding import latin1, platform_encode from sabnzbd.bpsmeter import BPSMeter @@ -325,7 +325,7 @@ class NzbQueue(TryList): self.save(nzo) if not (quiet or nzo.status in ('Fetching',)): - osx.sendGrowlMsg(T('NZB added to queue'), nzo.filename, osx.NOTIFICATION['download']) + growler.send_notification(T('NZB added to queue'), nzo.filename, 'download') if cfg.auto_sort(): self.sort_by_avg_age() diff --git a/sabnzbd/osxmenu.py b/sabnzbd/osxmenu.py index b645409..5041cbd 100644 --- a/sabnzbd/osxmenu.py +++ b/sabnzbd/osxmenu.py @@ -40,7 +40,7 @@ import sabnzbd.cfg from sabnzbd.constants import * from sabnzbd.misc import get_filename, get_ext, diskfree from sabnzbd.panic import launch_a_browser -from sabnzbd.utils import osx +import sabnzbd.growler as growler from sabnzbd.nzbqueue import NzbQueue import sabnzbd.config as config @@ -506,7 +506,7 @@ class SABnzbdDelegate(NSObject): if sabnzbd.NEW_VERSION and self.version_notify: #logging.info("[osx] New Version : %s" % (sabnzbd.NEW_VERSION)) new_release, new_rel_url = sabnzbd.NEW_VERSION.split(';') - osx.sendGrowlMsg("SABnzbd","%s : %s" % (T('New release available'),new_release),osx.NOTIFICATION['other']) + growler.send_notification("SABnzbd","%s : %s" % (T('New release available'), new_release), 'other') self.version_notify = 0 except : logging.info("[osx] versionUpdate Exception %s" % (sys.exc_info()[0])) @@ -728,7 +728,7 @@ class SABnzbdDelegate(NSObject): sabnzbd.halt() cherrypy.engine.exit() sabnzbd.SABSTOP = True - osx.sendGrowlMsg('SABnzbd',T('SABnzbd shutdown finished'),osx.NOTIFICATION['other']) + growler.send_notification('SABnzbd', T('SABnzbd shutdown finished'), growler.NOTIFICATION['other']) logging.info('Leaving SABnzbd') sys.stderr.flush() sys.stdout.flush() diff --git a/sabnzbd/postproc.py b/sabnzbd/postproc.py index af71913..2e92a5c 100644 --- a/sabnzbd/postproc.py +++ b/sabnzbd/postproc.py @@ -45,7 +45,7 @@ import sabnzbd.config as config import sabnzbd.cfg as cfg import sabnzbd.nzbqueue import sabnzbd.database as database -from sabnzbd.utils import osx +import sabnzbd.growler as growler #------------------------------------------------------------------------------ @@ -451,10 +451,10 @@ def process_job(nzo): ## Show final status in history if all_ok: - osx.sendGrowlMsg(T('Download Completed'), filename, osx.NOTIFICATION['complete']) + growler.send_notification(T('Download Completed'), filename, 'complete') nzo.status = 'Completed' else: - osx.sendGrowlMsg(T('Download Failed'), filename, osx.NOTIFICATION['complete']) + growler.send_notification(T('Download Failed'), filename, 'complete') nzo.status = 'Failed' except: @@ -463,7 +463,7 @@ def process_job(nzo): logging.info("Traceback: ", exc_info = True) crash_msg = T('see logfile') nzo.fail_msg = T('PostProcessing was aborted (%s)') % unicoder(crash_msg) - osx.sendGrowlMsg(T('Download Failed'), filename, osx.NOTIFICATION['complete']) + growler.send_notification(T('Download Failed'), filename, 'complete') nzo.status = 'Failed' par_error = True all_ok = False @@ -513,7 +513,7 @@ def parring(nzo, workdir): """ Perform par processing. Returns: (par_error, re_add) """ filename = nzo.final_name - osx.sendGrowlMsg(T('Post-processing'), nzo.final_name, osx.NOTIFICATION['pp']) + growler.send_notification(T('Post-processing'), nzo.final_name, 'pp') logging.info('Par2 check starting on %s', filename) ## Collect the par files diff --git a/sabnzbd/skintext.py b/sabnzbd/skintext.py index f3eb85c..e80ab9f 100644 --- a/sabnzbd/skintext.py +++ b/sabnzbd/skintext.py @@ -499,8 +499,7 @@ SKIN_TEXT = { 'feedSettings' : TT('Settings'), #: Tab title for Config->Feeds 'filters' : TT('Filters'), #: Tab title for Config->Feeds -# Config->Email - 'configEmail' : TT('Email Notifications'), #: Main Config page + 'configEmail' : TT('Notifications'), #: Main Config page 'emailOptions' : TT('Email Options'), #: Section header 'opt-email_endjob' : TT('Email Notification On Job Completion'), 'email-never' : TT('Never'), #: When to send email diff --git a/sabnzbd/utils/osx.py b/sabnzbd/utils/osx.py deleted file mode 100644 index f56dc0d..0000000 --- a/sabnzbd/utils/osx.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/python -OO -# Copyright 2008-2011 The SABnzbd-Team -# -# 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. -# -#""" -#TO FIX : Translations are not working with this implementation -# Growl Registration may only be done once per run ? -# Registration is made too early, the language module has not read the text file yet -#NOTIFICATION = {'startup':'grwl-notif-startup','download':'grwl-notif-dl','pp':'grwl-notif-pp','other':'grwl-notif-other'} -NOTIFICATION = {'startup':'1. On Startup/Shutdown','download':'2. On adding NZB','pp':'3. On post-processing','complete':'4. On download terminated','other':'5. Other Messages'} - -# For a future release, make texts translatable. -if 0: - #------------------------------------------------------------------------------ - # Define translatable message table - TT = lambda x:x - _NOTIFICATION = { - 'startup' : TT('Startup/Shutdown'), #: Message class for Growl server - 'download' : TT('Added NZB'), #: Message class for Growl server - 'pp' : TT('Post-processing started'), #: Message class for Growl server - 'complete' : TT('Job finished'), #: Message class for Growl server - 'other' : TT('Other Messages') #: Message class for Growl server - } - -try: - import Growl - import os.path - import logging - - if os.path.isfile('sabnzbdplus.icns'): - nIcon = Growl.Image.imageFromPath('sabnzbdplus.icns') - elif os.path.isfile('osx/resources/sabnzbdplus.icns'): - nIcon = Growl.Image.imageFromPath('osx/resources/sabnzbdplus.icns') - else: - nIcon = Growl.Image.imageWithIconForApplication('Terminal') - - def sendGrowlMsg(nTitle , nMsg, nType=NOTIFICATION['other']): - gnotifier = SABGrowlNotifier(applicationIcon=nIcon) - gnotifier.register() - #TO FIX - #gnotifier.notify(T(nType), nTitle, nMsg) - gnotifier.notify(nType, nTitle, nMsg) - - class SABGrowlNotifier(Growl.GrowlNotifier): - applicationName = "SABnzbd" - #TO FIX - #notifications = [T(notification) for notification in NOTIFICATION.values()] - notifications = NOTIFICATION.values() - -except ImportError: - def sendGrowlMsg(nTitle , nMsg, nType): - pass