diff --git a/.gitignore b/.gitignore index 0f95a8b0..11f92e28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /settings.conf -/logs/*.log \ No newline at end of file +/logs/*.log +/_source/ \ No newline at end of file diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 740523ac..0d0ddd8e 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -1,4 +1,5 @@ from couchpotato.core.auth import requires_auth +from couchpotato.core.event import fireEvent from couchpotato.core.logger import CPLog from couchpotato.environment import Env from flask.app import Flask @@ -30,7 +31,7 @@ def get_engine(): @web.route('/') @requires_auth def index(): - return render_template('index.html', sep = os.sep) + return render_template('index.html', sep = os.sep, fireEvent = fireEvent) @app.errorhandler(404) def page_not_found(error): diff --git a/couchpotato/cli.py b/couchpotato/cli.py index 92d4e50c..d637a850 100644 --- a/couchpotato/cli.py +++ b/couchpotato/cli.py @@ -1,6 +1,7 @@ from argparse import ArgumentParser from couchpotato import web from couchpotato.api import api +from couchpotato.core.event import fireEvent from libs.daemon import createDaemon from logging import handlers from werkzeug.contrib.cache import FileSystemCache @@ -49,7 +50,7 @@ def cmd_couchpotato(base_path, args): Env.set('data_dir', options.data_dir) Env.set('db_path', 'sqlite:///' + os.path.join(options.data_dir, 'couchpotato.db')) Env.set('cache_dir', os.path.join(options.data_dir, 'cache')) - Env.set('cache', FileSystemCache(Env.get('cache_dir'))) + Env.set('cache', FileSystemCache(os.path.join(Env.get('cache_dir'), 'python'))) Env.set('quiet', options.quiet) Env.set('daemonize', options.daemonize) Env.set('args', args) @@ -97,6 +98,7 @@ def cmd_couchpotato(base_path, args): from migrate.versioning.api import version_control, db_version, version, upgrade db = Env.get('db_path') repo = os.path.join('couchpotato', 'core', 'migration') + logging.getLogger('migrate').setLevel(logging.WARNING) # Disable logging for migration latest_db_version = version(repo) @@ -114,6 +116,7 @@ def cmd_couchpotato(base_path, args): from couchpotato.core.settings.model import setup setup() + fireEvent('app.load') # Create app from couchpotato import app @@ -128,11 +131,6 @@ def cmd_couchpotato(base_path, args): app.secret_key = api_key app.static_path = url_base + '/static' - # Add static url with url_base - app.add_url_rule(app.static_path + '/', - endpoint = 'static', - view_func = app.send_static_file) - # Register modules app.register_module(web, url_prefix = '%s/' % url_base) app.register_module(api, url_prefix = '%s/%s/' % (url_base, api_key if not debug else 'api')) diff --git a/couchpotato/core/__init__.py b/couchpotato/core/__init__.py index fdb92bd9..5dff90b2 100644 --- a/couchpotato/core/__init__.py +++ b/couchpotato/core/__init__.py @@ -9,35 +9,28 @@ config = [{ { 'tab': 'general', 'name': 'basics', - 'label': 'Basics', 'description': 'Needs restart before changes take effect.', 'options': [ { 'name': 'username', 'default': '', - 'type': 'string', - 'label': 'Username', }, { 'name': 'password', 'default': '', - 'password': True, - 'type': 'string', - 'label': 'Password', + 'type': 'password', }, { 'name': 'host', 'advanced': True, 'default': '0.0.0.0', - 'type': 'string', - 'label': 'Host', + 'label': 'IP', 'description': 'Host that I should listen to. "0.0.0.0" listens to all ips.', }, { 'name': 'port', 'default': 5000, 'type': 'int', - 'label': 'Port', 'description': 'The port I should listen to.', }, { @@ -52,14 +45,12 @@ config = [{ { 'tab': 'general', 'name': 'advanced', - 'label': 'Advanced', 'description': "For those who know what the're doing", 'advanced': True, 'options': [ { 'name': 'api_key', 'default': uuid4().hex, - 'type': 'string', 'readonly': True, 'label': 'Api Key', 'description': "This is top-secret! Don't share this!", @@ -74,9 +65,8 @@ config = [{ { 'name': 'url_base', 'default': '', - 'type': 'string', 'label': 'Url Base', - 'description': 'When using mod_proxy use this to prepend the url with this.', + 'description': 'When using mod_proxy use this to append the url with this.', }, ], }, diff --git a/couchpotato/core/downloaders/__init__.py b/couchpotato/core/downloaders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/couchpotato/core/downloaders/base.py b/couchpotato/core/downloaders/base.py new file mode 100644 index 00000000..f285f519 --- /dev/null +++ b/couchpotato/core/downloaders/base.py @@ -0,0 +1,20 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.plugins.base import Plugin +from couchpotato.environment import Env + +class Downloader(Plugin): + + def __init__(self): + addEvent('download', self.download) + + def download(self, data = {}): + pass + + def conf(self, attr): + return Env.setting(attr, self.__class__.__name__.lower()) + + def isDisabled(self): + return not self.isEnabled() + + def isEnabled(self): + return self.conf('enabled', True) diff --git a/couchpotato/core/downloaders/blackhole/__init__.py b/couchpotato/core/downloaders/blackhole/__init__.py new file mode 100644 index 00000000..df3550e9 --- /dev/null +++ b/couchpotato/core/downloaders/blackhole/__init__.py @@ -0,0 +1,32 @@ +from .main import Blackhole + +def start(): + return Blackhole() + +config = [{ + 'name': 'blackhole', + 'groups': [ + { + 'tab': 'downloaders', + 'name': 'blackhole', + 'label': 'Black hole', + 'description': 'Fill in your Sabnzbd settings.', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'bool', + 'label': 'Enabled', + 'description': 'Send snatched NZBs to Sabnzbd', + }, + { + 'name': 'directory', + 'default': '', + 'type': 'directory', + 'label': 'Directory', + 'description': 'Directory where the .nzb (or .torrent) file is saved to.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py new file mode 100644 index 00000000..55e5f790 --- /dev/null +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -0,0 +1,36 @@ +from __future__ import with_statement +from couchpotato.core.helpers.encoding import toSafeString +from couchpotato.core.logger import CPLog +from couchpotato.core.downloaders.base import Downloader +import os +import urllib + +log = CPLog(__name__) + +class Blackhole(Downloader): + + type = ['nzb', 'torrent'] + + def download(self, data = {}): + + if self.isDisabled() or not self.isCorrectType(data.get('type')): + return + + directory = self.conf('directory') + + if not directory or not os.path.isdir(directory): + log.error('No directory set for blackhole %s download.' % data.get('type')) + else: + fullPath = os.path.join(directory, toSafeString(data.get('name')) + '.' + data) + + if not os.path.isfile(fullPath): + log.info('Downloading %s to %s.' % (data.get('type'), fullPath)) + file = urllib.urlopen(data.get('url')).read() + with open(fullPath, 'wb') as f: + f.write(file) + + return True + else: + log.error('File %s already exists.' % fullPath) + + return False diff --git a/couchpotato/core/downloaders/sabnzbd/__init__.py b/couchpotato/core/downloaders/sabnzbd/__init__.py new file mode 100644 index 00000000..1d83b88b --- /dev/null +++ b/couchpotato/core/downloaders/sabnzbd/__init__.py @@ -0,0 +1,39 @@ +from .main import Sabnzbd + +def start(): + return Sabnzbd() + +config = [{ + 'name': 'sabnzbd', + 'groups': [ + { + 'tab': 'downloaders', + 'name': 'sabnzbd', + 'label': 'Sabnzbd', + 'description': 'Fill in your Sabnzbd settings.', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'bool', + 'label': 'Enabled', + 'description': 'Send snatched NZBs to Sabnzbd', + }, + { + 'name': 'host', + 'default': 'localhost:8080', + 'type': 'string', + 'label': 'Host', + 'description': 'Test', + }, + { + 'name': 'api_key', + 'default': '', + 'type': 'string', + 'label': 'Api Key', + 'description': 'Used for all calls to Sabnzbd.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py new file mode 100644 index 00000000..344aa5f1 --- /dev/null +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -0,0 +1,120 @@ +from couchpotato.core.downloaders.base import Downloader +from couchpotato.core.helpers.variable import cleanHost +from couchpotato.core.logger import CPLog +from tempfile import mkstemp +from urllib import urlencode +import base64 +import os +import re +import urllib2 + +log = CPLog(__name__) + +class Sabnzbd(Downloader): + + type = ['nzb'] + + def download(self, data = {}): + + if self.isDisabled() or not self.isCorrectType(data.get('type')): + return + + log.info("Sending '%s' to SABnzbd." % data.get('name')) + + if self.conf('ppDir') and data.get('imdb_id'): + try: + pp_script_fn = self.buildPp(data.get('imdb_id')) + except: + log.info("Failed to create post-processing script.") + pp_script_fn = False + if not pp_script_fn: + pp = False + else: + pp = True + else: + pp = False + + params = { + 'apikey': self.conf('apikey'), + 'cat': self.conf('category'), + 'mode': 'addurl', + 'name': data.get('url') + } + + # sabNzbd complains about "invalid archive file" for newzbin urls + # added using addurl, works fine with addid + if data.get('addbyid'): + params['mode'] = 'addid' + + if pp: + params['script'] = pp_script_fn + + url = cleanHost(self.conf('host')) + "api?" + urlencode(params) + log.info("URL: " + url) + + try: + r = urllib2.urlopen(url, timeout = 30) + except: + log.error("Unable to connect to SAB.") + return False + + result = r.read().strip() + if not result: + log.error("SABnzbd didn't return anything.") + return False + + log.debug("Result text from SAB: " + result) + if result == "ok": + log.info("NZB sent to SAB successfully.") + return True + elif result == "Missing authentication": + log.error("Incorrect username/password.") + return False + else: + log.error("Unknown error: " + result) + return False + + def buildPp(self, imdb_id): + + pp_script_path = self.getPpFile() + + scriptB64 = '''IyEvdXNyL2Jpbi9weXRob24KaW1wb3J0IG9zCmltcG9ydCBzeXMKcHJpbnQgIkNyZWF0aW5nIGNwLmNw +bmZvIGZvciAlcyIgJSBzeXMuYXJndlsxXQppbWRiSWQgPSB7W0lNREJJREhFUkVdfQpwYXRoID0gb3Mu +cGF0aC5qb2luKHN5cy5hcmd2WzFdLCAiY3AuY3BuZm8iKQp0cnk6CiBmID0gb3BlbihwYXRoLCAndycp +CmV4Y2VwdCBJT0Vycm9yOgogcHJpbnQgIlVuYWJsZSB0byBvcGVuICVzIGZvciB3cml0aW5nIiAlIHBh +dGgKIHN5cy5leGl0KDEpCnRyeToKIGYud3JpdGUob3MucGF0aC5iYXNlbmFtZShzeXMuYXJndlswXSkr +IlxuIitpbWRiSWQpCmV4Y2VwdDoKIHByaW50ICJVbmFibGUgdG8gd3JpdGUgdG8gZmlsZTogJXMiICUg +cGF0aAogc3lzLmV4aXQoMikKZi5jbG9zZSgpCnByaW50ICJXcm90ZSBpbWRiIGlkLCAlcywgdG8gZmls +ZTogJXMiICUgKGltZGJJZCwgcGF0aCkK''' + + script = re.sub(r"\{\[IMDBIDHERE\]\}", "'%s'" % imdb_id, base64.b64decode(scriptB64)) + + try: + f = open(pp_script_path, 'wb') + except: + log.info("Unable to open post-processing script for writing. Check permissions: %s" % pp_script_path) + return False + + try: + f.write(script) + f.close() + except: + log.info("Unable to write to post-processing script. Check permissions: %s" % pp_script_path) + return False + + log.info("Wrote post-processing script to: %s" % pp_script_path) + + return os.path.basename(pp_script_path) + + def getPpFile(self): + + pp_script_handle, pp_script_path = mkstemp(suffix = '.py', dir = self.conf('ppDir')) + pp_sh = os.fdopen(pp_script_handle) + pp_sh.close() + + try: + os.chmod(pp_script_path, int('777', 8)) + except: + log.info("Unable to set post-processing script permissions to 777 (may still work correctly): %s" % pp_script_path) + + return pp_script_path diff --git a/couchpotato/core/event.py b/couchpotato/core/event.py index 2babf0c3..84276b3c 100644 --- a/couchpotato/core/event.py +++ b/couchpotato/core/event.py @@ -1,4 +1,5 @@ from axl.axel import Event +from couchpotato.core.helpers.variable import merge_dicts from couchpotato.core.logger import CPLog import traceback @@ -19,31 +20,64 @@ def removeEvent(name, handler): e -= handler def fireEvent(name, *args, **kwargs): + log.debug('Firing "%s": %s, %s' % (name, args, kwargs)) try: + + # Return single handler + single = False + try: + del kwargs['single'] + single = True + except: pass + + # Merge items + merge = False + try: + del kwargs['merge'] + merge = True + except: pass + e = events[name] e.asynchronous = False result = e(*args, **kwargs) - results = [] - for r in result: - if r[0] == True: - results.append(r[1]) - else: - etype, value, tb = r[1] - log.debug(''.join(traceback.format_exception(etype, value, tb))) + if single and not merge: + results = result[0][1] + else: + results = [] + for r in result: + if r[0] == True: + results.append(r[1]) + else: + errorHandler(r[1]) + + # Merge the results + if merge: + merged = {} + for result in results: + merged = merge_dicts(merged, result) + + results = merged return results except Exception, e: - log.debug(e) + log.error('%s: %s' % (name, e)) def fireEventAsync(name, *args, **kwargs): + log.debug('Async "%s": %s, %s' % (name, args, kwargs)) try: e = events[name] e.asynchronous = True + e.error_handler = errorHandler + e(*args, **kwargs) return True except Exception, e: - log.debug(e) + log.error('%s: %s' % (name, e)) + +def errorHandler(error): + etype, value, tb = error + log.error(''.join(traceback.format_exception(etype, value, tb))) def getEvent(name): return events[name] diff --git a/couchpotato/core/helpers/encoding.py b/couchpotato/core/helpers/encoding.py index bade6027..791cc8b8 100644 --- a/couchpotato/core/helpers/encoding.py +++ b/couchpotato/core/helpers/encoding.py @@ -5,16 +5,19 @@ import unicodedata log = CPLog(__name__) + def toSafeString(original): valid_chars = "-_.() %s%s" % (ascii_letters, digits) cleanedFilename = unicodedata.normalize('NFKD', toUnicode(original)).encode('ASCII', 'ignore') return ''.join(c for c in cleanedFilename if c in valid_chars) + def simplifyString(original): string = toSafeString(original) split = re.split('\W+', string.lower()) return toUnicode(' '.join(split)) + def toUnicode(original, *args): try: if type(original) is unicode: diff --git a/couchpotato/core/helpers/request.py b/couchpotato/core/helpers/request.py index a85cdd2e..a26ab814 100644 --- a/couchpotato/core/helpers/request.py +++ b/couchpotato/core/helpers/request.py @@ -42,7 +42,7 @@ def dictToList(params): new = {} for x, value in params.iteritems(): try: - new_value = [dictToList(value2) for value2 in value.itervalues()] + new_value = [dictToList(value[k]) for k in sorted(value.iterkeys())] except: new_value = value diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py new file mode 100644 index 00000000..f32e6427 --- /dev/null +++ b/couchpotato/core/helpers/variable.py @@ -0,0 +1,38 @@ +import hashlib +import os.path + +def is_dict(object): + return isinstance(object, dict) + + +def merge_dicts(a, b): + assert is_dict(a), is_dict(b) + dst = a.copy() + + stack = [(dst, b)] + while stack: + current_dst, current_src = stack.pop() + for key in current_src: + if key not in current_dst: + current_dst[key] = current_src[key] + else: + if is_dict(current_src[key]) and is_dict(current_dst[key]) : + stack.append((current_dst[key], current_src[key])) + else: + current_dst[key] = current_src[key] + return dst + +def md5(text): + return hashlib.md5(text).hexdigest() + +def getExt(filename): + return os.path.splitext(filename)[1][1:] + +def cleanHost(host): + if not host.startswith(('http://', 'https://')): + host = 'http://' + host + + if not host.endswith('/'): + host += '/' + + return host diff --git a/couchpotato/core/loader.py b/couchpotato/core/loader.py index 1d9eaf45..aea0508b 100644 --- a/couchpotato/core/loader.py +++ b/couchpotato/core/loader.py @@ -1,4 +1,4 @@ -from couchpotato.core.event import fireEvent, fireEventAsync +from couchpotato.core.event import fireEvent from couchpotato.core.logger import CPLog import glob import os @@ -17,6 +17,8 @@ class Loader: self.paths = { 'plugin' : ('couchpotato.core.plugins', os.path.join(root, 'couchpotato', 'core', 'plugins')), 'provider' : ('couchpotato.core.providers', os.path.join(root, 'couchpotato', 'core', 'providers')), + 'notifications' : ('couchpotato.core.notifications', os.path.join(root, 'couchpotato', 'core', 'notifications')), + 'downloaders' : ('couchpotato.core.downloaders', os.path.join(root, 'couchpotato', 'core', 'downloaders')), } for type, tuple in self.paths.iteritems(): @@ -28,14 +30,17 @@ class Loader: for module_name, plugin in sorted(self.modules.iteritems()): # Load module - m = getattr(self.loadModule(module_name), plugin.get('name')) + try: + m = getattr(self.loadModule(module_name), plugin.get('name')) - log.info("Loading '%s'" % module_name) + log.info("Loading %s: %s" % (plugin['type'], plugin['name'])) - # Save default settings for plugin/provider - did_save += self.loadSettings(m, module_name, save = False) + # Save default settings for plugin/provider + did_save += self.loadSettings(m, module_name, save = False) - self.loadPlugins(m, plugin.get('name')) + self.loadPlugins(m, plugin.get('name')) + except Exception, e: + log.error(e) if did_save: fireEvent('settings.save') @@ -51,12 +56,12 @@ class Loader: def loadSettings(self, module, name, save = True): try: for section in module.config: - fireEventAsync('settings.options', section['name'], section) + fireEvent('settings.options', section['name'], section) options = {} for group in section['groups']: for option in group['options']: options[option['name']] = option['default'] - fireEventAsync('settings.register', section_name = section['name'], options = options, save = save) + fireEvent('settings.register', section_name = section['name'], options = options, save = save) return True except Exception, e: log.debug("Failed loading settings for '%s': %s" % (name, e)) @@ -67,7 +72,7 @@ class Loader: module.start() return True except Exception, e: - log.debug("Failed loading plugin '%s': %s" % (name, e)) + log.error("Failed loading plugin '%s': %s" % (name, e)) return False def addModule(self, type, module, name): diff --git a/couchpotato/core/notifications/__init__.py b/couchpotato/core/notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/couchpotato/core/notifications/base.py b/couchpotato/core/notifications/base.py new file mode 100644 index 00000000..b14501c2 --- /dev/null +++ b/couchpotato/core/notifications/base.py @@ -0,0 +1,29 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.request import jsonified +from couchpotato.core.plugins.base import Plugin +from couchpotato.environment import Env + +class Notification(Plugin): + + default_title = 'CouchPotato' + test_message = 'ZOMG Lazors Pewpewpew!' + + def __init__(self): + addEvent('notify', self.notify) + + def notify(self, message = '', data = {}): + pass + + def conf(self, attr): + return Env.setting(attr, self.__class__.__name__.lower()) + + def isDisabled(self): + return not self.isEnabled() + + def isEnabled(self): + return self.conf('enabled', True) + + def test(self): + success = self.notify(message = self.test_message) + + return jsonified({'success': success}) diff --git a/couchpotato/core/notifications/growl/__init__.py b/couchpotato/core/notifications/growl/__init__.py new file mode 100644 index 00000000..43bef1ca --- /dev/null +++ b/couchpotato/core/notifications/growl/__init__.py @@ -0,0 +1,33 @@ +from .main import Growl + +def start(): + return Growl() + +config = [{ + 'name': 'growl', + 'groups': [ + { + 'tab': 'notifications', + 'name': 'growl', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'enabler', + 'description': '', + }, + { + 'name': 'host', + 'default': 'localhost', + 'description': '', + }, + { + 'name': 'password', + 'default': '', + 'type': 'password', + 'description': '', + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/growl/growl.py b/couchpotato/core/notifications/growl/growl.py new file mode 100644 index 00000000..ffc4e65a --- /dev/null +++ b/couchpotato/core/notifications/growl/growl.py @@ -0,0 +1,111 @@ +# Based on netprowl by the following authors. + +# Altered 1st October 2010 - Tim Child. +# Have added the ability for the command line arguments to take a password. + +# Altered 1-17-2010 - Tanner Stokes - www.tannr.com +# Added support for command line arguments + +# ORIGINAL CREDITS +# """Growl 0.6 Network Protocol Client for Python""" +# __version__ = "0.6.3" +# __author__ = "Rui Carmo (http://the.taoofmac.com)" +# __copyright__ = "(C) 2004 Rui Carmo. Code under BSD License." +# __contributors__ = "Ingmar J Stein (Growl Team), John Morrissey (hashlib patch)" + +import struct + +try: + import hashlib + md5_constructor = hashlib.md5 +except ImportError: + import md5 + md5_constructor = md5.new + +GROWL_UDP_PORT = 9887 +GROWL_PROTOCOL_VERSION = 1 +GROWL_TYPE_REGISTRATION = 0 +GROWL_TYPE_NOTIFICATION = 1 + + +class GrowlRegistrationPacket: + """Builds a Growl Network Registration packet. + Defaults to emulating the command-line growlnotify utility.""" + + def __init__(self, application = "CouchPotato", password = None): + self.notifications = [] + self.defaults = [] # array of indexes into notifications + self.application = application.encode("utf-8") + self.password = password + + def addNotification(self, notification = "General Notification", enabled = True): + """Adds a notification type and sets whether it is enabled on the GUI""" + + self.notifications.append(notification) + if enabled: + self.defaults.append(len(self.notifications) - 1) + + def payload(self): + """Returns the packet payload.""" + self.data = struct.pack("!BBH", + GROWL_PROTOCOL_VERSION, + GROWL_TYPE_REGISTRATION, + len(self.application) + ) + self.data += struct.pack("BB", + len(self.notifications), + len(self.defaults) + ) + self.data += self.application + for notification in self.notifications: + encoded = notification.encode("utf-8") + self.data += struct.pack("!H", len(encoded)) + self.data += encoded + for default in self.defaults: + self.data += struct.pack("B", default) + self.checksum = md5() + self.checksum.update(self.data) + if self.password: + self.checksum.update(self.password) + self.data += self.checksum.digest() + return self.data + +class GrowlNotificationPacket: + """Builds a Growl Network Notification packet. + Defaults to emulating the command-line growlnotify utility.""" + + def __init__(self, application = "CouchPotato", + notification = "General Notification", title = "Title", + description = "Description", priority = 0, sticky = False, password = None): + + self.application = application.encode("utf-8") + self.notification = notification.encode("utf-8") + self.title = title.encode("utf-8") + self.description = description.encode("utf-8") + flags = (priority & 0x07) * 2 + if priority < 0: + flags |= 0x08 + if sticky: + flags = flags | 0x0100 + self.data = struct.pack("!BBHHHHH", + GROWL_PROTOCOL_VERSION, + GROWL_TYPE_NOTIFICATION, + flags, + len(self.notification), + len(self.title), + len(self.description), + len(self.application) + ) + self.data += self.notification + self.data += self.title + self.data += self.description + self.data += self.application + self.checksum = md5_constructor() + self.checksum.update(self.data) + if password: + self.checksum.update(password) + self.data += self.checksum.digest() + + def payload(self): + """Returns the packet payload.""" + return self.data diff --git a/couchpotato/core/notifications/growl/main.py b/couchpotato/core/notifications/growl/main.py new file mode 100644 index 00000000..c17f270a --- /dev/null +++ b/couchpotato/core/notifications/growl/main.py @@ -0,0 +1,45 @@ +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +from couchpotato.core.notifications.growl.growl import GROWL_UDP_PORT, \ + GrowlRegistrationPacket, GrowlNotificationPacket +from couchpotato.environment import Env +from socket import AF_INET, SOCK_DGRAM, socket + +log = CPLog(__name__) + + +class Growl(Notification): + + def __init__(self): + addEvent('notify', self.notify) + addEvent('notify.growl', self.notify) + + addApiView('notify.growl.test', self.test) + + def conf(self, attr): + return Env.setting(attr, 'growl') + + def notify(self, message = '', data = {}): + + if self.isDisabled(): + return + + hosts = [x.strip() for x in self.conf('host').split(",")] + password = self.conf('password') + + for curHost in hosts: + addr = (curHost, GROWL_UDP_PORT) + + s = socket(AF_INET, SOCK_DGRAM) + p = GrowlRegistrationPacket(password = password) + p.addNotification() + s.sendto(p.payload(), addr) + + # send notification + p = GrowlNotificationPacket(title = self.default_title, description = message, priority = 0, sticky = False, password = password) + s.sendto(p.payload(), addr) + s.close() + + log.info('Growl notifications sent.') diff --git a/couchpotato/core/notifications/nmj/__init__.py b/couchpotato/core/notifications/nmj/__init__.py new file mode 100644 index 00000000..a949fa13 --- /dev/null +++ b/couchpotato/core/notifications/nmj/__init__.py @@ -0,0 +1,37 @@ +from .main import NMJ + +def start(): + return NMJ() + +config = [{ + 'name': 'nmj', + 'groups': [ + { + 'tab': 'notifications', + 'name': 'nmj', + 'label': 'NMJ', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'enabler', + }, + { + 'name': 'host', + 'default': 'localhost', + 'description': '', + }, + { + 'name': 'database', + 'default': '', + 'description': '', + }, + { + 'name': 'mount', + 'default': '', + 'description': '', + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/nmj/main.py b/couchpotato/core/notifications/nmj/main.py new file mode 100644 index 00000000..1098d010 --- /dev/null +++ b/couchpotato/core/notifications/nmj/main.py @@ -0,0 +1,131 @@ +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.request import getParams, jsonified +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +from couchpotato.environment import Env +import re +import telnetlib +import urllib +import urllib2 + +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + +log = CPLog(__name__) + + +class NMJ(Notification): + + def __init__(self): + addEvent('notify', self.notify) + addEvent('notify.nmj', self.notify) + + addApiView('notify.nmj.test', self.test) + addApiView('notify.nmj.auto_config', self.autoConfig) + + def conf(self, attr): + return Env.setting(attr, 'nmj') + + def autoConfig(self): + + params = getParams() + host = params.get('host', 'localhost') + + database = '' + mount = '' + + try: + terminal = telnetlib.Telnet(host) + except Exception: + log.error('Warning: unable to get a telnet session to %s' % (host)) + return self.failed() + + log.debug('Connected to %s via telnet' % (host)) + terminal.read_until('sh-3.00# ') + terminal.write('cat /tmp/source\n') + terminal.write('cat /tmp/netshare\n') + terminal.write('exit\n') + tnoutput = terminal.read_all() + + match = re.search(r'(.+\.db)\r\n?(.+)(?=sh-3.00# cat /tmp/netshare)', tnoutput) + + if match: + database = match.group(1) + device = match.group(2) + log.info('Found NMJ database %s on device %s' % (database, device)) + else: + log.error('Could not get current NMJ database on %s, NMJ is probably not running!' % (host)) + return self.failed() + + if device.startswith('NETWORK_SHARE/'): + match = re.search('.*(?=\r\n?%s)' % (re.escape(device[14:])), tnoutput) + + if match: + mount = match.group().replace('127.0.0.1', host) + log.info('Found mounting url on the Popcorn Hour in configuration: %s' % (mount)) + else: + log.error('Detected a network share on the Popcorn Hour, but could not get the mounting url') + return self.failed() + + return jsonified({ + 'success': True, + 'database': database, + 'mount': mount, + }) + + def notify(self, message = '', data = {}): + + if self.isDisabled(): + return False + + host = self.conf('host') + mount = self.conf('mount') + database = self.conf('database') + + if self.mount: + try: + req = urllib2.Request(mount) + log.debug('Try to mount network drive via url: %s' % (mount)) + handle = urllib2.urlopen(req) + except IOError, e: + log.error('Warning: Couldn\'t contact popcorn hour on host %s: %s' % (host, e)) + return False + + params = { + 'arg0': 'scanner_start', + 'arg1': database, + 'arg2': 'background', + 'arg3': '', + } + params = urllib.urlencode(params) + UPDATE_URL = 'http://%(host)s:8008/metadata_database?%(params)s' + updateUrl = UPDATE_URL % {'host': host, 'params': params} + + try: + req = urllib2.Request(updateUrl) + log.debug('Sending NMJ scan update command via url: %s' % (updateUrl)) + handle = urllib2.urlopen(req) + response = handle.read() + except IOError, e: + log.error('Warning: Couldn\'t contact Popcorn Hour on host %s: %s' % (host, e)) + return False + + try: + et = etree.fromstring(response) + result = et.findtext('returnValue') + except SyntaxError, e: + log.error('Unable to parse XML returned from the Popcorn Hour: %s' % (e)) + return False + + if int(result) > 0: + log.error('Popcorn Hour returned an errorcode: %s' % (result)) + return False + else: + log.info('NMJ started background scan') + return True + + def failed(self): + return jsonified({'success': False}) diff --git a/couchpotato/core/notifications/notifo/__init__.py b/couchpotato/core/notifications/notifo/__init__.py new file mode 100644 index 00000000..eb009c96 --- /dev/null +++ b/couchpotato/core/notifications/notifo/__init__.py @@ -0,0 +1,32 @@ +from .main import Notifo + +def start(): + return Notifo() + +config = [{ + 'name': 'notifo', + 'groups': [ + { + 'tab': 'notifications', + 'name': 'notifo', + 'description': '', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'enabler', + }, + { + 'name': 'username', + 'default': '', + 'type': 'string', + }, + { + 'name': 'password', + 'default': '', + 'type': 'password', + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/notifo/main.py b/couchpotato/core/notifications/notifo/main.py new file mode 100644 index 00000000..0bea3673 --- /dev/null +++ b/couchpotato/core/notifications/notifo/main.py @@ -0,0 +1,53 @@ +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +from couchpotato.environment import Env +from flask.helpers import json +import base64 +import urllib +import urllib2 + +log = CPLog(__name__) + + +class Notifo(Notification): + + url = 'https://api.notifo.com/v1/send_notification' + + def __init__(self): + addEvent('notify', self.notify) + addEvent('notify.notifo', self.notify) + + addApiView('notify.notifo.test', self.test) + + def conf(self, attr): + return Env.setting(attr, 'notifo') + + def notify(self, message = '', data = {}): + + if self.isDisabled(): + return False + + try: + data = urllib.urlencode({ + 'msg': toUnicode(message), + }) + + req = urllib2.Request(self.url) + authHeader = "Basic %s" % base64.encodestring('%s:%s' % (self.conf('username'), self.conf('api_key')))[:-1] + req.add_header("Authorization", authHeader) + + handle = urllib2.urlopen(req, data) + result = json.load(handle) + + if result['status'] != 'success' or result['response_message'] != 'OK': + raise Exception + + except Exception, e: + log.error('Notification failed: %s' % e) + return False + + log.info('Notifo notification successful.') + return True diff --git a/couchpotato/core/notifications/plex/__init__.py b/couchpotato/core/notifications/plex/__init__.py new file mode 100644 index 00000000..f8d94779 --- /dev/null +++ b/couchpotato/core/notifications/plex/__init__.py @@ -0,0 +1,33 @@ +from .main import Plex + +def start(): + return Plex() + +config = [{ + 'name': 'plex', + 'groups': [ + { + 'tab': 'notifications', + 'name': 'plex', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'enabler', + 'description': '', + }, + { + 'name': 'host', + 'default': 'localhost', + 'description': '', + }, + { + 'name': 'password', + 'default': '', + 'type': 'password', + 'description': '', + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/plex/main.py b/couchpotato/core/notifications/plex/main.py new file mode 100644 index 00000000..6756ef01 --- /dev/null +++ b/couchpotato/core/notifications/plex/main.py @@ -0,0 +1,43 @@ +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +from xml.dom import minidom +import urllib + +log = CPLog(__name__) + + +class Plex(Notification): + + def __init__(self): + addEvent('notify', self.notify) + addEvent('notify.plex', self.notify) + + addApiView('notify.plex.test', self.test) + + def notify(self, message = '', data = {}): + + if self.isDisabled(): + return + + log.info('Sending notification to Plex') + hosts = [x.strip() for x in self.conf('host').split(",")] + + for host in hosts: + + source_type = ['movie'] + base_url = 'http://%s/library/sections' % host + refresh_url = '%s/%%s/refresh' % base_url + + try: + xml_sections = minidom.parse(urllib.urlopen(base_url)) + sections = xml_sections.getElementsByTagName('Directory') + for s in sections: + if s.getAttribute('type') in source_type: + url = refresh_url % s.getAttribute('key') + x = urllib.urlopen(url) + except: + log.error('Plex library update failed for %s.' % host) + + return True diff --git a/couchpotato/core/notifications/prowl/__init__.py b/couchpotato/core/notifications/prowl/__init__.py new file mode 100644 index 00000000..4903c249 --- /dev/null +++ b/couchpotato/core/notifications/prowl/__init__.py @@ -0,0 +1,35 @@ +from .main import Prowl + +def start(): + return Prowl() + +config = [{ + 'name': 'prowl', + 'groups': [ + { + 'tab': 'notifications', + 'name': 'prowl', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'enabler', + 'description': '', + }, + { + 'name': 'api_key', + 'default': '', + 'label': 'Api key', + 'description': '', + }, + { + 'name': 'priority', + 'default': '0', + 'type': 'dropdown', + 'description': '', + 'values': [('Very Low', -2), ('Moderate', -1), ('Normal', 0), ('High', 1), ('Emergency', 2)] + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/prowl/main.py b/couchpotato/core/notifications/prowl/main.py new file mode 100644 index 00000000..5886323b --- /dev/null +++ b/couchpotato/core/notifications/prowl/main.py @@ -0,0 +1,51 @@ +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +from httplib import HTTPSConnection +from urllib import urlencode + +log = CPLog(__name__) + + +class Prowl(Notification): + + def __init__(self): + addEvent('notify', self.notify) + addEvent('notify.prowl', self.notify) + + addApiView('notify.prowl.test', self.test) + + def notify(self, message = '', data = {}): + + if self.isDisabled(): + return + + http_handler = HTTPSConnection('api.prowlapp.com') + + data = { + 'apikey': self.conf('api_key'), + 'application': self.default_title, + 'event': self.default_title, + 'description': toUnicode(message), + 'priority': self.conf('priority'), + } + + http_handler.request('POST', + '/publicapi/add', + headers = {'Content-type': 'application/x-www-form-urlencoded'}, + body = urlencode(data) + ) + response = http_handler.getresponse() + request_status = response.status + + if request_status == 200: + log.info('Prowl notifications sent.') + return True + elif request_status == 401: + log.error('Prowl auth failed: %s' % response.reason) + return False + else: + log.error('Prowl notification failed.') + return False diff --git a/couchpotato/core/notifications/xbmc/__init__.py b/couchpotato/core/notifications/xbmc/__init__.py new file mode 100644 index 00000000..ec77252d --- /dev/null +++ b/couchpotato/core/notifications/xbmc/__init__.py @@ -0,0 +1,38 @@ +from .main import XBMC + +def start(): + return XBMC() + +config = [{ + 'name': 'xbmc', + 'groups': [ + { + 'tab': 'notifications', + 'name': 'xbmc', + 'options': [ + { + 'name': 'enabled', + 'default': False, + 'type': 'enabler', + 'description': '', + }, + { + 'name': 'host', + 'default': 'localhost:8080', + 'description': '', + }, + { + 'name': 'username', + 'default': 'xbmc', + 'description': '', + }, + { + 'name': 'password', + 'default': 'xbmc', + 'type': 'password', + 'description': '', + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py new file mode 100644 index 00000000..6b86e6e7 --- /dev/null +++ b/couchpotato/core/notifications/xbmc/main.py @@ -0,0 +1,47 @@ +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +import base64 +import urllib +import urllib2 + +log = CPLog(__name__) + + +class XBMC(Notification): + + def __init__(self): + addEvent('notify', self.notify) + addEvent('notify.xbmc', self.notify) + + addApiView('notify.xbmc.test', self.test) + + def notify(self, message = '', data = {}): + + if self.isDisabled(): + return + + for host in [x.strip() for x in self.conf('host').split(",")]: + self.send({'command': 'ExecBuiltIn', 'parameter': 'Notification(CouchPotato, %s)' % message}, host) + self.send({'command': 'ExecBuiltIn', 'parameter': 'XBMC.updatelibrary(video)'}, host) + + return True + + def send(self, command, host): + + url = 'http://%s/xbmcCmds/xbmcHttp/?%s' % (host, urllib.urlencode(command)) + + try: + req = urllib2.Request(url) + if self.password: + authHeader = "Basic %s" % base64.encodestring('%s:%s' % (self.conf('username'), self.conf('password')))[:-1] + req.add_header("Authorization", authHeader) + + urllib2.urlopen(req, timeout = 10).read() + except Exception, e: + log.error("Couldn't sent command to XBMC. %s" % e) + return False + + log.info('XBMC notification to %s successful.' % host) + return True diff --git a/couchpotato/core/plugins/file/__init__.py b/couchpotato/core/plugins/file/__init__.py new file mode 100644 index 00000000..54d9cbe5 --- /dev/null +++ b/couchpotato/core/plugins/file/__init__.py @@ -0,0 +1,6 @@ +from .main import FileManager + +def start(): + return FileManager() + +config = [] diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py new file mode 100644 index 00000000..ccf89d54 --- /dev/null +++ b/couchpotato/core/plugins/file/main.py @@ -0,0 +1,103 @@ +from couchpotato import get_session +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.variable import md5, getExt +from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin +from couchpotato.core.settings.model import FileType, File +from couchpotato.environment import Env +from flask.helpers import send_from_directory +import os.path +import urllib2 + +log = CPLog(__name__) + + +class FileManager(Plugin): + + def __init__(self): + addEvent('file.add', self.add) + addEvent('file.download', self.download) + addEvent('file.types', self.getTypes) + + addApiView('file.cache/', self.showImage) + + def showImage(self, file = ''): + + cache_dir = Env.get('cache_dir') + filename = file.replace(cache_dir[1:] + '/', '') + + return send_from_directory(cache_dir, filename) + + def download(self, url = '', dest = None, overwrite = False): + + try: + file = urllib2.urlopen(url) + + if not dest: # to Cache + dest = os.path.join(Env.get('cache_dir'), '%s.%s' % (md5(url), getExt(url))) + + if overwrite or not os.path.exists(dest): + log.debug('Writing file to: %s' % dest) + output = open(dest, 'wb') + output.write(file.read()) + output.close() + else: + log.debug('File already exists: %s' % dest) + + return dest + + except Exception, e: + log.error('Unable to download file "%s": %s' % (url, e)) + + return False + + def add(self, path = '', part = 1, type = (), properties = {}): + + db = get_session() + + f = db.query(File).filter_by(path = path).first() + if not f: + f = File() + db.add(f) + + f.path = path + f.part = part + f.type_id = self.getType(type).id + + db.commit() + + db.expunge(f) + return f + + def getType(self, type): + + db = get_session() + + type, identifier = type + + ft = db.query(FileType).filter_by(identifier = identifier).first() + if not ft: + ft = FileType( + type = type, + identifier = identifier, + name = identifier[0].capitalize() + identifier[1:] + ) + + db.add(ft) + db.commit() + + return ft + + def getTypes(self): + + db = get_session() + + results = db.query(FileType).all() + + types = [] + for type in results: + temp = type.to_dict() + types.append(temp) + + return types diff --git a/couchpotato/core/plugins/file_browser/__init__.py b/couchpotato/core/plugins/file_browser/__init__.py index a15b213a..976fcd10 100644 --- a/couchpotato/core/plugins/file_browser/__init__.py +++ b/couchpotato/core/plugins/file_browser/__init__.py @@ -1,4 +1,4 @@ -from couchpotato.core.plugins.file_browser.main import FileBrowser +from .main import FileBrowser def start(): return FileBrowser() diff --git a/couchpotato/core/plugins/library/__init__.py b/couchpotato/core/plugins/library/__init__.py index e13a2e0f..f5970329 100644 --- a/couchpotato/core/plugins/library/__init__.py +++ b/couchpotato/core/plugins/library/__init__.py @@ -1,4 +1,4 @@ -from couchpotato.core.plugins.library.main import LibraryPlugin +from .main import LibraryPlugin def start(): return LibraryPlugin() diff --git a/couchpotato/core/plugins/library/main.py b/couchpotato/core/plugins/library/main.py index dad3c352..cb80c6e7 100644 --- a/couchpotato/core/plugins/library/main.py +++ b/couchpotato/core/plugins/library/main.py @@ -1,32 +1,81 @@ from couchpotato import get_session -from couchpotato.core.event import addEvent +from couchpotato.core.event import addEvent, fireEventAsync, fireEvent +from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin -from couchpotato.core.settings.model import Library +from couchpotato.core.settings.model import Library, LibraryTitle +log = CPLog(__name__) class LibraryPlugin(Plugin): def __init__(self): addEvent('library.add', self.add) + addEvent('library.update', self.update) def add(self, attrs = {}): - db = get_session(); + db = get_session() l = db.query(Library).filter_by(identifier = attrs.get('identifier')).first() - if not l: l = Library( - name = attrs.get('name'), year = attrs.get('year'), identifier = attrs.get('identifier'), - description = attrs.get('description') + plot = attrs.get('plot'), + tagline = attrs.get('tagline') ) + + title = LibraryTitle( + title = attrs.get('title') + ) + + l.titles.append(title) + db.add(l) db.commit() + # Update library info + fireEventAsync('library.update', library = l, default_title = attrs.get('title', '')) + + #db.remove() return l - def update(self, item): + def update(self, library, default_title = ''): - pass + db = get_session() + library = db.query(Library).filter_by(identifier = library.identifier).first() + + info = fireEvent('provider.movie.info', merge = True, identifier = library.identifier) + + # Main info + library.plot = info.get('plot', '') + library.tagline = info.get('tagline', '') + library.year = info.get('year', 0) + + # Titles + [db.delete(title) for title in library.titles] + titles = info.get('titles') + + log.debug('Adding titles: %s' % titles) + for title in titles: + t = LibraryTitle( + title = title, + default = title.lower() == default_title.lower() + ) + library.titles.append(t) + + db.commit() + + # Files + images = info.get('images') + for type in images: + for image in images[type]: + file_path = fireEvent('file.download', url = image, single = True) + file = fireEvent('file.add', path = file_path, type = ('image', type[:-1]), single = True) + try: + library.files.append(file) + db.commit() + except: + log.debug('File already attached to library') + + fireEvent('library.update.after') diff --git a/couchpotato/core/plugins/movie/__init__.py b/couchpotato/core/plugins/movie/__init__.py index 51ec65b0..4df29ad8 100644 --- a/couchpotato/core/plugins/movie/__init__.py +++ b/couchpotato/core/plugins/movie/__init__.py @@ -1,4 +1,4 @@ -from couchpotato.core.plugins.movie.main import MoviePlugin +from .main import MoviePlugin def start(): return MoviePlugin() diff --git a/couchpotato/core/plugins/movie/main.py b/couchpotato/core/plugins/movie/main.py index cd5c5acc..59716f97 100644 --- a/couchpotato/core/plugins/movie/main.py +++ b/couchpotato/core/plugins/movie/main.py @@ -1,9 +1,9 @@ from couchpotato import get_session from couchpotato.api import addApiView -from couchpotato.core.event import fireEvent +from couchpotato.core.event import fireEvent, fireEventAsync from couchpotato.core.helpers.request import getParams, jsonified from couchpotato.core.plugins.base import Plugin -from couchpotato.core.settings.model import Movie, Release, Profile +from couchpotato.core.settings.model import Movie from couchpotato.environment import Env from urllib import urlencode @@ -13,30 +13,28 @@ class MoviePlugin(Plugin): def __init__(self): addApiView('movie.search', self.search) addApiView('movie.list', self.list) + addApiView('movie.refresh', self.refresh) + addApiView('movie.add', self.add) + addApiView('movie.edit', self.edit) + addApiView('movie.delete', self.delete) def list(self): - a = getParams() + params = getParams() + db = get_session() - results = get_session().query(Movie).filter( - Movie.releases.any( - Release.status.has(identifier = 'wanted') - ) - ).all() + results = db.query(Movie).filter( + Movie.status.has(identifier = params.get('status', 'active')) + ).all() movies = [] for movie in results: - temp = { - 'id': movie.id, - 'name': movie.id, - 'releases': [], - } - for release in movie.releases: - temp['releases'].append({ - 'status': release.status.label, - 'quality': release.quality.label - }) + temp = movie.to_dict(deep = { + 'releases': {'status': {}, 'quality': {}}, + 'library': {'titles': {}, 'files':{}}, + 'files': {} + }) movies.append(temp) @@ -46,22 +44,41 @@ class MoviePlugin(Plugin): 'movies': movies, }) + def refresh(self): + + params = getParams() + db = get_session() + + movie = db.query(Movie).filter_by(id = params.get('id')).first() + + # Get current selected title + default_title = '' + for title in movie.library.titles: + if title.default: default_title = title.title + + if movie: + #addEvent('library.update.after', ) + fireEventAsync('library.update', library = movie.library, default_title = default_title) + + return jsonified({ + 'success': True, + }) + def search(self): - a = getParams() - cache_key = '%s/%s' % (__name__, urlencode(a)) + params = getParams() + cache_key = '%s/%s' % (__name__, urlencode(params)) movies = Env.get('cache').get(cache_key) if not movies: - results = fireEvent('provider.movie.search', q = a.get('q')) + results = fireEvent('provider.movie.search', q = params.get('q')) # Combine movie results movies = [] for r in results: movies += r - Env.get('cache').set(cache_key, movies, timeout = 10) - + Env.get('cache').set(cache_key, movies) return jsonified({ 'success': True, @@ -71,24 +88,46 @@ class MoviePlugin(Plugin): def add(self): - a = getParams() + params = getParams() db = get_session(); - library = fireEvent('library.add', attrs = a) - profile = db.query(Profile).filter_by(identifier = a.get('profile_identifier')) - - m = db.query(Movie).filter_by(library = library).first() + library = fireEvent('library.add', single = True, attrs = params) + status = fireEvent('status.add', 'active', single = True) + m = db.query(Movie).filter_by(library_id = library.id).first() if not m: m = Movie( - library = library, - profile = profile, + library_id = library.id, + profile_id = params.get('profile_id') ) db.add(m) - db.commit() + + m.status_id = status.id + db.commit() return jsonified({ 'success': True, 'added': True, - 'params': a, + 'movie': m.to_dict(deep = { + 'releases': {'status': {}, 'quality': {}}, + 'library': {'titles': {}} + }) + }) + + def edit(self): + pass + + def delete(self): + + params = getParams() + db = get_session() + + status = fireEvent('status.add', 'deleted', single = True) + + movie = db.query(Movie).filter_by(id = params.get('id')).first() + movie.status_id = status.id + db.commit() + + return jsonified({ + 'success': True, }) diff --git a/couchpotato/core/plugins/profile/__init__.py b/couchpotato/core/plugins/profile/__init__.py index b104d5f0..ac19b018 100644 --- a/couchpotato/core/plugins/profile/__init__.py +++ b/couchpotato/core/plugins/profile/__init__.py @@ -1,4 +1,4 @@ -from couchpotato.core.plugins.profile.main import ProfilePlugin +from .main import ProfilePlugin def start(): return ProfilePlugin() diff --git a/couchpotato/core/plugins/profile/main.py b/couchpotato/core/plugins/profile/main.py index 0b5d6a75..d088ac36 100644 --- a/couchpotato/core/plugins/profile/main.py +++ b/couchpotato/core/plugins/profile/main.py @@ -1,28 +1,90 @@ +from couchpotato import get_session from couchpotato.api import addApiView from couchpotato.core.event import addEvent -from couchpotato.core.helpers.request import jsonified, getParams +from couchpotato.core.helpers.request import jsonified, getParams, getParam +from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin +from couchpotato.core.settings.model import Profile, ProfileType + +log = CPLog(__name__) + class ProfilePlugin(Plugin): def __init__(self): - addEvent('profile.get', self.get) + addEvent('profile.all', self.all) addApiView('profile.save', self.save) addApiView('profile.delete', self.delete) - def get(self, key = ''): + def all(self): - pass + db = get_session() + profiles = db.query(Profile).all() + + temp = [] + for profile in profiles: + temp.append(profile.to_dict(deep = {'types': {}})) + + return temp def save(self): - a = getParams() + params = getParams() + + db = get_session() + + p = db.query(Profile).filter_by(id = params.get('id')).first() + if not p: + p = Profile() + db.add(p) + + p.label = params.get('label') + p.order = params.get('order', p.order if p.order else 0) + p.core = params.get('core', False) + + #delete old types + [db.delete(t) for t in p.types] + + order = 0 + for type in params.get('types', []): + t = ProfileType( + order = order, + finish = type.get('finish'), + wait_for = params.get('wait_for'), + quality_id = type.get('quality_id') + ) + p.types.append(t) + + order += 1 + + db.commit() return jsonified({ 'success': True, - 'a': a + 'profile': p.to_dict(deep = {'types': {}}) }) def delete(self): - pass + + id = getParam('id') + + db = get_session() + + success = False + message = '' + try: + p = db.query(Profile).filter_by(id = id).first() + + db.delete(p) + db.commit() + + success = True + except Exception, e: + message = 'Failed deleting Profile: %s' % e + log.error(message) + + return jsonified({ + 'success': success, + 'message': message + }) diff --git a/couchpotato/core/plugins/quality/__init__.py b/couchpotato/core/plugins/quality/__init__.py new file mode 100644 index 00000000..e1b97ad0 --- /dev/null +++ b/couchpotato/core/plugins/quality/__init__.py @@ -0,0 +1,6 @@ +from .main import QualityPlugin + +def start(): + return QualityPlugin() + +config = [] diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py new file mode 100644 index 00000000..493353e5 --- /dev/null +++ b/couchpotato/core/plugins/quality/main.py @@ -0,0 +1,97 @@ +from couchpotato import get_session +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.settings.model import Quality, Profile, ProfileType + + +log = CPLog(__name__) + +class QualityPlugin: + + qualities = [ + {'identifier': 'bd50', 'size': (15000, 60000), 'label': 'BR-Disk', 'alternative': ['1080p', 'bd25'], 'allow': [], 'ext':[], 'tags': ['x264', 'h264', 'blu ray']}, + {'identifier': '1080p', 'size': (5000, 20000), 'label': '1080P', 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['x264', 'h264', 'bluray']}, + {'identifier': '720p', 'size': (3500, 10000), 'label': '720P', 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['x264', 'h264', 'bluray']}, + {'identifier': 'brrip', 'size': (700, 7000), 'label': 'BR-Rip', 'alternative': ['bdrip'], 'allow': ['720p'], 'ext':['mkv', 'avi']}, + {'identifier': 'dvdr', 'size': (3000, 10000), 'label': 'DVD-R', 'alternative': [], 'allow': [], 'ext':['iso', 'img'], 'tags': ['pal', 'ntsc']}, + {'identifier': 'dvdrip', 'size': (600, 2400), 'label': 'DVD-Rip', 'alternative': [], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, + {'identifier': 'scr', 'size': (600, 1600), 'label': 'Screener', 'alternative': ['dvdscr'], 'allow': ['dvdr'], 'ext':['avi', 'mpg', 'mpeg']}, + {'identifier': 'r5', 'size': (600, 1000), 'label': 'R5', 'alternative': [], 'allow': ['dvdr'], 'ext':['avi', 'mpg', 'mpeg']}, + {'identifier': 'tc', 'size': (600, 1000), 'label': 'TeleCine', 'alternative': ['telecine'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, + {'identifier': 'ts', 'size': (600, 1000), 'label': 'TeleSync', 'alternative': ['telesync'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, + {'identifier': 'cam', 'size': (600, 1000), 'label': 'Cam', 'alternative': [], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']} + ] + pre_releases = ['cam', 'ts', 'tc', 'r5', 'scr'] + + def __init__(self): + addEvent('quality.all', self.all) + addEvent('app.load', self.fill) + + def all(self): + + db = get_session() + + qualities = db.query(Quality).all() + + temp = [] + for quality in qualities: + q = dict(self.getQuality(quality.identifier), **quality.to_dict()) + temp.append(q) + + return temp + + def getQuality(self, identifier): + + for q in self.qualities: + if identifier == q.get('identifier'): + return q + + def fill(self): + + db = get_session(); + + order = 0 + for q in self.qualities: + + # Create quality + quality = db.query(Quality).filter_by(identifier = q.get('identifier')).first() + + if not quality: + log.info('Creating quality: %s' % q.get('label')) + quality = Quality() + db.add(quality) + + quality.order = order + quality.identifier = q.get('identifier') + quality.label = q.get('label') + quality.size_min, quality.size_max = q.get('size') + + # Create single quality profile + profile = db.query(Profile).filter( + Profile.core == True + ).filter( + Profile.types.any(quality = quality) + ).all() + + if not profile: + log.info('Creating profile: %s' % q.get('label')) + profile = Profile( + core = True, + label = toUnicode(quality.label), + order = order + ) + db.add(profile) + + profile_type = ProfileType( + quality = quality, + profile = profile, + finish = True, + order = 0 + ) + profile.types.append(profile_type) + + order += 1 + db.commit() + + return True diff --git a/couchpotato/core/plugins/status/__init__.py b/couchpotato/core/plugins/status/__init__.py new file mode 100644 index 00000000..fb5b4cc7 --- /dev/null +++ b/couchpotato/core/plugins/status/__init__.py @@ -0,0 +1,6 @@ +from .main import StatusPlugin + +def start(): + return StatusPlugin() + +config = [] diff --git a/couchpotato/core/plugins/status/main.py b/couchpotato/core/plugins/status/main.py new file mode 100644 index 00000000..747cecf7 --- /dev/null +++ b/couchpotato/core/plugins/status/main.py @@ -0,0 +1,68 @@ +from couchpotato import get_session +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.settings.model import Status + +log = CPLog(__name__) + +class StatusPlugin: + + statuses = { + 'active': 'Active', + 'done': 'Done', + 'downloaded': 'Downloaded', + 'wanted': 'Wanted', + 'deleted': 'Deleted', + } + + def __init__(self): + addEvent('status.add', self.add) + addEvent('status.all', self.all) + addEvent('app.load', self.fill) + + def all(self): + + db = get_session() + + statuses = db.query(Status).all() + + temp = [] + for status in statuses: + s = status.to_dict() + temp.append(s) + + return temp + + def add(self, identifier): + + db = get_session() + + s = db.query(Status).filter_by(identifier = identifier).first() + if not s: + s = Status( + identifier = identifier, + label = identifier.capitalize() + ) + db.add(s) + db.commit() + + #db.remove() + return s + + def fill(self): + + db = get_session() + + for identifier, label in self.statuses.iteritems(): + s = db.query(Status).filter_by(identifier = identifier).first() + if not s: + log.info('Creating status: %s' % label) + s = Status( + identifier = identifier, + label = toUnicode(label) + ) + db.add(s) + + s.label = label + db.commit() diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index ec9bc013..9676dff8 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -1,8 +1,9 @@ from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin log = CPLog(__name__) -class Provider(): +class Provider(Plugin): type = None # movie, nzb, torrent, subtitle, trailer timeout = 10 # Default timeout for url requests diff --git a/couchpotato/core/providers/tmdb/__init__.py b/couchpotato/core/providers/themoviedb/__init__.py similarity index 100% rename from couchpotato/core/providers/tmdb/__init__.py rename to couchpotato/core/providers/themoviedb/__init__.py diff --git a/couchpotato/core/providers/themoviedb/main.py b/couchpotato/core/providers/themoviedb/main.py new file mode 100644 index 00000000..4d541eff --- /dev/null +++ b/couchpotato/core/providers/themoviedb/main.py @@ -0,0 +1,116 @@ +from __future__ import with_statement +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import simplifyString, toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.providers.base import Provider +from couchpotato.environment import Env +from libs.themoviedb import tmdb +import copy + +log = CPLog(__name__) + +class TMDBWrapper(Provider): + """Api for theMovieDb""" + + type = 'movie' + apiUrl = 'http://api.themoviedb.org/2.1' + imageUrl = 'http://hwcdn.themoviedb.org' + + def __init__(self): + addEvent('provider.movie.search', self.search) + addEvent('provider.movie.info', self.getInfo) + + # Use base wrapper + tmdb.Config.api_key = self.conf('api_key') + + def conf(self, attr): + return Env.setting(attr, 'themoviedb') + + def search(self, q, limit = 12): + ''' Find movie by name ''' + + if self.isDisabled(): + return False + + log.debug('TheMovieDB - Searching for movie: %s' % q) + raw = tmdb.search(simplifyString(q)) + + results = [] + if raw: + try: + nr = 0 + for movie in raw: + + results.append(self.parseMovie(movie)) + + nr += 1 + if nr == limit: + break + + log.info('TheMovieDB - Found: %s' % [result['titles'][0] + ' (' + str(result['year']) + ')' for result in results]) + return results + except SyntaxError, e: + log.error('Failed to parse XML response: %s' % e) + return False + + return results + + def getInfo(self, identifier = None): + result = {} + + movie = tmdb.imdbLookup(id = identifier)[0] + + if movie: + result = self.parseMovie(movie) + + return result + + def parseMovie(self, movie): + + year = str(movie.get('released', 'none'))[:4] + + # Poster url + poster = self.getImage(movie, type = 'poster') + backdrop = self.getImage(movie, type = 'backdrop') + + # 1900 is the same as None + if year == '1900' or year.lower() == 'none': + year = None + + movie_data = { + 'id': int(movie.get('id', 0)), + 'titles': [toUnicode(movie.get('name'))], + 'images': { + 'posters': [poster], + 'backdrops': [backdrop], + }, + 'imdb': movie.get('imdb_id'), + 'year': year, + 'plot': movie.get('overview', ''), + 'tagline': '', + } + + # Add alternative names + for alt in ['original_name', 'alternative_name']: + alt_name = toUnicode(movie.get(alt)) + if alt_name and not alt_name in movie_data['titles'] and alt_name.lower() != 'none' and alt_name != None: + movie_data['titles'].append(alt_name) + + return movie_data + + def getImage(self, movie, type = 'poster'): + + image = '' + for image in movie.get('images', []): + if(image.get('type') == type): + image = image.get('thumb') + break + + return image + + def isDisabled(self): + if self.conf('api_key') == '': + log.error('No API key provided.') + True + else: + False diff --git a/couchpotato/core/providers/tmdb/main.py b/couchpotato/core/providers/tmdb/main.py deleted file mode 100644 index e24a98eb..00000000 --- a/couchpotato/core/providers/tmdb/main.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import with_statement -from couchpotato.core.event import addEvent -from couchpotato.core.helpers.encoding import simplifyString, toUnicode -from couchpotato.core.logger import CPLog -from couchpotato.core.providers.base import Provider -from couchpotato.environment import Env -from libs.themoviedb import tmdb -from urllib import quote_plus -import copy -import simplejson as json -import urllib2 - -log = CPLog(__name__) - -class TMDBWrapper(Provider): - """Api for theMovieDb""" - - type = 'movie' - apiUrl = 'http://api.themoviedb.org/2.1' - imageUrl = 'http://hwcdn.themoviedb.org' - - def __init__(self): - addEvent('provider.movie.search', self.search) - addEvent('provider.movie.info', self.getInfo) - - # Use base wrapper - tmdb.Config.api_key = self.conf('api_key') - - def conf(self, attr): - return Env.setting(attr, 'themoviedb') - - def search(self, q, limit = 12, alternative = True): - ''' Find movie by name ''' - - if self.isDisabled(): - return False - - log.debug('TheMovieDB - Searching for movie: %s' % q) - - raw = tmdb.search(simplifyString(q)) - - #url = "%s/%s/%s/json/%s/%s" % (self.apiUrl, 'Movie.search', 'en', self.conf('api_key'), quote_plus(simplifyString(q))) - - -# data = urllib2.urlopen(url) -# jsn = json.load(data) - - if raw: - log.debug('TheMovieDB - Parsing RSS') - try: - results = [] - nr = 0 - for movie in raw: - - for k, x in movie.iteritems(): - print k - print x - - year = str(movie.get('released', 'none'))[:4] - - # Poster url - poster = '' - for p in movie.get('images'): - if(p.get('type') == 'poster'): - poster = p.get('thumb') - break - - # 1900 is the same as None - if year == '1900' or year.lower() == 'none': - year = None - - movie_data = { - 'id': int(movie.get('id', 0)), - 'name': toUnicode(movie.get('name')), - 'poster': poster, - 'imdb': movie.get('imdb_id'), - 'year': year, - 'tagline': 'This is the tagline of the movie', - } - results.append(copy.deepcopy(movie_data)) - - alternativeName = movie.get('alternative_name') - if alternativeName and alternative: - if alternativeName.lower() != movie['name'].lower() and alternativeName.lower() != 'none' and alternativeName != None: - movie_data['name'] = toUnicode(alternativeName) - results.append(copy.deepcopy(movie_data)) - nr += 1 - if nr == limit: - break - - log.info('TheMovieDB - Found: %s' % [result['name'] + u' (' + str(result['year']) + ')' for result in results]) - return results - except SyntaxError, e: - log.error('TheMovieDB - Failed to parse XML response from TheMovieDb: %s' % e) - return False - - return results - - def getInfo(self): - pass - - def isDisabled(self): - if self.conf('api_key') == '': - log.error('TheMovieDB - No API key provided for TheMovieDB') - True - else: - False diff --git a/couchpotato/core/settings/model.py b/couchpotato/core/settings/model.py index f8351f97..6394b974 100644 --- a/couchpotato/core/settings/model.py +++ b/couchpotato/core/settings/model.py @@ -2,8 +2,10 @@ from elixir.entity import Entity from elixir.fields import Field from elixir.options import options_defaults from elixir.relationships import OneToMany, ManyToOne -from sqlalchemy.types import Integer, String, Unicode, UnicodeText, Boolean, \ - Float +from libs.elixir.options import using_options +from libs.elixir.relationships import ManyToMany +from sqlalchemy.types import Integer, Unicode, UnicodeText, Boolean, Float, \ + String options_defaults["shortnames"] = True @@ -20,24 +22,48 @@ class Movie(Entity): The files belonging to the movie object are global for the whole movie such as trailers, nfo, thumbnails""" - library = ManyToOne('Library') + last_edit = Field(Integer) + library = ManyToOne('Library') + status = ManyToOne('Status') profile = ManyToOne('Profile') releases = OneToMany('Release') - files = OneToMany('File') + files = ManyToMany('File') class Library(Entity): + """""" - title = Field(Unicode) year = Field(Integer) - identifier = Field(Unicode) + identifier = Field(String(20)) rating = Field(Float) plot = Field(UnicodeText) tagline = Field(UnicodeText(255)) + status = ManyToOne('Status') movie = OneToMany('Movie') + titles = OneToMany('LibraryTitle') + files = ManyToMany('File') + + +class LibraryTitle(Entity): + """""" + + title = Field(Unicode) + default = Field(Boolean) + + language = OneToMany('Language') + libraries = ManyToOne('Library') + + +class Language(Entity): + """""" + + identifier = Field(String(20)) + label = Field(Unicode) + + titles = ManyToOne('LibraryTitle') class Release(Entity): @@ -47,7 +73,7 @@ class Release(Entity): movie = ManyToOne('Movie') status = ManyToOne('Status') quality = ManyToOne('Quality') - files = OneToMany('File') + files = ManyToMany('File') history = OneToMany('History') @@ -55,41 +81,49 @@ class Status(Entity): """The status of a release, such as Downloaded, Deleted, Wanted etc""" identifier = Field(String(20), unique = True) - label = Field(String(20)) + label = Field(Unicode(20)) releases = OneToMany('Release') + movies = OneToMany('Movie') class Quality(Entity): """Quality name of a release, DVD, 720P, DVD-Rip etc""" + using_options(order_by = 'order') identifier = Field(String(20), unique = True) - label = Field(String(20)) + label = Field(Unicode(20)) + order = Field(Integer) + + size_min = Field(Integer) + size_max = Field(Integer) releases = OneToMany('Release') - profile_types = ManyToOne('ProfileType') + profile_types = OneToMany('ProfileType') class Profile(Entity): """""" + using_options(order_by = 'order') - identifier = Field(String(20), unique = True) label = Field(Unicode(50)) order = Field(Integer) - wait_for = Field(Integer) + core = Field(Boolean) + hide = Field(Boolean) movie = OneToMany('Movie') - profile_type = OneToMany('ProfileType') + types = OneToMany('ProfileType', cascade = 'all, delete-orphan') class ProfileType(Entity): """""" + using_options(order_by = 'order') order = Field(Integer) - mark_completed = Field(Boolean) + finish = Field(Boolean) wait_for = Field(Integer) - type = OneToMany('Quality') + quality = ManyToOne('Quality') profile = ManyToOne('Profile') @@ -97,19 +131,22 @@ class File(Entity): """File that belongs to a release.""" path = Field(Unicode(255), nullable = False, unique = True) - part = Field(Integer) + part = Field(Integer, default = 1) - history = OneToMany('RenameHistory') - movie = ManyToOne('Movie') - release = ManyToOne('Release') type = ManyToOne('FileType') properties = OneToMany('FileProperty') + history = OneToMany('RenameHistory') + movie = ManyToMany('Movie') + release = ManyToMany('Release') + library = ManyToMany('Library') + class FileType(Entity): """Types could be trailer, subtitle, movie, partial movie etc.""" identifier = Field(String(20), unique = True) + type = Field(Unicode(20)) name = Field(Unicode(50), nullable = False) files = OneToMany('File') @@ -135,8 +172,8 @@ class History(Entity): class RenameHistory(Entity): """Remembers from where to where files have been moved.""" - old = Field(String(255)) - new = Field(String(255)) + old = Field(Unicode(255)) + new = Field(Unicode(255)) file = ManyToOne('File') diff --git a/couchpotato/static/images/delete.png b/couchpotato/static/images/delete.png new file mode 100644 index 00000000..276ea15f Binary files /dev/null and b/couchpotato/static/images/delete.png differ diff --git a/couchpotato/static/images/favicon.ico b/couchpotato/static/images/favicon.ico index 46a56a53..f93f9fbe 100644 Binary files a/couchpotato/static/images/favicon.ico and b/couchpotato/static/images/favicon.ico differ diff --git a/couchpotato/static/images/handle.png b/couchpotato/static/images/handle.png new file mode 100644 index 00000000..f78e248d Binary files /dev/null and b/couchpotato/static/images/handle.png differ diff --git a/couchpotato/static/images/homescreen.png b/couchpotato/static/images/homescreen.png new file mode 100644 index 00000000..d8028a4c Binary files /dev/null and b/couchpotato/static/images/homescreen.png differ diff --git a/couchpotato/static/scripts/block/search.js b/couchpotato/static/scripts/block/search.js index 88fbf252..ec4582b4 100644 --- a/couchpotato/static/scripts/block/search.js +++ b/couchpotato/static/scripts/block/search.js @@ -34,6 +34,7 @@ Block.Search = new Class({ self.spinner = new Spinner(self.result_container); self.OuterClickStack = new EventStack.OuterClick(); + History.addEvent('change', self.hideResults.bind(self, true)); //debug //self.input.set('value', 'kick ass') @@ -126,16 +127,16 @@ Block.Search = new Class({ Object.each(json.movies, function(movie){ - if(!movie.imdb || (movie.imdb && !self.results.getElement('#'+movie.imdb))){ + // if(!movie.imdb || (movie.imdb && !self.results.getElement('#'+movie.imdb))){ var m = new Block.Search.Item(movie); $(m).inject(self.results) self.movies[movie.imdb || 'r-'+Math.floor(Math.random()*10000)] = m - } - else { - self.movies[movie.imdb].alternativeName({ - 'name': movie.name - }) - } + // } + // else { + // self.movies[movie.imdb].alternativeTitle({ + // 'title': movie.title + // }) + // } }); @@ -157,7 +158,7 @@ Block.Search.Item = new Class({ var self = this; self.info = info; - self.alternative_names = []; + self.alternative_titles = []; self.create(); @@ -167,7 +168,7 @@ Block.Search.Item = new Class({ create: function(){ var self = this; - var info = self.info + var info = self.info; self.el = new Element('div.movie', { 'id': info.imdb @@ -182,12 +183,12 @@ Block.Search.Item = new Class({ 'click': self.showOptions.bind(self) } }).adopt( - self.thumbnail = info.poster ? new Element('img.thumbnail', { - 'src': info.poster + self.thumbnail = info.images.posters.length > 0 ? new Element('img.thumbnail', { + 'src': info.images.posters[0] }) : null, new Element('div.info').adopt( - self.name = new Element('h2', { - 'text': info.name + self.title = new Element('h2', { + 'text': info.titles[0] }).adopt( self.year = info.year ? new Element('span', { 'text': info.year @@ -214,15 +215,18 @@ Block.Search.Item = new Class({ }) } - self.alternativeName({ - 'name': info.name - }); + + info.titles.each(function(title){ + self.alternativeTitle({ + 'title': title + }); + }) }, - alternativeName: function(alternative){ + alternativeTitle: function(alternative){ var self = this; - self.alternative_names.include(alternative); + self.alternative_titles.include(alternative); }, showOptions: function(){ @@ -246,8 +250,8 @@ Block.Search.Item = new Class({ Api.request('movie.add', { 'data': { 'identifier': self.info.imdb, - 'name': self.name_select.get('value'), - 'quality': self.quality_select.get('value') + 'title': self.title_select.get('value'), + 'profile_id': self.profile_select.get('value') }, 'useSpinner': true, 'spinnerTarget': self.options, @@ -277,14 +281,14 @@ Block.Search.Item = new Class({ self.options.adopt( new Element('div').adopt( - self.info.poster ? new Element('img.thumbnail', { - 'src': self.info.poster + self.info.images.posters.length > 0 ? new Element('img.thumbnail', { + 'src': self.info.images.posters[0] }) : null, - self.name_select = new Element('select', { - 'name': 'name' + self.title_select = new Element('select', { + 'name': 'title' }), - self.quality_select = new Element('select', { - 'name': 'profile_identifier' + self.profile_select = new Element('select', { + 'name': 'profile' }), new Element('a.button', { 'text': 'Add', @@ -295,17 +299,17 @@ Block.Search.Item = new Class({ ) ); - Array.each(self.alternative_names, function(alt){ + Array.each(self.alternative_titles, function(alt){ new Element('option', { - 'text': alt.name - }).inject(self.name_select) + 'text': alt.title + }).inject(self.title_select) }) - Array.each(Quality.profiles, function(q){ + Array.each(Quality.profiles, function(profile){ new Element('option', { - 'value': q.indentifier, - 'text': q.label - }).inject(self.quality_select) + 'value': profile.id ? profile.id : profile.data.id, + 'text': profile.label ? profile.label : profile.data.label + }).inject(self.profile_select) }); self.options.addClass('set'); diff --git a/couchpotato/static/scripts/couchpotato.js b/couchpotato/static/scripts/couchpotato.js index d2d469fb..77227e66 100644 --- a/couchpotato/static/scripts/couchpotato.js +++ b/couchpotato/static/scripts/couchpotato.js @@ -114,8 +114,8 @@ var ApiClass = new Class({ }, options)).send() }, - createUrl: function(action){ - return this.options.url + (action || 'default') + '/' + createUrl: function(action, params){ + return this.options.url + (action || 'default') + '/' + (params ? '?'+Object.toQueryString(params) : '') }, getOption: function(name){ @@ -186,6 +186,17 @@ var p = function(){ console.log(arguments) }; +function randomString(length, extra) { + var chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" + (extra ? '-._!@#$%^&*()+=' : ''); + var stringLength = length || 8; + var randomString = ''; + for (var i = 0; i < stringLength; i++) { + var rnum = Math.floor(Math.random() * chars.length); + randomString += chars.charAt(rnum); + } + return randomString; +} + (function(){ var keyPaths = []; diff --git a/couchpotato/static/scripts/file.js b/couchpotato/static/scripts/file.js new file mode 100644 index 00000000..b960a2bb --- /dev/null +++ b/couchpotato/static/scripts/file.js @@ -0,0 +1,77 @@ +var File = new Class({ + + initialize: function(file){ + var self = this; + + self.data = file; + self.type = File.Type.get(file.type_id); + + self['create'+(self.type.type).capitalize()]() + + }, + + createImage: function(){ + var self = this; + + self.el = new Element('div.type_image').adopt( + new Element('img', { + 'src': Api.createUrl('file.cache') + self.data.path.substring(1) + '/' + }) + ) + }, + + toElement: function(){ + return this.el; + } + +}); + +var FileSelect = new Class({ + + multiple: function(type, files, single){ + + var results = files.filter(function(file){ + return file.type_id == File.Type.get(type).id; + }); + + if(single){ + results = new File(results.pop()); + } + else { + + } + + return results; + + }, + + single: function(type, files){ + return this.multiple(type, files, true); + } + +}); +window.File.Select = new FileSelect(); + +var FileTypeBase = new Class({ + + setup: function(types){ + var self = this; + + self.typesById = {}; + self.typesByKey = {}; + Object.each(types, function(type){ + self.typesByKey[type.identifier] = type; + self.typesById[type.id] = type; + }); + + }, + + get: function(identifier){ + if(typeOf(identifier) == 'number') + return this.typesById[identifier] + else + return this.typesByKey[identifier] + } + +}); +window.File.Type = new FileTypeBase(); diff --git a/couchpotato/static/scripts/library/mootools.js b/couchpotato/static/scripts/library/mootools.js index 6ccaf033..6dc82f2d 100644 --- a/couchpotato/static/scripts/library/mootools.js +++ b/couchpotato/static/scripts/library/mootools.js @@ -3,10 +3,10 @@ MooTools: the javascript framework web build: - - http://mootools.net/core/bd6349d3fbc489736e5aefb01157c8a8 + - http://mootools.net/core/c1215700e7dedaa9d48503126daf2111 packager build: - - packager build Core/Class Core/Class.Extras Core/Element Core/Element.Style Core/Element.Dimensions Core/Fx.Tween Core/Fx.Transitions Core/Request.JSON Core/DOMReady + - packager build Core/Class Core/Class.Extras Core/Element Core/Element.Style Core/Element.Dimensions Core/Fx.Tween Core/Fx.Morph Core/Fx.Transitions Core/Request.JSON Core/DOMReady /* --- @@ -4085,6 +4085,85 @@ Element.implement({ }); +/* +--- + +name: Fx.Morph + +description: Formerly Fx.Styles, effect to transition any number of CSS properties for an element using an object of rules, or CSS based selector rules. + +license: MIT-style license. + +requires: Fx.CSS + +provides: Fx.Morph + +... +*/ + +Fx.Morph = new Class({ + + Extends: Fx.CSS, + + initialize: function(element, options){ + this.element = this.subject = document.id(element); + this.parent(options); + }, + + set: function(now){ + if (typeof now == 'string') now = this.search(now); + for (var p in now) this.render(this.element, p, now[p], this.options.unit); + return this; + }, + + compute: function(from, to, delta){ + var now = {}; + for (var p in from) now[p] = this.parent(from[p], to[p], delta); + return now; + }, + + start: function(properties){ + if (!this.check(properties)) return this; + if (typeof properties == 'string') properties = this.search(properties); + var from = {}, to = {}; + for (var p in properties){ + var parsed = this.prepare(this.element, p, properties[p]); + from[p] = parsed.from; + to[p] = parsed.to; + } + return this.parent(from, to); + } + +}); + +Element.Properties.morph = { + + set: function(options){ + this.get('morph').cancel().setOptions(options); + return this; + }, + + get: function(){ + var morph = this.retrieve('morph'); + if (!morph){ + morph = new Fx.Morph(this, {link: 'cancel'}); + this.store('morph', morph); + } + return morph; + } + +}; + +Element.implement({ + + morph: function(props){ + this.get('morph').start(props); + return this; + } + +}); + + /* --- diff --git a/couchpotato/static/scripts/library/mootools_more.js b/couchpotato/static/scripts/library/mootools_more.js index 808330cd..58767dce 100644 --- a/couchpotato/static/scripts/library/mootools_more.js +++ b/couchpotato/static/scripts/library/mootools_more.js @@ -1,6 +1,6 @@ // MooTools: the javascript framework. -// Load this file's selection again by visiting: http://mootools.net/more/a6033a03c64d978cd0033a1d420e16e0 -// Or build this file again with packager using: packager build More/Element.Forms More/Element.Delegation More/Element.Shortcuts More/Request.JSONP More/Spinner +// Load this file's selection again by visiting: http://mootools.net/more/2b832e45b9bf2f9e5fdbdafc9b16febf +// Or build this file again with packager using: packager build More/Element.Forms More/Element.Delegation More/Element.Shortcuts More/Fx.Slide More/Sortables More/Request.JSONP More/Spinner /* --- @@ -767,6 +767,849 @@ Document.implement({ }); +/* +--- + +script: Fx.Slide.js + +name: Fx.Slide + +description: Effect to slide an element in and out of view. + +license: MIT-style license + +authors: + - Valerio Proietti + +requires: + - Core/Fx + - Core/Element.Style + - /MooTools.More + +provides: [Fx.Slide] + +... +*/ + +Fx.Slide = new Class({ + + Extends: Fx, + + options: { + mode: 'vertical', + wrapper: false, + hideOverflow: true, + resetHeight: false + }, + + initialize: function(element, options){ + element = this.element = this.subject = document.id(element); + this.parent(options); + options = this.options; + + var wrapper = element.retrieve('wrapper'), + styles = element.getStyles('margin', 'position', 'overflow'); + + if (options.hideOverflow) styles = Object.append(styles, {overflow: 'hidden'}); + if (options.wrapper) wrapper = document.id(options.wrapper).setStyles(styles); + + if (!wrapper) wrapper = new Element('div', { + styles: styles + }).wraps(element); + + element.store('wrapper', wrapper).setStyle('margin', 0); + if (element.getStyle('overflow') == 'visible') element.setStyle('overflow', 'hidden'); + + this.now = []; + this.open = true; + this.wrapper = wrapper; + + this.addEvent('complete', function(){ + this.open = (wrapper['offset' + this.layout.capitalize()] != 0); + if (this.open && options.resetHeight) wrapper.setStyle('height', ''); + }, true); + }, + + vertical: function(){ + this.margin = 'margin-top'; + this.layout = 'height'; + this.offset = this.element.offsetHeight; + }, + + horizontal: function(){ + this.margin = 'margin-left'; + this.layout = 'width'; + this.offset = this.element.offsetWidth; + }, + + set: function(now){ + this.element.setStyle(this.margin, now[0]); + this.wrapper.setStyle(this.layout, now[1]); + return this; + }, + + compute: function(from, to, delta){ + return [0, 1].map(function(i){ + return Fx.compute(from[i], to[i], delta); + }); + }, + + start: function(how, mode){ + if (!this.check(how, mode)) return this; + this[mode || this.options.mode](); + + var margin = this.element.getStyle(this.margin).toInt(), + layout = this.wrapper.getStyle(this.layout).toInt(), + caseIn = [[margin, layout], [0, this.offset]], + caseOut = [[margin, layout], [-this.offset, 0]], + start; + + switch (how){ + case 'in': start = caseIn; break; + case 'out': start = caseOut; break; + case 'toggle': start = (layout == 0) ? caseIn : caseOut; + } + return this.parent(start[0], start[1]); + }, + + slideIn: function(mode){ + return this.start('in', mode); + }, + + slideOut: function(mode){ + return this.start('out', mode); + }, + + hide: function(mode){ + this[mode || this.options.mode](); + this.open = false; + return this.set([-this.offset, 0]); + }, + + show: function(mode){ + this[mode || this.options.mode](); + this.open = true; + return this.set([0, this.offset]); + }, + + toggle: function(mode){ + return this.start('toggle', mode); + } + +}); + +Element.Properties.slide = { + + set: function(options){ + this.get('slide').cancel().setOptions(options); + return this; + }, + + get: function(){ + var slide = this.retrieve('slide'); + if (!slide){ + slide = new Fx.Slide(this, {link: 'cancel'}); + this.store('slide', slide); + } + return slide; + } + +}; + +Element.implement({ + + slide: function(how, mode){ + how = how || 'toggle'; + var slide = this.get('slide'), toggle; + switch (how){ + case 'hide': slide.hide(mode); break; + case 'show': slide.show(mode); break; + case 'toggle': + var flag = this.retrieve('slide:flag', slide.open); + slide[flag ? 'slideOut' : 'slideIn'](mode); + this.store('slide:flag', !flag); + toggle = true; + break; + default: slide.start(how, mode); + } + if (!toggle) this.eliminate('slide:flag'); + return this; + } + +}); + + +/* +--- + +script: Drag.js + +name: Drag + +description: The base Drag Class. Can be used to drag and resize Elements using mouse events. + +license: MIT-style license + +authors: + - Valerio Proietti + - Tom Occhinno + - Jan Kassens + +requires: + - Core/Events + - Core/Options + - Core/Element.Event + - Core/Element.Style + - Core/Element.Dimensions + - /MooTools.More + +provides: [Drag] +... + +*/ + +var Drag = new Class({ + + Implements: [Events, Options], + + options: {/* + onBeforeStart: function(thisElement){}, + onStart: function(thisElement, event){}, + onSnap: function(thisElement){}, + onDrag: function(thisElement, event){}, + onCancel: function(thisElement){}, + onComplete: function(thisElement, event){},*/ + snap: 6, + unit: 'px', + grid: false, + style: true, + limit: false, + handle: false, + invert: false, + preventDefault: false, + stopPropagation: false, + modifiers: {x: 'left', y: 'top'} + }, + + initialize: function(){ + var params = Array.link(arguments, { + 'options': Type.isObject, + 'element': function(obj){ + return obj != null; + } + }); + + this.element = document.id(params.element); + this.document = this.element.getDocument(); + this.setOptions(params.options || {}); + var htype = typeOf(this.options.handle); + this.handles = ((htype == 'array' || htype == 'collection') ? $$(this.options.handle) : document.id(this.options.handle)) || this.element; + this.mouse = {'now': {}, 'pos': {}}; + this.value = {'start': {}, 'now': {}}; + + this.selection = (Browser.ie) ? 'selectstart' : 'mousedown'; + + + if (Browser.ie && !Drag.ondragstartFixed){ + document.ondragstart = Function.from(false); + Drag.ondragstartFixed = true; + } + + this.bound = { + start: this.start.bind(this), + check: this.check.bind(this), + drag: this.drag.bind(this), + stop: this.stop.bind(this), + cancel: this.cancel.bind(this), + eventStop: Function.from(false) + }; + this.attach(); + }, + + attach: function(){ + this.handles.addEvent('mousedown', this.bound.start); + return this; + }, + + detach: function(){ + this.handles.removeEvent('mousedown', this.bound.start); + return this; + }, + + start: function(event){ + var options = this.options; + + if (event.rightClick) return; + + if (options.preventDefault) event.preventDefault(); + if (options.stopPropagation) event.stopPropagation(); + this.mouse.start = event.page; + + this.fireEvent('beforeStart', this.element); + + var limit = options.limit; + this.limit = {x: [], y: []}; + + var styles = this.element.getStyles('left', 'right', 'top', 'bottom'); + this._invert = { + x: options.modifiers.x == 'left' && styles.left == 'auto' && !isNaN(styles.right.toInt()) && (options.modifiers.x = 'right'), + y: options.modifiers.y == 'top' && styles.top == 'auto' && !isNaN(styles.bottom.toInt()) && (options.modifiers.y = 'bottom') + }; + + var z, coordinates; + for (z in options.modifiers){ + if (!options.modifiers[z]) continue; + + var style = this.element.getStyle(options.modifiers[z]); + + // Some browsers (IE and Opera) don't always return pixels. + if (style && !style.match(/px$/)){ + if (!coordinates) coordinates = this.element.getCoordinates(this.element.getOffsetParent()); + style = coordinates[options.modifiers[z]]; + } + + if (options.style) this.value.now[z] = (style || 0).toInt(); + else this.value.now[z] = this.element[options.modifiers[z]]; + + if (options.invert) this.value.now[z] *= -1; + if (this._invert[z]) this.value.now[z] *= -1; + + this.mouse.pos[z] = event.page[z] - this.value.now[z]; + + if (limit && limit[z]){ + var i = 2; + while (i--){ + var limitZI = limit[z][i]; + if (limitZI || limitZI === 0) this.limit[z][i] = (typeof limitZI == 'function') ? limitZI() : limitZI; + } + } + } + + if (typeOf(this.options.grid) == 'number') this.options.grid = { + x: this.options.grid, + y: this.options.grid + }; + + var events = { + mousemove: this.bound.check, + mouseup: this.bound.cancel + }; + events[this.selection] = this.bound.eventStop; + this.document.addEvents(events); + }, + + check: function(event){ + if (this.options.preventDefault) event.preventDefault(); + var distance = Math.round(Math.sqrt(Math.pow(event.page.x - this.mouse.start.x, 2) + Math.pow(event.page.y - this.mouse.start.y, 2))); + if (distance > this.options.snap){ + this.cancel(); + this.document.addEvents({ + mousemove: this.bound.drag, + mouseup: this.bound.stop + }); + this.fireEvent('start', [this.element, event]).fireEvent('snap', this.element); + } + }, + + drag: function(event){ + var options = this.options; + + if (options.preventDefault) event.preventDefault(); + this.mouse.now = event.page; + + for (var z in options.modifiers){ + if (!options.modifiers[z]) continue; + this.value.now[z] = this.mouse.now[z] - this.mouse.pos[z]; + + if (options.invert) this.value.now[z] *= -1; + if (this._invert[z]) this.value.now[z] *= -1; + + if (options.limit && this.limit[z]){ + if ((this.limit[z][1] || this.limit[z][1] === 0) && (this.value.now[z] > this.limit[z][1])){ + this.value.now[z] = this.limit[z][1]; + } else if ((this.limit[z][0] || this.limit[z][0] === 0) && (this.value.now[z] < this.limit[z][0])){ + this.value.now[z] = this.limit[z][0]; + } + } + + if (options.grid[z]) this.value.now[z] -= ((this.value.now[z] - (this.limit[z][0]||0)) % options.grid[z]); + + if (options.style) this.element.setStyle(options.modifiers[z], this.value.now[z] + options.unit); + else this.element[options.modifiers[z]] = this.value.now[z]; + } + + this.fireEvent('drag', [this.element, event]); + }, + + cancel: function(event){ + this.document.removeEvents({ + mousemove: this.bound.check, + mouseup: this.bound.cancel + }); + if (event){ + this.document.removeEvent(this.selection, this.bound.eventStop); + this.fireEvent('cancel', this.element); + } + }, + + stop: function(event){ + var events = { + mousemove: this.bound.drag, + mouseup: this.bound.stop + }; + events[this.selection] = this.bound.eventStop; + this.document.removeEvents(events); + if (event) this.fireEvent('complete', [this.element, event]); + } + +}); + +Element.implement({ + + makeResizable: function(options){ + var drag = new Drag(this, Object.merge({ + modifiers: { + x: 'width', + y: 'height' + } + }, options)); + + this.store('resizer', drag); + return drag.addEvent('drag', function(){ + this.fireEvent('resize', drag); + }.bind(this)); + } + +}); + + +/* +--- + +script: Drag.Move.js + +name: Drag.Move + +description: A Drag extension that provides support for the constraining of draggables to containers and droppables. + +license: MIT-style license + +authors: + - Valerio Proietti + - Tom Occhinno + - Jan Kassens + - Aaron Newton + - Scott Kyle + +requires: + - Core/Element.Dimensions + - /Drag + +provides: [Drag.Move] + +... +*/ + +Drag.Move = new Class({ + + Extends: Drag, + + options: {/* + onEnter: function(thisElement, overed){}, + onLeave: function(thisElement, overed){}, + onDrop: function(thisElement, overed, event){},*/ + droppables: [], + container: false, + precalculate: false, + includeMargins: true, + checkDroppables: true + }, + + initialize: function(element, options){ + this.parent(element, options); + element = this.element; + + this.droppables = $$(this.options.droppables); + this.container = document.id(this.options.container); + + if (this.container && typeOf(this.container) != 'element') + this.container = document.id(this.container.getDocument().body); + + if (this.options.style){ + if (this.options.modifiers.x == "left" && this.options.modifiers.y == "top"){ + var parentStyles, + parent = element.getOffsetParent(); + var styles = element.getStyles('left', 'top'); + if (parent && (styles.left == 'auto' || styles.top == 'auto')){ + element.setPosition(element.getPosition(parent)); + } + } + + if (element.getStyle('position') == 'static') element.setStyle('position', 'absolute'); + } + + this.addEvent('start', this.checkDroppables, true); + this.overed = null; + }, + + start: function(event){ + if (this.container) this.options.limit = this.calculateLimit(); + + if (this.options.precalculate){ + this.positions = this.droppables.map(function(el){ + return el.getCoordinates(); + }); + } + + this.parent(event); + }, + + calculateLimit: function(){ + var element = this.element, + container = this.container, + + offsetParent = document.id(element.getOffsetParent()) || document.body, + containerCoordinates = container.getCoordinates(offsetParent), + elementMargin = {}, + elementBorder = {}, + containerMargin = {}, + containerBorder = {}, + offsetParentPadding = {}; + + ['top', 'right', 'bottom', 'left'].each(function(pad){ + elementMargin[pad] = element.getStyle('margin-' + pad).toInt(); + elementBorder[pad] = element.getStyle('border-' + pad).toInt(); + containerMargin[pad] = container.getStyle('margin-' + pad).toInt(); + containerBorder[pad] = container.getStyle('border-' + pad).toInt(); + offsetParentPadding[pad] = offsetParent.getStyle('padding-' + pad).toInt(); + }, this); + + var width = element.offsetWidth + elementMargin.left + elementMargin.right, + height = element.offsetHeight + elementMargin.top + elementMargin.bottom, + left = 0, + top = 0, + right = containerCoordinates.right - containerBorder.right - width, + bottom = containerCoordinates.bottom - containerBorder.bottom - height; + + if (this.options.includeMargins){ + left += elementMargin.left; + top += elementMargin.top; + } else { + right += elementMargin.right; + bottom += elementMargin.bottom; + } + + if (element.getStyle('position') == 'relative'){ + var coords = element.getCoordinates(offsetParent); + coords.left -= element.getStyle('left').toInt(); + coords.top -= element.getStyle('top').toInt(); + + left -= coords.left; + top -= coords.top; + if (container.getStyle('position') != 'relative'){ + left += containerBorder.left; + top += containerBorder.top; + } + right += elementMargin.left - coords.left; + bottom += elementMargin.top - coords.top; + + if (container != offsetParent){ + left += containerMargin.left + offsetParentPadding.left; + top += ((Browser.ie6 || Browser.ie7) ? 0 : containerMargin.top) + offsetParentPadding.top; + } + } else { + left -= elementMargin.left; + top -= elementMargin.top; + if (container != offsetParent){ + left += containerCoordinates.left + containerBorder.left; + top += containerCoordinates.top + containerBorder.top; + } + } + + return { + x: [left, right], + y: [top, bottom] + }; + }, + + getDroppableCoordinates: function(element){ + var position = element.getCoordinates(); + if (element.getStyle('position') == 'fixed'){ + var scroll = window.getScroll(); + position.left += scroll.x; + position.right += scroll.x; + position.top += scroll.y; + position.bottom += scroll.y; + } + return position; + }, + + checkDroppables: function(){ + var overed = this.droppables.filter(function(el, i){ + el = this.positions ? this.positions[i] : this.getDroppableCoordinates(el); + var now = this.mouse.now; + return (now.x > el.left && now.x < el.right && now.y < el.bottom && now.y > el.top); + }, this).getLast(); + + if (this.overed != overed){ + if (this.overed) this.fireEvent('leave', [this.element, this.overed]); + if (overed) this.fireEvent('enter', [this.element, overed]); + this.overed = overed; + } + }, + + drag: function(event){ + this.parent(event); + if (this.options.checkDroppables && this.droppables.length) this.checkDroppables(); + }, + + stop: function(event){ + this.checkDroppables(); + this.fireEvent('drop', [this.element, this.overed, event]); + this.overed = null; + return this.parent(event); + } + +}); + +Element.implement({ + + makeDraggable: function(options){ + var drag = new Drag.Move(this, options); + this.store('dragger', drag); + return drag; + } + +}); + + +/* +--- + +script: Sortables.js + +name: Sortables + +description: Class for creating a drag and drop sorting interface for lists of items. + +license: MIT-style license + +authors: + - Tom Occhino + +requires: + - Core/Fx.Morph + - /Drag.Move + +provides: [Sortables] + +... +*/ + +var Sortables = new Class({ + + Implements: [Events, Options], + + options: {/* + onSort: function(element, clone){}, + onStart: function(element, clone){}, + onComplete: function(element){},*/ + opacity: 1, + clone: false, + revert: false, + handle: false, + dragOptions: {} + }, + + initialize: function(lists, options){ + this.setOptions(options); + + this.elements = []; + this.lists = []; + this.idle = true; + + this.addLists($$(document.id(lists) || lists)); + + if (!this.options.clone) this.options.revert = false; + if (this.options.revert) this.effect = new Fx.Morph(null, Object.merge({ + duration: 250, + link: 'cancel' + }, this.options.revert)); + }, + + attach: function(){ + this.addLists(this.lists); + return this; + }, + + detach: function(){ + this.lists = this.removeLists(this.lists); + return this; + }, + + addItems: function(){ + Array.flatten(arguments).each(function(element){ + this.elements.push(element); + var start = element.retrieve('sortables:start', function(event){ + this.start.call(this, event, element); + }.bind(this)); + (this.options.handle ? element.getElement(this.options.handle) || element : element).addEvent('mousedown', start); + }, this); + return this; + }, + + addLists: function(){ + Array.flatten(arguments).each(function(list){ + this.lists.include(list); + this.addItems(list.getChildren()); + }, this); + return this; + }, + + removeItems: function(){ + return $$(Array.flatten(arguments).map(function(element){ + this.elements.erase(element); + var start = element.retrieve('sortables:start'); + (this.options.handle ? element.getElement(this.options.handle) || element : element).removeEvent('mousedown', start); + + return element; + }, this)); + }, + + removeLists: function(){ + return $$(Array.flatten(arguments).map(function(list){ + this.lists.erase(list); + this.removeItems(list.getChildren()); + + return list; + }, this)); + }, + + getClone: function(event, element){ + if (!this.options.clone) return new Element(element.tagName).inject(document.body); + if (typeOf(this.options.clone) == 'function') return this.options.clone.call(this, event, element, this.list); + var clone = element.clone(true).setStyles({ + margin: 0, + position: 'absolute', + visibility: 'hidden', + width: element.getStyle('width') + }).addEvent('mousedown', function(event){ + element.fireEvent('mousedown', event); + }); + //prevent the duplicated radio inputs from unchecking the real one + if (clone.get('html').test('radio')){ + clone.getElements('input[type=radio]').each(function(input, i){ + input.set('name', 'clone_' + i); + if (input.get('checked')) element.getElements('input[type=radio]')[i].set('checked', true); + }); + } + + return clone.inject(this.list).setPosition(element.getPosition(element.getOffsetParent())); + }, + + getDroppables: function(){ + var droppables = this.list.getChildren().erase(this.clone).erase(this.element); + if (!this.options.constrain) droppables.append(this.lists).erase(this.list); + return droppables; + }, + + insert: function(dragging, element){ + var where = 'inside'; + if (this.lists.contains(element)){ + this.list = element; + this.drag.droppables = this.getDroppables(); + } else { + where = this.element.getAllPrevious().contains(element) ? 'before' : 'after'; + } + this.element.inject(element, where); + this.fireEvent('sort', [this.element, this.clone]); + }, + + start: function(event, element){ + if ( + !this.idle || + event.rightClick || + ['button', 'input', 'a'].contains(event.target.get('tag')) + ) return; + + this.idle = false; + this.element = element; + this.opacity = element.get('opacity'); + this.list = element.getParent(); + this.clone = this.getClone(event, element); + + this.drag = new Drag.Move(this.clone, Object.merge({ + + droppables: this.getDroppables() + }, this.options.dragOptions)).addEvents({ + onSnap: function(){ + event.stop(); + this.clone.setStyle('visibility', 'visible'); + this.element.set('opacity', this.options.opacity || 0); + this.fireEvent('start', [this.element, this.clone]); + }.bind(this), + onEnter: this.insert.bind(this), + onCancel: this.end.bind(this), + onComplete: this.end.bind(this) + }); + + this.clone.inject(this.element, 'before'); + this.drag.start(event); + }, + + end: function(){ + this.drag.detach(); + this.element.set('opacity', this.opacity); + if (this.effect){ + var dim = this.element.getStyles('width', 'height'), + clone = this.clone, + pos = clone.computePosition(this.element.getPosition(this.clone.getOffsetParent())); + + var destroy = function(){ + this.removeEvent('cancel', destroy); + clone.destroy(); + }; + + this.effect.element = clone; + this.effect.start({ + top: pos.top, + left: pos.left, + width: dim.width, + height: dim.height, + opacity: 0.25 + }).addEvent('cancel', destroy).chain(destroy); + } else { + this.clone.destroy(); + } + this.reset(); + }, + + reset: function(){ + this.idle = true; + this.fireEvent('complete', this.element); + }, + + serialize: function(){ + var params = Array.link(arguments, { + modifier: Type.isFunction, + index: function(obj){ + return obj != null; + } + }); + var serial = this.lists.map(function(list){ + return list.getChildren().map(params.modifier || function(element){ + return element.get('id'); + }, this); + }, this); + + var index = params.index; + if (this.lists.length == 1) index = 0; + return (index || index === 0) && index >= 0 && index < this.lists.length ? serial[index] : serial; + } + +}); + + /* --- diff --git a/couchpotato/static/scripts/page/settings.js b/couchpotato/static/scripts/page/settings.js index 90c5e868..c2990620 100644 --- a/couchpotato/static/scripts/page/settings.js +++ b/couchpotato/static/scripts/page/settings.js @@ -11,6 +11,12 @@ Page.Settings = new Class({ }, 'providers': { 'label': 'Providers' + }, + 'downloaders': { + 'label': 'Downloaders' + }, + 'notifications': { + 'label': 'Notifications' } }, @@ -27,13 +33,13 @@ Page.Settings = new Class({ openTab: function(action){ var self = this; - action = action || self.action + var action = action || self.action; if(self.current) self.toggleTab(self.current, true); - self.toggleTab(action) - self.current = action; + var tab = self.toggleTab(action) + self.current = tab == self.tabs.general ? 'general' : action; }, @@ -47,6 +53,7 @@ Page.Settings = new Class({ t.tab[a](c); t.content[a](c); + return t }, getData: function(onComplete){ @@ -121,7 +128,7 @@ Page.Settings = new Class({ // Add options to group group.options.sortBy('order').each(function(option){ - var class_name = (option.type || 'input').capitalize(); + var class_name = (option.type || 'string').capitalize(); var input = new Option[class_name](self, section_name, option.name, option); input.inject(group_el); }); @@ -145,13 +152,13 @@ Page.Settings = new Class({ var tab_el = new Element('li').adopt( new Element('a', { 'href': '/'+self.name+'/'+tab_name+'/', - 'text': tab.label.capitalize() + 'text': (tab.label || tab.name).capitalize() }) ).inject(self.tabs_container); if(!self.tabs[tab_name]) self.tabs[tab_name] = { - 'label': tab.label + 'label': tab.label || tab.name } self.tabs[tab_name] = Object.merge(self.tabs[tab_name], { @@ -171,7 +178,7 @@ Page.Settings = new Class({ 'class': group.advanced ? 'inlineLabels advanced' : 'inlineLabels' }).adopt( new Element('h2', { - 'text': group.label + 'text': group.label || group.name.capitalize() }).adopt( new Element('span.hint', { 'text': group.description @@ -222,6 +229,13 @@ var OptionBase = new Class({ }, create: function(){}, + + createLabel: function(){ + var self = this; + return new Element('label', { + 'text': self.options.label || self.options.name.capitalize() + }) + }, setAdvanced: function(){ this.el.addClass(this.options.advanced ? 'advanced': '') @@ -319,9 +333,7 @@ Option.String = new Class({ var self = this self.el.adopt( - new Element('label', { - 'text': self.options.label - }), + self.createLabel(), self.input = new Element('input', { 'type': 'text', 'name': self.postName(), @@ -337,18 +349,17 @@ Option.Dropdown = new Class({ create: function(){ var self = this - new Element('label', { - 'text': self.options.label - }).adopt( + self.el.adopt( + self.createLabel(), self.input = new Element('select', { 'name': self.postName() }) - ).inject(self.el) + ) - Object.each(self.options.values, function(label, value){ + Object.each(self.options.values, function(value){ new Element('option', { - 'text': label, - 'value': value + 'text': value[0], + 'value': value[1] }).inject(self.input) }) @@ -366,24 +377,31 @@ Option.Checkbox = new Class({ var randomId = 'option-'+Math.floor(Math.random()*1000000) - new Element('label', { - 'text': self.options.label, - 'for': randomId - }).inject(self.el); - - self.input = new Element('input', { - 'type': 'checkbox', - 'value': self.getSettingValue(), - 'checked': self.getSettingValue() !== undefined, - 'id': randomId - }).inject(self.el); + self.el.adopt( + self.createLabel().set('for', randomId), + self.input = new Element('input', { + 'type': 'checkbox', + 'value': self.getSettingValue(), + 'checked': self.getSettingValue() !== undefined, + 'id': randomId + }) + ) } }); +Option.Password = new Class({ + Extends: Option.String, + type: 'password' +}); + Option.Bool = new Class({ Extends: Option.Checkbox }); +Option.Enabler = new Class({ + Extends: Option.Bool +}); + Option.Int = new Class({ Extends: Option.String }); @@ -401,9 +419,7 @@ Option.Directory = new Class({ self.el.adopt( - new Element('label', { - 'text': self.options.label - }), + self.createLabel(), self.input = new Element('span', { 'text': self.getSettingValue(), 'events': { diff --git a/couchpotato/static/scripts/page/wanted.js b/couchpotato/static/scripts/page/wanted.js index 53a15c52..7bbfceea 100644 --- a/couchpotato/static/scripts/page/wanted.js +++ b/couchpotato/static/scripts/page/wanted.js @@ -18,19 +18,28 @@ Page.Wanted = new Class({ if(!self.movie_container) self.movie_container = new Element('div.movies').inject(self.el); - + self.movie_container.empty(); Object.each(self.movies, function(info){ var m = new Movie(self, {}, info); $(m).inject(self.movie_container); }); + + self.movie_container.addEvents({ + 'mouseenter:relay(.movie)': function(e, el){ + el.addClass('hover') + }, + 'mouseleave:relay(.movie)': function(e, el){ + el.removeClass('hover') + } + }) }, get: function(status, onComplete){ var self = this if(self.movies.length == 0) - Api.request('movie', { + Api.request('movie.list', { 'data': {}, 'onComplete': function(json){ self.store(json.movies); @@ -58,16 +67,280 @@ var Movie = new Class({ self.data = data; + self.profile = Quality.getProfile(data.profile_id); self.parent(self, options); }, create: function(){ var self = this; - self.el = new Element('div.movie', { - 'text': self.data.name + self.el = new Element('div.movie').adopt( + self.data_container = new Element('div').adopt( + self.thumbnail = File.Select.single('poster', self.data.library.files), + self.title = new Element('div.title', { + 'text': self.getTitle() + }), + self.description = new Element('div.description', { + 'text': self.data.library.plot + }), + self.rating = new Element('div.rating', { + 'text': self.data.library.rating || 10 + }), + self.year = new Element('div.year', { + 'text': self.data.library.year || 'Unknown' + }), + self.quality = new Element('div.quality', { + 'text': self.profile.get('label') + }), + self.actions = new Element('div.actions').adopt( + self.action_imdb = new Movie.Action.IMDB(self), + self.action_edit = new Movie.Action.Edit(self), + self.action_refresh = new Movie.Action.Refresh(self), + self.action_delete = new Movie.Action.Delete(self) + ) + ) + ); + + }, + + getTitle: function(){ + var self = this; + + var titles = self.data.library.titles; + + var title = titles.filter(function(title){ + return title['default'] + }).pop() + + if(title) + return title.title + else if(titles.length > 0) + return titles[0].title + + return 'Unknown movie' + }, + + get: function(attr){ + return this.data[attr] || this.data.library[attr] + } + +}); + +var MovieAction = new Class({ + + class_name: 'action', + + initialize: function(movie){ + var self = this; + self.movie = movie; + + self.create(); + self.el.addClass(self.class_name) + }, + + create: function(){}, + + disable: function(){ + this.el.addClass('disable') + }, + + enable: function(){ + this.el.removeClass('disable') + }, + + toElement: function(){ + return this.el + } + +}) + +Movie.Action = {} + +Movie.Action.Edit = new Class({ + + Extends: MovieAction, + + create: function(){ + var self = this; + + self.el = new Element('a.edit', { + 'text': 'edit', + 'title': 'Refresh the movie info and do a forced search', + 'events': { + 'click': self.editMovie.bind(self) + } }); + }, + + editMovie: function(e){ + var self = this; + (e).stop(); + + self.optionContainer = new Element('div.options').adopt( + $(self.movie.thumbnail).clone(), + self.title_select = new Element('select', { + 'name': 'title' + }), + self.profile_select = new Element('select', { + 'name': 'profile' + }), + new Element('a.button.edit', { + 'text': 'Save', + 'events': { + 'click': self.save.bind(self) + } + }) + ).inject(self.movie, 'top'); + }, + + save: function(){ + var self = this; + + Api.request('movie.edit', { + 'data': { + 'default_title': self.title_select.get('value'), + 'profile_id': self.profile_select.get('value') + }, + 'useSpinner': true, + 'spinnerTarget': self.movie + }) + } + +}) + +Movie.Action.IMDB = new Class({ + + Extends: MovieAction, + id: null, + + create: function(){ + var self = this; + + self.id = self.movie.get('identifier'); + + self.el = new Element('a.imdb', { + 'text': 'imdb', + 'title': 'Go to the IMDB page of ' + self.movie.getTitle(), + 'events': { + 'click': self.gotoIMDB.bind(self) + } + }); + + if(!self.id) self.disable(); + }, + + gotoIMDB: function(e){ + var self = this; + (e).stop(); + + window.open('http://www.imdb.com/title/'+self.id+'/'); + } + +}) + +Movie.Action.Refresh = new Class({ + + Extends: MovieAction, + + create: function(){ + var self = this; + + self.el = new Element('a.refresh', { + 'text': 'refresh', + 'title': 'Refresh the movie info and do a forced search', + 'events': { + 'click': self.doSearch.bind(self) + } + }); + + }, + + doSearch: function(e){ + var self = this; + (e).stop(); + + Api.request('movie.refresh', { + 'data': { + 'id': self.movie.get('id') + } + }) + } + +}) + +Movie.Action.Delete = new Class({ + + Extends: MovieAction, + + Implements: [Chain], + + create: function(){ + var self = this; + + self.el = new Element('a.delete', { + 'text': 'delete', + 'title': 'Remove the movie from your wanted list', + 'events': { + 'click': self.showConfirm.bind(self) + } + }); + + }, + + showConfirm: function(e){ + var self = this; + (e).stop(); + + self.mask = $(self.movie).mask({ + 'destroyOnHide': true + }); + + $(self.mask).adopt( + new Element('a.button.delete', { + 'text': 'Delete movie', + 'events': { + 'click': self.del.bind(self) + } + }), + new Element('span', { + 'text': 'or' + }), + new Element('a.button.cancel', { + 'text': 'Cancel', + 'events': { + 'click': self.mask.hide.bind(self.mask) + } + }) + ); + }, + + del: function(e){ + (e).stop() + var self = this; + + var movie = $(self.movie); + + self.chain( + function(){ + $(self.mask).empty().addClass('loading'); + self.callChain(); + }, + function(){ + Api.request('movie.delete', { + 'data': { + 'id': self.movie.get('id') + }, + 'onComplete': function(){ + p(movie, $(self.movie)) + movie.slide('in'); + } + }) + } + ); + + self.callChain(); + } }) diff --git a/couchpotato/static/scripts/quality.js b/couchpotato/static/scripts/quality.js index f15bc680..9dd82d44 100644 --- a/couchpotato/static/scripts/quality.js +++ b/couchpotato/static/scripts/quality.js @@ -6,13 +6,19 @@ var QualityBase = new Class({ setup: function(data){ var self = this; - self.profiles = data.profiles; self.qualities = data.qualities; + self.profiles = {} + Object.each(data.profiles, self.createProfilesClass.bind(self)); + App.addEvent('load', self.addSettings.bind(self)) }, + getProfile: function(id){ + return this.profiles[id] + }, + addSettings: function(){ var self = this; @@ -47,22 +53,34 @@ var QualityBase = new Class({ new Element('a.add_new', { 'text': 'Create a new quality profile', 'events': { - 'click': self.createNewProfile.bind(self) + 'click': function(){ + var profile = self.createProfilesClass(); + $(profile).inject(self.profile_container, 'top') + } } }), self.profile_container = new Element('div.container') ) - Object.each(self.profiles, self.createNewProfile.bind(self)) + Object.each(self.profiles, function(profile){ + if(!profile.isCore()) + $(profile).inject(self.profile_container, 'top') + }) }, - createNewProfile: function(data, nr){ + createProfilesClass: function(data){ var self = this; - self.profiles[nr] = new Profile(data); - $(self.profiles[nr]).inject(self.profile_container) - + if(data){ + return self.profiles[data.id] = new Profile(data); + } + else { + var data = { + 'id': randomString() + } + return self.profiles[data.id] = new Profile(data); + } }, /** @@ -95,7 +113,7 @@ var QualityBase = new Class({ window.Quality = new QualityBase(); var Profile = new Class({ - + data: {}, types: [], @@ -119,12 +137,9 @@ var Profile = new Class({ var data = self.data; - self.el = new Element('div', { - 'class': 'profile' - }).adopt( - new Element('h4', {'text': data.label}), - new Element('span.delete', { - 'html': 'del', + self.el = new Element('div.profile').adopt( + self.header = new Element('h4', {'text': data.label}), + new Element('span.delete.icon', { 'events': { 'click': self.del.bind(self) } @@ -135,25 +150,29 @@ var Profile = new Class({ new Element('label', {'text':'Name'}), new Element('input.label.textInput.large', { 'type':'text', - 'value': data.label + 'value': data.label, + 'events': { + 'keyup': function(){ + self.header.set('text', this.get('value')) + } + } }) ), new Element('div.ctrlHolder').adopt( new Element('label', {'text':'Wait'}), new Element('input.wait_for.textInput.xsmall', { 'type':'text', - 'value': data.wait_for + 'value': data.types && data.types.length > 0 ? data.types[0].wait_for : 0 }), new Element('span', {'text':' day(s) for better quality.'}) ), new Element('div.ctrlHolder').adopt( new Element('label', {'text': 'Qualities'}), - self.type_container = new Element('div.types').adopt( - new Element('div.head').adopt( - new Element('span.quality_type', {'text': 'Search for'}), - new Element('span.finish', {'html': 'Finish'}) - ) + new Element('div.head').adopt( + new Element('span.quality_type', {'text': 'Search for'}), + new Element('span.finish', {'html': 'Finish'}) ), + self.type_container = new Element('ol.types'), new Element('a.addType', { 'text': 'Add another quality to search for.', 'href': '#', @@ -164,6 +183,8 @@ var Profile = new Class({ ) ); + self.makeSortable() + if(data.types) Object.each(data.types, self.addType.bind(self)) }, @@ -174,11 +195,19 @@ var Profile = new Class({ if(self.save_timer) clearTimeout(self.save_timer); self.save_timer = (function(){ + var data = self.getData(); + if(data.types.length < 2) return; + Api.request('profile.save', { 'data': self.getData(), 'useSpinner': true, 'spinnerOptions': { 'target': self.el + }, + 'onComplete': function(json){ + if(json.success){ + self.data = json.profile + } } }); }).delay(delay, self) @@ -194,12 +223,15 @@ var Profile = new Class({ 'wait_for' : self.el.getElement('.wait_for').get('value'), 'types': [] } - - Object.each(self.types, function(type){ - if(!type.deleted) - data.types.include(type.getData()); + + Array.each(self.type_container.getElements('.type'), function(type){ + if(!type.hasClass('deleted')) + data.types.include({ + 'quality_id': type.getElement('select').get('value'), + 'finish': +type.getElement('input[type=checkbox]').checked + }); }) - + return data }, @@ -208,6 +240,7 @@ var Profile = new Class({ var t = new Profile.Type(data); $(t).inject(self.type_container); + self.sortable.addItems($(t)); self.types.include(t); @@ -216,6 +249,8 @@ var Profile = new Class({ del: function(){ var self = this; + if(!confirm('Are you sure you want to delete this profile?')) return + Api.request('profile.delete', { 'data': { 'id': self.data.id @@ -224,12 +259,35 @@ var Profile = new Class({ 'spinnerOptions': { 'target': self.el }, - 'onComplete': function(){ - self.el.destroy(); + 'onComplete': function(json){ + if(json.success) + self.el.destroy(); + else + alert(json.message) } }); }, + makeSortable: function(){ + var self = this; + + self.sortable = new Sortables(self.type_container, { + 'revert': true, + //'clone': true, + 'handle': '.handle', + 'opacity': 0.5, + 'onComplete': self.save.bind(self, 300) + }); + }, + + get: function(attr){ + return this.data[attr] + }, + + isCore: function(){ + return this.data.core + }, + toElement: function(){ return this.el } @@ -252,7 +310,7 @@ Profile.Type = Class({ var self = this; var data = self.data; - self.el = new Element('div.type').adopt( + self.el = new Element('li.type').adopt( new Element('span.quality_type').adopt( self.fillQualities() ), @@ -263,15 +321,12 @@ Profile.Type = Class({ 'checked': data.finish }) ), - new Element('span.delete', { - 'html': 'del', + new Element('span.delete.icon', { 'events': { 'click': self.del.bind(self) } }), - new Element('span', { - 'class':'handle' - }) + new Element('span.handle') ) }, @@ -284,21 +339,21 @@ Profile.Type = Class({ Object.each(Quality.qualities, function(q){ new Element('option', { 'text': q.label, - 'value': q.identifier + 'value': q.id }).inject(self.qualities) }); - self.qualities.set('value', self.data.quality); + self.qualities.set('value', self.data.quality_id); return self.qualities; }, - + getData: function(){ var self = this; - + return { - 'quality': self.qualities.get('value'), + 'quality_id': self.qualities.get('value'), 'finish': +self.finish.checked } }, @@ -306,6 +361,7 @@ Profile.Type = Class({ del: function(){ var self = this; + self.el.addClass('deleted'); self.el.hide(); self.deleted = true; }, diff --git a/couchpotato/static/scripts/status.js b/couchpotato/static/scripts/status.js new file mode 100644 index 00000000..7967ac58 --- /dev/null +++ b/couchpotato/static/scripts/status.js @@ -0,0 +1,11 @@ +var StatusBase = new Class({ + + setup: function(statuses){ + var self = this; + + self.statuses = statuses; + + } + +}); +window.Status = new StatusBase(); diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index 0cc01d56..724e150b 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -121,6 +121,12 @@ form { cursor: pointer; } +/*** Icons ***/ +.icon.delete { + background: url('../images/delete.png') no-repeat; + display: inline-block; +} + /*** Navigation ***/ .header { background: #f7f7f7; diff --git a/couchpotato/static/style/movie_add.css b/couchpotato/static/style/plugin/movie_add.css similarity index 93% rename from couchpotato/static/style/movie_add.css rename to couchpotato/static/style/plugin/movie_add.css index 8e507de4..bf5f9fa5 100644 --- a/couchpotato/static/style/movie_add.css +++ b/couchpotato/static/style/plugin/movie_add.css @@ -24,7 +24,7 @@ margin: 0 0 -5px -20px; top: 4px; right: 5px; - background: url('../images/close_button.png') 0 center no-repeat; + background: url('../../images/close_button.png') 0 center no-repeat; cursor: pointer; } .search_form .input a:hover { background-position: -12px center; } @@ -46,7 +46,7 @@ -moz-border-radius: 3px; } .search_form .spinner { - background: #fff url('../images/spinner.gif') no-repeat center 70px; + background: #fff url('../../images/spinner.gif') no-repeat center 70px; } .search_form .pointer { @@ -83,7 +83,7 @@ display: inline-block; margin-right: 10px; } - .search_form .results .movie .options select[name=name] { width: 180px; } + .search_form .results .movie .options select[name=title] { width: 180px; } .search_form .results .movie .options select[name=quality] { width: 90px; } .search_form .results .movie .options .button { diff --git a/couchpotato/static/style/plugin/quality.css b/couchpotato/static/style/plugin/quality.css new file mode 100644 index 00000000..c4abc1d0 --- /dev/null +++ b/couchpotato/static/style/plugin/quality.css @@ -0,0 +1,21 @@ +/* @override http://localhost:5000/static/style/plugin/quality.css */ + + +.profile > .delete { + background-position: center; + height: 20px; + width: 20px; +} + +.profile .types .type .handle { + background: url('../../images/handle.png') center; + display: inline-block; + height: 20px; + width: 20px; +} + +.profile .types .type .delete { + background-position: center; + height: 20px; + width: 20px; +} \ No newline at end of file diff --git a/couchpotato/templates/_desktop.html b/couchpotato/templates/_desktop.html index dd5c4c77..bac63a71 100644 --- a/couchpotato/templates/_desktop.html +++ b/couchpotato/templates/_desktop.html @@ -1,12 +1,13 @@ - - - + + + - - + + + @@ -30,8 +31,11 @@ + + +