Browse Source

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.
pull/7/head
ShyPike 14 years ago
parent
commit
53aed799a1
  1. 16
      INSTALL.txt
  2. 8
      SABnzbd.py
  3. 443
      gntp/__init__.py
  4. 130
      gntp/notifier.py
  5. 30
      interfaces/Classic/templates/config_email.tmpl
  6. 50
      interfaces/Plush/templates/config_email.tmpl
  7. 33
      interfaces/smpl/templates/config_email.tmpl
  8. 10
      interfaces/smpl/templates/main.tmpl
  9. 1
      package.py
  10. 2
      sabnzbd/__init__.py
  11. 5
      sabnzbd/cfg.py
  12. 6
      sabnzbd/downloader.py
  13. 223
      sabnzbd/growler.py
  14. 19
      sabnzbd/interface.py
  15. 11
      sabnzbd/misc.py
  16. 4
      sabnzbd/newzbin.py
  17. 4
      sabnzbd/nzbqueue.py
  18. 6
      sabnzbd/osxmenu.py
  19. 10
      sabnzbd/postproc.py
  20. 3
      sabnzbd/skintext.py
  21. 65
      sabnzbd/utils/osx.py

16
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

8
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)

443
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/<version> <messagetype> <encryptionAlgorithmID>[:<ivValue>][ <keyHashAlgorithmID>:<keyHash>.<salt>]
match = re.match('GNTP/(?P<version>\d+\.\d+) (?P<messagetype>REGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)'+
' (?P<encryptionAlgorithmID>[A-Z0-9]+(:(?P<ivValue>[A-F0-9]+))?) ?'+
'((?P<keyHashAlgorithmID>[A-Z0-9]+):(?P<keyHash>[A-F0-9]+).(?P<salt>[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<version>\d+\.\d+) (?P<messagetype>REGISTER|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')

130
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

30
interfaces/Classic/templates/config_email.tmpl

@ -52,11 +52,39 @@ $T('explain-email_account')<br>
<strong>$T('opt-email_pwd'):</strong><br>
$T('explain-email_pwd')<br>
<input type="password" size="35" name="email_pwd" value="$email_pwd">
<input type="hidden" name="session" value="$session">
</fieldset>
<!--#if $have_growl or $have_ntfosd#-->
<fieldset class="EntryFieldSet">
<legend>$T('growlSettings')</legend>
<!--#if $have_ntfosd#-->
<label><input type="checkbox" name="ntfosd_enable" value="1" <!--#if $ntfosd_enable != "0" then "checked=1" else ""#--> /> <strong>$T('opt-ntfosd_enable'):</strong></label><br>
$T('explain-ntfosd_enable')
<br/>
<br/>
<!--#end if#-->
<!--#if $have_growl#-->
<label><input type="checkbox" name="growl_enable" value="1" <!--#if $growl_enable != "0" then "checked=1" else ""#--> /> <strong>$T('opt-growl_enable'):</strong></label><br>
$T('explain-growl_enable')
<br/>
<br/>
<strong>$T('opt-growl_server'):</strong><br>
$T('explain-growl_server')<br>
<input type="text" size="35" name="growl_server" value="$growl_server">
<br>
<br>
<strong>$T('opt-growl_password'):</strong><br>
$T('explain-growl_password')<br>
<input type="password" size="35" name="growl_password" value="$growl_password">
</fieldset>
<!--#end if#-->
<!--#end if#-->
</div>
<input type="hidden" name="session" value="$session">
<p><input type="submit" value="$T('button-saveChanges')">&nbsp;&nbsp;
<input type="button" onclick="if (confirm('$T('askTestEmail').replace("'","`") ')) { this.form.action='testmail?session=$session&'; this.form.submit(); return false;}" value="$T('link-testEmail')"/>
<input type="button" onclick="this.form.action='testnotification?session=$session&'; this.form.submit(); return false;"value="$T('testNotify')"/>
</p>
</form>
<!--#if $lastmail#-->

50
interfaces/Plush/templates/config_email.tmpl

