diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index 248d2bc..c1be7e7 100644 --- a/couchpotato/core/_base/clientscript/main.py +++ b/couchpotato/core/_base/clientscript/main.py @@ -49,6 +49,7 @@ class ClientScript(Plugin): 'scripts/page/settings.js', 'scripts/page/about.js', 'scripts/page/manage.js', + 'scripts/misc/downloaders.js', ], } diff --git a/couchpotato/core/downloaders/base.py b/couchpotato/core/downloaders/base.py index 71da65e..3bcf1f3 100644 --- a/couchpotato/core/downloaders/base.py +++ b/couchpotato/core/downloaders/base.py @@ -1,4 +1,5 @@ from base64 import b32decode, b16encode +from couchpotato.api import addApiView from couchpotato.core.event import addEvent from couchpotato.core.helpers.variable import mergeDicts from couchpotato.core.logger import CPLog @@ -42,6 +43,7 @@ class Downloader(Provider): addEvent('download.remove_failed', self._removeFailed) addEvent('download.pause', self._pause) addEvent('download.process_complete', self._processComplete) + addApiView('download.%s.test' % self.getName().lower(), self._test) def getEnabledProtocol(self): for download_protocol in self.protocol: @@ -158,6 +160,15 @@ class Downloader(Provider): (d_manual and manual or d_manual is False) and \ (not data or self.isCorrectProtocol(data.get('protocol'))) + def _test(self): + t = self.test() + if isinstance(t, tuple): + return {'success': t[0], 'msg': t[1]} + return {'success': t} + + def test(self): + return False + def _pause(self, release_download, pause = True): if self.isDisabled(manual = True, data = {}): return diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index 8449d09..9a01835 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -1,5 +1,6 @@ from __future__ import with_statement from couchpotato.core.downloaders.base import Downloader +from couchpotato.core.helpers.encoding import sp from couchpotato.core.logger import CPLog from couchpotato.environment import Env import os @@ -67,6 +68,20 @@ class Blackhole(Downloader): return False + def test(self): + directory = self.conf('directory') + if directory and os.path.isdir(directory): + + test_file = sp(os.path.join(directory, 'couchpotato_test.txt')) + + # Check if folder is writable + self.createFile(test_file, 'This is a test file') + if os.path.isfile(test_file): + os.remove(test_file) + return True + + return False + def getEnabledProtocol(self): if self.conf('use_for') == 'both': return super(Blackhole, self).getEnabledProtocol() diff --git a/couchpotato/core/downloaders/deluge/main.py b/couchpotato/core/downloaders/deluge/main.py index c5f8016..5930095 100644 --- a/couchpotato/core/downloaders/deluge/main.py +++ b/couchpotato/core/downloaders/deluge/main.py @@ -20,14 +20,14 @@ class Deluge(Downloader): log = CPLog(__name__) drpc = None - def connect(self): + def connect(self, reconnect = False): # Load host from config and split out port. host = cleanHost(self.conf('host'), protocol = False).split(':') if not isInt(host[1]): log.error('Config properties are not filled in correctly, port is missing.') return False - if not self.drpc: + if not self.drpc or reconnect: self.drpc = DelugeRPC(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) return self.drpc @@ -86,6 +86,11 @@ class Deluge(Downloader): log.info('Torrent sent to Deluge successfully.') return self.downloadReturnId(remote_torrent) + def test(self): + if self.connect(True) and self.drpc.test(): + return True + return False + def getAllDownloadStatus(self, ids): log.debug('Checking Deluge download status.') @@ -178,6 +183,13 @@ class DelugeRPC(object): self.client = DelugeClient() self.client.connect(self.host, int(self.port), self.username, self.password) + def test(self): + try: + self.connect() + except: + return False + return True + def add_torrent_magnet(self, torrent, options): torrent_id = False try: diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index a690572..3dad867 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -16,7 +16,6 @@ log = CPLog(__name__) class NZBGet(Downloader): protocol = ['nzb'] - rpc = 'xmlrpc' def download(self, data = None, media = None, filedata = None): @@ -31,8 +30,7 @@ class NZBGet(Downloader): nzb_name = ss('%s.nzb' % self.createNzbName(data, media)) - url = cleanHost(host = self.conf('host'), ssl = self.conf('ssl'), username = self.conf('username'), password = self.conf('password')) + self.rpc - rpc = xmlrpclib.ServerProxy(url) + rpc = self.getRPC() try: if rpc.writelog('INFO', 'CouchPotato connected to drop off %s.' % nzb_name): @@ -68,12 +66,31 @@ class NZBGet(Downloader): log.error('NZBGet could not add %s to the queue.', nzb_name) return False + def test(self): + rpc = self.getRPC() + + try: + if rpc.writelog('INFO', 'CouchPotato connected to test connection'): + log.debug('Successfully connected to NZBGet') + else: + log.info('Successfully connected to NZBGet, but unable to send a message') + except socket.error: + log.error('NZBGet is not responding. Please ensure that NZBGet is running and host setting is correct.') + return False + except xmlrpclib.ProtocolError as e: + if e.errcode == 401: + log.error('Password is incorrect.') + else: + log.error('Protocol Error: %s', e) + return False + + return True + def getAllDownloadStatus(self, ids): log.debug('Checking NZBGet download status.') - url = cleanHost(host = self.conf('host'), ssl = self.conf('ssl'), username = self.conf('username'), password = self.conf('password')) + self.rpc - rpc = xmlrpclib.ServerProxy(url) + rpc = self.getRPC() try: if rpc.writelog('INFO', 'CouchPotato connected to check status'): @@ -158,8 +175,7 @@ class NZBGet(Downloader): log.info('%s failed downloading, deleting...', release_download['name']) - url = cleanHost(host = self.conf('host'), ssl = self.conf('ssl'), username = self.conf('username'), password = self.conf('password')) + self.rpc - rpc = xmlrpclib.ServerProxy(url) + rpc = self.getRPC() try: if rpc.writelog('INFO', 'CouchPotato connected to delete some history'): @@ -194,3 +210,7 @@ class NZBGet(Downloader): return False return True + + def getRPC(self): + url = cleanHost(host = self.conf('host'), ssl = self.conf('ssl'), username = self.conf('username'), password = self.conf('password')) + self.rpc + return xmlrpclib.ServerProxy(url) diff --git a/couchpotato/core/downloaders/nzbvortex/main.py b/couchpotato/core/downloaders/nzbvortex/main.py index 205ceb1..d1525c8 100644 --- a/couchpotato/core/downloaders/nzbvortex/main.py +++ b/couchpotato/core/downloaders/nzbvortex/main.py @@ -36,12 +36,20 @@ class NZBVortex(Downloader): time.sleep(10) raw_statuses = self.call('nzb') - nzb_id = [nzb['id'] for nzb in raw_statuses.get('nzbs', []) if os.path.basename(item['nzbFileName']) == nzb_filename][0] + nzb_id = [nzb['id'] for nzb in raw_statuses.get('nzbs', []) if os.path.basename(nzb['nzbFileName']) == nzb_filename][0] return self.downloadReturnId(nzb_id) except: log.error('Something went wrong sending the NZB file: %s', traceback.format_exc()) return False + def test(self): + try: + login_result = self.login() + except: + return False + + return login_result + def getAllDownloadStatus(self, ids): raw_statuses = self.call('nzb') diff --git a/couchpotato/core/downloaders/pneumatic/main.py b/couchpotato/core/downloaders/pneumatic/main.py index 6af22d2..bc1f6d0 100644 --- a/couchpotato/core/downloaders/pneumatic/main.py +++ b/couchpotato/core/downloaders/pneumatic/main.py @@ -1,5 +1,6 @@ from __future__ import with_statement from couchpotato.core.downloaders.base import Downloader +from couchpotato.core.helpers.encoding import sp from couchpotato.core.logger import CPLog import os import traceback @@ -56,3 +57,17 @@ class Pneumatic(Downloader): log.info('Failed to download file %s: %s', (data.get('name'), traceback.format_exc())) return False return False + + def test(self): + directory = self.conf('directory') + if directory and os.path.isdir(directory): + + test_file = sp(os.path.join(directory, 'couchpotato_test.txt')) + + # Check if folder is writable + self.createFile(test_file, 'This is a test file') + if os.path.isfile(test_file): + os.remove(test_file) + return True + + return False diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py index 4a593fd..f793cad 100755 --- a/couchpotato/core/downloaders/rtorrent/__init__.py +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -31,8 +31,8 @@ config = [{ { 'name': 'host', 'default': 'localhost:80', - 'description': 'Hostname with port or XML-RPC Endpoint URI. Usually scgi://localhost:5000 ' - 'or localhost:80' + 'description': 'RPC Communication URI. Usually scgi://localhost:5000, ' + 'httprpc://localhost/rutorrent or localhost:80' }, { 'name': 'ssl', @@ -46,7 +46,7 @@ config = [{ 'type': 'string', 'default': 'RPC2', 'advanced': True, - 'description': 'Change if you don\'t run rTorrent RPC at the default url.', + 'description': 'Change if your RPC mount is at a different path.', }, { 'name': 'username', diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index cfd1dce..c5850f9 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -1,14 +1,15 @@ -from base64 import b16encode, b32decode -from bencode import bencode, bdecode from couchpotato.core.downloaders.base import Downloader, ReleaseDownloadList from couchpotato.core.event import fireEvent, addEvent from couchpotato.core.helpers.encoding import sp from couchpotato.core.helpers.variable import cleanHost, splitString from couchpotato.core.logger import CPLog +from base64 import b16encode, b32decode +from bencode import bencode, bdecode from datetime import timedelta from hashlib import sha1 from rtorrent import RTorrent from rtorrent.err import MethodError +from urlparse import urlparse import os log = CPLog(__name__) @@ -18,12 +19,14 @@ class rTorrent(Downloader): protocol = ['torrent', 'torrent_magnet'] rt = None + error_msg = '' # Migration url to host options def __init__(self): super(rTorrent, self).__init__() addEvent('app.load', self.migrate) + addEvent('setting.save.rtorrent.*.after', self.settingsChanged) def migrate(self): @@ -37,12 +40,25 @@ class rTorrent(Downloader): self.deleteConf('url') - def connect(self): + def settingsChanged(self): + # Reset active connection if settings have changed + if self.rt: + log.debug('Settings have changed, closing active connection') + + self.rt = None + return True + + def connect(self, reconnect = False): # Already connected? - if self.rt is not None: + if not reconnect and self.rt is not None: return self.rt - url = cleanHost(self.conf('host'), protocol = True, ssl = self.conf('ssl')) + self.conf('rpc_url') + url = cleanHost(self.conf('host'), protocol = True, ssl = self.conf('ssl')) + parsed = urlparse(url) + + # rpc_url is only used on http/https scgi pass-through + if parsed.scheme in ['http', 'https']: + url += self.conf('rpc_url') if self.conf('username') and self.conf('password'): self.rt = RTorrent( @@ -53,8 +69,24 @@ class rTorrent(Downloader): else: self.rt = RTorrent(url) + self.error_msg = '' + try: + self.rt._verify_conn() + except AssertionError as e: + self.error_msg = e.message + self.rt = None + return self.rt + def test(self): + if self.connect(True): + return True + + if self.error_msg: + return False, 'Connection failed: ' + self.error_msg + + return False + def _update_provider_group(self, name, data): if data.get('seed_time'): log.info('seeding time ignored, not supported') @@ -104,7 +136,7 @@ class rTorrent(Downloader): return False group_name = 'cp_' + data.get('provider').lower() - if not self._update_provider_group(group_name, data): + if not self.updateProviderGroup(group_name, data): return False torrent_params = {} @@ -159,6 +191,21 @@ class rTorrent(Downloader): log.error('Failed to send torrent to rTorrent: %s', err) return False + def getTorrentStatus(self, torrent): + if torrent.hashing or torrent.hash_checking or torrent.message: + return 'busy' + + if not torrent.complete: + return 'busy' + + if not torrent.open: + return 'completed' + + if torrent.state and torrent.active: + return 'seeding' + + return 'busy' + def getAllDownloadStatus(self, ids): log.debug('Checking rTorrent download status.') @@ -183,17 +230,10 @@ class rTorrent(Downloader): torrent_files.append(sp(file_path)) - status = 'busy' - if torrent.complete: - if torrent.active: - status = 'seeding' - else: - status = 'completed' - release_downloads.append({ 'id': torrent.info_hash, 'name': torrent.name, - 'status': status, + 'status': self.getTorrentStatus(torrent), 'seed_ratio': torrent.ratio, 'original_status': torrent.state, 'timeleft': str(timedelta(seconds = float(torrent.left_bytes) / torrent.down_rate)) if torrent.down_rate > 0 else -1, diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py index 72c2370..ba58c09 100644 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -64,6 +64,26 @@ class Sabnzbd(Downloader): log.error('Error getting data from SABNZBd: %s', sab_data) return False + def test(self): + try: + sab_data = self.call({ + 'mode': 'version', + }) + v = sab_data.split('.') + if int(v[0]) == 0 and int(v[1]) < 7: + return False, 'Your Sabnzbd client is too old, please update to newest version.' + + # the version check will work even with wrong api key, so we need the next check as well + sab_data = self.call({ + 'mode': 'qstatus', + }) + if not sab_data: + return False + except: + return False + + return True + def getAllDownloadStatus(self, ids): log.debug('Checking SABnzbd download status.') diff --git a/couchpotato/core/downloaders/synology/main.py b/couchpotato/core/downloaders/synology/main.py index f964f37..7e5b609 100644 --- a/couchpotato/core/downloaders/synology/main.py +++ b/couchpotato/core/downloaders/synology/main.py @@ -45,6 +45,16 @@ class Synology(Downloader): finally: return self.downloadReturnId('') if response else False + def test(self): + host = cleanHost(self.conf('host'), protocol = False).split(':') + try: + srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) + test_result = srpc.test() + except: + return False + + return test_result + def getEnabledProtocol(self): if self.conf('use_for') == 'both': return super(Synology, self).getEnabledProtocol() @@ -147,3 +157,6 @@ class SynologyRPC(object): self._logout() return result + + def test(self): + return bool(self._login()) diff --git a/couchpotato/core/downloaders/transmission/main.py b/couchpotato/core/downloaders/transmission/main.py index 2daeab4..4c42bf0 100644 --- a/couchpotato/core/downloaders/transmission/main.py +++ b/couchpotato/core/downloaders/transmission/main.py @@ -19,14 +19,14 @@ class Transmission(Downloader): log = CPLog(__name__) trpc = None - def connect(self): + def connect(self, reconnect = False): # Load host from config and split out port. host = cleanHost(self.conf('host'), protocol = False).split(':') if not isInt(host[1]): log.error('Config properties are not filled in correctly, port is missing.') return False - if not self.trpc: + if not self.trpc or reconnect: self.trpc = TransmissionRPC(host[0], port = host[1], rpc_url = self.conf('rpc_url').strip('/ '), username = self.conf('username'), password = self.conf('password')) return self.trpc @@ -83,6 +83,11 @@ class Transmission(Downloader): log.info('Torrent sent to Transmission successfully.') return self.downloadReturnId(remote_torrent['torrent-added']['hashString']) + def test(self): + if self.connect(True) and self.trpc.get_session(): + return True + return False + def getAllDownloadStatus(self, ids): log.debug('Checking Transmission download status.') diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index e0d6a92..6a5e425 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -115,6 +115,17 @@ class uTorrent(Downloader): return self.downloadReturnId(torrent_hash) + def test(self): + if self.connect(): + build_version = self.utorrent_api.get_build() + if not build_version: + return False + if build_version < 25406: # This build corresponds to version 3.0.0 stable + return False, 'Your uTorrent client is too old, please update to newest version.' + return True + + return False + def getAllDownloadStatus(self, ids): log.debug('Checking uTorrent download status.') @@ -322,3 +333,10 @@ class uTorrentAPI(object): def get_files(self, hash): action = 'action=getfiles&hash=%s' % hash return self._request(action) + + def get_build(self): + data = self._request('') + if not data: + return False + response = json.loads(data) + return int(response.get('build')) diff --git a/couchpotato/core/notifications/pushbullet/main.py b/couchpotato/core/notifications/pushbullet/main.py index 15120f0..487fb3a 100644 --- a/couchpotato/core/notifications/pushbullet/main.py +++ b/couchpotato/core/notifications/pushbullet/main.py @@ -1,5 +1,5 @@ from couchpotato.core.helpers.encoding import toUnicode -from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.helpers.variable import splitString from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification import base64 @@ -32,7 +32,7 @@ class Pushbullet(Notification): response = self.request( 'pushes', cache = False, - device_id = device, + device_iden = device, type = 'note', title = self.default_title, body = toUnicode(message) @@ -46,24 +46,7 @@ class Pushbullet(Notification): return successful == len(devices) def getDevices(self): - devices = [d.strip() for d in self.conf('devices').split(',')] - - # Remove empty items - devices = [d for d in devices if len(d)] - - # Break on any ids that aren't integers - valid_devices = [] - - for device_id in devices: - d = tryInt(device_id, None) - - if not d: - log.error('Device ID "%s" is not valid', device_id) - return None - - valid_devices.append(d) - - return valid_devices + return splitString(self.conf('devices')) def request(self, method, cache = True, **kwargs): try: diff --git a/couchpotato/core/notifications/trakt/main.py b/couchpotato/core/notifications/trakt/main.py index c759c6d..399f76d 100644 --- a/couchpotato/core/notifications/trakt/main.py +++ b/couchpotato/core/notifications/trakt/main.py @@ -10,6 +10,7 @@ class Trakt(Notification): 'base': 'http://api.trakt.tv/%s', 'library': 'movie/library/%s', 'unwatchlist': 'movie/unwatchlist/%s', + 'test': 'account/test/%s', } listen_to = ['movie.downloaded'] @@ -17,25 +18,39 @@ class Trakt(Notification): def notify(self, message = '', data = None, listener = None): if not data: data = {} - post_data = { - 'username': self.conf('automation_username'), - 'password' : self.conf('automation_password'), - 'movies': [{ - 'imdb_id': data['library']['identifier'], - 'title': data['library']['titles'][0]['title'], - 'year': data['library']['year'] - }] if data else [] - } + if listener == 'test': - result = self.call((self.urls['library'] % self.conf('automation_api_key')), post_data) - if self.conf('remove_watchlist_enabled'): - result = result and self.call((self.urls['unwatchlist'] % self.conf('automation_api_key')), post_data) + post_data = { + 'username': self.conf('automation_username'), + 'password': self.conf('automation_password'), + } - return result + result = self.call((self.urls['test'] % self.conf('automation_api_key')), post_data) + + return result + + else: + + post_data = { + 'username': self.conf('automation_username'), + 'password': self.conf('automation_password'), + 'movies': [{ + 'imdb_id': data['library']['identifier'], + 'title': data['library']['titles'][0]['title'], + 'year': data['library']['year'] + }] if data else [] + } + + result = self.call((self.urls['library'] % self.conf('automation_api_key')), post_data) + if self.conf('remove_watchlist_enabled'): + result = result and self.call((self.urls['unwatchlist'] % self.conf('automation_api_key')), post_data) + + return result def call(self, method_url, post_data): try: + response = self.getJsonData(self.urls['base'] % method_url, data = post_data, cache_timeout = 1) if response: if response.get('status') == "success": diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 0625535..378ed50 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -110,7 +110,7 @@ class Plugin(object): f.write(content) f.close() os.chmod(path, Env.getPermission('file')) - except Exception as e: + except: log.error('Unable writing to file "%s": %s', (path, traceback.format_exc())) if os.path.isfile(path): os.remove(path) @@ -172,7 +172,10 @@ class Plugin(object): log.info('Opening url: %s %s, data: %s', (method, url, [x for x in data.keys()] if isinstance(data, dict) else 'with data')) response = r.request(method, url, verify = False, **kwargs) - data = response.content + if response.status_code == requests.codes.ok: + data = response.content + else: + response.raise_for_status() self.http_failed_request[host] = 0 except (IOError, MaxRetryError, Timeout): diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index f86beba..1bf4ec9 100755 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -317,7 +317,7 @@ class Renamer(Plugin): cd = 1 if multiple else 0 for current_file in sorted(list(group['files'][file_type])): - current_file = toUnicode(current_file) + current_file = sp(current_file) # Original filename replacements['original'] = os.path.splitext(os.path.basename(current_file))[0] @@ -607,7 +607,7 @@ class Renamer(Plugin): rename_files = {} def test(s): - return current_file[:-len(replacements['ext'])] in s + return current_file[:-len(replacements['ext'])] in sp(s) for extra in set(filter(test, group['files'][extra_type])): replacements['ext'] = getExt(extra) diff --git a/couchpotato/core/providers/automation/goodfilms/main.py b/couchpotato/core/providers/automation/goodfilms/main.py index c4a7bd9..e668a4f 100644 --- a/couchpotato/core/providers/automation/goodfilms/main.py +++ b/couchpotato/core/providers/automation/goodfilms/main.py @@ -7,7 +7,7 @@ log = CPLog(__name__) class Goodfilms(Automation): - url = 'http://goodfil.ms/%s/queue?page=%d&without_layout=1' + url = 'https://goodfil.ms/%s/queue?page=%d&without_layout=1' interval = 1800 diff --git a/couchpotato/core/providers/automation/itunes/main.py b/couchpotato/core/providers/automation/itunes/main.py index 7676324..086c981 100644 --- a/couchpotato/core/providers/automation/itunes/main.py +++ b/couchpotato/core/providers/automation/itunes/main.py @@ -22,7 +22,7 @@ class ITunes(Automation, RSS): urls = splitString(self.conf('automation_urls')) namespace = 'http://www.w3.org/2005/Atom' - namespace_im = 'http://itunes.apple.com/rss' + namespace_im = 'https://rss.itunes.apple.com' index = -1 for url in urls: diff --git a/couchpotato/core/providers/metadata/base.py b/couchpotato/core/providers/metadata/base.py index 72d0760..d1274ad 100644 --- a/couchpotato/core/providers/metadata/base.py +++ b/couchpotato/core/providers/metadata/base.py @@ -1,4 +1,5 @@ from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.helpers.encoding import sp from couchpotato.core.helpers.variable import mergeDicts from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin @@ -48,6 +49,9 @@ class MetaDataBase(Plugin): if content: log.debug('Creating %s file: %s', (file_type, name)) if os.path.isfile(content): + content = sp(content) + name = sp(name) + shutil.copy2(content, name) shutil.copyfile(content, name) @@ -59,7 +63,7 @@ class MetaDataBase(Plugin): group['renamed_files'].append(name) try: - os.chmod(name, Env.getPermission('file')) + os.chmod(sp(name), Env.getPermission('file')) except: log.debug('Failed setting permissions for %s: %s', (name, traceback.format_exc())) diff --git a/couchpotato/core/providers/nzb/nzbclub/main.py b/couchpotato/core/providers/nzb/nzbclub/main.py index 778cdbc..643f247 100644 --- a/couchpotato/core/providers/nzb/nzbclub/main.py +++ b/couchpotato/core/providers/nzb/nzbclub/main.py @@ -13,7 +13,7 @@ log = CPLog(__name__) class NZBClub(NZBProvider, RSS): urls = { - 'search': 'http://www.nzbclub.com/nzbfeed.aspx?%s', + 'search': 'https://www.nzbclub.com/nzbfeeds.aspx?%s', } http_time_between_calls = 4 #seconds diff --git a/couchpotato/core/providers/torrent/ilovetorrents/main.py b/couchpotato/core/providers/torrent/ilovetorrents/main.py index 6d56ea4..f8ed67a 100644 --- a/couchpotato/core/providers/torrent/ilovetorrents/main.py +++ b/couchpotato/core/providers/torrent/ilovetorrents/main.py @@ -12,12 +12,12 @@ log = CPLog(__name__) class ILoveTorrents(TorrentProvider): urls = { - 'download': 'http://www.ilovetorrents.me/%s', - 'detail': 'http://www.ilovetorrents.me/%s', - 'search': 'http://www.ilovetorrents.me/browse.php?search=%s&page=%s&cat=%s', - 'test': 'http://www.ilovetorrents.me/', - 'login': 'http://www.ilovetorrents.me/takelogin.php', - 'login_check': 'http://www.ilovetorrents.me' + 'download': 'https://www.ilovetorrents.me/%s', + 'detail': 'https//www.ilovetorrents.me/%s', + 'search': 'https://www.ilovetorrents.me/browse.php?search=%s&page=%s&cat=%s', + 'test': 'https://www.ilovetorrents.me/', + 'login': 'https://www.ilovetorrents.me/takelogin.php', + 'login_check': 'https://www.ilovetorrents.me' } cat_ids = [ diff --git a/couchpotato/core/providers/torrent/iptorrents/main.py b/couchpotato/core/providers/torrent/iptorrents/main.py index 35ce25c..4a2c6db 100644 --- a/couchpotato/core/providers/torrent/iptorrents/main.py +++ b/couchpotato/core/providers/torrent/iptorrents/main.py @@ -11,11 +11,11 @@ log = CPLog(__name__) class IPTorrents(TorrentProvider): urls = { - 'test': 'http://www.iptorrents.com/', - 'base_url': 'http://www.iptorrents.com', - 'login': 'http://www.iptorrents.com/torrents/', - 'login_check': 'http://www.iptorrents.com/inbox.php', - 'search': 'http://www.iptorrents.com/torrents/?l%d=1%s&q=%s&qf=ti&p=%d', + 'test': 'https://www.iptorrents.com/', + 'base_url': 'https://www.iptorrents.com', + 'login': 'https://www.iptorrents.com/torrents/', + 'login_check': 'https://www.iptorrents.com/inbox.php', + 'search': 'https://www.iptorrents.com/torrents/?l%d=1%s&q=%s&qf=ti&p=%d', } cat_ids = [ diff --git a/couchpotato/core/providers/torrent/passthepopcorn/main.py b/couchpotato/core/providers/torrent/passthepopcorn/main.py index 66cad33..57a36c2 100644 --- a/couchpotato/core/providers/torrent/passthepopcorn/main.py +++ b/couchpotato/core/providers/torrent/passthepopcorn/main.py @@ -89,11 +89,11 @@ class PassThePopcorn(TorrentProvider): if 'GoldenPopcorn' in torrent and torrent['GoldenPopcorn']: torrentdesc += ' HQ' if self.conf('prefer_golden'): - torrentscore += 200 + torrentscore += 5000 if 'Scene' in torrent and torrent['Scene']: torrentdesc += ' Scene' if self.conf('prefer_scene'): - torrentscore += 50 + torrentscore += 2000 if 'RemasterTitle' in torrent and torrent['RemasterTitle']: torrentdesc += self.htmlToASCII(' %s' % torrent['RemasterTitle']) diff --git a/couchpotato/core/providers/torrent/sceneaccess/main.py b/couchpotato/core/providers/torrent/sceneaccess/main.py index a43c49b..c1c871e 100644 --- a/couchpotato/core/providers/torrent/sceneaccess/main.py +++ b/couchpotato/core/providers/torrent/sceneaccess/main.py @@ -15,7 +15,7 @@ class SceneAccess(TorrentProvider): 'login': 'https://www.sceneaccess.eu/login', 'login_check': 'https://www.sceneaccess.eu/inbox', 'detail': 'https://www.sceneaccess.eu/details?id=%s', - 'search': 'https://www.sceneaccess.eu/browse?method=2&c%d=%d', + 'search': 'https://www.sceneaccess.eu/browse?c%d=%d', 'download': 'https://www.sceneaccess.eu/%s', } @@ -40,7 +40,7 @@ class SceneAccess(TorrentProvider): arguments = tryUrlencode({ 'search': movie['library']['identifier'], - 'method': 1, + 'method': 3, }) url = "%s&%s" % (url, arguments) diff --git a/couchpotato/core/providers/torrent/thepiratebay/main.py b/couchpotato/core/providers/torrent/thepiratebay/main.py index f8b7778..c366c51 100644 --- a/couchpotato/core/providers/torrent/thepiratebay/main.py +++ b/couchpotato/core/providers/torrent/thepiratebay/main.py @@ -31,15 +31,13 @@ class ThePirateBay(TorrentMagnetProvider): proxy_list = [ 'https://tpb.ipredator.se', 'https://thepiratebay.se', - 'https://depiraatbaai.be', - 'https://piratereverse.info', - 'https://tpb.pirateparty.org.uk', - 'https://argumentomteemigreren.nl', - 'https://livepirate.com', + 'http://pirateproxy.ca', + 'http://tpb.al', + 'http://www.tpb.gr', + 'http://nl.tpb.li', + 'http://proxybay.eu', 'https://www.getpirate.com', - 'https://tpb.partipirate.org', - 'https://tpb.piraten.lu', - 'https://kuiken.co', + 'http://pirateproxy.ca', ] def _searchOnTitle(self, title, movie, quality, results): diff --git a/couchpotato/core/providers/torrent/torrentshack/__init__.py b/couchpotato/core/providers/torrent/torrentshack/__init__.py index 0e55211..058236e 100644 --- a/couchpotato/core/providers/torrent/torrentshack/__init__.py +++ b/couchpotato/core/providers/torrent/torrentshack/__init__.py @@ -11,7 +11,7 @@ config = [{ 'tab': 'searcher', 'list': 'torrent_providers', 'name': 'TorrentShack', - 'description': 'See TorrentShack', + 'description': 'See TorrentShack', 'options': [ { 'name': 'enabled', diff --git a/couchpotato/core/providers/torrent/yify/main.py b/couchpotato/core/providers/torrent/yify/main.py index 6deaf7b..fe1b820 100644 --- a/couchpotato/core/providers/torrent/yify/main.py +++ b/couchpotato/core/providers/torrent/yify/main.py @@ -18,9 +18,9 @@ class Yify(TorrentMagnetProvider): proxy_list = [ 'http://yify.unlocktorrent.com', - 'http://yify.ftwnet.co.uk', 'http://yify-torrents.com.come.in', 'http://yts.re', + 'http://yts.im' 'https://yify-torrents.im', ] diff --git a/couchpotato/core/settings/__init__.py b/couchpotato/core/settings/__init__.py index 3b57517..0e65c77 100644 --- a/couchpotato/core/settings/__init__.py +++ b/couchpotato/core/settings/__init__.py @@ -200,6 +200,7 @@ class Settings(object): # After save (for re-interval etc) fireEvent('setting.save.%s.%s.after' % (section, option), single = True) + fireEvent('setting.save.%s.*.after' % section, single = True) return { 'success': True, diff --git a/couchpotato/static/scripts/misc/downloaders.js b/couchpotato/static/scripts/misc/downloaders.js new file mode 100644 index 0000000..5127275 --- /dev/null +++ b/couchpotato/static/scripts/misc/downloaders.js @@ -0,0 +1,75 @@ +var DownloadersBase = new Class({ + + Implements: [Events], + + initialize: function(){ + var self = this; + + // Add test buttons to settings page + App.addEvent('load', self.addTestButtons.bind(self)); + + }, + + // Downloaders setting tests + addTestButtons: function(){ + var self = this; + + var setting_page = App.getPage('Settings'); + setting_page.addEvent('create', function(){ + Object.each(setting_page.tabs.downloaders.groups, self.addTestButton.bind(self)) + }) + + }, + + addTestButton: function(fieldset, plugin_name){ + var self = this, + button_name = self.testButtonName(fieldset); + + if(button_name.contains('Downloaders')) return; + + new Element('.ctrlHolder.test_button').adopt( + new Element('a.button', { + 'text': button_name, + 'events': { + 'click': function(){ + var button = fieldset.getElement('.test_button .button'); + button.set('text', 'Connecting...'); + + Api.request('download.'+plugin_name+'.test', { + 'onComplete': function(json){ + + button.set('text', button_name); + + if(json.success){ + var message = new Element('span.success', { + 'text': 'Connection successful' + }).inject(button, 'after') + } + else { + var msg_text = 'Connection failed. Check logs for details.'; + if(json.hasOwnProperty('msg')) msg_text = json.msg; + var message = new Element('span.failed', { + 'text': msg_text + }).inject(button, 'after') + } + + (function(){ + message.destroy(); + }).delay(3000) + } + }); + } + } + }) + ).inject(fieldset); + + }, + + testButtonName: function(fieldset){ + var name = String(fieldset.getElement('h2').innerHTML).substring(0,String(fieldset.getElement('h2').innerHTML).indexOf("= MIN_RTORRENT_VERSION diff --git a/libs/rtorrent/common.py b/libs/rtorrent/common.py index 371c71c..668865b 100755 --- a/libs/rtorrent/common.py +++ b/libs/rtorrent/common.py @@ -17,7 +17,8 @@ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - +import urlparse +import os from rtorrent.compat import is_py3 @@ -84,3 +85,67 @@ def safe_repr(fmt, *args, **kwargs): return out.encode("utf-8") else: return fmt.format(*args, **kwargs) + + +def split_path(path): + fragments = path.split('/') + + if len(fragments) == 1: + return fragments + + if not fragments[-1]: + return fragments[:-1] + + return fragments + + +def join_path(base, path): + # Return if we have a new absolute path + if os.path.isabs(path): + return path + + # non-absolute base encountered + if base and not os.path.isabs(base): + raise NotImplementedError() + + return '/'.join(split_path(base) + split_path(path)) + + +def join_uri(base, uri, construct=True): + p_uri = urlparse.urlparse(uri) + + # Return if there is nothing to join + if not p_uri.path: + return base + + scheme, netloc, path, params, query, fragment = urlparse.urlparse(base) + + # Switch to 'uri' parts + _, _, _, params, query, fragment = p_uri + + path = join_path(path, p_uri.path) + + result = urlparse.ParseResult(scheme, netloc, path, params, query, fragment) + + if not construct: + return result + + # Construct from parts + return urlparse.urlunparse(result) + + +def update_uri(uri, construct=True, **kwargs): + if isinstance(uri, urlparse.ParseResult): + uri = dict(uri._asdict()) + + if type(uri) is not dict: + raise ValueError("Unknown URI type") + + uri.update(kwargs) + + result = urlparse.ParseResult(**uri) + + if not construct: + return result + + return urlparse.urlunparse(result) diff --git a/libs/tmdb3/__init__.py b/libs/tmdb3/__init__.py index 92ca551..d5e35b3 100755 --- a/libs/tmdb3/__init__.py +++ b/libs/tmdb3/__init__.py @@ -2,7 +2,8 @@ from tmdb_api import Configuration, searchMovie, searchMovieWithYear, \ searchPerson, searchStudio, searchList, searchCollection, \ - Person, Movie, Collection, Genre, List, __version__ + searchSeries, Person, Movie, Collection, Genre, List, \ + Series, Studio, Network, Episode, Season, __version__ from request import set_key, set_cache from locales import get_locale, set_locale from tmdb_auth import get_session, set_session diff --git a/libs/tmdb3/cache.py b/libs/tmdb3/cache.py index 3b10677..463d7a2 100755 --- a/libs/tmdb3/cache.py +++ b/libs/tmdb3/cache.py @@ -7,20 +7,27 @@ # Purpose: Caching framework to store TMDb API results #----------------------- +import time +import os + from tmdb_exceptions import * from cache_engine import Engines import cache_null import cache_file -class Cache( object ): + +class Cache(object): """ - This class implements a persistent cache, backed in a file specified in - the object creation. The file is protected for safe, concurrent access - by multiple instances using flock. - This cache uses JSON for speed and storage efficiency, so only simple - data types are supported. - Data is stored in a simple format {key:(expiretimestamp, data)} + This class implements a cache framework, allowing selecting of a + pluggable engine. The framework stores data in a key/value manner, + along with a lifetime, after which data will be expired and + pulled fresh next time it is requested from the cache. + + This class defines a wrapper to be used with query functions. The + wrapper will automatically cache the inputs and outputs of the + wrapped function, pulling the output from local storage for + subsequent calls with those inputs. """ def __init__(self, engine=None, *args, **kwargs): self._engine = None @@ -37,7 +44,7 @@ class Cache( object ): self._age = max(self._age, obj.creation) def _expire(self): - for k,v in self._data.items(): + for k, v in self._data.items(): if v.expired: del self._data[k] @@ -87,19 +94,22 @@ class Cache( object ): self.__doc__ = func.__doc__ def __call__(self, *args, **kwargs): - if self.func is None: # decorator is waiting to be given a function + if self.func is None: + # decorator is waiting to be given a function if len(kwargs) or (len(args) != 1): - raise TMDBCacheError('Cache.Cached decorator must be called '+\ - 'a single callable argument before it '+\ - 'be used.') + raise TMDBCacheError( + 'Cache.Cached decorator must be called a single ' + + 'callable argument before it be used.') elif args[0] is None: - raise TMDBCacheError('Cache.Cached decorator called before '+\ - 'being given a function to wrap.') + raise TMDBCacheError( + 'Cache.Cached decorator called before being given ' + + 'a function to wrap.') elif not callable(args[0]): - raise TMDBCacheError('Cache.Cached must be provided a '+\ - 'callable object.') + raise TMDBCacheError( + 'Cache.Cached must be provided a callable object.') return self.__class__(self.cache, self.callback, args[0]) elif self.inst.lifetime == 0: + # lifetime of zero means never cache return self.func(*args, **kwargs) else: key = self.callback() @@ -118,4 +128,3 @@ class Cache( object ): func = self.func.__get__(inst, owner) callback = self.callback.__get__(inst, owner) return self.__class__(self.cache, callback, func, inst) - diff --git a/libs/tmdb3/cache_engine.py b/libs/tmdb3/cache_engine.py index 99ad4cd..1101955 100755 --- a/libs/tmdb3/cache_engine.py +++ b/libs/tmdb3/cache_engine.py @@ -10,35 +10,46 @@ import time from weakref import ref -class Engines( object ): + +class Engines(object): + """ + Static collector for engines to register against. + """ def __init__(self): self._engines = {} + def register(self, engine): self._engines[engine.__name__] = engine self._engines[engine.name] = engine + def __getitem__(self, key): return self._engines[key] + def __contains__(self, key): return self._engines.__contains__(key) + Engines = Engines() -class CacheEngineType( type ): + +class CacheEngineType(type): """ Cache Engine Metaclass that registers new engines against the cache for named selection and use. """ - def __init__(mcs, name, bases, attrs): - super(CacheEngineType, mcs).__init__(name, bases, attrs) + def __init__(cls, name, bases, attrs): + super(CacheEngineType, cls).__init__(name, bases, attrs) if name != 'CacheEngine': # skip base class - Engines.register(mcs) + Engines.register(cls) -class CacheEngine( object ): - __metaclass__ = CacheEngineType +class CacheEngine(object): + __metaclass__ = CacheEngineType name = 'unspecified' + def __init__(self, parent): self.parent = ref(parent) + def configure(self): raise RuntimeError def get(self, date): @@ -48,7 +59,8 @@ class CacheEngine( object ): def expire(self, key): raise RuntimeError -class CacheObject( object ): + +class CacheObject(object): """ Cache object class, containing one stored record. """ @@ -64,7 +76,7 @@ class CacheObject( object ): @property def expired(self): - return (self.remaining == 0) + return self.remaining == 0 @property def remaining(self): diff --git a/libs/tmdb3/cache_file.py b/libs/tmdb3/cache_file.py index 5918071..4e96581 100755 --- a/libs/tmdb3/cache_file.py +++ b/libs/tmdb3/cache_file.py @@ -12,6 +12,7 @@ import struct import errno import json +import time import os import io @@ -54,11 +55,11 @@ def _donothing(*args, **kwargs): try: import fcntl - class Flock( object ): + class Flock(object): """ - Context manager to flock file for the duration the object exists. - Referenced file will be automatically unflocked as the interpreter - exits the context. + Context manager to flock file for the duration the object + exists. Referenced file will be automatically unflocked as the + interpreter exits the context. Supports an optional callback to process the error and optionally suppress it. """ @@ -69,8 +70,10 @@ try: self.fileobj = fileobj self.operation = operation self.callback = callback + def __enter__(self): fcntl.flock(self.fileobj, self.operation) + def __exit__(self, exc_type, exc_value, exc_tb): suppress = False if callable(self.callback): @@ -101,9 +104,11 @@ except ImportError: self.fileobj = fileobj self.operation = operation self.callback = callback + def __enter__(self): self.size = os.path.getsize(self.fileobj.name) msvcrt.locking(self.fileobj.fileno(), self.operation, self.size) + def __exit__(self, exc_type, exc_value, exc_tb): suppress = False if callable(self.callback): @@ -118,7 +123,7 @@ except ImportError: if filename.startswith('~'): # check for home directory return os.path.expanduser(filename) - elif (ord(filename[0]) in (range(65,91)+range(99,123))) \ + elif (ord(filename[0]) in (range(65, 91) + range(99, 123))) \ and (filename[1:3] == ':\\'): # check for absolute drive path (e.g. C:\...) return filename @@ -126,12 +131,12 @@ except ImportError: # check for absolute UNC path (e.g. \\server\...) return filename # return path with temp directory prepended - return os.path.expandvars(os.path.join('%TEMP%',filename)) + return os.path.expandvars(os.path.join('%TEMP%', filename)) -class FileCacheObject( CacheObject ): - _struct = struct.Struct('dII') # double and two ints - # timestamp, lifetime, position +class FileCacheObject(CacheObject): + _struct = struct.Struct('dII') # double and two ints + # timestamp, lifetime, position @classmethod def fromFile(cls, fd): @@ -150,7 +155,7 @@ class FileCacheObject( CacheObject ): @property def size(self): if self._size is None: - self._buff.seek(0,2) + self._buff.seek(0, 2) size = self._buff.tell() if size == 0: if (self._key is None) or (self._data is None): @@ -159,8 +164,10 @@ class FileCacheObject( CacheObject ): self._size = self._buff.tell() self._size = size return self._size + @size.setter - def size(self, value): self._size = value + def size(self, value): + self._size = value @property def key(self): @@ -170,16 +177,20 @@ class FileCacheObject( CacheObject ): except: pass return self._key + @key.setter - def key(self, value): self._key = value + def key(self, value): + self._key = value @property def data(self): if self._data is None: self._key, self._data = json.loads(self._buff.getvalue()) return self._data + @data.setter - def data(self, value): self._data = value + def data(self, value): + self._data = value def load(self, fd): fd.seek(self.position) @@ -199,7 +210,7 @@ class FileCacheObject( CacheObject ): class FileEngine( CacheEngine ): """Simple file-backed engine.""" name = 'file' - _struct = struct.Struct('HH') # two shorts for version and count + _struct = struct.Struct('HH') # two shorts for version and count _version = 2 def __init__(self, parent): @@ -219,7 +230,6 @@ class FileEngine( CacheEngine ): if self.cachefile is None: raise TMDBCacheError("No cache filename given.") - self.cachefile = parse_filename(self.cachefile) try: @@ -246,7 +256,7 @@ class FileEngine( CacheEngine ): else: # let the unhandled error continue through raise - elif e.errno == errno.EACCESS: + elif e.errno == errno.EACCES: # file exists, but we do not have permission to access it raise TMDBCacheReadError(self.cachefile) else: @@ -257,7 +267,7 @@ class FileEngine( CacheEngine ): self._init_cache() self._open('r+b') - with Flock(self.cachefd, Flock.LOCK_SH): # lock for shared access + with Flock(self.cachefd, Flock.LOCK_SH): # return any new objects in the cache return self._read(date) @@ -265,7 +275,7 @@ class FileEngine( CacheEngine ): self._init_cache() self._open('r+b') - with Flock(self.cachefd, Flock.LOCK_EX): # lock for exclusive access + with Flock(self.cachefd, Flock.LOCK_EX): newobjs = self._read(self.age) newobjs.append(FileCacheObject(key, value, lifetime)) @@ -283,7 +293,8 @@ class FileEngine( CacheEngine ): # already opened in requested mode, nothing to do self.cachefd.seek(0) return - except: pass # catch issue of no cachefile yet opened + except: + pass # catch issue of no cachefile yet opened self.cachefd = io.open(self.cachefile, mode) def _read(self, date): @@ -310,7 +321,7 @@ class FileEngine( CacheEngine ): return [] # get end of file - self.cachefd.seek(0,2) + self.cachefd.seek(0, 2) position = self.cachefd.tell() newobjs = [] emptycount = 0 @@ -348,7 +359,7 @@ class FileEngine( CacheEngine ): data = data[-1] # determine write position of data in cache - self.cachefd.seek(0,2) + self.cachefd.seek(0, 2) end = self.cachefd.tell() data.position = end @@ -387,5 +398,3 @@ class FileEngine( CacheEngine ): def expire(self, key): pass - - diff --git a/libs/tmdb3/cache_null.py b/libs/tmdb3/cache_null.py index a59741c..8c360da 100755 --- a/libs/tmdb3/cache_null.py +++ b/libs/tmdb3/cache_null.py @@ -9,11 +9,19 @@ from cache_engine import CacheEngine -class NullEngine( CacheEngine ): + +class NullEngine(CacheEngine): """Non-caching engine for debugging.""" name = 'null' - def configure(self): pass - def get(self, date): return [] - def put(self, key, value, lifetime): return [] - def expire(self, key): pass + def configure(self): + pass + + def get(self, date): + return [] + + def put(self, key, value, lifetime): + return [] + + def expire(self, key): + pass diff --git a/libs/tmdb3/locales.py b/libs/tmdb3/locales.py index 97efec7..0ef0310 100755 --- a/libs/tmdb3/locales.py +++ b/libs/tmdb3/locales.py @@ -11,7 +11,8 @@ import locale syslocale = None -class LocaleBase( object ): + +class LocaleBase(object): __slots__ = ['__immutable'] _stored = {} fallthrough = False @@ -24,19 +25,21 @@ class LocaleBase( object ): def __setattr__(self, key, value): if getattr(self, '__immutable', False): raise NotImplementedError(self.__class__.__name__ + - ' does not support modification.') + ' does not support modification.') super(LocaleBase, self).__setattr__(key, value) def __delattr__(self, key): if getattr(self, '__immutable', False): raise NotImplementedError(self.__class__.__name__ + - ' does not support modification.') + ' does not support modification.') super(LocaleBase, self).__delattr__(key) def __lt__(self, other): return (id(self) != id(other)) and (str(self) > str(other)) + def __gt__(self, other): return (id(self) != id(other)) and (str(self) < str(other)) + def __eq__(self, other): return (id(self) == id(other)) or (str(self) == str(other)) @@ -48,9 +51,10 @@ class LocaleBase( object ): return cls._stored[key.lower()] except: raise TMDBLocaleError("'{0}' is not a known valid {1} code."\ - .format(key, cls.__name__)) + .format(key, cls.__name__)) + -class Language( LocaleBase ): +class Language(LocaleBase): __slots__ = ['ISO639_1', 'ISO639_2', 'ISO639_2B', 'englishname', 'nativename'] _stored = {} @@ -69,12 +73,13 @@ class Language( LocaleBase ): def __repr__(self): return u"".format(self) -class Country( LocaleBase ): + +class Country(LocaleBase): __slots__ = ['alpha2', 'name'] _stored = {} def __init__(self, alpha2, name): - self.alpha2 = alpha2 + self.alpha2 = alpha2 self.name = name super(Country, self).__init__(alpha2) @@ -84,7 +89,8 @@ class Country( LocaleBase ): def __repr__(self): return u"".format(self) -class Locale( LocaleBase ): + +class Locale(LocaleBase): __slots__ = ['language', 'country', 'encoding'] def __init__(self, language, country, encoding): @@ -120,6 +126,7 @@ class Locale( LocaleBase ): # just return unmodified and hope for the best return dat + def set_locale(language=None, country=None, fallthrough=False): global syslocale LocaleBase.fallthrough = fallthrough @@ -142,6 +149,7 @@ def set_locale(language=None, country=None, fallthrough=False): syslocale = Locale(language, country, sysenc) + def get_locale(language=-1, country=-1): """Output locale using provided attributes, or return system locale.""" global syslocale diff --git a/libs/tmdb3/pager.py b/libs/tmdb3/pager.py index 6cb874c..ebcb9d2 100755 --- a/libs/tmdb3/pager.py +++ b/libs/tmdb3/pager.py @@ -8,7 +8,8 @@ from collections import Sequence, Iterator -class PagedIterator( Iterator ): + +class PagedIterator(Iterator): def __init__(self, parent): self._parent = parent self._index = -1 @@ -23,7 +24,8 @@ class PagedIterator( Iterator ): raise StopIteration return self._parent[self._index] -class UnpagedData( object ): + +class UnpagedData(object): def copy(self): return self.__class__() @@ -33,10 +35,11 @@ class UnpagedData( object ): def __rmul__(self, other): return (self.copy() for a in range(other)) -class PagedList( Sequence ): + +class PagedList(Sequence): """ - List-like object, with support for automatically grabbing additional - pages from a data source. + List-like object, with support for automatically grabbing + additional pages from a data source. """ _iter_class = None @@ -87,17 +90,19 @@ class PagedList( Sequence ): pagestart += 1 def _getpage(self, page): - raise NotImplementedError("PagedList._getpage() must be provided "+\ + raise NotImplementedError("PagedList._getpage() must be provided " + "by subclass") -class PagedRequest( PagedList ): + +class PagedRequest(PagedList): """ - Derived PageList that provides a list-like object with automatic paging - intended for use with search requests. + Derived PageList that provides a list-like object with automatic + paging intended for use with search requests. """ def __init__(self, request, handler=None): self._request = request - if handler: self._handler = handler + if handler: + self._handler = handler super(PagedRequest, self).__init__(self._getpage(1), 20) def _getpage(self, page): @@ -105,5 +110,7 @@ class PagedRequest( PagedList ): res = req.readJSON() self._len = res['total_results'] for item in res['results']: - yield self._handler(item) - + if item is None: + yield None + else: + yield self._handler(item) diff --git a/libs/tmdb3/request.py b/libs/tmdb3/request.py index 109630d..2d51dcd 100755 --- a/libs/tmdb3/request.py +++ b/libs/tmdb3/request.py @@ -15,6 +15,7 @@ from cache import Cache from urllib import urlencode import urllib2 import json +import os DEBUG = False cache = Cache(filename='pytmdb3.cache') @@ -22,10 +23,11 @@ cache = Cache(filename='pytmdb3.cache') #DEBUG = True #cache = Cache(engine='null') + def set_key(key): """ - Specify the API key to use retrieving data from themoviedb.org. This - key must be set before any calls will function. + Specify the API key to use retrieving data from themoviedb.org. + This key must be set before any calls will function. """ if len(key) != 32: raise TMDBKeyInvalid("Specified API key must be 128-bit hex") @@ -35,42 +37,50 @@ def set_key(key): raise TMDBKeyInvalid("Specified API key must be 128-bit hex") Request._api_key = key + def set_cache(engine=None, *args, **kwargs): """Specify caching engine and properties.""" cache.configure(engine, *args, **kwargs) -class Request( urllib2.Request ): + +class Request(urllib2.Request): _api_key = None _base_url = "http://api.themoviedb.org/3/" @property def api_key(self): if self._api_key is None: - raise TMDBKeyMissing("API key must be specified before "+\ + raise TMDBKeyMissing("API key must be specified before " + "requests can be made") return self._api_key def __init__(self, url, **kwargs): - """Return a request object, using specified API path and arguments.""" + """ + Return a request object, using specified API path and + arguments. + """ kwargs['api_key'] = self.api_key self._url = url.lstrip('/') - self._kwargs = dict([(kwa,kwv) for kwa,kwv in kwargs.items() + self._kwargs = dict([(kwa, kwv) for kwa, kwv in kwargs.items() if kwv is not None]) locale = get_locale() kwargs = {} - for k,v in self._kwargs.items(): + for k, v in self._kwargs.items(): kwargs[k] = locale.encode(v) - url = '{0}{1}?{2}'.format(self._base_url, self._url, urlencode(kwargs)) + url = '{0}{1}?{2}'\ + .format(self._base_url, self._url, urlencode(kwargs)) urllib2.Request.__init__(self, url) self.add_header('Accept', 'application/json') - self.lifetime = 3600 # 1hr + self.lifetime = 3600 # 1hr def new(self, **kwargs): - """Create a new instance of the request, with tweaked arguments.""" + """ + Create a new instance of the request, with tweaked arguments. + """ args = dict(self._kwargs) - for k,v in kwargs.items(): + for k, v in kwargs.items(): if v is None: if k in args: del args[k] @@ -119,35 +129,35 @@ class Request( urllib2.Request ): # no error from TMDB, just raise existing error raise e handle_status(data, url) - #if DEBUG: - # import pprint - # pprint.PrettyPrinter().pprint(data) + if DEBUG: + import pprint + pprint.PrettyPrinter().pprint(data) return data status_handlers = { 1: None, 2: TMDBRequestInvalid('Invalid service - This service does not exist.'), - 3: TMDBRequestError('Authentication Failed - You do not have '+\ + 3: TMDBRequestError('Authentication Failed - You do not have ' + 'permissions to access this service.'), - 4: TMDBRequestInvalid("Invalid format - This service doesn't exist "+\ + 4: TMDBRequestInvalid("Invalid format - This service doesn't exist " + 'in that format.'), - 5: TMDBRequestInvalid('Invalid parameters - Your request parameters '+\ + 5: TMDBRequestInvalid('Invalid parameters - Your request parameters ' + 'are incorrect.'), - 6: TMDBRequestInvalid('Invalid id - The pre-requisite id is invalid '+\ + 6: TMDBRequestInvalid('Invalid id - The pre-requisite id is invalid ' + 'or not found.'), 7: TMDBKeyInvalid('Invalid API key - You must be granted a valid key.'), - 8: TMDBRequestError('Duplicate entry - The data you tried to submit '+\ + 8: TMDBRequestError('Duplicate entry - The data you tried to submit ' + 'already exists.'), 9: TMDBOffline('This service is tempirarily offline. Try again later.'), - 10: TMDBKeyRevoked('Suspended API key - Access to your account has been '+\ - 'suspended, contact TMDB.'), - 11: TMDBError('Internal error - Something went wrong. Contact TMDb.'), - 12: None, - 13: None, - 14: TMDBRequestError('Authentication Failed.'), - 15: TMDBError('Failed'), - 16: TMDBError('Device Denied'), - 17: TMDBError('Session Denied')} + 10: TMDBKeyRevoked('Suspended API key - Access to your account has been ' + + 'suspended, contact TMDB.'), + 11: TMDBError('Internal error - Something went wrong. Contact TMDb.'), + 12: None, + 13: None, + 14: TMDBRequestError('Authentication Failed.'), + 15: TMDBError('Failed'), + 16: TMDBError('Device Denied'), + 17: TMDBError('Session Denied')} def handle_status(data, query): status = status_handlers[data.get('status_code', 1)] diff --git a/libs/tmdb3/tmdb_api.py b/libs/tmdb3/tmdb_api.py index b5cb0a9..1c8fabd 100755 --- a/libs/tmdb3/tmdb_api.py +++ b/libs/tmdb3/tmdb_api.py @@ -13,8 +13,8 @@ # (http://creativecommons.org/licenses/GPL/2.0/) #----------------------- -__title__ = "tmdb_api - Simple-to-use Python interface to TMDB's API v3 "+\ - "(www.themoviedb.org)" +__title__ = ("tmdb_api - Simple-to-use Python interface to TMDB's API v3 " + + "(www.themoviedb.org)") __author__ = "Raymond Wagner" __purpose__ = """ This Python library is intended to provide a series of classes and methods @@ -22,7 +22,7 @@ for search and retrieval of text metadata and image URLs from TMDB. Preliminary API specifications can be found at http://help.themoviedb.org/kb/api/about-3""" -__version__="v0.6.17" +__version__ = "v0.7.0" # 0.1.0 Initial development # 0.2.0 Add caching mechanism for API queries # 0.2.1 Temporary work around for broken search paging @@ -59,8 +59,9 @@ __version__="v0.6.17" # 0.6.14 Add support for Lists # 0.6.15 Add ability to search Collections # 0.6.16 Make absent primary images return None (previously u'') -# 0.6.17 Add userrating/votes to Image, add overview to Collection, remove +# 0.6.17 Add userrating/votes to Image, add overview to Collection, remove # releasedate sorting from Collection Movies +# 0.7.0 Add support for television series data from request import set_key, Request from util import Datapoint, Datalist, Datadict, Element, NameRepr, SearchRepr @@ -69,10 +70,14 @@ from locales import get_locale, set_locale from tmdb_auth import get_session, set_session from tmdb_exceptions import * +import json +import urllib +import urllib2 import datetime DEBUG = False + def process_date(datestr): try: return datetime.date(*[int(x) for x in datestr.split('-')]) @@ -82,34 +87,40 @@ def process_date(datestr): import traceback _,_,tb = sys.exc_info() f,l,_,_ = traceback.extract_tb(tb)[-1] - warnings.warn_explicit(('"{0}" is not a supported date format. ' - 'Please fix upstream data at http://www.themoviedb.org.')\ - .format(datestr), Warning, f, l) + warnings.warn_explicit(('"{0}" is not a supported date format. ' + + 'Please fix upstream data at ' + + 'http://www.themoviedb.org.' + ).format(datestr), Warning, f, l) return None -class Configuration( Element ): + +class Configuration(Element): images = Datapoint('images') + def _populate(self): return Request('configuration') + Configuration = Configuration() -class Account( NameRepr, Element ): + +class Account(NameRepr, Element): def _populate(self): return Request('account', session_id=self._session.sessionid) - id = Datapoint('id') - adult = Datapoint('include_adult') - country = Datapoint('iso_3166_1') - language = Datapoint('iso_639_1') - name = Datapoint('name') - username = Datapoint('username') + id = Datapoint('id') + adult = Datapoint('include_adult') + country = Datapoint('iso_3166_1') + language = Datapoint('iso_639_1') + name = Datapoint('name') + username = Datapoint('username') @property def locale(self): return get_locale(self.language, self.country) + def searchMovie(query, locale=None, adult=False, year=None): - kwargs = {'query':query, 'include_adult':adult} + kwargs = {'query': query, 'include_adult': adult} if year is not None: try: kwargs['year'] = year.year @@ -117,6 +128,7 @@ def searchMovie(query, locale=None, adult=False, year=None): kwargs['year'] = year return MovieSearchResult(Request('search/movie', **kwargs), locale=locale) + def searchMovieWithYear(query, locale=None, adult=False): year = None if (len(query) > 6) and (query[-1] == ')') and (query[-6] == '('): @@ -134,70 +146,95 @@ def searchMovieWithYear(query, locale=None, adult=False): year = None return searchMovie(query, locale, adult, year) -class MovieSearchResult( SearchRepr, PagedRequest ): + +class MovieSearchResult(SearchRepr, PagedRequest): """Stores a list of search matches.""" _name = None def __init__(self, request, locale=None): if locale is None: locale = get_locale() super(MovieSearchResult, self).__init__( - request.new(language=locale.language), - lambda x: Movie(raw=x, locale=locale)) + request.new(language=locale.language), + lambda x: Movie(raw=x, locale=locale)) + +def searchSeries(query, first_air_date_year=None, search_type=None, locale=None): + return SeriesSearchResult( + Request('search/tv', query=query, first_air_date_year=first_air_date_year, search_type=search_type), + locale=locale) + + +class SeriesSearchResult(SearchRepr, PagedRequest): + """Stores a list of search matches.""" + _name = None + def __init__(self, request, locale=None): + if locale is None: + locale = get_locale() + super(SeriesSearchResult, self).__init__( + request.new(language=locale.language), + lambda x: Series(raw=x, locale=locale)) def searchPerson(query, adult=False): return PeopleSearchResult(Request('search/person', query=query, include_adult=adult)) -class PeopleSearchResult( SearchRepr, PagedRequest ): + +class PeopleSearchResult(SearchRepr, PagedRequest): """Stores a list of search matches.""" _name = None def __init__(self, request): - super(PeopleSearchResult, self).__init__(request, - lambda x: Person(raw=x)) + super(PeopleSearchResult, self).__init__( + request, lambda x: Person(raw=x)) + def searchStudio(query): return StudioSearchResult(Request('search/company', query=query)) -class StudioSearchResult( SearchRepr, PagedRequest ): + +class StudioSearchResult(SearchRepr, PagedRequest): """Stores a list of search matches.""" _name = None def __init__(self, request): - super(StudioSearchResult, self).__init__(request, - lambda x: Studio(raw=x)) + super(StudioSearchResult, self).__init__( + request, lambda x: Studio(raw=x)) + def searchList(query, adult=False): ListSearchResult(Request('search/list', query=query, include_adult=adult)) -class ListSearchResult( SearchRepr, PagedRequest ): + +class ListSearchResult(SearchRepr, PagedRequest): """Stores a list of search matches.""" _name = None def __init__(self, request): - super(ListSearchResult, self).__init__(request, - lambda x: List(raw=x)) + super(ListSearchResult, self).__init__( + request, lambda x: List(raw=x)) + def searchCollection(query, locale=None): return CollectionSearchResult(Request('search/collection', query=query), locale=locale) -class CollectionSearchResult( SearchRepr, PagedRequest ): + +class CollectionSearchResult(SearchRepr, PagedRequest): """Stores a list of search matches.""" _name=None def __init__(self, request, locale=None): if locale is None: locale = get_locale() super(CollectionSearchResult, self).__init__( - request.new(language=locale.language), - lambda x: Collection(raw=x, locale=locale)) - -class Image( Element ): - filename = Datapoint('file_path', initarg=1, - handler=lambda x: x.lstrip('/')) - aspectratio = Datapoint('aspect_ratio') - height = Datapoint('height') - width = Datapoint('width') - language = Datapoint('iso_639_1') - userrating = Datapoint('vote_average') - votes = Datapoint('vote_count') + request.new(language=locale.language), + lambda x: Collection(raw=x, locale=locale)) + + +class Image(Element): + filename = Datapoint('file_path', initarg=1, + handler=lambda x: x.lstrip('/')) + aspectratio = Datapoint('aspect_ratio') + height = Datapoint('height') + width = Datapoint('width') + language = Datapoint('iso_639_1') + userrating = Datapoint('vote_average') + votes = Datapoint('vote_count') def sizes(self): return ['original'] @@ -205,19 +242,28 @@ class Image( Element ): def geturl(self, size='original'): if size not in self.sizes(): raise TMDBImageSizeError - url = Configuration.images['base_url'].rstrip('/') + url = Configuration.images['secure_base_url'].rstrip('/') return url+'/{0}/{1}'.format(size, self.filename) # sort preferring locale's language, but keep remaining ordering consistent def __lt__(self, other): + if not isinstance(other, Image): + return False return (self.language == self._locale.language) \ and (self.language != other.language) + def __gt__(self, other): + if not isinstance(other, Image): + return True return (self.language != other.language) \ and (other.language == self._locale.language) + # direct match for comparison def __eq__(self, other): + if not isinstance(other, Image): + return False return self.filename == other.filename + # special handling for boolean to see if exists def __nonzero__(self): if len(self.filename) == 0: @@ -228,20 +274,28 @@ class Image( Element ): # BASE62 encoded filename, no need to worry about unicode return u"<{0.__class__.__name__} '{0.filename}'>".format(self) -class Backdrop( Image ): + +class Backdrop(Image): def sizes(self): return Configuration.images['backdrop_sizes'] -class Poster( Image ): + + +class Poster(Image): def sizes(self): return Configuration.images['poster_sizes'] -class Profile( Image ): + + +class Profile(Image): def sizes(self): return Configuration.images['profile_sizes'] -class Logo( Image ): + + +class Logo(Image): def sizes(self): return Configuration.images['logo_sizes'] -class AlternateTitle( Element ): + +class AlternateTitle(Element): country = Datapoint('iso_3166_1') title = Datapoint('title') @@ -249,28 +303,31 @@ class AlternateTitle( Element ): def __lt__(self, other): return (self.country == self._locale.country) \ and (self.country != other.country) + def __gt__(self, other): return (self.country != other.country) \ and (other.country == self._locale.country) + def __eq__(self, other): return self.country == other.country def __repr__(self): return u"<{0.__class__.__name__} '{0.title}' ({0.country})>"\ - .format(self).encode('utf-8') - -class Person( Element ): - id = Datapoint('id', initarg=1) - name = Datapoint('name') - biography = Datapoint('biography') - dayofbirth = Datapoint('birthday', default=None, handler=process_date) - dayofdeath = Datapoint('deathday', default=None, handler=process_date) - homepage = Datapoint('homepage') - birthplace = Datapoint('place_of_birth') - profile = Datapoint('profile_path', handler=Profile, \ - raw=False, default=None) - adult = Datapoint('adult') - aliases = Datalist('also_known_as') + .format(self).encode('utf-8') + + +class Person(Element): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + biography = Datapoint('biography') + dayofbirth = Datapoint('birthday', default=None, handler=process_date) + dayofdeath = Datapoint('deathday', default=None, handler=process_date) + homepage = Datapoint('homepage') + birthplace = Datapoint('place_of_birth') + profile = Datapoint('profile_path', handler=Profile, + raw=False, default=None) + adult = Datapoint('adult') + aliases = Datalist('also_known_as') def __repr__(self): return u"<{0.__class__.__name__} '{0.name}'>"\ @@ -278,55 +335,63 @@ class Person( Element ): def _populate(self): return Request('person/{0}'.format(self.id)) + def _populate_credits(self): - return Request('person/{0}/credits'.format(self.id), \ - language=self._locale.language) + return Request('person/{0}/credits'.format(self.id), + language=self._locale.language) def _populate_images(self): return Request('person/{0}/images'.format(self.id)) - roles = Datalist('cast', handler=lambda x: ReverseCast(raw=x), \ - poller=_populate_credits) - crew = Datalist('crew', handler=lambda x: ReverseCrew(raw=x), \ - poller=_populate_credits) - profiles = Datalist('profiles', handler=Profile, poller=_populate_images) + roles = Datalist('cast', handler=lambda x: ReverseCast(raw=x), + poller=_populate_credits) + crew = Datalist('crew', handler=lambda x: ReverseCrew(raw=x), + poller=_populate_credits) + profiles = Datalist('profiles', handler=Profile, poller=_populate_images) + -class Cast( Person ): - character = Datapoint('character') - order = Datapoint('order') +class Cast(Person): + character = Datapoint('character') + order = Datapoint('order') def __repr__(self): return u"<{0.__class__.__name__} '{0.name}' as '{0.character}'>"\ - .format(self).encode('utf-8') + .format(self).encode('utf-8') + -class Crew( Person ): - job = Datapoint('job') - department = Datapoint('department') +class Crew(Person): + job = Datapoint('job') + department = Datapoint('department') def __repr__(self): return u"<{0.__class__.__name__} '{0.name}','{0.job}'>"\ - .format(self).encode('utf-8') + .format(self).encode('utf-8') + -class Keyword( Element ): +class Keyword(Element): id = Datapoint('id') name = Datapoint('name') def __repr__(self): - return u"<{0.__class__.__name__} {0.name}>".format(self).encode('utf-8') + return u"<{0.__class__.__name__} {0.name}>"\ + .format(self).encode('utf-8') -class Release( Element ): - certification = Datapoint('certification') - country = Datapoint('iso_3166_1') - releasedate = Datapoint('release_date', handler=process_date) + +class Release(Element): + certification = Datapoint('certification') + country = Datapoint('iso_3166_1') + releasedate = Datapoint('release_date', handler=process_date) def __repr__(self): return u"<{0.__class__.__name__} {0.country}, {0.releasedate}>"\ - .format(self).encode('utf-8') + .format(self).encode('utf-8') -class Trailer( Element ): - name = Datapoint('name') - size = Datapoint('size') - source = Datapoint('source') -class YoutubeTrailer( Trailer ): +class Trailer(Element): + name = Datapoint('name') + size = Datapoint('size') + source = Datapoint('source') + + +class YoutubeTrailer(Trailer): def geturl(self): return "http://www.youtube.com/watch?v={0}".format(self.source) @@ -334,8 +399,9 @@ class YoutubeTrailer( Trailer ): # modified BASE64 encoding, no need to worry about unicode return u"<{0.__class__.__name__} '{0.name}'>".format(self) -class AppleTrailer( Element ): - name = Datapoint('name') + +class AppleTrailer(Element): + name = Datapoint('name') sources = Datadict('sources', handler=Trailer, attr='size') def sizes(self): @@ -344,84 +410,91 @@ class AppleTrailer( Element ): def geturl(self, size=None): if size is None: # sort assuming ###p format for now, take largest resolution - size = str(sorted([int(size[:-1]) for size in self.sources])[-1])+'p' + size = str(sorted( + [int(size[:-1]) for size in self.sources] + )[-1]) + 'p' return self.sources[size].source def __repr__(self): return u"<{0.__class__.__name__} '{0.name}'>".format(self) -class Translation( Element ): - name = Datapoint('name') - language = Datapoint('iso_639_1') - englishname = Datapoint('english_name') + +class Translation(Element): + name = Datapoint('name') + language = Datapoint('iso_639_1') + englishname = Datapoint('english_name') def __repr__(self): return u"<{0.__class__.__name__} '{0.name}' ({0.language})>"\ - .format(self).encode('utf-8') + .format(self).encode('utf-8') -class Genre( NameRepr, Element ): - id = Datapoint('id') - name = Datapoint('name') + +class Genre(NameRepr, Element): + id = Datapoint('id') + name = Datapoint('name') def _populate_movies(self): return Request('genre/{0}/movies'.format(self.id), \ - language=self._locale.language) + language=self._locale.language) @property def movies(self): if 'movies' not in self._data: search = MovieSearchResult(self._populate_movies(), \ - locale=self._locale) + locale=self._locale) search._name = "{0.name} Movies".format(self) self._data['movies'] = search return self._data['movies'] @classmethod def getAll(cls, locale=None): - class GenreList( Element ): + class GenreList(Element): genres = Datalist('genres', handler=Genre) + def _populate(self): return Request('genre/list', language=self._locale.language) return GenreList(locale=locale).genres - - -class Studio( NameRepr, Element ): - id = Datapoint('id', initarg=1) - name = Datapoint('name') - description = Datapoint('description') - headquarters = Datapoint('headquarters') - logo = Datapoint('logo_path', handler=Logo, \ - raw=False, default=None) + + +class Studio(NameRepr, Element): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + description = Datapoint('description') + headquarters = Datapoint('headquarters') + logo = Datapoint('logo_path', handler=Logo, raw=False, default=None) # FIXME: manage not-yet-defined handlers in a way that will propogate # locale information properly - parent = Datapoint('parent_company', \ - handler=lambda x: Studio(raw=x)) + parent = Datapoint('parent_company', handler=lambda x: Studio(raw=x)) def _populate(self): return Request('company/{0}'.format(self.id)) + def _populate_movies(self): - return Request('company/{0}/movies'.format(self.id), \ - language=self._locale.language) + return Request('company/{0}/movies'.format(self.id), + language=self._locale.language) # FIXME: add a cleaner way of adding types with no additional processing @property def movies(self): if 'movies' not in self._data: - search = MovieSearchResult(self._populate_movies(), \ - locale=self._locale) + search = MovieSearchResult(self._populate_movies(), + locale=self._locale) search._name = "{0.name} Movies".format(self) self._data['movies'] = search return self._data['movies'] -class Country( NameRepr, Element ): - code = Datapoint('iso_3166_1') - name = Datapoint('name') -class Language( NameRepr, Element ): - code = Datapoint('iso_639_1') - name = Datapoint('name') +class Country(NameRepr, Element): + code = Datapoint('iso_3166_1') + name = Datapoint('name') + + +class Language(NameRepr, Element): + code = Datapoint('iso_639_1') + name = Datapoint('name') + -class Movie( Element ): +class Movie(Element): @classmethod def latest(cls): req = Request('latest/movie') @@ -459,7 +532,7 @@ class Movie( Element ): account = Account(session=session) res = MovieSearchResult( Request('account/{0}/favorite_movies'.format(account.id), - session_id=session.sessionid)) + session_id=session.sessionid)) res._name = "Favorites" return res @@ -470,7 +543,7 @@ class Movie( Element ): account = Account(session=session) res = MovieSearchResult( Request('account/{0}/rated_movies'.format(account.id), - session_id=session.sessionid)) + session_id=session.sessionid)) res._name = "Movies You Rated" return res @@ -481,7 +554,7 @@ class Movie( Element ): account = Account(session=session) res = MovieSearchResult( Request('account/{0}/movie_watchlist'.format(account.id), - session_id=session.sessionid)) + session_id=session.sessionid)) res._name = "Movies You're Watching" return res @@ -500,104 +573,116 @@ class Movie( Element ): movie._populate() return movie - id = Datapoint('id', initarg=1) - title = Datapoint('title') - originaltitle = Datapoint('original_title') - tagline = Datapoint('tagline') - overview = Datapoint('overview') - runtime = Datapoint('runtime') - budget = Datapoint('budget') - revenue = Datapoint('revenue') - releasedate = Datapoint('release_date', handler=process_date) - homepage = Datapoint('homepage') - imdb = Datapoint('imdb_id') - - backdrop = Datapoint('backdrop_path', handler=Backdrop, \ - raw=False, default=None) - poster = Datapoint('poster_path', handler=Poster, \ - raw=False, default=None) - - popularity = Datapoint('popularity') - userrating = Datapoint('vote_average') - votes = Datapoint('vote_count') - - adult = Datapoint('adult') - collection = Datapoint('belongs_to_collection', handler=lambda x: \ + id = Datapoint('id', initarg=1) + title = Datapoint('title') + originaltitle = Datapoint('original_title') + tagline = Datapoint('tagline') + overview = Datapoint('overview') + runtime = Datapoint('runtime') + budget = Datapoint('budget') + revenue = Datapoint('revenue') + releasedate = Datapoint('release_date', handler=process_date) + homepage = Datapoint('homepage') + imdb = Datapoint('imdb_id') + + backdrop = Datapoint('backdrop_path', handler=Backdrop, + raw=False, default=None) + poster = Datapoint('poster_path', handler=Poster, + raw=False, default=None) + + popularity = Datapoint('popularity') + userrating = Datapoint('vote_average') + votes = Datapoint('vote_count') + + adult = Datapoint('adult') + collection = Datapoint('belongs_to_collection', handler=lambda x: \ Collection(raw=x)) - genres = Datalist('genres', handler=Genre) - studios = Datalist('production_companies', handler=Studio) - countries = Datalist('production_countries', handler=Country) - languages = Datalist('spoken_languages', handler=Language) + genres = Datalist('genres', handler=Genre) + studios = Datalist('production_companies', handler=Studio) + countries = Datalist('production_countries', handler=Country) + languages = Datalist('spoken_languages', handler=Language) def _populate(self): return Request('movie/{0}'.format(self.id), \ - language=self._locale.language) + language=self._locale.language) + def _populate_titles(self): kwargs = {} if not self._locale.fallthrough: kwargs['country'] = self._locale.country - return Request('movie/{0}/alternative_titles'.format(self.id), **kwargs) + return Request('movie/{0}/alternative_titles'.format(self.id), + **kwargs) + def _populate_cast(self): return Request('movie/{0}/casts'.format(self.id)) + def _populate_images(self): kwargs = {} if not self._locale.fallthrough: kwargs['language'] = self._locale.language return Request('movie/{0}/images'.format(self.id), **kwargs) + def _populate_keywords(self): return Request('movie/{0}/keywords'.format(self.id)) + def _populate_releases(self): return Request('movie/{0}/releases'.format(self.id)) + def _populate_trailers(self): - return Request('movie/{0}/trailers'.format(self.id), \ + return Request('movie/{0}/trailers'.format(self.id), language=self._locale.language) + def _populate_translations(self): return Request('movie/{0}/translations'.format(self.id)) alternate_titles = Datalist('titles', handler=AlternateTitle, \ - poller=_populate_titles, sort=True) - cast = Datalist('cast', handler=Cast, \ - poller=_populate_cast, sort='order') - crew = Datalist('crew', handler=Crew, poller=_populate_cast) - backdrops = Datalist('backdrops', handler=Backdrop, \ - poller=_populate_images, sort=True) - posters = Datalist('posters', handler=Poster, \ - poller=_populate_images, sort=True) - keywords = Datalist('keywords', handler=Keyword, \ - poller=_populate_keywords) - releases = Datadict('countries', handler=Release, \ - poller=_populate_releases, attr='country') - youtube_trailers = Datalist('youtube', handler=YoutubeTrailer, \ - poller=_populate_trailers) - apple_trailers = Datalist('quicktime', handler=AppleTrailer, \ - poller=_populate_trailers) - translations = Datalist('translations', handler=Translation, \ - poller=_populate_translations) + poller=_populate_titles, sort=True) + + # FIXME: this data point will need to be changed to 'credits' at some point + cast = Datalist('cast', handler=Cast, + poller=_populate_cast, sort='order') + + crew = Datalist('crew', handler=Crew, poller=_populate_cast) + backdrops = Datalist('backdrops', handler=Backdrop, + poller=_populate_images, sort=True) + posters = Datalist('posters', handler=Poster, + poller=_populate_images, sort=True) + keywords = Datalist('keywords', handler=Keyword, + poller=_populate_keywords) + releases = Datadict('countries', handler=Release, + poller=_populate_releases, attr='country') + youtube_trailers = Datalist('youtube', handler=YoutubeTrailer, + poller=_populate_trailers) + apple_trailers = Datalist('quicktime', handler=AppleTrailer, + poller=_populate_trailers) + translations = Datalist('translations', handler=Translation, + poller=_populate_translations) def setFavorite(self, value): - req = Request('account/{0}/favorite'.format(\ - Account(session=self._session).id), - session_id=self._session.sessionid) - req.add_data({'movie_id':self.id, 'favorite':str(bool(value)).lower()}) + req = Request('account/{0}/favorite'.format( + Account(session=self._session).id), + session_id=self._session.sessionid) + req.add_data({'movie_id': self.id, + 'favorite': str(bool(value)).lower()}) req.lifetime = 0 req.readJSON() def setRating(self, value): if not (0 <= value <= 10): raise TMDBError("Ratings must be between '0' and '10'.") - req = Request('movie/{0}/rating'.format(self.id), \ - session_id=self._session.sessionid) + req = Request('movie/{0}/rating'.format(self.id), + session_id=self._session.sessionid) req.lifetime = 0 req.add_data({'value':value}) req.readJSON() def setWatchlist(self, value): - req = Request('account/{0}/movie_watchlist'.format(\ - Account(session=self._session).id), - session_id=self._session.sessionid) + req = Request('account/{0}/movie_watchlist'.format( + Account(session=self._session).id), + session_id=self._session.sessionid) req.lifetime = 0 - req.add_data({'movie_id':self.id, - 'movie_watchlist':str(bool(value)).lower()}) + req.add_data({'movie_id': self.id, + 'movie_watchlist': str(bool(value)).lower()}) req.readJSON() def getSimilar(self): @@ -605,9 +690,9 @@ class Movie( Element ): @property def similar(self): - res = MovieSearchResult(Request('movie/{0}/similar_movies'\ - .format(self.id)), - locale=self._locale) + res = MovieSearchResult(Request( + 'movie/{0}/similar_movies'.format(self.id)), + locale=self._locale) res._name = 'Similar to {0}'.format(self._printable_name()) return res @@ -629,61 +714,197 @@ class Movie( Element ): return s def __repr__(self): - return u"<{0} {1}>".format(self.__class__.__name__,\ + return u"<{0} {1}>".format(self.__class__.__name__, self._printable_name()).encode('utf-8') + class ReverseCast( Movie ): - character = Datapoint('character') + character = Datapoint('character') def __repr__(self): - return u"<{0.__class__.__name__} '{0.character}' on {1}>"\ - .format(self, self._printable_name()).encode('utf-8') + return (u"<{0.__class__.__name__} '{0.character}' on {1}>" + .format(self, self._printable_name()).encode('utf-8')) + class ReverseCrew( Movie ): - department = Datapoint('department') - job = Datapoint('job') + department = Datapoint('department') + job = Datapoint('job') def __repr__(self): - return u"<{0.__class__.__name__} '{0.job}' for {1}>"\ - .format(self, self._printable_name()).encode('utf-8') + return (u"<{0.__class__.__name__} '{0.job}' for {1}>" + .format(self, self._printable_name()).encode('utf-8')) + -class Collection( NameRepr, Element ): - id = Datapoint('id', initarg=1) - name = Datapoint('name') +class Collection(NameRepr, Element): + id = Datapoint('id', initarg=1) + name = Datapoint('name') backdrop = Datapoint('backdrop_path', handler=Backdrop, \ - raw=False, default=None) - poster = Datapoint('poster_path', handler=Poster, \ - raw=False, default=None) - members = Datalist('parts', handler=Movie) + raw=False, default=None) + poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) + members = Datalist('parts', handler=Movie) overview = Datapoint('overview') def _populate(self): - return Request('collection/{0}'.format(self.id), \ - language=self._locale.language) + return Request('collection/{0}'.format(self.id), + language=self._locale.language) + def _populate_images(self): kwargs = {} if not self._locale.fallthrough: kwargs['language'] = self._locale.language return Request('collection/{0}/images'.format(self.id), **kwargs) - backdrops = Datalist('backdrops', handler=Backdrop, \ - poller=_populate_images, sort=True) - posters = Datalist('posters', handler=Poster, \ - poller=_populate_images, sort=True) + backdrops = Datalist('backdrops', handler=Backdrop, + poller=_populate_images, sort=True) + posters = Datalist('posters', handler=Poster, + poller=_populate_images, sort=True) -class List( NameRepr, Element ): - id = Datapoint('id', initarg=1) - name = Datapoint('name') - author = Datapoint('created_by') +class List(NameRepr, Element): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + author = Datapoint('created_by') description = Datapoint('description') - favorites = Datapoint('favorite_count') - language = Datapoint('iso_639_1') - count = Datapoint('item_count') - poster = Datapoint('poster_path', handler=Poster, \ - raw=False, default=None) - - members = Datalist('items', handler=Movie) + favorites = Datapoint('favorite_count') + language = Datapoint('iso_639_1') + count = Datapoint('item_count') + poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) + members = Datalist('items', handler=Movie) def _populate(self): return Request('list/{0}'.format(self.id)) +class Network(NameRepr,Element): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + +class Episode(NameRepr, Element): + episode_number = Datapoint('episode_number', initarg=3) + season_number = Datapoint('season_number', initarg=2) + series_id = Datapoint('series_id', initarg=1) + air_date = Datapoint('air_date', handler=process_date) + overview = Datapoint('overview') + name = Datapoint('name') + userrating = Datapoint('vote_average') + votes = Datapoint('vote_count') + id = Datapoint('id') + production_code = Datapoint('production_code') + still = Datapoint('still_path', handler=Backdrop, raw=False, default=None) + + def _populate(self): + return Request('tv/{0}/season/{1}/episode/{2}'.format(self.series_id, self.season_number, self.episode_number), + language=self._locale.language) + + def _populate_cast(self): + return Request('tv/{0}/season/{1}/episode/{2}/credits'.format( + self.series_id, self.season_number, self.episode_number), + language=self._locale.language) + + def _populate_external_ids(self): + return Request('tv/{0}/season/{1}/episode/{2}/external_ids'.format( + self.series_id, self.season_number, self.episode_number)) + + def _populate_images(self): + kwargs = {} + if not self._locale.fallthrough: + kwargs['language'] = self._locale.language + return Request('tv/{0}/season/{1}/episode/{2}/images'.format( + self.series_id, self.season_number, self.episode_number), **kwargs) + + cast = Datalist('cast', handler=Cast, + poller=_populate_cast, sort='order') + guest_stars = Datalist('guest_stars', handler=Cast, + poller=_populate_cast, sort='order') + crew = Datalist('crew', handler=Crew, poller=_populate_cast) + imdb_id = Datapoint('imdb_id', poller=_populate_external_ids) + freebase_id = Datapoint('freebase_id', poller=_populate_external_ids) + freebase_mid = Datapoint('freebase_mid', poller=_populate_external_ids) + tvdb_id = Datapoint('tvdb_id', poller=_populate_external_ids) + tvrage_id = Datapoint('tvrage_id', poller=_populate_external_ids) + stills = Datalist('stills', handler=Backdrop, poller=_populate_images, sort=True) + +class Season(NameRepr, Element): + season_number = Datapoint('season_number', initarg=2) + series_id = Datapoint('series_id', initarg=1) + id = Datapoint('id') + air_date = Datapoint('air_date', handler=process_date) + poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) + overview = Datapoint('overview') + name = Datapoint('name') + episodes = Datadict('episodes', attr='episode_number', handler=Episode, + passthrough={'series_id': 'series_id', 'season_number': 'season_number'}) + + def _populate(self): + return Request('tv/{0}/season/{1}'.format(self.series_id, self.season_number), + language=self._locale.language) + + def _populate_images(self): + kwargs = {} + if not self._locale.fallthrough: + kwargs['language'] = self._locale.language + return Request('tv/{0}/season/{1}/images'.format(self.series_id, self.season_number), **kwargs) + + def _populate_external_ids(self): + return Request('tv/{0}/season/{1}/external_ids'.format(self.series_id, self.season_number)) + + posters = Datalist('posters', handler=Poster, + poller=_populate_images, sort=True) + + freebase_id = Datapoint('freebase_id', poller=_populate_external_ids) + freebase_mid = Datapoint('freebase_mid', poller=_populate_external_ids) + tvdb_id = Datapoint('tvdb_id', poller=_populate_external_ids) + tvrage_id = Datapoint('tvrage_id', poller=_populate_external_ids) + +class Series(NameRepr, Element): + id = Datapoint('id', initarg=1) + backdrop = Datapoint('backdrop_path', handler=Backdrop, raw=False, default=None) + authors = Datalist('created_by', handler=Person) + episode_run_times = Datalist('episode_run_time') + first_air_date = Datapoint('first_air_date', handler=process_date) + last_air_date = Datapoint('last_air_date', handler=process_date) + genres = Datalist('genres', handler=Genre) + homepage = Datapoint('homepage') + in_production = Datapoint('in_production') + languages = Datalist('languages') + origin_countries = Datalist('origin_country') + name = Datapoint('name') + original_name = Datapoint('original_name') + number_of_episodes = Datapoint('number_of_episodes') + number_of_seasons = Datapoint('number_of_seasons') + overview = Datapoint('overview') + popularity = Datapoint('popularity') + status = Datapoint('status') + userrating = Datapoint('vote_average') + votes = Datapoint('vote_count') + poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) + networks = Datalist('networks', handler=Network) + seasons = Datadict('seasons', attr='season_number', handler=Season, passthrough={'id': 'series_id'}) + + def _populate(self): + return Request('tv/{0}'.format(self.id), + language=self._locale.language) + + def _populate_cast(self): + return Request('tv/{0}/credits'.format(self.id)) + + def _populate_images(self): + kwargs = {} + if not self._locale.fallthrough: + kwargs['language'] = self._locale.language + return Request('tv/{0}/images'.format(self.id), **kwargs) + + def _populate_external_ids(self): + return Request('tv/{0}/external_ids'.format(self.id)) + + cast = Datalist('cast', handler=Cast, + poller=_populate_cast, sort='order') + crew = Datalist('crew', handler=Crew, poller=_populate_cast) + backdrops = Datalist('backdrops', handler=Backdrop, + poller=_populate_images, sort=True) + posters = Datalist('posters', handler=Poster, + poller=_populate_images, sort=True) + + imdb_id = Datapoint('imdb_id', poller=_populate_external_ids) + freebase_id = Datapoint('freebase_id', poller=_populate_external_ids) + freebase_mid = Datapoint('freebase_mid', poller=_populate_external_ids) + tvdb_id = Datapoint('tvdb_id', poller=_populate_external_ids) + tvrage_id = Datapoint('tvrage_id', poller=_populate_external_ids) diff --git a/libs/tmdb3/tmdb_auth.py b/libs/tmdb3/tmdb_auth.py index 8583b99..b447b5a 100755 --- a/libs/tmdb3/tmdb_auth.py +++ b/libs/tmdb3/tmdb_auth.py @@ -11,7 +11,7 @@ from datetime import datetime as _pydatetime, \ tzinfo as _pytzinfo import re -class datetime( _pydatetime ): +class datetime(_pydatetime): """Customized datetime class with ISO format parsing.""" _reiso = re.compile('(?P[0-9]{4})' '-(?P[0-9]{1,2})' @@ -27,21 +27,27 @@ class datetime( _pydatetime ): '(?P[0-9]{2})?' ')?') - class _tzinfo( _pytzinfo): + class _tzinfo(_pytzinfo): def __init__(self, direc='+', hr=0, min=0): if direc == '-': hr = -1*int(hr) self._offset = timedelta(hours=int(hr), minutes=int(min)) - def utcoffset(self, dt): return self._offset - def tzname(self, dt): return '' - def dst(self, dt): return timedelta(0) + + def utcoffset(self, dt): + return self._offset + + def tzname(self, dt): + return '' + + def dst(self, dt): + return timedelta(0) @classmethod def fromIso(cls, isotime, sep='T'): match = cls._reiso.match(isotime) if match is None: - raise TypeError("time data '%s' does not match ISO 8601 format" \ - % isotime) + raise TypeError("time data '%s' does not match ISO 8601 format" + % isotime) dt = [int(a) for a in match.groups()[:5]] if match.group('sec') is not None: @@ -52,9 +58,9 @@ class datetime( _pydatetime ): if match.group('tz') == 'Z': tz = cls._tzinfo() elif match.group('tzmin'): - tz = cls._tzinfo(*match.group('tzdirec','tzhour','tzmin')) + tz = cls._tzinfo(*match.group('tzdirec', 'tzhour', 'tzmin')) else: - tz = cls._tzinfo(*match.group('tzdirec','tzhour')) + tz = cls._tzinfo(*match.group('tzdirec', 'tzhour')) dt.append(0) dt.append(tz) return cls(*dt) @@ -64,10 +70,12 @@ from tmdb_exceptions import * syssession = None + def set_session(sessionid): global syssession syssession = Session(sessionid) + def get_session(sessionid=None): global syssession if sessionid: @@ -77,8 +85,8 @@ def get_session(sessionid=None): else: return Session.new() -class Session( object ): +class Session(object): @classmethod def new(cls): return cls(None) @@ -91,9 +99,9 @@ class Session( object ): if self._sessionid is None: if self._authtoken is None: raise TMDBError("No Auth Token to produce Session for") - # TODO: check authtokenexpiration against current time - req = Request('authentication/session/new', \ - request_token=self._authtoken) + # TODO: check authtoken expiration against current time + req = Request('authentication/session/new', + request_token=self._authtoken) req.lifetime = 0 dat = req.readJSON() if not dat['success']: @@ -128,4 +136,3 @@ class Session( object ): @property def callbackurl(self): return "http://www.themoviedb.org/authenticate/"+self._authtoken - diff --git a/libs/tmdb3/tmdb_exceptions.py b/libs/tmdb3/tmdb_exceptions.py index 35e0364..f85fbcf 100755 --- a/libs/tmdb3/tmdb_exceptions.py +++ b/libs/tmdb3/tmdb_exceptions.py @@ -6,23 +6,24 @@ # Author: Raymond Wagner #----------------------- -class TMDBError( Exception ): - Error = 0 - KeyError = 10 - KeyMissing = 20 - KeyInvalid = 30 - KeyRevoked = 40 - RequestError = 50 - RequestInvalid = 51 - PagingIssue = 60 - CacheError = 70 - CacheReadError = 71 - CacheWriteError = 72 - CacheDirectoryError = 73 - ImageSizeError = 80 - HTTPError = 90 - Offline = 100 - LocaleError = 110 + +class TMDBError(Exception): + Error = 0 + KeyError = 10 + KeyMissing = 20 + KeyInvalid = 30 + KeyRevoked = 40 + RequestError = 50 + RequestInvalid = 51 + PagingIssue = 60 + CacheError = 70 + CacheReadError = 71 + CacheWriteError = 72 + CacheDirectoryError = 73 + ImageSizeError = 80 + HTTPError = 90 + Offline = 100 + LocaleError = 110 def __init__(self, msg=None, errno=0): self.errno = errno @@ -30,60 +31,77 @@ class TMDBError( Exception ): self.errno = getattr(self, 'TMDB'+self.__class__.__name__, errno) self.args = (msg,) -class TMDBKeyError( TMDBError ): + +class TMDBKeyError(TMDBError): pass -class TMDBKeyMissing( TMDBKeyError ): + +class TMDBKeyMissing(TMDBKeyError): pass -class TMDBKeyInvalid( TMDBKeyError ): + +class TMDBKeyInvalid(TMDBKeyError): pass -class TMDBKeyRevoked( TMDBKeyInvalid ): + +class TMDBKeyRevoked(TMDBKeyInvalid): pass -class TMDBRequestError( TMDBError ): + +class TMDBRequestError(TMDBError): pass -class TMDBRequestInvalid( TMDBRequestError ): + +class TMDBRequestInvalid(TMDBRequestError): pass -class TMDBPagingIssue( TMDBRequestError ): + +class TMDBPagingIssue(TMDBRequestError): pass -class TMDBCacheError( TMDBRequestError ): + +class TMDBCacheError(TMDBRequestError): pass -class TMDBCacheReadError( TMDBCacheError ): + +class TMDBCacheReadError(TMDBCacheError): def __init__(self, filename): super(TMDBCacheReadError, self).__init__( - "User does not have permission to access cache file: {0}.".format(filename)) + "User does not have permission to access cache file: {0}."\ + .format(filename)) self.filename = filename -class TMDBCacheWriteError( TMDBCacheError ): + +class TMDBCacheWriteError(TMDBCacheError): def __init__(self, filename): super(TMDBCacheWriteError, self).__init__( - "User does not have permission to write cache file: {0}.".format(filename)) + "User does not have permission to write cache file: {0}."\ + .format(filename)) self.filename = filename -class TMDBCacheDirectoryError( TMDBCacheError ): + +class TMDBCacheDirectoryError(TMDBCacheError): def __init__(self, filename): super(TMDBCacheDirectoryError, self).__init__( - "Directory containing cache file does not exist: {0}.".format(filename)) + "Directory containing cache file does not exist: {0}."\ + .format(filename)) self.filename = filename -class TMDBImageSizeError( TMDBError ): + +class TMDBImageSizeError(TMDBError ): pass -class TMDBHTTPError( TMDBError ): + +class TMDBHTTPError(TMDBError): def __init__(self, err): self.httperrno = err.code self.response = err.fp.read() super(TMDBHTTPError, self).__init__(str(err)) -class TMDBOffline( TMDBError ): - pass -class TMDBLocaleError( TMDBError ): +class TMDBOffline(TMDBError): pass + +class TMDBLocaleError(TMDBError): + pass diff --git a/libs/tmdb3/util.py b/libs/tmdb3/util.py index bba9fcc..a0d2e28 100755 --- a/libs/tmdb3/util.py +++ b/libs/tmdb3/util.py @@ -10,13 +10,15 @@ from copy import copy from locales import get_locale from tmdb_auth import get_session -class NameRepr( object ): + +class NameRepr(object): """Mixin for __repr__ methods using 'name' attribute.""" def __repr__(self): return u"<{0.__class__.__name__} '{0.name}'>"\ - .format(self).encode('utf-8') + .format(self).encode('utf-8') + -class SearchRepr( object ): +class SearchRepr(object): """ Mixin for __repr__ methods for classes with '_name' and '_request' attributes. @@ -25,10 +27,11 @@ class SearchRepr( object ): name = self._name if self._name else self._request._kwargs['query'] return u"".format(name).encode('utf-8') -class Poller( object ): + +class Poller(object): """ - Wrapper for an optional callable to populate an Element derived class - with raw data, or data from a Request. + Wrapper for an optional callable to populate an Element derived + class with raw data, or data from a Request. """ def __init__(self, func, lookup, inst=None): self.func = func @@ -60,7 +63,7 @@ class Poller( object ): if not callable(self.func): raise RuntimeError('Poller object called without a source function') req = self.func() - if (('language' in req._kwargs) or ('country' in req._kwargs)) \ + if ('language' in req._kwargs) or ('country' in req._kwargs) \ and self.inst._locale.fallthrough: # request specifies a locale filter, and fallthrough is enabled # run a first pass with specified filter @@ -79,7 +82,7 @@ class Poller( object ): def apply(self, data, set_nones=True): # apply data directly, bypassing callable function unfilled = False - for k,v in self.lookup.items(): + for k, v in self.lookup.items(): if (k in data) and \ ((data[k] is not None) if callable(self.func) else True): # argument received data, populate it @@ -100,32 +103,38 @@ class Poller( object ): unfilled = True return unfilled -class Data( object ): + +class Data(object): """ Basic response definition class This maps to a single key in a JSON dictionary received from the API """ def __init__(self, field, initarg=None, handler=None, poller=None, - raw=True, default=u'', lang=False): + raw=True, default=u'', lang=None, passthrough={}): """ - This defines how the dictionary value is to be processed by the poller - field -- defines the dictionary key that filters what data this uses - initarg -- (optional) specifies that this field must be supplied - when creating a new instance of the Element class this - definition is mapped to. Takes an integer for the order - it should be used in the input arguments - handler -- (optional) callable used to process the received value - before being stored in the Element object. - poller -- (optional) callable to be used if data is requested and - this value has not yet been defined. the callable should - return a dictionary of data from a JSON query. many - definitions may share a single poller, which will be - and the data used to populate all referenced definitions - based off their defined field - raw -- (optional) if the specified handler is an Element class, - the data will be passed into it using the 'raw' keyword - attribute. setting this to false will force the data to - instead be passed in as the first argument + This defines how the dictionary value is to be processed by the + poller + field -- defines the dictionary key that filters what data + this uses + initarg -- (optional) specifies that this field must be + supplied when creating a new instance of the Element + class this definition is mapped to. Takes an integer + for the order it should be used in the input + arguments + handler -- (optional) callable used to process the received + value before being stored in the Element object. + poller -- (optional) callable to be used if data is requested + and this value has not yet been defined. the + callable should return a dictionary of data from a + JSON query. many definitions may share a single + poller, which will be and the data used to populate + all referenced definitions based off their defined + field + raw -- (optional) if the specified handler is an Element + class, the data will be passed into it using the + 'raw' keyword attribute. setting this to false + will force the data to instead be passed in as the + first argument """ self.field = field self.initarg = initarg @@ -133,6 +142,7 @@ class Data( object ): self.raw = raw self.default = default self.sethandler(handler) + self.passthrough = passthrough def __get__(self, inst, owner): if inst is None: @@ -151,6 +161,9 @@ class Data( object ): if isinstance(value, Element): value._locale = inst._locale value._session = inst._session + + for source, dest in self.passthrough: + setattr(value, dest, getattr(inst, source)) inst._data[self.field] = value def sethandler(self, handler): @@ -162,37 +175,44 @@ class Data( object ): else: self.handler = lambda x: handler(x) -class Datapoint( Data ): + +class Datapoint(Data): pass -class Datalist( Data ): + +class Datalist(Data): """ Response definition class for list data This maps to a key in a JSON dictionary storing a list of data """ - def __init__(self, field, handler=None, poller=None, sort=None, raw=True): + def __init__(self, field, handler=None, poller=None, sort=None, raw=True, passthrough={}): """ - This defines how the dictionary value is to be processed by the poller - field -- defines the dictionary key that filters what data this uses - handler -- (optional) callable used to process the received value - before being stored in the Element object. - poller -- (optional) callable to be used if data is requested and - this value has not yet been defined. the callable should - return a dictionary of data from a JSON query. many - definitions may share a single poller, which will be - and the data used to populate all referenced definitions - based off their defined field - sort -- (optional) name of attribute in resultant data to be used - to sort the list after processing. this effectively - a handler be defined to process the data into something - that has attributes - raw -- (optional) if the specified handler is an Element class, - the data will be passed into it using the 'raw' keyword - attribute. setting this to false will force the data to - instead be passed in as the first argument + This defines how the dictionary value is to be processed by the + poller + field -- defines the dictionary key that filters what data + this uses + handler -- (optional) callable used to process the received + value before being stored in the Element object. + poller -- (optional) callable to be used if data is requested + and this value has not yet been defined. the + callable should return a dictionary of data from a + JSON query. many definitions may share a single + poller, which will be and the data used to populate + all referenced definitions based off their defined + field + sort -- (optional) name of attribute in resultant data to be + used to sort the list after processing. this + effectively requires a handler be defined to process + the data into something that has attributes + raw -- (optional) if the specified handler is an Element + class, the data will be passed into it using the + 'raw' keyword attribute. setting this to false will + force the data to instead be passed in as the first + argument """ - super(Datalist, self).__init__(field, None, handler, poller, raw) + super(Datalist, self).__init__(field, None, handler, poller, raw, passthrough=passthrough) self.sort = sort + def __set__(self, inst, value): data = [] if value: @@ -201,6 +221,10 @@ class Datalist( Data ): if isinstance(val, Element): val._locale = inst._locale val._session = inst._session + + for source, dest in self.passthrough.items(): + setattr(val, dest, getattr(inst, source)) + data.append(val) if self.sort: if self.sort is True: @@ -209,45 +233,52 @@ class Datalist( Data ): data.sort(key=lambda x: getattr(x, self.sort)) inst._data[self.field] = data -class Datadict( Data ): + +class Datadict(Data): """ Response definition class for dictionary data This maps to a key in a JSON dictionary storing a dictionary of data """ def __init__(self, field, handler=None, poller=None, raw=True, - key=None, attr=None): + key=None, attr=None, passthrough={}): """ - This defines how the dictionary value is to be processed by the poller - field -- defines the dictionary key that filters what data this uses - handler -- (optional) callable used to process the received value - before being stored in the Element object. - poller -- (optional) callable to be used if data is requested and - this value has not yet been defined. the callable should - return a dictionary of data from a JSON query. many - definitions may share a single poller, which will be - and the data used to populate all referenced definitions - based off their defined field - key -- (optional) name of key in resultant data to be used as - the key in the stored dictionary. if this is not the - field name from the source data is used instead - attr -- (optional) name of attribute in resultant data to be used + This defines how the dictionary value is to be processed by the + poller + field -- defines the dictionary key that filters what data + this uses + handler -- (optional) callable used to process the received + value before being stored in the Element object. + poller -- (optional) callable to be used if data is requested + and this value has not yet been defined. the + callable should return a dictionary of data from a + JSON query. many definitions may share a single + poller, which will be and the data used to populate + all referenced definitions based off their defined + field + key -- (optional) name of key in resultant data to be used as the key in the stored dictionary. if this is not the field name from the source data is used instead - raw -- (optional) if the specified handler is an Element class, - the data will be passed into it using the 'raw' keyword - attribute. setting this to false will force the data to - instead be passed in as the first argument + attr -- (optional) name of attribute in resultant data to be + used as the key in the stored dictionary. if this is + not the field name from the source data is used + instead + raw -- (optional) if the specified handler is an Element + class, the data will be passed into it using the + 'raw' keyword attribute. setting this to false will + force the data to instead be passed in as the first + argument """ if key and attr: raise TypeError("`key` and `attr` cannot both be defined") - super(Datadict, self).__init__(field, None, handler, poller, raw) + super(Datadict, self).__init__(field, None, handler, poller, raw, passthrough=passthrough) if key: self.getkey = lambda x: x[key] elif attr: self.getkey = lambda x: getattr(x, attr) else: - raise TypeError("Datadict requires `key` or `attr` be defined "+\ + raise TypeError("Datadict requires `key` or `attr` be defined " + "for populating the dictionary") + def __set__(self, inst, value): data = {} if value: @@ -256,6 +287,10 @@ class Datadict( Data ): if isinstance(val, Element): val._locale = inst._locale val._session = inst._session + + for source, dest in self.passthrough.items(): + setattr(val, dest, getattr(inst, source)) + data[self.getkey(val)] = val inst._data[self.field] = data @@ -286,7 +321,7 @@ class ElementType( type ): # extract copies of each defined Poller function # from parent classes pollers[k] = attr.func - for k,attr in attrs.items(): + for k, attr in attrs.items(): if isinstance(attr, Data): data[k] = attr if '_populate' in attrs: @@ -295,9 +330,9 @@ class ElementType( type ): # process all defined Data attribues, testing for use as an initial # argument, and building a list of what Pollers are used to populate # which Data points - pollermap = dict([(k,[]) for k in pollers]) + pollermap = dict([(k, []) for k in pollers]) initargs = [] - for k,v in data.items(): + for k, v in data.items(): v.name = k if v.initarg: initargs.append(v) @@ -313,7 +348,7 @@ class ElementType( type ): # wrap each used poller function with a Poller class, and push into # the new class attributes - for k,v in pollermap.items(): + for k, v in pollermap.items(): if len(v) == 0: continue lookup = dict([(attr.field, attr.name) for attr in v]) @@ -326,8 +361,8 @@ class ElementType( type ): attrs[attr.name] = attr # build sorted list of arguments used for intialization - attrs['_InitArgs'] = tuple([a.name for a in \ - sorted(initargs, key=lambda x: x.initarg)]) + attrs['_InitArgs'] = tuple( + [a.name for a in sorted(initargs, key=lambda x: x.initarg)]) return type.__new__(mcs, name, bases, attrs) def __call__(cls, *args, **kwargs): @@ -346,21 +381,23 @@ class ElementType( type ): if 'raw' in kwargs: # if 'raw' keyword is supplied, create populate object manually if len(args) != 0: - raise TypeError('__init__() takes exactly 2 arguments (1 given)') + raise TypeError( + '__init__() takes exactly 2 arguments (1 given)') obj._populate.apply(kwargs['raw'], False) else: # if not, the number of input arguments must exactly match that # defined by the Data definitions if len(args) != len(cls._InitArgs): - raise TypeError('__init__() takes exactly {0} arguments ({1} given)'\ + raise TypeError( + '__init__() takes exactly {0} arguments ({1} given)'\ .format(len(cls._InitArgs)+1, len(args)+1)) - for a,v in zip(cls._InitArgs, args): + for a, v in zip(cls._InitArgs, args): setattr(obj, a, v) obj.__init__() return obj + class Element( object ): __metaclass__ = ElementType _lang = 'en' -