diff --git a/headphones/classes.py b/headphones/classes.py new file mode 100644 index 00000000..5dc37eb6 --- /dev/null +++ b/headphones/classes.py @@ -0,0 +1,131 @@ +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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 3 of the License, or +# (at your option) any later version. +# +# Sick Beard 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 Sick Beard. If not, see . + + + +import headphones + +import urllib +import datetime + +from common import USER_AGENT + +class HeadphonesURLopener(urllib.FancyURLopener): + version = USER_AGENT + +class AuthURLOpener(HeadphonesURLopener): + """ + URLOpener class that supports http auth without needing interactive password entry. + If the provided username/password don't work it simply fails. + + user: username to use for HTTP auth + pw: password to use for HTTP auth + """ + def __init__(self, user, pw): + self.username = user + self.password = pw + + # remember if we've tried the username/password before + self.numTries = 0 + + # call the base class + urllib.FancyURLopener.__init__(self) + + def prompt_user_passwd(self, host, realm): + """ + Override this function and instead of prompting just give the + username/password that were provided when the class was instantiated. + """ + + # if this is the first try then provide a username/password + if self.numTries == 0: + self.numTries = 1 + return (self.username, self.password) + + # if we've tried before then return blank which cancels the request + else: + return ('', '') + + # this is pretty much just a hack for convenience + def openit(self, url): + self.numTries = 0 + return HeadphonesURLopener.open(self, url) + +class SearchResult: + """ + Represents a search result from an indexer. + """ + + def __init__(self): + self.provider = -1 + + # URL to the NZB/torrent file + self.url = "" + + # used by some providers to store extra info associated with the result + self.extraInfo = [] + + # quality of the release + self.quality = -1 + + # release name + self.name = "" + + def __str__(self): + + if self.provider == None: + return "Invalid provider, unable to print self" + + myString = self.provider.name + " @ " + self.url + "\n" + myString += "Extra Info:\n" + for extra in self.extraInfo: + myString += " " + extra + "\n" + return myString + +class NZBSearchResult(SearchResult): + """ + Regular NZB result with an URL to the NZB + """ + resultType = "nzb" + +class NZBDataSearchResult(SearchResult): + """ + NZB result where the actual NZB XML data is stored in the extraInfo + """ + resultType = "nzbdata" + +class TorrentSearchResult(SearchResult): + """ + Torrent result with an URL to the torrent + """ + resultType = "torrent" + +class Proper: + def __init__(self, name, url, date): + self.name = name + self.url = url + self.date = date + self.provider = None + self.quality = -1 + + self.tvdbid = -1 + self.season = -1 + self.episode = -1 + + def __str__(self): + return str(self.date)+" "+self.name+" "+str(self.season)+"x"+str(self.episode)+" of "+str(self.tvdbid) diff --git a/headphones/common.py b/headphones/common.py new file mode 100644 index 00000000..cebebcb3 --- /dev/null +++ b/headphones/common.py @@ -0,0 +1,162 @@ +''' +Created on Aug 1, 2011 + +@author: Michael +''' +import platform, operator, os, re + +from headphones import version + +#Identify Our Application +USER_AGENT = 'Headphones/-'+version.HEADPHONES_VERSION+' ('+platform.system()+' '+platform.release()+')' + +### Notification Types +NOTIFY_SNATCH = 1 +NOTIFY_DOWNLOAD = 2 + +notifyStrings = {} +notifyStrings[NOTIFY_SNATCH] = "Started Download" +notifyStrings[NOTIFY_DOWNLOAD] = "Download Finished" + +### Release statuses +UNKNOWN = -1 # should never happen +UNAIRED = 1 # releases that haven't dropped yet +SNATCHED = 2 # qualified with quality +WANTED = 3 # releases we don't have but want to get +DOWNLOADED = 4 # qualified with quality +SKIPPED = 5 # releases we don't want +ARCHIVED = 6 # releases that you don't have locally (counts toward download completion stats) +IGNORED = 7 # releases that you don't want included in your download stats +SNATCHED_PROPER = 9 # qualified with quality + +class Quality: + + NONE = 0 + B192 = 1<<1 # 2 + VBR = 1<<2 # 4 + B256 = 1<<3 # 8 + B320 = 1<<4 #16 + FLAC = 1<<5 #32 + + # put these bits at the other end of the spectrum, far enough out that they shouldn't interfere + UNKNOWN = 1<<15 + + qualityStrings = {NONE: "N/A", + UNKNOWN: "Unknown", + B192: "MP3 192", + VBR: "MP3 VBR", + B256: "MP3 256", + B320: "MP3 320", + FLAC: "Flac"} + + statusPrefixes = {DOWNLOADED: "Downloaded", + SNATCHED: "Snatched"} + + @staticmethod + def _getStatusStrings(status): + toReturn = {} + for x in Quality.qualityStrings.keys(): + toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status]+" ("+Quality.qualityStrings[x]+")" + return toReturn + + @staticmethod + def combineQualities(anyQualities, bestQualities): + anyQuality = 0 + bestQuality = 0 + if anyQualities: + anyQuality = reduce(operator.or_, anyQualities) + if bestQualities: + bestQuality = reduce(operator.or_, bestQualities) + return anyQuality | (bestQuality<<16) + + @staticmethod + def splitQuality(quality): + anyQualities = [] + bestQualities = [] + for curQual in Quality.qualityStrings.keys(): + if curQual & quality: + anyQualities.append(curQual) + if curQual<<16 & quality: + bestQualities.append(curQual) + + return (anyQualities, bestQualities) + + @staticmethod + def nameQuality(name): + + name = os.path.basename(name) + + # if we have our exact text then assume we put it there + for x in Quality.qualityStrings: + if x == Quality.UNKNOWN: + continue + + regex = '\W'+Quality.qualityStrings[x].replace(' ','\W')+'\W' + regex_match = re.search(regex, name, re.I) + if regex_match: + return x + + checkName = lambda list, func: func([re.search(x, name, re.I) for x in list]) + + #TODO: fix quality checking here + if checkName(["mp3", "192"], any) and not checkName(["flac"], all): + return Quality.B192 + elif checkName(["mp3", "256"], any) and not checkName(["flac"], all): + return Quality.B256 + elif checkName(["mp3", "vbr"], any) and not checkName(["flac"], all): + return Quality.VBR + elif checkName(["mp3", "320"], any) and not checkName(["flac"], all): + return Quality.B320 + else: + return Quality.UNKNOWN + + @staticmethod + def assumeQuality(name): + + if name.lower().endswith(".mp3"): + return Quality.MP3 + elif name.lower().endswith(".flac"): + return Quality.LOSSLESS + else: + return Quality.UNKNOWN + + @staticmethod + def compositeStatus(status, quality): + return status + 100 * quality + + @staticmethod + def qualityDownloaded(status): + return (status - DOWNLOADED) / 100 + + @staticmethod + def splitCompositeStatus(status): + """Returns a tuple containing (status, quality)""" + for x in sorted(Quality.qualityStrings.keys(), reverse=True): + if status > x*100: + return (status-x*100, x) + + return (Quality.NONE, status) + + @staticmethod + def statusFromName(name, assume=True): + quality = Quality.nameQuality(name) + if assume and quality == Quality.UNKNOWN: + quality = Quality.assumeQuality(name) + return Quality.compositeStatus(DOWNLOADED, quality) + + DOWNLOADED = None + SNATCHED = None + SNATCHED_PROPER = None + +Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in Quality.qualityStrings.keys()] +Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()] +Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in Quality.qualityStrings.keys()] + +MP3 = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR], []) +LOSSLESS = Quality.combineQualities([Quality.FLAC], []) +ANY = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR, Quality.FLAC], []) + +qualityPresets = (MP3, LOSSLESS, ANY) +qualityPresetStrings = {MP3: "MP3 (All bitrates 192+)", + LOSSLESS: "Lossless (flac)", + ANY: "Any"} \ No newline at end of file diff --git a/headphones/sab.py b/headphones/sab.py new file mode 100644 index 00000000..bc9d8053 --- /dev/null +++ b/headphones/sab.py @@ -0,0 +1,123 @@ +# This file is part of Sick Beard. +# +# Sick Beard 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 3 of the License, or +# (at your option) any later version. +# +# Sick Beard 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 Sick Beard. If not, see . + + + +import urllib, httplib +import datetime + +import headphones + +from lib import MultipartPostHandler +import urllib2, cookielib + +from headphones.common import USER_AGENT +from headphones import logger + + +def sendNZB(nzb): + + params = {} + + if headphones.SAB_USERNAME != None: + params['ma_username'] = headphones.SAB_USERNAME + if headphones.SAB_PASSWORD != None: + params['ma_password'] = headphones.SAB_PASSWORD + if headphones.SAB_APIKEY != None: + params['apikey'] = headphones.SAB_APIKEY + if headphones.SAB_CATEGORY != None: + params['cat'] = headphones.SAB_CATEGORY + + +# # if released recently make it high priority +# for curEp in nzb.episodes: +# if datetime.date.today() - curEp.airdate <= datetime.timedelta(days=7): +# params['priority'] = 1 + + # if it's a normal result we just pass SAB the URL + if nzb.resultType == "nzb": + # for newzbin results send the ID to sab specifically + if nzb.provider.getID() == 'newzbin': + id = nzb.provider.getIDFromURL(nzb.url) + if not id: + logger.info("Unable to send NZB to sab, can't find ID in URL "+str(nzb.url)) + return False + params['mode'] = 'addid' + params['name'] = id + else: + params['mode'] = 'addurl' + params['name'] = nzb.url + + # if we get a raw data result we want to upload it to SAB + elif nzb.resultType == "nzbdata": + params['mode'] = 'addfile' + multiPartParams = {"nzbfile": (nzb.name+".nzb", nzb.extraInfo[0])} + + url = "http://" + headphones.SAB_HOST + "/" + "api?" + urllib.urlencode(params) + + logger.info(u"Sending NZB to SABnzbd") + + logger.info(u"URL: " + url) + + try: + + if nzb.resultType == "nzb": + f = urllib.urlopen(url) + elif nzb.resultType == "nzbdata": + cookies = cookielib.CookieJar() + opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookies), + MultipartPostHandler.MultipartPostHandler) + + req = urllib2.Request(url, + multiPartParams, + headers={'User-Agent': USER_AGENT}) + + f = opener.open(req) + + except (EOFError, IOError), e: + logger.info(u"Unable to connect to SAB: ") + return False + + except httplib.InvalidURL, e: + logger.info(u"Invalid SAB host, check your config: ") + return False + + if f == None: + logger.info(u"No data returned from SABnzbd, NZB not sent") + return False + + try: + result = f.readlines() + except Exception, e: + logger.info(u"Error trying to get result from SAB, NZB not sent: ") + return False + + if len(result) == 0: + logger.info(u"No data returned from SABnzbd, NZB not sent") + return False + + sabText = result[0].strip() + + logger.info(u"Result text from SAB: " + sabText) + + if sabText == "ok": + logger.info(u"NZB sent to SAB successfully") + return True + elif sabText == "Missing authentication": + logger.info(u"Incorrect username/password sent to SAB, NZB not sent") + return False + else: + logger.info(u"Unknown failure sending NZB to sab. Return text is: " + sabText) + return False \ No newline at end of file diff --git a/lib/MultipartPostHandler.py b/lib/MultipartPostHandler.py new file mode 100644 index 00000000..82fa59c6 --- /dev/null +++ b/lib/MultipartPostHandler.py @@ -0,0 +1,88 @@ +#!/usr/bin/python + +#### +# 06/2010 Nic Wolfe +# 02/2006 Will Holcomb +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# + +import urllib +import urllib2 +import mimetools, mimetypes +import os, sys + +# Controls how sequences are uncoded. If true, elements may be given multiple values by +# assigning a sequence. +doseq = 1 + +class MultipartPostHandler(urllib2.BaseHandler): + handler_order = urllib2.HTTPHandler.handler_order - 10 # needs to run first + + def http_request(self, request): + data = request.get_data() + if data is not None and type(data) != str: + v_files = [] + v_vars = [] + try: + for(key, value) in data.items(): + if type(value) in (file, list, tuple): + v_files.append((key, value)) + else: + v_vars.append((key, value)) + except TypeError: + systype, value, traceback = sys.exc_info() + raise TypeError, "not a valid non-string sequence or mapping object", traceback + + if len(v_files) == 0: + data = urllib.urlencode(v_vars, doseq) + else: + boundary, data = MultipartPostHandler.multipart_encode(v_vars, v_files) + contenttype = 'multipart/form-data; boundary=%s' % boundary + if(request.has_header('Content-Type') + and request.get_header('Content-Type').find('multipart/form-data') != 0): + print "Replacing %s with %s" % (request.get_header('content-type'), 'multipart/form-data') + request.add_unredirected_header('Content-Type', contenttype) + + request.add_data(data) + return request + + @staticmethod + def multipart_encode(vars, files, boundary = None, buffer = None): + if boundary is None: + boundary = mimetools.choose_boundary() + if buffer is None: + buffer = '' + for(key, value) in vars: + buffer += '--%s\r\n' % boundary + buffer += 'Content-Disposition: form-data; name="%s"' % key + buffer += '\r\n\r\n' + value + '\r\n' + for(key, fd) in files: + + # allow them to pass in a file or a tuple with name & data + if type(fd) == file: + name_in = fd.name + fd.seek(0) + data_in = fd.read() + elif type(fd) in (tuple, list): + name_in, data_in = fd + + filename = os.path.basename(name_in) + contenttype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + buffer += '--%s\r\n' % boundary + buffer += 'Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (key, filename) + buffer += 'Content-Type: %s\r\n' % contenttype + # buffer += 'Content-Length: %s\r\n' % file_size + buffer += '\r\n' + data_in + '\r\n' + buffer += '--%s--\r\n\r\n' % boundary + return boundary, buffer + + https_request = http_request \ No newline at end of file