diff --git a/couchpotato/core/notifications/growl/main.py b/couchpotato/core/notifications/growl/main.py index 4aa1c312..7f1398d9 100644 --- a/couchpotato/core/notifications/growl/main.py +++ b/couchpotato/core/notifications/growl/main.py @@ -37,8 +37,11 @@ class Growl(Notification): ) self.growl.register() self.registered = True - except: - log.error('Failed register of growl: %s', traceback.format_exc()) + except Exception, e: + if 'timed out' in str(e): + self.registered = True + else: + log.error('Failed register of growl: %s', traceback.format_exc()) def notify(self, message = '', data = {}, listener = None): if self.isDisabled(): return diff --git a/couchpotato/core/notifications/toasty/__init__.py b/couchpotato/core/notifications/toasty/__init__.py new file mode 100644 index 00000000..25d27ecd --- /dev/null +++ b/couchpotato/core/notifications/toasty/__init__.py @@ -0,0 +1,32 @@ +from .main import Toasty + +def start(): + return Toasty() + +config = [{ + 'name': 'toasty', + 'groups': [ + { + 'tab': 'notifications', + 'name': 'toasty', + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + }, + { + 'name': 'api_key', + 'label': 'Device ID', + }, + { + 'name': 'on_snatch', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Also send message when movie is snatched.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/toasty/main.py b/couchpotato/core/notifications/toasty/main.py new file mode 100644 index 00000000..638c75dd --- /dev/null +++ b/couchpotato/core/notifications/toasty/main.py @@ -0,0 +1,30 @@ +from couchpotato.core.helpers.encoding import toUnicode, tryUrlencode +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +import traceback + +log = CPLog(__name__) + +class Toasty(Notification): + + urls = { + 'api': 'http://api.supertoasty.com/notify/%s?%s' + } + + def notify(self, message = '', data = {}, listener = None): + if self.isDisabled(): return + + data = { + 'title': self.default_title, + 'text': toUnicode(message), + 'sender': toUnicode("CouchPotato"), + 'image': 'https://raw.github.com/RuudBurger/CouchPotatoServer/master/couchpotato/static/images/homescreen.png', + } + + try: + self.urlopen(self.urls['api'] % (self.conf('api_key'), tryUrlencode(data)), show_error = False) + return True + except: + log.error('Toasty failed: %s', traceback.format_exc()) + + return False diff --git a/couchpotato/core/notifications/xbmc/__init__.py b/couchpotato/core/notifications/xbmc/__init__.py index 588c5e2b..ee2b4cc2 100644 --- a/couchpotato/core/notifications/xbmc/__init__.py +++ b/couchpotato/core/notifications/xbmc/__init__.py @@ -10,6 +10,7 @@ config = [{ 'tab': 'notifications', 'name': 'xbmc', 'label': 'XBMC', + 'description': 'v11 (Eden) and v12 (Frodo)', 'options': [ { 'name': 'enabled', diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index 96bb2cf8..eef94695 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -4,6 +4,7 @@ from couchpotato.core.notifications.base import Notification from flask.helpers import json import base64 import traceback +import urllib log = CPLog(__name__) @@ -11,27 +12,147 @@ log = CPLog(__name__) class XBMC(Notification): listen_to = ['renamer.after'] + use_json_notifications = {} def notify(self, message = '', data = {}, listener = None): if self.isDisabled(): return hosts = splitString(self.conf('host')) + successful = 0 for host in hosts: - response = self.request(host, [ - ('GUI.ShowNotification', {"title":"CouchPotato", "message":message}), - ('VideoLibrary.Scan', {}), - ]) + + if self.use_json_notifications.get(host) is None: + self.getXBMCJSONversion(host, message = message) + + if self.use_json_notifications.get(host): + response = self.request(host, [ + ('GUI.ShowNotification', {'title':self.default_title, 'message':message}), + ('VideoLibrary.Scan', {}), + ]) + else: + response = self.notifyXBMCnoJSON(host, {'title':self.default_title, 'message':message}) + response += self.request(host, [('VideoLibrary.Scan', {})]) try: for result in response: - if result['result'] == "OK": + if (result.get('result') and result['result'] == 'OK'): successful += 1 + elif (result.get('error')): + log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) + except: log.error('Failed parsing results: %s', traceback.format_exc()) return successful == len(hosts) * 2 + def getXBMCJSONversion(self, host, message = ''): + + success = False + + # XBMC JSON-RPC version request + response = self.request(host, [ + ('JSONRPC.Version', {}) + ]) + for result in response: + if (result.get('result') and type(result['result']['version']).__name__ == 'int'): + # only v2 and v4 return an int object + # v6 (as of XBMC v12(Frodo)) is required to send notifications + xbmc_rpc_version = str(result['result']['version']) + + log.debug('XBMC JSON-RPC Version: %s ; Notifications by JSON-RPC only supported for v6 [as of XBMC v12(Frodo)]', xbmc_rpc_version) + + # disable JSON use + self.use_json_notifications[host] = False + + # send the text message + resp = self.notifyXBMCnoJSON(host, {'title':self.default_title, 'message':message}) + for result in resp: + if (result.get('result') and result['result'] == 'OK'): + log.debug('Message delivered successfully!') + success = True + break + elif (result.get('error')): + log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) + break + + elif (result.get('result') and type(result['result']['version']).__name__ == 'dict'): + # XBMC JSON-RPC v6 returns an array object containing + # major, minor and patch number + xbmc_rpc_version = str(result['result']['version']['major']) + xbmc_rpc_version += '.' + str(result['result']['version']['minor']) + xbmc_rpc_version += '.' + str(result['result']['version']['patch']) + + log.debug('XBMC JSON-RPC Version: %s', xbmc_rpc_version) + + # ok, XBMC version is supported + self.use_json_notifications[host] = True + + # send the text message + resp = self.request(host, [('GUI.ShowNotification', {'title':self.default_title, 'message':message})]) + for result in resp: + if (result.get('result') and result['result'] == 'OK'): + log.debug('Message delivered successfully!') + success = True + break + elif (result.get('error')): + log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) + break + + # error getting version info (we do have contact with XBMC though) + elif (result.get('error')): + log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) + + log.debug('Use JSON notifications: %s ', self.use_json_notifications) + + return success + + def notifyXBMCnoJSON(self, host, data): + + server = 'http://%s/xbmcCmds/' % host + + # title, message [, timeout , image #can be added!] + cmd = "xbmcHttp?command=ExecBuiltIn(Notification('%s','%s'))" % (urllib.quote(data['title']), urllib.quote(data['message'])) + server += cmd + + # I have no idea what to set to, just tried text/plain and seems to be working :) + headers = { + 'Content-Type': 'text/plain', + } + + # authentication support + if self.conf('password'): + base64string = base64.encodestring('%s:%s' % (self.conf('username'), self.conf('password'))).replace('\n', '') + headers['Authorization'] = 'Basic %s' % base64string + + try: + log.debug('Sending non-JSON-type request to %s: %s', (host, data)) + + # response wil either be 'OK': + # + #
  • OK + # + # + # or 'Error': + # + #
  • Error: + # + # + response = self.urlopen(server, headers = headers) + + if 'OK' in response: + log.debug('Returned from non-JSON-type request %s: %s', (host, response)) + # manually fake expected response array + return [{'result': 'OK'}] + else: + log.error('Returned from non-JSON-type request %s: %s', (host, response)) + # manually fake expected response array + return [{'result': 'Error'}] + + except: + log.error('Failed sending non-JSON-type request to XBMC: %s', traceback.format_exc()) + return [{'result': 'Error'}] + def request(self, host, requests): server = 'http://%s/jsonrpc' % host diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index 971fece5..684f6821 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -341,7 +341,7 @@ class Scanner(Plugin): group['files']['movie'] = self.getMediaFiles(group['unsorted_files']) if len(group['files']['movie']) == 0: - log.error('Couldn\t find any movie files for %s', identifier) + log.error('Couldn\'t find any movie files for %s', identifier) continue log.debug('Getting metadata for %s', identifier) diff --git a/couchpotato/core/providers/automation/itunes/__init__.py b/couchpotato/core/providers/automation/itunes/__init__.py new file mode 100644 index 00000000..368c1e43 --- /dev/null +++ b/couchpotato/core/providers/automation/itunes/__init__.py @@ -0,0 +1,35 @@ +from .main import ITunes + +def start(): + return ITunes() + +config = [{ + 'name': 'itunes', + 'groups': [ + { + 'tab': 'automation', + 'name': 'itunes_automation', + 'label': 'iTunes', + 'description': 'From any iTunes Store feed. Url should be the RSS link. (uses minimal requirements)', + 'options': [ + { + 'name': 'automation_enabled', + 'default': False, + 'type': 'enabler', + }, + { + 'name': 'automation_urls_use', + 'label': 'Use', + 'default': ',', + }, + { + 'name': 'automation_urls', + 'label': 'url', + 'type': 'combined', + 'combine': ['automation_urls_use', 'automation_urls'], + 'default': 'https://itunes.apple.com/rss/topmovies/limit=25/xml,', + }, + ], + }, + ], +}] diff --git a/couchpotato/core/providers/automation/itunes/main.py b/couchpotato/core/providers/automation/itunes/main.py new file mode 100644 index 00000000..14ca2a82 --- /dev/null +++ b/couchpotato/core/providers/automation/itunes/main.py @@ -0,0 +1,63 @@ +from couchpotato.core.helpers.rss import RSS +from couchpotato.core.helpers.variable import md5, splitString, tryInt +from couchpotato.core.logger import CPLog +from couchpotato.core.providers.automation.base import Automation +from xml.etree.ElementTree import QName +import datetime +import traceback +import xml.etree.ElementTree as XMLTree + +log = CPLog(__name__) + + +class ITunes(Automation, RSS): + + interval = 1800 + + def getIMDBids(self): + + if self.isDisabled(): + return + + movies = [] + + enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] + urls = splitString(self.conf('automation_urls')) + + namespace = 'http://www.w3.org/2005/Atom' + namespaceIM = 'http://itunes.apple.com/rss' + + index = -1 + for url in urls: + + index += 1 + if not enablers[index]: + continue + + try: + cache_key = 'itunes.rss.%s' % md5(url) + rss_data = self.getCache(cache_key, url) + + data = XMLTree.fromstring(rss_data) + + if data is not None: + entry_tag = str(QName(namespace, 'entry')) + rss_movies = self.getElements(data, entry_tag) + + for movie in rss_movies: + name_tag = str(QName(namespaceIM, 'name')) + name = self.getTextElement(movie, name_tag) + + releaseDate_tag = str(QName(namespaceIM, 'releaseDate')) + releaseDateText = self.getTextElement(movie, releaseDate_tag) + year = datetime.datetime.strptime(releaseDateText, '%Y-%m-%dT00:00:00-07:00').strftime("%Y") + + imdb = self.search(name, year) + + if imdb and self.isMinimalMovie(imdb): + movies.append(imdb['imdb']) + + except: + log.error('Failed loading iTunes rss feed: %s %s', (url, traceback.format_exc())) + + return movies diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index 213fa43f..9c143d81 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -95,15 +95,20 @@ class YarrProvider(Provider): urllib2.install_opener(opener) log.info2('Logging into %s', self.urls['login']) f = opener.open(self.urls['login'], self.getLoginParams()) - f.read() + output = f.read() f.close() - self.login_opener = opener - return True + + if self.loginSuccess(output): + self.login_opener = opener + return True except: log.error('Failed to login %s: %s', (self.getName(), traceback.format_exc())) return False + def loginSuccess(self, output): + return True + def loginDownload(self, url = '', nzb_id = ''): try: if not self.login_opener and not self.login(): diff --git a/couchpotato/core/providers/nzb/ftdworld/main.py b/couchpotato/core/providers/nzb/ftdworld/main.py index 81ac8edd..c5a06652 100644 --- a/couchpotato/core/providers/nzb/ftdworld/main.py +++ b/couchpotato/core/providers/nzb/ftdworld/main.py @@ -1,12 +1,10 @@ -from bs4 import BeautifulSoup from couchpotato.core.helpers.encoding import toUnicode, tryUrlencode from couchpotato.core.helpers.variable import tryInt from couchpotato.core.logger import CPLog from couchpotato.core.providers.nzb.base import NZBProvider from couchpotato.environment import Env from dateutil.parser import parse -import re -import time +import traceback log = CPLog(__name__) @@ -14,7 +12,7 @@ log = CPLog(__name__) class FTDWorld(NZBProvider): urls = { - 'search': 'http://ftdworld.net/category.php?%s', + 'search': 'http://ftdworld.net/api/index.php?%s', 'detail': 'http://ftdworld.net/spotinfo.php?id=%s', 'download': 'http://ftdworld.net/cgi-bin/nzbdown.pl?fileID=%s', 'login': 'http://ftdworld.net/index.php', @@ -25,7 +23,7 @@ class FTDWorld(NZBProvider): cat_ids = [ ([4, 11], ['dvdr']), ([1], ['cam', 'ts', 'dvdrip', 'tc', 'r5', 'scr', 'brrip']), - ([10, 13, 14], ['bd50', '720p', '1080p']), + ([7, 10, 13, 14], ['bd50', '720p', '1080p']), ] cat_backup_id = 1 @@ -43,38 +41,29 @@ class FTDWorld(NZBProvider): 'ctype': ','.join([str(x) for x in self.getCatId(quality['identifier'])]), }) - data = self.getHTMLData(self.urls['search'] % params, opener = self.login_opener) + data = self.getJsonData(self.urls['search'] % params, opener = self.login_opener) if data: try: - html = BeautifulSoup(data) - main_table = html.find('table', attrs = {'id':'ftdresult'}) - - if not main_table: + if data.get('numRes') == 0: return - items = main_table.find_all('tr', attrs = {'class': re.compile('tcontent')}) - - for item in items: - tds = item.find_all('td') - nzb_id = tryInt(item.attrs['data-spot']) - - up = item.find('img', attrs = {'src': re.compile('up.png')}) - down = item.find('img', attrs = {'src': re.compile('down.png')}) + for item in data.get('data'): + nzb_id = tryInt(item.get('id')) results.append({ 'id': nzb_id, - 'name': toUnicode(item.find('a', attrs = {'href': re.compile('./spotinfo')}).text.strip()), - 'age': self.calculateAge(int(time.mktime(parse(tds[2].text).timetuple()))), + 'name': toUnicode(item.get('Title')), + 'age': self.calculateAge(tryInt(item.get('Created'))), 'url': self.urls['download'] % nzb_id, 'download': self.loginDownload, 'detail_url': self.urls['detail'] % nzb_id, - 'score': (tryInt(up.attrs['title'].split(' ')[0]) * 3) - (tryInt(down.attrs['title'].split(' ')[0]) * 3) if up else 0, + 'score': (tryInt(item.get('webPlus', 0)) - tryInt(item.get('webMin', 0))) * 3, }) except: - log.error('Failed to parse HTML response from FTDWorld') + log.error('Failed to parse HTML response from FTDWorld: %s', traceback.format_exc()) def getLoginParams(self): return tryUrlencode({ @@ -82,3 +71,6 @@ class FTDWorld(NZBProvider): 'passlogin': self.conf('password'), 'submit': 'Log In', }) + + def loginSuccess(self, output): + return 'password is incorrect' not in output diff --git a/couchpotato/core/providers/nzb/newznab/main.py b/couchpotato/core/providers/nzb/newznab/main.py index c7c27a8a..f1f0d48e 100644 --- a/couchpotato/core/providers/nzb/newznab/main.py +++ b/couchpotato/core/providers/nzb/newznab/main.py @@ -136,6 +136,6 @@ class Newznab(NZBProvider, RSS): self.limits_reached[host] = time.time() return 'try_next' - log.error('Failed download from %s', (host, traceback.format_exc())) + log.error('Failed download from %s: %s', (host, traceback.format_exc())) return 'try_next' diff --git a/init/freebsd b/init/freebsd old mode 100755 new mode 100644 index 11714406..d3899332 --- a/init/freebsd +++ b/init/freebsd @@ -1,92 +1,49 @@ #!/bin/sh # # PROVIDE: couchpotato -# REQUIRE: sabnzbd +# REQUIRE: DAEMON # KEYWORD: shutdown -# -# Add the following lines to /etc/rc.conf.local or /etc/rc.conf -# to enable this service: -# -# couchpotato_enable (bool): Set to NO by default. -# Set it to YES to enable it. -# couchpotato_user: The user account CouchPotato daemon runs as what -# you want it to be. It uses '_sabnzbd' user by -# default. Do not sets it as empty or it will run -# as root. -# couchpotato_dir: Directory where CouchPotato lives. -# Default: /usr/local/couchpotato -# couchpotato_chdir: Change to this directory before running CouchPotato. -# Default is same as couchpotato_dir. -# couchpotato_pid: The name of the pidfile to create. -# Default is couchpotato.pid in couchpotato_dir. + +# Add the following lines to /etc/rc.conf to enable couchpotato: +# couchpotato_enable: Set to NO by default. Set it to YES to enable it. +# couchpotato_user: The user account CouchPotato daemon runs as what +# you want it to be. +# couchpotato_dir: Directory where CouchPotato lives. +# Default: /usr/local/CouchPotatoServer +# couchpotato_datadir: Directory where CouchPotato user data lives. +# Default: $couchpotato_dir/data +# couchpotato_conf: Directory where CouchPotato user data lives. +# Default: $couchpotato_datadir/settings.conf +# couchpotato_pid: Full path to PID file. +# Default: $couchpotato_datadir/couchpotato.pid +# couchpotato_flags: Set additonal flags as needed. . /etc/rc.subr name="couchpotato" -rcvar=${name}_enable - -# Required, for some reason, to find all our binaries when starting via service. -PATH="/usr/bin:/usr/local/bin:$PATH" +rcvar=couchpotato_enable load_rc_config ${name} -: ${couchpotato_enable:="NO"} -: ${couchpotato_user:="_sabnzbd"} -: ${couchpotato_dir:="/usr/local/couchpotato"} -: ${couchpotato_chdir:="${couchpotato_dir}"} -: ${couchpotato_pid:="${couchpotato_dir}/couchpotato.pid"} -: ${couchpotato_conf:="${couchpotato_dir}/data/settings.conf"} +: ${couchpotato_enable:=NO} +: ${couchpotato_user:=} #default is root +: ${couchpotato_dir:=/usr/local/CouchPotatoServer} +: ${couchpotato_datadir:=${couchpotato_dir}/data} +: ${couchpotato_conf:=} #default is datadir/settings.conf +: ${couchpotato_pid:=} #default is datadir/couchpotato.pid +: ${couchpotato_flags:=} -WGET="/usr/local/bin/wget" # You need wget for this script to safely shutdown CouchPotato. -if [ -e "${couchpotato_conf}" ]; then - HOST=`grep -A14 "\[core\]" "${couchpotato_conf}"|awk -F" = " '/^host/ {print $2}'` - PORT=`grep -A14 "\[core\]" "${couchpotato_conf}"|awk -F" = " '/^port/ {print $2}'` - CPAPI=`grep -A14 "\[core\]" "${couchpotato_conf}"|awk -F" = " '/^api_key/ {print $2}'` +command="${couchpotato_dir}/CouchPotato.py" +command_interpreter="/usr/local/bin/python" +command_args="--daemon --data_dir ${couchpotato_datadir}" + +# append optional flags +if [ -n "${couchpotato_pid}" ]; then + pidfile=${couchpotato_pid} + couchpotato_flags="${couchpotato_flags} --pid_file ${couchpotato_pid}" fi - -status_cmd="${name}_status" -stop_cmd="${name}_stop" - -command="/usr/sbin/daemon" -command_args="-f -p ${couchpotato_pid} python ${couchpotato_dir}/CouchPotato.py ${couchpotato_flags}" - -# Check for wget and refuse to start without it. -if [ ! -x "${WGET}" ]; then - warn "couchpotato not started: You need wget to safely shut down CouchPotato." - exit 1 +if [ -n "${couchpotato_conf}" ]; then + couchpotato_flags="${couchpotato_flags} --config_file ${couchpotato_conf}" fi -# Ensure user is root when running this script. -if [ `id -u` != "0" ]; then - echo "Oops, you should be root before running this!" - exit 1 -fi - -verify_couchpotato_pid() { - # Make sure the pid corresponds to the CouchPotato process. - pid=`cat ${couchpotato_pid} 2>/dev/null` - ps -p ${pid} | grep -q "python ${couchpotato_dir}/CouchPotato.py" - return $? -} - -# Try to stop CouchPotato cleanly by calling shutdown over http. -couchpotato_stop() { - if [ ! -e "${couchpotato_conf}" ]; then - echo "CouchPotato's settings file does not exist. Try starting CouchPotato, as this should create the file." - exit 1 - fi - echo "Stopping $name" - verify_couchpotato_pid - ${WGET} -O - -q "http://${HOST}:${PORT}/api/${CPAPI}/app.shutdown/" >/dev/null - if [ -n "${pid}" ]; then - wait_for_pids ${pid} - echo "Stopped" - fi -} - -couchpotato_status() { - verify_couchpotato_pid && echo "$name is running as ${pid}" || echo "$name is not running" -} - run_rc_command "$1" - diff --git a/libs/gntp/__init__.py b/libs/gntp/__init__.py index 7e0d60fc..eabbfa47 100755 --- a/libs/gntp/__init__.py +++ b/libs/gntp/__init__.py @@ -1,8 +1,9 @@ -import hashlib import re +import hashlib import time +import StringIO -__version__ = '0.6' +__version__ = '0.8' #GNTP/ [:][ :.] GNTP_INFO_LINE = re.compile( @@ -19,7 +20,7 @@ GNTP_INFO_LINE_SHORT = re.compile( GNTP_HEADER = re.compile('([\w-]+):(.+)') -GNTP_EOL = u'\r\n' +GNTP_EOL = '\r\n' class BaseError(Exception): @@ -43,6 +44,14 @@ class UnsupportedError(BaseError): errordesc = 'Currently unsupported by gntp.py' +class _GNTPBuffer(StringIO.StringIO): + """GNTP Buffer class""" + def writefmt(self, message = "", *args): + """Shortcut function for writing GNTP Headers""" + self.write((message % args).encode('utf8', 'replace')) + self.write(GNTP_EOL) + + class _GNTPBase(object): """Base initilization @@ -206,8 +215,8 @@ class _GNTPBase(object): if not match: continue - key = match.group(1).strip() - val = match.group(2).strip() + key = unicode(match.group(1).strip(), 'utf8', 'replace') + val = unicode(match.group(2).strip(), 'utf8', 'replace') dict[key] = val return dict @@ -217,6 +226,15 @@ class _GNTPBase(object): else: self.headers[key] = unicode('%s' % value, 'utf8', 'replace') + def add_resource(self, data): + """Add binary resource + + :param string data: Binary Data + """ + identifier = hashlib.md5(data).hexdigest() + self.resources[identifier] = data + return 'x-growl-resource://%s' % identifier + def decode(self, data, password = None): """Decode GNTP Message @@ -229,19 +247,30 @@ class _GNTPBase(object): self.headers = self._parse_dict(parts[0]) def encode(self): - """Encode a GNTP Message + """Encode a generic GNTP Message - :return string: Encoded GNTP Message ready to be sent + :return string: GNTP Message ready to be sent """ - self.validate() - message = self._format_info() + GNTP_EOL + buffer = _GNTPBuffer() + + buffer.writefmt(self._format_info()) + #Headers for k, v in self.headers.iteritems(): - message += u'%s: %s%s' % (k, v, GNTP_EOL) + buffer.writefmt('%s: %s', k, v) + buffer.writefmt() - message += GNTP_EOL - return message + #Resources + for resource, data in self.resources.iteritems(): + buffer.writefmt('Identifier: %s', resource) + buffer.writefmt('Length: %d', len(data)) + buffer.writefmt() + buffer.write(data) + buffer.writefmt() + buffer.writefmt() + + return buffer.getvalue() class GNTPRegister(_GNTPBase): @@ -290,7 +319,7 @@ class GNTPRegister(_GNTPBase): for i, part in enumerate(parts): if i == 0: - continue # Skip Header + continue # Skip Header if part.strip() == '': continue notice = self._parse_dict(part) @@ -319,22 +348,33 @@ class GNTPRegister(_GNTPBase): :return string: Encoded GNTP Registration message """ - self.validate() - message = self._format_info() + GNTP_EOL + buffer = _GNTPBuffer() + + buffer.writefmt(self._format_info()) + #Headers for k, v in self.headers.iteritems(): - message += u'%s: %s%s' % (k, v, GNTP_EOL) + buffer.writefmt('%s: %s', k, v) + buffer.writefmt() #Notifications if len(self.notifications) > 0: for notice in self.notifications: - message += GNTP_EOL for k, v in notice.iteritems(): - message += u'%s: %s%s' % (k, v, GNTP_EOL) + buffer.writefmt('%s: %s', k, v) + buffer.writefmt() - message += GNTP_EOL - return message + #Resources + for resource, data in self.resources.iteritems(): + buffer.writefmt('Identifier: %s', resource) + buffer.writefmt('Length: %d', len(data)) + buffer.writefmt() + buffer.write(data) + buffer.writefmt() + buffer.writefmt() + + return buffer.getvalue() class GNTPNotice(_GNTPBase): @@ -379,7 +419,7 @@ class GNTPNotice(_GNTPBase): for i, part in enumerate(parts): if i == 0: - continue # Skip Header + continue # Skip Header if part.strip() == '': continue notice = self._parse_dict(part) @@ -388,21 +428,6 @@ class GNTPNotice(_GNTPBase): #open('notice.png','wblol').write(notice['Data']) self.resources[notice.get('Identifier')] = notice - def encode(self): - """Encode a GNTP Notification Message - - :return string: GNTP Notification Message ready to be sent - """ - self.validate() - - message = self._format_info() + GNTP_EOL - #Headers - for k, v in self.headers.iteritems(): - message += u'%s: %s%s' % (k, v, GNTP_EOL) - - message += GNTP_EOL - return message - class GNTPSubscribe(_GNTPBase): """Represents a GNTP Subscribe Command @@ -457,7 +482,8 @@ class GNTPError(_GNTPBase): self.add_header('Error-Description', errordesc) def error(self): - return self.headers['Error-Code'], self.headers['Error-Description'] + return (self.headers.get('Error-Code', None), + self.headers.get('Error-Description', None)) def parse_gntp(data, password = None): diff --git a/libs/gntp/notifier.py b/libs/gntp/notifier.py index 300e4a6c..539dae2a 100755 --- a/libs/gntp/notifier.py +++ b/libs/gntp/notifier.py @@ -22,43 +22,6 @@ __all__ = [ logger = logging.getLogger(__name__) -def mini(description, applicationName = 'PythonMini', noteType = "Message", - title = "Mini Message", applicationIcon = None, hostname = 'localhost', - password = None, port = 23053, sticky = False, priority = None, - callback = None): - """Single notification function - - Simple notification function in one line. Has only one required parameter - and attempts to use reasonable defaults for everything else - :param string description: Notification message - - .. warning:: - For now, only URL callbacks are supported. In the future, the - callback argument will also support a function - """ - growl = GrowlNotifier( - applicationName = applicationName, - notifications = [noteType], - defaultNotifications = [noteType], - hostname = hostname, - password = password, - port = port, - ) - result = growl.register() - if result is not True: - return result - - return growl.notify( - noteType = noteType, - title = title, - description = description, - icon = applicationIcon, - sticky = sticky, - priority = priority, - callback = callback, - ) - - class GrowlNotifier(object): """Helper class to simplfy sending Growl messages @@ -93,10 +56,12 @@ class GrowlNotifier(object): def _checkIcon(self, data): ''' Check the icon to see if it's valid - @param data: - @todo Consider checking for a valid URL + + If it's a simple URL icon, then we return True. If it's a data icon + then we return False ''' - return data + logger.info('Checking icon') + return data.startswith('http') def register(self): """Send GNTP Registration @@ -112,7 +77,11 @@ class GrowlNotifier(object): enabled = notification in self.defaultNotifications register.add_notification(notification, enabled) if self.applicationIcon: - register.add_header('Application-Icon', self.applicationIcon) + if self._checkIcon(self.applicationIcon): + register.add_header('Application-Icon', self.applicationIcon) + else: + id = register.add_resource(self.applicationIcon) + register.add_header('Application-Icon', id) if self.password: register.set_password(self.password, self.passwordHash) self.add_origin_info(register) @@ -120,7 +89,7 @@ class GrowlNotifier(object): return self._send('register', register) def notify(self, noteType, title, description, icon = None, sticky = False, - priority = None, callback = None): + priority = None, callback = None, identifier = None): """Send a GNTP notifications .. warning:: @@ -151,11 +120,18 @@ class GrowlNotifier(object): if priority: notice.add_header('Notification-Priority', priority) if icon: - notice.add_header('Notification-Icon', self._checkIcon(icon)) + if self._checkIcon(icon): + notice.add_header('Notification-Icon', icon) + else: + id = notice.add_resource(icon) + notice.add_header('Notification-Icon', id) + if description: notice.add_header('Notification-Text', description) if callback: notice.add_header('Notification-Callback-Target', callback) + if identifier: + notice.add_header('Notification-Coalescing-ID', identifier) self.add_origin_info(notice) self.notify_hook(notice) @@ -193,9 +169,10 @@ class GrowlNotifier(object): def subscribe_hook(self, packet): pass - def _send(self, type, packet): + def _send(self, messagetype, packet): """Send the GNTP Packet""" + packet.validate() data = packet.encode() logger.debug('To : %s:%s <%s>\n%s', self.hostname, self.port, packet.__class__, data) @@ -203,7 +180,7 @@ class GrowlNotifier(object): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(self.socketTimeout) s.connect((self.hostname, self.port)) - s.send(data.encode('utf8', 'replace')) + s.send(data) recv_data = s.recv(1024) while not recv_data.endswith("\r\n\r\n"): recv_data += s.recv(1024) @@ -212,11 +189,51 @@ class GrowlNotifier(object): logger.debug('From : %s:%s <%s>\n%s', self.hostname, self.port, response.__class__, response) - if response.info['messagetype'] == '-OK': + if type(response) == gntp.GNTPOK: return True logger.error('Invalid response: %s', response.error()) return response.error() + +def mini(description, applicationName = 'PythonMini', noteType = "Message", + title = "Mini Message", applicationIcon = None, hostname = 'localhost', + password = None, port = 23053, sticky = False, priority = None, + callback = None, notificationIcon = None, identifier = None, + notifierFactory = GrowlNotifier): + """Single notification function + + Simple notification function in one line. Has only one required parameter + and attempts to use reasonable defaults for everything else + :param string description: Notification message + + .. warning:: + For now, only URL callbacks are supported. In the future, the + callback argument will also support a function + """ + growl = notifierFactory( + applicationName = applicationName, + notifications = [noteType], + defaultNotifications = [noteType], + applicationIcon = applicationIcon, + hostname = hostname, + password = password, + port = port, + ) + result = growl.register() + if result is not True: + return result + + return growl.notify( + noteType = noteType, + title = title, + description = description, + icon = notificationIcon, + sticky = sticky, + priority = priority, + callback = callback, + identifier = identifier, + ) + if __name__ == '__main__': # If we're running this module directly we're likely running it as a test # so extra debugging is useful