@ -111,6 +111,54 @@
</fieldset>
</div><!-- /component-group2 -->
<!--#if $have_growl or $have_ntfosd#-->
<div id="core-component-group3" class="component-group clearfix">
<div class="component-group-desc">
<h3>$T('growlSettings')</h3>
</div>
<fieldset class="component-group-list">
<!--#if $have_ntfosd#-->
<div class="field-pair">
<input type="checkbox" name="ntfosd_enable" id="ntfosd_enable" value="1" <!--#if $ntfosd_enable != "0" then "checked=1" else ""#--> />
<label class="clearfix" for="ntfosd_enable">
<span class="component-title">$T('opt-ntfosd_enable')</span>
<span class="component-desc">$T('explain-ntfosd_enable')</span>
</label>
</div>
<!--#end if#-->
<!--#if $have_growl#-->
<div class="field-pair">
<input type="checkbox" name="growl_enable" id="growl_enable" value="1" <!--#if $growl_enable != "0" then "checked=1" else ""#--> />
<label class="clearfix" for="growl_enable">
<span class="component-title">$T('opt-growl_enable')</span>
<span class="component-desc">$T('explain-growl_enable')</span>
</label>
</div>
<div class="field-pair">
<label class="nocheck clearfix" for="growl_server">
<span class="component-title">$T('opt-growl_server')</span>
<input type="text" name="growl_server" id="growl_server" value="$growl_server"/>
</label>
<label class="nocheck clearfix">
<span class="component-title">&nbsp;</span>
<span class="component-desc">$T('explain-growl_server')</span>
</label>
</div>
<div class="field-pair">
<label class="nocheck clearfix" for="growl_password">
<span class="component-title">$T('opt-growl_password')</span>
<input type="password" size="35" name="growl_password" id="growl_password" value="$growl_password"/>
</label>
<label class="nocheck clearfix">
<span class="component-title">&nbsp;</span>
<span class="component-desc">$T('explain-growl_password')</span>
</label>
</div>
<!--#end if#-->
</fieldset>
</div><!-- /component-group3 -->
<!--#end if#-->
<div class="component-group-last clearfix">
<div class="component-group-desc">
<h3>&nbsp;</h3>
@ -120,6 +168,8 @@
<a id="save"><span class="config_sprite_container sprite_config_save">&nbsp;</span> $T('button-saveChanges')</a>
<a id="test_email" href="testmail?session=$session" rel="$T('askTestEmail')">
<span class="config_sprite_container sprite_config_email_test">&nbsp;</span> $T('link-testEmail')</a>
<a id="test_notification" href="testnotification?session=$session">
<span class="config_sprite_container sprite_config_email_test">&nbsp;</span> $T('testNotify')</a>
</div>
<!--#if $lastmail#-->
&nbsp;&nbsp;&nbsp;&nbsp;$T('emailResult') = <b>$lastmail</b>

33
interfaces/smpl/templates/config_email.tmpl

@ -67,13 +67,44 @@
<br class="clear" />
</fieldset>
<!--#if $have_growl or $have_ntfosd#-->
<fieldset class="EntryFieldSet">
<legend>$T('growlSettings')</legend>
<hr />
<!--#if $have_ntfosd#-->
<label><span class="label">$T('opt-ntfosd_enable'):</span>
<input class="radio" type="checkbox" name="ntfosd_enable" value="1" <!--#if $ntfosd_enable != "0" then "checked=1" else ""#--> />
<span class="tips">$T('explain-ntfosd_enable')</span></label>
<br class="clear" />
<!--#end if#-->
<!--#if $have_growl#-->
<label><span class="label">$T('opt-growl_enable'):</span>
<input class="radio" type="checkbox" name="growl_enable" value="1" <!--#if $growl_enable != "0" then "checked=1" else ""#--> />
<span class="tips">$T('explain-growl_enable')</span></label>
<br class="clear" />
<label class="label">$T('opt-growl_server'):</label>
<input type="text" size="35" name="growl_server" value="$growl_server">
<span class="tips">$T('explain-growl_server')</span>
<br class="clear" />
<label class="label">$T('opt-growl_password'):</label>
<input type="password" size="35" name="growl_password" value="$growl_password">
<span class="tips">$T('explain-growl_password')</span>
<br class="clear" />
<!--#end if#-->
</fieldset>
<!--#end if#-->
</div>
<p>
<input type="button" size="40" value="$T('button-saveChanges')" onclick="javascript:submitconfig('config/email/saveEmail', this, 'configEmail')">
</p>
<br class="clear" />
<a class="config" onClick="testemail();">Test E-Mail</a>
<a class="config" onClick="testemail();">$T('link-testEmail')</a>
<a class="config" onClick="testnotification();">$T('testNotify')</a>
<!--#if $lastmail#-->
&nbsp;&nbsp;&nbsp;&nbsp;$T('emailResult') = <b>$lastmail</b>
<!--#end if#-->

10
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);

1
package.py

@ -307,6 +307,7 @@ data_files = [
'COPYRIGHT.txt',
'ISSUES.txt',
'nzb.ico',
'sabnzbd.ico',
'Sample-PostProc.cmd',
'Sample-PostProc.sh',
'PKG-INFO',

2
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)

5
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)

6
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():

223
sabnzbd/growler.py

@ -0,0 +1,223 @@
#!/usr/bin/python -OO
# Copyright 2008-2011 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.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()

19
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','')

11
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
"""

4
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)

4
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()

6
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()

10
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

3
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

65
sabnzbd/utils/osx.py

@ -1,65 +0,0 @@
#!/usr/bin/python -OO
# Copyright 2008-2011 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.
#
#"""
#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
Loading…
Cancel
Save