diff --git a/README.md b/README.md index 8d1e5b8..e38ea0e 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,23 @@ Linux (ubuntu / debian): * Make it executable. `sudo chmod +x /etc/init.d/couchpotato` * Add it to defaults. `sudo update-rc.d couchpotato defaults` * Open your browser and go to: `http://localhost:5050/` + + +FreeBSD : + +* Update your ports tree `sudo portsnap fetch update` +* Install Python 2.6+ [lang/python](http://www.freshports.org/lang/python) with `cd /usr/ports/lang/python; sudo make install clean` +* Install port [databases/py-sqlite3](http://www.freshports.org/databases/py-sqlite3) with `cd /usr/ports/databases/py-sqlite3; sudo make install clean` +* Add a symlink to 'python2' `sudo ln -s /usr/local/bin/python /usr/local/bin/python2` +* Install port [ftp/libcurl](http://www.freshports.org/ftp/libcurl) with `cd /usr/ports/ftp/fpc-libcurl; sudo make install clean` +* Install port [ftp/curl](http://www.freshports.org/ftp/bcurl), deselect 'Asynchronous DNS resolution via c-ares' when prompted as part of config `cd /usr/ports/ftp/fpc-libcurl; sudo make install clean` +* Install port [textproc/docbook-xml-450](http://www.freshports.org/textproc/docbook-xml-450) with `cd /usr/ports/textproc/docbook-xml-450; sudo make install clean` +* Install port [GIT](http://git-scm.com/) with `cd /usr/ports/devel/git; sudo make install clean` +* 'cd' to the folder of your choosing. +* Run `git clone https://github.com/RuudBurger/CouchPotatoServer.git` +* Then run `sudo python CouchPotatoServer/CouchPotato.py` to start for the first time +* To run on boot copy the init script. `sudo cp CouchPotatoServer/init/freebsd /etc/rc.d/couchpotato` +* Change the paths inside the init script. `sudo vim /etc/init.d/couchpotato` +* Make init script executable. `sudo chmod +x /etc/rc.d/couchpotato` +* Add init to startup. `sudo echo 'couchpotato_enable="YES"' >> /etc/rc.conf` +* Open your browser and go to: `http://server:5050/` diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 7058fac..8dc691d 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -1,84 +1,85 @@ -from couchpotato.api import api_docs, api_docs_missing +from couchpotato.api import api_docs, api_docs_missing, api from couchpotato.core.auth import requires_auth from couchpotato.core.event import fireEvent -from couchpotato.core.helpers.request import getParams, jsonified from couchpotato.core.helpers.variable import md5 from couchpotato.core.logger import CPLog from couchpotato.environment import Env -from flask.app import Flask -from flask.blueprints import Blueprint -from flask.globals import request -from flask.helpers import url_for -from flask.templating import render_template from sqlalchemy.engine import create_engine from sqlalchemy.orm import scoped_session from sqlalchemy.orm.session import sessionmaker -from werkzeug.utils import redirect +from tornado import template +from tornado.web import RequestHandler import os import time log = CPLog(__name__) -app = Flask(__name__, static_folder = 'nope') -web = Blueprint('web', __name__) +views = {} +template_loader = template.Loader(os.path.join(os.path.dirname(__file__), 'templates')) +# Main web handler +@requires_auth +class WebHandler(RequestHandler): + def get(self, route, *args, **kwargs): + route = route.strip('/') + if not views.get(route): + page_not_found(self) + return + self.write(views[route]()) + +def addView(route, func, static = False): + views[route] = func def get_session(engine = None): return Env.getSession(engine) -def addView(route, func, static = False): - web.add_url_rule(route + ('' if static else '/'), endpoint = route if route else 'index', view_func = func) -""" Web view """ -@web.route('/') -@requires_auth +# Web view def index(): - return render_template('index.html', sep = os.sep, fireEvent = fireEvent, env = Env) + return template_loader.load('index.html').generate(sep = os.sep, fireEvent = fireEvent, Env = Env) +addView('', index) -""" Api view """ -@web.route('docs/') -@requires_auth +# API docs def apiDocs(): - from couchpotato import app routes = [] - for route, x in sorted(app.view_functions.iteritems()): - if route[0:4] == 'api.': - routes += [route[4:].replace('::', '.')] + + for route in api.iterkeys(): + routes.append(route) if api_docs.get(''): del api_docs[''] del api_docs_missing[''] - return render_template('api.html', fireEvent = fireEvent, routes = sorted(routes), api_docs = api_docs, api_docs_missing = sorted(api_docs_missing)) -@web.route('getkey/') -def getApiKey(): + return template_loader.load('api.html').generate(fireEvent = fireEvent, routes = sorted(routes), api_docs = api_docs, api_docs_missing = sorted(api_docs_missing), Env = Env) - api = None - params = getParams() - username = Env.setting('username') - password = Env.setting('password') +addView('docs', apiDocs) - if (params.get('u') == md5(username) or not username) and (params.get('p') == password or not password): - api = Env.setting('api_key') +# Make non basic auth option to get api key +class KeyHandler(RequestHandler): + def get(self, *args, **kwargs): + api = None + username = Env.setting('username') + password = Env.setting('password') - return jsonified({ - 'success': api is not None, - 'api_key': api - }) + if (self.get_argument('u') == md5(username) or not username) and (self.get_argument('p') == password or not password): + api = Env.setting('api_key') -@app.errorhandler(404) -def page_not_found(error): - index_url = url_for('web.index') - url = request.path[len(index_url):] + self.write({ + 'success': api is not None, + 'api_key': api + }) + +def page_not_found(rh): + index_url = Env.get('web_base') + url = rh.request.uri[len(index_url):] if url[:3] != 'api': - if request.path != '/': - r = request.url.replace(request.path, index_url + '#' + url) - else: - r = '%s%s' % (request.url.rstrip('/'), index_url + '#' + url) - return redirect(r) + r = index_url + '#' + url.lstrip('/') + rh.redirect(r) else: if not Env.get('dev'): time.sleep(0.1) - return 'Wrong API key used', 404 + + rh.set_status(404) + rh.write('Wrong API key used') diff --git a/couchpotato/api.py b/couchpotato/api.py index 58fd310..029ebce 100644 --- a/couchpotato/api.py +++ b/couchpotato/api.py @@ -1,20 +1,22 @@ -from flask.blueprints import Blueprint -from flask.helpers import url_for +from couchpotato.core.helpers.request import getParams from tornado.web import RequestHandler, asynchronous -from werkzeug.utils import redirect +import json +import urllib -api = Blueprint('api', __name__) -api_docs = {} -api_docs_missing = [] +api = {} api_nonblock = {} +api_docs = {} +api_docs_missing = [] +# NonBlock API handler class NonBlockHandler(RequestHandler): stoppers = [] @asynchronous - def get(self, route): + def get(self, route, *args, **kwargs): + route = route.strip('/') start, stop = api_nonblock[route] self.stoppers.append(stop) @@ -32,25 +34,51 @@ class NonBlockHandler(RequestHandler): self.stoppers = [] +def addNonBlockApiView(route, func_tuple, docs = None, **kwargs): + api_nonblock[route] = func_tuple -def addApiView(route, func, static = False, docs = None, **kwargs): - api.add_url_rule(route + ('' if static else '/'), endpoint = route.replace('.', '::') if route else 'index', view_func = func, **kwargs) if docs: api_docs[route[4:] if route[0:4] == 'api.' else route] = docs else: api_docs_missing.append(route) -def addNonBlockApiView(route, func_tuple, docs = None, **kwargs): - api_nonblock[route] = func_tuple +# Blocking API handler +class ApiHandler(RequestHandler): + + def get(self, route, *args, **kwargs): + route = route.strip('/') + if not api.get(route): + self.write('API call doesn\'t seem to exist') + return + + kwargs = {} + for x in self.request.arguments: + kwargs[x] = urllib.unquote(self.get_argument(x)) + + # Split array arguments + kwargs = getParams(kwargs) + + # Remove t random string + try: del kwargs['t'] + except: pass + + # Check JSONP callback + result = api[route](**kwargs) + jsonp_callback = self.get_argument('callback_func', default = None) + + if jsonp_callback: + self.write(str(jsonp_callback) + '(' + json.dumps(result) + ')') + elif isinstance(result, (tuple)) and result[0] == 'redirect': + self.redirect(result[1]) + else: + self.write(result) + +def addApiView(route, func, static = False, docs = None, **kwargs): + + if static: func(route) + else: api[route] = func if docs: api_docs[route[4:] if route[0:4] == 'api.' else route] = docs else: api_docs_missing.append(route) - -""" Api view """ -def index(): - index_url = url_for('web.index') - return redirect(index_url + 'docs/') - -addApiView('', index) diff --git a/couchpotato/core/_base/_core/main.py b/couchpotato/core/_base/_core/main.py index d2b6e2d..4ad37d6 100644 --- a/couchpotato/core/_base/_core/main.py +++ b/couchpotato/core/_base/_core/main.py @@ -1,6 +1,5 @@ from couchpotato.api import addApiView from couchpotato.core.event import fireEvent, addEvent -from couchpotato.core.helpers.request import jsonified from couchpotato.core.helpers.variable import cleanHost, md5 from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin @@ -68,12 +67,12 @@ class Core(Plugin): return True - def available(self): - return jsonified({ + def available(self, **kwargs): + return { 'success': True - }) + } - def shutdown(self): + def shutdown(self, **kwargs): if self.shutdown_started: return False @@ -83,7 +82,7 @@ class Core(Plugin): return 'shutdown' - def restart(self): + def restart(self, **kwargs): if self.shutdown_started: return False @@ -156,10 +155,10 @@ class Core(Plugin): host = 'localhost' port = Env.setting('port') - return '%s:%d%s' % (cleanHost(host).rstrip('/'), int(port), '/' + Env.setting('url_base').lstrip('/') if Env.setting('url_base') else '') + return '%s:%d%s' % (cleanHost(host).rstrip('/'), int(port), Env.get('web_base')) def createApiUrl(self): - return '%s/api/%s' % (self.createBaseUrl(), Env.setting('api_key')) + return '%sapi/%s' % (self.createBaseUrl(), Env.setting('api_key')) def version(self): ver = fireEvent('updater.info', single = True) @@ -170,10 +169,10 @@ class Core(Plugin): return '%s - %s-%s - v2' % (platf, ver.get('version')['type'], ver.get('version')['hash']) - def versionView(self): - return jsonified({ + def versionView(self, **kwargs): + return { 'version': self.version() - }) + } def signalHandler(self): if Env.get('daemonized'): return diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index 323c2e4..fece6fa 100644 --- a/couchpotato/core/_base/clientscript/main.py +++ b/couchpotato/core/_base/clientscript/main.py @@ -8,7 +8,6 @@ from minify.cssmin import cssmin from minify.jsmin import jsmin import os import re -import time import traceback log = CPLog(__name__) @@ -122,7 +121,7 @@ class ClientScript(Plugin): # Combine all files together with some comments data = '' for r in raw: - data += self.comment.get(file_type) % (r.get('file'), r.get('date')) + data += self.comment.get(file_type) % (ss(r.get('file')), r.get('date')) data += r.get('data') + '\n\n' self.createFile(out, data.strip()) diff --git a/couchpotato/core/_base/updater/main.py b/couchpotato/core/_base/updater/main.py index e4e936c..38b7d36 100644 --- a/couchpotato/core/_base/updater/main.py +++ b/couchpotato/core/_base/updater/main.py @@ -1,7 +1,6 @@ from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent, fireEventAsync from couchpotato.core.helpers.encoding import ss -from couchpotato.core.helpers.request import jsonified from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.environment import Env @@ -36,7 +35,7 @@ class Updater(Plugin): addEvent('app.load', self.setCrons) addEvent('updater.info', self.info) - addApiView('updater.info', self.getInfo, docs = { + addApiView('updater.info', self.info, docs = { 'desc': 'Get updater information', 'return': { 'type': 'object', @@ -86,25 +85,24 @@ class Updater(Plugin): if self.updater.check(): if not self.available_notified and self.conf('notification') and not self.conf('automatic'): - fireEvent('updater.available', message = 'A new update is available', data = self.updater.info()) + info = self.updater.info() + version_date = datetime.fromtimestamp(info['update_version']['date']) + fireEvent('updater.available', message = 'A new update with hash "%s" is available, this version is from %s' % (info['update_version']['hash'], version_date), data = info) self.available_notified = True return True return False - def info(self): + def info(self, **kwargs): return self.updater.info() - def getInfo(self): - return jsonified(self.updater.info()) - - def checkView(self): - return jsonified({ + def checkView(self, **kwargs): + return { 'update_available': self.check(force = True), 'info': self.updater.info() - }) + } - def doUpdateView(self): + def doUpdateView(self, **kwargs): self.check() if not self.updater.update_version: @@ -119,9 +117,9 @@ class Updater(Plugin): if not success: success = True - return jsonified({ + return { 'success': success - }) + } class BaseUpdater(Plugin): @@ -138,9 +136,6 @@ class BaseUpdater(Plugin): def doUpdate(self): pass - def getInfo(self): - return jsonified(self.info()) - def info(self): return { 'last_check': self.last_check, @@ -279,6 +274,7 @@ class SourceUpdater(BaseUpdater): if download_data.get('type') == 'zip': zip = zipfile.ZipFile(destination) zip.extractall(extracted_path) + zip.close() else: tar = tarfile.open(destination) tar.extractall(path = extracted_path) diff --git a/couchpotato/core/auth.py b/couchpotato/core/auth.py index 0111b9a..e58016b 100644 --- a/couchpotato/core/auth.py +++ b/couchpotato/core/auth.py @@ -1,26 +1,40 @@ from couchpotato.core.helpers.variable import md5 from couchpotato.environment import Env -from flask import request, Response -from functools import wraps +import base64 def check_auth(username, password): return username == Env.setting('username') and password == Env.setting('password') -def authenticate(): - return Response( - 'This is not the page you are looking for. *waves hand*', 401, - {'WWW-Authenticate': 'Basic realm="CouchPotato Login"'} - ) +def requires_auth(handler_class): -def requires_auth(f): + def wrap_execute(handler_execute): - @wraps(f) - def decorated(*args, **kwargs): - auth = getattr(request, 'authorization') - if Env.setting('username') and Env.setting('password'): - if (not auth or not check_auth(auth.username.decode('latin1'), md5(auth.password.decode('latin1').encode(Env.get('encoding'))))): - return authenticate() + def require_basic_auth(handler, kwargs): + if Env.setting('username') and Env.setting('password'): - return f(*args, **kwargs) + auth_header = handler.request.headers.get('Authorization') + auth_decoded = base64.decodestring(auth_header[6:]) if auth_header else None + if auth_decoded: + username, password = auth_decoded.split(':', 2) - return decorated + if auth_header is None or not auth_header.startswith('Basic ') or (not check_auth(username.decode('latin'), md5(password.decode('latin')))): + handler.set_status(401) + handler.set_header('WWW-Authenticate', 'Basic realm="CouchPotato Login"') + handler._transforms = [] + handler.finish() + + return False + + return True + + def _execute(self, transforms, *args, **kwargs): + + if not require_basic_auth(self, kwargs): + return False + return handler_execute(self, transforms, *args, **kwargs) + + return _execute + + handler_class._execute = wrap_execute(handler_class._execute) + + return handler_class diff --git a/couchpotato/core/downloaders/base.py b/couchpotato/core/downloaders/base.py index 8ccc136..900fd8c 100644 --- a/couchpotato/core/downloaders/base.py +++ b/couchpotato/core/downloaders/base.py @@ -16,7 +16,7 @@ class Downloader(Provider): torrent_sources = [ 'http://torrage.com/torrent/%s.torrent', - 'http://torcache.net/torrent/%s.torrent', + 'https://torcache.net/torrent/%s.torrent', ] torrent_trackers = [ diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index fc54f02..43061e1 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -104,12 +104,21 @@ class NZBGet(Downloader): nzb_id = [param['Value'] for param in item['Parameters'] if param['Name'] == 'couchpotato'][0] except: nzb_id = item['NZBID'] + + + timeleft = -1 + try: + if item['ActiveDownloads'] > 0 and item['DownloadRate'] > 0 and not (status['DownloadPaused'] or status['Download2Paused']): + timeleft = str(timedelta(seconds = item['RemainingSizeMB'] / status['DownloadRate'] * 2 ^ 20)) + except: + pass + statuses.append({ 'id': nzb_id, 'name': item['NZBFilename'], 'original_status': 'DOWNLOADING' if item['ActiveDownloads'] > 0 else 'QUEUED', # Seems to have no native API function for time left. This will return the time left after NZBGet started downloading this item - 'timeleft': str(timedelta(seconds = item['RemainingSizeMB'] / status['DownloadRate'] * 2 ^ 20)) if item['ActiveDownloads'] > 0 and not (status['DownloadPaused'] or status['Download2Paused']) else -1, + 'timeleft': timeleft, }) for item in queue: # 'Parameters' is not passed in rpc.postqueue diff --git a/couchpotato/core/downloaders/sabnzbd/__init__.py b/couchpotato/core/downloaders/sabnzbd/__init__.py index 6c976f1..f17db9c 100644 --- a/couchpotato/core/downloaders/sabnzbd/__init__.py +++ b/couchpotato/core/downloaders/sabnzbd/__init__.py @@ -11,7 +11,7 @@ config = [{ 'list': 'download_providers', 'name': 'sabnzbd', 'label': 'Sabnzbd', - 'description': 'Use SABnzbd to download NZBs.', + 'description': 'Use SABnzbd (0.7+) to download NZBs.', 'wizard': True, 'options': [ { diff --git a/couchpotato/core/downloaders/synology/__init__.py b/couchpotato/core/downloaders/synology/__init__.py index 00a135d..8be16f6 100644 --- a/couchpotato/core/downloaders/synology/__init__.py +++ b/couchpotato/core/downloaders/synology/__init__.py @@ -18,7 +18,7 @@ config = [{ 'name': 'enabled', 'default': 0, 'type': 'enabler', - 'radio_group': 'torrent', + 'radio_group': 'nzb,torrent', }, { 'name': 'host', @@ -33,6 +33,13 @@ config = [{ 'type': 'password', }, { + 'name': 'use_for', + 'label': 'Use for', + 'default': 'both', + 'type': 'dropdown', + 'values': [('usenet & torrents', 'both'), ('usenet', 'nzb'), ('torrent', 'torrent')], + }, + { 'name': 'manual', 'default': 0, 'type': 'bool', diff --git a/couchpotato/core/downloaders/synology/main.py b/couchpotato/core/downloaders/synology/main.py index 6e40598..8721274 100644 --- a/couchpotato/core/downloaders/synology/main.py +++ b/couchpotato/core/downloaders/synology/main.py @@ -1,22 +1,21 @@ from couchpotato.core.downloaders.base import Downloader from couchpotato.core.helpers.encoding import isInt from couchpotato.core.logger import CPLog -import httplib import json -import urllib -import urllib2 - +import requests log = CPLog(__name__) + class Synology(Downloader): - type = ['torrent_magnet'] + type = ['nzb', 'torrent', 'torrent_magnet'] log = CPLog(__name__) def download(self, data, movie, filedata = None): - log.error('Sending "%s" (%s) to Synology.', (data.get('name'), data.get('type'))) + response = False + log.error('Sending "%s" (%s) to Synology.', (data['name'], data['type'])) # Load host from config and split out port. host = self.conf('host').split(':') @@ -24,20 +23,41 @@ class Synology(Downloader): log.error('Config properties are not filled in correctly, port is missing.') return False - if data.get('type') == 'torrent': - log.error('Can\'t add binary torrent file') - return False - try: - # Send request to Transmission + # Send request to Synology srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) - remote_torrent = srpc.add_torrent_uri(data.get('url')) - log.info('Response: %s', remote_torrent) - return remote_torrent['success'] + if data['type'] == 'torrent_magnet': + log.info('Adding torrent URL %s', data['url']) + response = srpc.create_task(url = data['url']) + elif data['type'] in ['nzb', 'torrent']: + log.info('Adding %s' % data['type']) + if not filedata: + log.error('No %s data found' % data['type']) + else: + filename = data['name'] + '.' + data['type'] + response = srpc.create_task(filename = filename, filedata = filedata) except Exception, err: log.error('Exception while adding torrent: %s', err) - return False + finally: + return response + + def getEnabledDownloadType(self): + if self.conf('use_for') == 'both': + return super(Synology, self).getEnabledDownloadType() + elif self.conf('use_for') == 'torrent': + return ['torrent', 'torrent_magnet'] + else: + return ['nzb'] + def isEnabled(self, manual, data = {}): + for_type = ['both'] + if data and 'torrent' in data.get('type'): + for_type.append('torrent') + elif data: + for_type.append(data.get('type')) + + return super(Synology, self).isEnabled(manual, data) and\ + ((self.conf('use_for') in for_type)) class SynologyRPC(object): @@ -58,11 +78,13 @@ class SynologyRPC(object): args = {'api': 'SYNO.API.Auth', 'account': self.username, 'passwd': self.password, 'version': 2, 'method': 'login', 'session': self.session_name, 'format': 'sid'} response = self._req(self.auth_url, args) - if response['success'] == True: + if response['success']: self.sid = response['data']['sid'] - log.debug('Sid=%s', self.sid) - return response - elif self.username or self.password: + log.debug('sid=%s', self.sid) + else: + log.error('Couldn\'t login to Synology, %s', response) + return response['success'] + else: log.error('User or password missing, not using authentication.') return False @@ -70,36 +92,51 @@ class SynologyRPC(object): args = {'api':'SYNO.API.Auth', 'version':1, 'method':'logout', 'session':self.session_name, '_sid':self.sid} return self._req(self.auth_url, args) - def _req(self, url, args): - req_url = url + '?' + urllib.urlencode(args) + def _req(self, url, args, files = None): + response = {'success': False} try: - req_open = urllib2.urlopen(req_url) - response = json.loads(req_open.read()) + req = requests.post(url, data = args, files = files) + req.raise_for_status() + response = json.loads(req.text) if response['success'] == True: log.info('Synology action successfull') return response - except httplib.InvalidURL, err: - log.error('Invalid Transmission host, check your config %s', err) - return False - except urllib2.HTTPError, err: + except requests.ConnectionError, err: + log.error('Synology connection error, check your config %s', err) + except requests.HTTPError, err: log.error('SynologyRPC HTTPError: %s', err) - return False - except urllib2.URLError, err: - log.error('Unable to connect to Synology %s', err) - return False + except Exception, err: + log.error('Exception: %s', err) + finally: + return response + + def create_task(self, url = None, filename = None, filedata = None): + ''' Creates new download task in Synology DownloadStation. Either specify + url or pair (filename, filedata). - def add_torrent_uri(self, torrent): - log.info('Adding torrent URL %s', torrent) - response = {} + Returns True if task was created, False otherwise + ''' + result = False # login - login = self._login() - if len(login) > 0 and login['success'] == True: - log.info('Login success, adding torrent') - args = {'api':'SYNO.DownloadStation.Task', 'version':1, 'method':'create', 'uri':torrent, '_sid':self.sid} - response = self._req(self.download_url, args) + if self._login(): + args = {'api': 'SYNO.DownloadStation.Task', + 'version': '1', + 'method': 'create', + '_sid': self.sid} + if url: + log.info('Login success, adding torrent URI') + args['uri'] = url + response = self._req(self.download_url, args = args) + log.info('Response: %s', response) + result = response['success'] + elif filename and filedata: + log.info('Login success, adding torrent') + files = {'file': (filename, filedata)} + response = self._req(self.download_url, args = args, files = files) + log.info('Response: %s', response) + result = response['success'] + else: + log.error('Invalid use of SynologyRPC.create_task: either url or filename+filedata must be specified') self._logout() - else: - log.error('Couldn\'t login to Synology, %s', login) - return response - + return result diff --git a/couchpotato/core/event.py b/couchpotato/core/event.py index 1fd6ab2..0e0b4a7 100644 --- a/couchpotato/core/event.py +++ b/couchpotato/core/event.py @@ -22,14 +22,22 @@ def addEvent(name, handler, priority = 100): def createHandle(*args, **kwargs): try: - parent = handler.im_self - bc = hasattr(parent, 'beforeCall') - if bc: parent.beforeCall(handler) + # Open handler + has_parent = hasattr(handler, 'im_self') + if has_parent: + parent = handler.im_self + bc = hasattr(parent, 'beforeCall') + if bc: parent.beforeCall(handler) + + # Main event h = runHandler(name, handler, *args, **kwargs) - ac = hasattr(parent, 'afterCall') - if ac: parent.afterCall(handler) + + # Close handler + if has_parent: + ac = hasattr(parent, 'afterCall') + if ac: parent.afterCall(handler) except: - h = runHandler(name, handler, *args, **kwargs) + log.error('Failed creating handler %s %s: %s', (name, handler, traceback.format_exc())) return h @@ -43,7 +51,7 @@ def removeEvent(name, handler): e -= handler def fireEvent(name, *args, **kwargs): - if not events.get(name): return + if not events.has_key(name): return e = Event(name = name, threads = 10, asynch = kwargs.get('async', False), exc_info = True, traceback = True, lock = threading.RLock()) @@ -133,8 +141,6 @@ def fireEvent(name, *args, **kwargs): options['on_complete']() return results - except KeyError, e: - pass except Exception: log.error('%s: %s', (name, traceback.format_exc())) diff --git a/couchpotato/core/helpers/request.py b/couchpotato/core/helpers/request.py index 3c6558b..c224979 100644 --- a/couchpotato/core/helpers/request.py +++ b/couchpotato/core/helpers/request.py @@ -1,15 +1,11 @@ from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.helpers.variable import natcmp -from flask.globals import current_app -from flask.helpers import json, make_response from urllib import unquote -from werkzeug.urls import url_decode -import flask import re -def getParams(): - params = url_decode(getattr(flask.request, 'environ').get('QUERY_STRING', '')) +def getParams(params): + reg = re.compile('^[a-z0-9_\.]+$') current = temp = {} @@ -36,6 +32,8 @@ def getParams(): current = current[item] else: temp[param] = toUnicode(unquote(value)) + if temp[param].lower() in ['true', 'false']: + temp[param] = temp[param].lower() != 'false' return dictToList(temp) @@ -54,29 +52,3 @@ def dictToList(params): new = params return new - -def getParam(attr, default = None): - try: - return getParams().get(attr, default) - except: - return default - -def padded_jsonify(callback, *args, **kwargs): - content = str(callback) + '(' + json.dumps(dict(*args, **kwargs)) + ')' - return getattr(current_app, 'response_class')(content, mimetype = 'text/javascript') - -def jsonify(mimetype, *args, **kwargs): - content = json.dumps(dict(*args, **kwargs)) - return getattr(current_app, 'response_class')(content, mimetype = mimetype) - -def jsonified(*args, **kwargs): - callback = getParam('callback_func', None) - if callback: - content = padded_jsonify(callback, *args, **kwargs) - else: - content = jsonify('application/json', *args, **kwargs) - - response = make_response(content) - response.cache_control.no_cache = True - - return response diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index 25def9a..fa8a8b5 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -181,5 +181,6 @@ def possibleTitles(raw_title): def randomString(size = 8, chars = string.ascii_uppercase + string.digits): return ''.join(random.choice(chars) for x in range(size)) -def splitString(str, split_on = ','): - return [x.strip() for x in str.split(split_on)] if str else [] +def splitString(str, split_on = ',', clean = True): + list = [x.strip() for x in str.split(split_on)] if str else [] + return filter(None, list) if clean else list diff --git a/couchpotato/core/logger.py b/couchpotato/core/logger.py index 68a6c3f..69a031f 100644 --- a/couchpotato/core/logger.py +++ b/couchpotato/core/logger.py @@ -4,7 +4,7 @@ import re class CPLog(object): context = '' - replace_private = ['api', 'apikey', 'api_key', 'password', 'username', 'h', 'uid', 'key'] + replace_private = ['api', 'apikey', 'api_key', 'password', 'username', 'h', 'uid', 'key', 'passkey'] def __init__(self, context = ''): if context.endswith('.main'): diff --git a/couchpotato/core/notifications/base.py b/couchpotato/core/notifications/base.py index 8d1608a..7418e1a 100644 --- a/couchpotato/core/notifications/base.py +++ b/couchpotato/core/notifications/base.py @@ -1,6 +1,5 @@ from couchpotato.api import addApiView from couchpotato.core.event import addEvent -from couchpotato.core.helpers.request import jsonified from couchpotato.core.logger import CPLog from couchpotato.core.providers.base import Provider from couchpotato.environment import Env @@ -50,7 +49,7 @@ class Notification(Provider): def notify(self, message = '', data = {}, listener = None): pass - def test(self): + def test(self, **kwargs): test_type = self.testNotifyName() @@ -62,7 +61,9 @@ class Notification(Provider): listener = 'test' ) - return jsonified({'success': success}) + return { + 'success': success + } def testNotifyName(self): return 'notify.%s.test' % self.getName().lower() diff --git a/couchpotato/core/notifications/core/main.py b/couchpotato/core/notifications/core/main.py index 6aacd23..b6c07f5 100644 --- a/couchpotato/core/notifications/core/main.py +++ b/couchpotato/core/notifications/core/main.py @@ -2,7 +2,6 @@ from couchpotato import get_session from couchpotato.api import addApiView, addNonBlockApiView from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.encoding import toUnicode -from couchpotato.core.helpers.request import jsonified, getParam from couchpotato.core.helpers.variable import tryInt, splitString from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification @@ -11,6 +10,7 @@ from couchpotato.environment import Env from sqlalchemy.sql.expression import or_ import threading import time +import traceback import uuid log = CPLog(__name__) @@ -62,11 +62,9 @@ class CoreNotifier(Notification): db.commit() - def markAsRead(self): + def markAsRead(self, ids = None, **kwargs): - ids = None - if getParam('ids'): - ids = splitString(getParam('ids')) + ids = splitString(ids) if ids else None db = get_session() @@ -79,14 +77,13 @@ class CoreNotifier(Notification): db.commit() - return jsonified({ + return { 'success': True - }) + } - def listView(self): + def listView(self, limit_offset = None, **kwargs): db = get_session() - limit_offset = getParam('limit_offset', None) q = db.query(Notif) @@ -105,11 +102,11 @@ class CoreNotifier(Notification): ndict['type'] = 'notification' notifications.append(ndict) - return jsonified({ + return { 'success': True, 'empty': len(notifications) == 0, 'notifications': notifications - }) + } def checkMessages(self): @@ -150,6 +147,8 @@ class CoreNotifier(Notification): def frontend(self, type = 'notification', data = {}, message = None): + log.debug('Notifying frontend') + self.m_lock.acquire() notification = { 'message_id': str(uuid.uuid4()), @@ -168,11 +167,13 @@ class CoreNotifier(Notification): 'result': [notification], }) except: - break + log.debug('Failed sending to listener: %s', traceback.format_exc()) self.m_lock.release() self.cleanMessages() + log.debug('Done notifying frontend') + def addListener(self, callback, last_id = None): if last_id: @@ -194,9 +195,11 @@ class CoreNotifier(Notification): if listener == callback: self.listeners.remove(list_tuple) except: - pass + log.debug('Failed removing listener: %s', traceback.format_exc()) def cleanMessages(self): + + log.debug('Cleaning messages') self.m_lock.acquire() for message in self.messages: @@ -204,8 +207,11 @@ class CoreNotifier(Notification): self.messages.remove(message) self.m_lock.release() + log.debug('Done cleaning messages') def getMessages(self, last_id): + + log.debug('Getting messages with id: %s', last_id) self.m_lock.acquire() recent = [] @@ -216,15 +222,16 @@ class CoreNotifier(Notification): recent = self.messages[index:] self.m_lock.release() + log.debug('Returning for %s %s messages', (last_id, len(recent or []))) return recent or [] - def listener(self): + def listener(self, init = False, **kwargs): messages = [] # Get unread - if getParam('init'): + if init: db = get_session() notifications = db.query(Notif) \ @@ -235,7 +242,7 @@ class CoreNotifier(Notification): ndict['type'] = 'notification' messages.append(ndict) - return jsonified({ + return { 'success': True, 'result': messages, - }) + } diff --git a/couchpotato/core/notifications/nmj/main.py b/couchpotato/core/notifications/nmj/main.py index cdf531d..695f53b 100644 --- a/couchpotato/core/notifications/nmj/main.py +++ b/couchpotato/core/notifications/nmj/main.py @@ -1,7 +1,6 @@ from couchpotato.api import addApiView from couchpotato.core.event import addEvent from couchpotato.core.helpers.encoding import tryUrlencode -from couchpotato.core.helpers.request import getParams, jsonified from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification import re @@ -22,10 +21,7 @@ class NMJ(Notification): addApiView(self.testNotifyName(), self.test) addApiView('notify.nmj.auto_config', self.autoConfig) - def autoConfig(self): - - params = getParams() - host = params.get('host', 'localhost') + def autoConfig(self, host = 'localhost', **kwargs): database = '' mount = '' @@ -63,11 +59,11 @@ class NMJ(Notification): log.error('Detected a network share on the Popcorn Hour, but could not get the mounting url') return self.failed() - return jsonified({ + return { 'success': True, 'database': database, 'mount': mount, - }) + } def addToLibrary(self, message = None, group = {}): if self.isDisabled(): return @@ -113,9 +109,13 @@ class NMJ(Notification): return True def failed(self): - return jsonified({'success': False}) + return { + 'success': False + } - def test(self): - return jsonified({'success': self.addToLibrary()}) + def test(self, **kwargs): + return { + 'success': self.addToLibrary() + } diff --git a/couchpotato/core/notifications/notifo/main.py b/couchpotato/core/notifications/notifo/main.py index 312055a..6e4d7ad 100644 --- a/couchpotato/core/notifications/notifo/main.py +++ b/couchpotato/core/notifications/notifo/main.py @@ -1,8 +1,8 @@ from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification -from flask.helpers import json import base64 +import json import traceback log = CPLog(__name__) diff --git a/couchpotato/core/notifications/plex/main.py b/couchpotato/core/notifications/plex/main.py index 95139ee..86da9cd 100644 --- a/couchpotato/core/notifications/plex/main.py +++ b/couchpotato/core/notifications/plex/main.py @@ -1,6 +1,5 @@ from couchpotato.core.event import addEvent from couchpotato.core.helpers.encoding import tryUrlencode -from couchpotato.core.helpers.request import jsonified from couchpotato.core.helpers.variable import cleanHost from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification @@ -73,7 +72,7 @@ class Plex(Notification): log.info('Plex notification to %s successful.', host) return True - def test(self): + def test(self, **kwargs): test_type = self.testNotifyName() @@ -86,4 +85,6 @@ class Plex(Notification): ) success2 = self.addToLibrary() - return jsonified({'success': success or success2}) + return { + 'success': success or success2 + } diff --git a/couchpotato/core/notifications/synoindex/main.py b/couchpotato/core/notifications/synoindex/main.py index 01bd2fc..315520e 100644 --- a/couchpotato/core/notifications/synoindex/main.py +++ b/couchpotato/core/notifications/synoindex/main.py @@ -1,5 +1,4 @@ from couchpotato.core.event import addEvent -from couchpotato.core.helpers.request import jsonified from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification import os @@ -32,5 +31,7 @@ class Synoindex(Notification): return True - def test(self): - return jsonified({'success': os.path.isfile(self.index_path)}) + def test(self, **kwargs): + return { + 'success': os.path.isfile(self.index_path) + } diff --git a/couchpotato/core/notifications/twitter/main.py b/couchpotato/core/notifications/twitter/main.py index 2db1974..59fbb3a 100644 --- a/couchpotato/core/notifications/twitter/main.py +++ b/couchpotato/core/notifications/twitter/main.py @@ -1,12 +1,10 @@ from couchpotato.api import addApiView from couchpotato.core.helpers.encoding import tryUrlencode -from couchpotato.core.helpers.request import jsonified, getParam from couchpotato.core.helpers.variable import cleanHost from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification -from flask.helpers import url_for +from couchpotato.environment import Env from pytwitter import Api, parse_qsl -from werkzeug.utils import redirect import oauth2 log = CPLog(__name__) @@ -70,10 +68,9 @@ class Twitter(Notification): return True - def getAuthorizationUrl(self): + def getAuthorizationUrl(self, host = None, **kwargs): - referer = getParam('host') - callback_url = cleanHost(referer) + '%snotify.%s.credentials/' % (url_for('api.index').lstrip('/'), self.getName().lower()) + callback_url = cleanHost(host) + '%snotify.%s.credentials/' % (Env.get('api_base').lstrip('/'), self.getName().lower()) oauth_consumer = oauth2.Consumer(self.consumer_key, self.consumer_secret) oauth_client = oauth2.Client(oauth_consumer) @@ -82,31 +79,29 @@ class Twitter(Notification): if resp['status'] != '200': log.error('Invalid response from Twitter requesting temp token: %s', resp['status']) - return jsonified({ + return { 'success': False, - }) + } else: self.request_token = dict(parse_qsl(content)) auth_url = self.urls['authorize'] + ("?oauth_token=%s" % self.request_token['oauth_token']) log.info('Redirecting to "%s"', auth_url) - return jsonified({ + return { 'success': True, 'url': auth_url, - }) + } - def getCredentials(self): - - key = getParam('oauth_verifier') + def getCredentials(self, oauth_verifier, **kwargs): token = oauth2.Token(self.request_token['oauth_token'], self.request_token['oauth_token_secret']) - token.set_verifier(key) + token.set_verifier(oauth_verifier) oauth_consumer = oauth2.Consumer(key = self.consumer_key, secret = self.consumer_secret) oauth_client = oauth2.Client(oauth_consumer, token) - resp, content = oauth_client.request(self.urls['access'], method = 'POST', body = 'oauth_verifier=%s' % key) + resp, content = oauth_client.request(self.urls['access'], method = 'POST', body = 'oauth_verifier=%s' % oauth_verifier) access_token = dict(parse_qsl(content)) if resp['status'] != '200': @@ -121,4 +116,4 @@ class Twitter(Notification): self.request_token = None - return redirect(url_for('web.index') + 'settings/notifications/') + return 'redirect', Env.get('web_base') + 'settings/notifications/' diff --git a/couchpotato/core/notifications/xbmc/__init__.py b/couchpotato/core/notifications/xbmc/__init__.py index a480845..e3c467c 100644 --- a/couchpotato/core/notifications/xbmc/__init__.py +++ b/couchpotato/core/notifications/xbmc/__init__.py @@ -32,6 +32,13 @@ config = [{ 'type': 'password', }, { + 'name': 'only_first', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Only update the first host when movie snatched, useful for synced XBMC', + }, + { 'name': 'on_snatch', 'default': 0, 'type': 'bool', diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index f60b917..ad6fa60 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -1,8 +1,10 @@ from couchpotato.core.helpers.variable import splitString from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification -from flask.helpers import json +from urllib2 import URLError import base64 +import json +import socket import traceback import urllib @@ -20,19 +22,30 @@ class XBMC(Notification): hosts = splitString(self.conf('host')) successful = 0 + max_successful = 0 for host in hosts: if self.use_json_notifications.get(host) is None: self.getXBMCJSONversion(host, message = message) if self.use_json_notifications.get(host): - response = self.request(host, [ + calls = [ ('GUI.ShowNotification', {'title': self.default_title, 'message': message, 'image': self.getNotificationImage('small')}), - ('VideoLibrary.Scan', {}), - ]) + ] + + if not self.conf('only_first') or hosts.index(host) == 0: + calls.append(('VideoLibrary.Scan', {})) + + max_successful += len(calls) + response = self.request(host, calls) else: response = self.notifyXBMCnoJSON(host, {'title':self.default_title, 'message':message}) - response += self.request(host, [('VideoLibrary.Scan', {})]) + + if not self.conf('only_first') or hosts.index(host) == 0: + response += self.request(host, [('VideoLibrary.Scan', {})]) + max_successful += 1 + + max_successful += 1 try: for result in response: @@ -44,7 +57,7 @@ class XBMC(Notification): except: log.error('Failed parsing results: %s', traceback.format_exc()) - return successful == len(hosts) * 2 + return successful == max_successful def getXBMCJSONversion(self, host, message = ''): @@ -53,7 +66,7 @@ class XBMC(Notification): # XBMC JSON-RPC version request response = self.request(host, [ ('JSONRPC.Version', {}) - ]) + ]) for result in response: if (result.get('result') and type(result['result']['version']).__name__ == 'int'): # only v2 and v4 return an int object @@ -138,7 +151,7 @@ class XBMC(Notification): #