From 85fc1c01ee9c05990de5abec55668204db536143 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 9 Apr 2011 20:18:51 +0200 Subject: [PATCH] Notifications --- .gitignore | 3 +- couchpotato/__init__.py | 3 +- couchpotato/cli.py | 10 +- couchpotato/core/__init__.py | 16 +- couchpotato/core/downloaders/__init__.py | 0 couchpotato/core/downloaders/base.py | 20 + .../core/downloaders/blackhole/__init__.py | 32 + .../core/downloaders/blackhole/main.py | 36 + .../core/downloaders/sabnzbd/__init__.py | 39 + couchpotato/core/downloaders/sabnzbd/main.py | 120 +++ couchpotato/core/event.py | 52 +- couchpotato/core/helpers/encoding.py | 3 + couchpotato/core/helpers/request.py | 2 +- couchpotato/core/helpers/variable.py | 38 + couchpotato/core/loader.py | 23 +- couchpotato/core/notifications/__init__.py | 0 couchpotato/core/notifications/base.py | 29 + .../core/notifications/growl/__init__.py | 33 + couchpotato/core/notifications/growl/growl.py | 111 +++ couchpotato/core/notifications/growl/main.py | 45 + .../core/notifications/nmj/__init__.py | 37 + couchpotato/core/notifications/nmj/main.py | 131 +++ .../core/notifications/notifo/__init__.py | 32 + couchpotato/core/notifications/notifo/main.py | 53 ++ .../core/notifications/plex/__init__.py | 33 + couchpotato/core/notifications/plex/main.py | 43 + .../core/notifications/prowl/__init__.py | 35 + couchpotato/core/notifications/prowl/main.py | 51 ++ .../core/notifications/xbmc/__init__.py | 38 + couchpotato/core/notifications/xbmc/main.py | 47 + couchpotato/core/plugins/file/__init__.py | 6 + couchpotato/core/plugins/file/main.py | 103 +++ .../core/plugins/file_browser/__init__.py | 2 +- couchpotato/core/plugins/library/__init__.py | 2 +- couchpotato/core/plugins/library/main.py | 65 +- couchpotato/core/plugins/movie/__init__.py | 2 +- couchpotato/core/plugins/movie/main.py | 103 ++- couchpotato/core/plugins/profile/__init__.py | 2 +- couchpotato/core/plugins/profile/main.py | 76 +- couchpotato/core/plugins/quality/__init__.py | 6 + couchpotato/core/plugins/quality/main.py | 97 ++ couchpotato/core/plugins/status/__init__.py | 6 + couchpotato/core/plugins/status/main.py | 68 ++ couchpotato/core/providers/base.py | 3 +- .../{tmdb => themoviedb}/__init__.py | 0 couchpotato/core/providers/themoviedb/main.py | 116 +++ couchpotato/core/providers/tmdb/main.py | 107 --- couchpotato/core/settings/model.py | 79 +- couchpotato/static/images/delete.png | Bin 0 -> 228 bytes couchpotato/static/images/favicon.ico | Bin 1406 -> 28858 bytes couchpotato/static/images/handle.png | Bin 0 -> 206 bytes couchpotato/static/images/homescreen.png | Bin 0 -> 6575 bytes couchpotato/static/scripts/block/search.js | 70 +- couchpotato/static/scripts/couchpotato.js | 15 +- couchpotato/static/scripts/file.js | 77 ++ .../static/scripts/library/mootools.js | 83 +- .../static/scripts/library/mootools_more.js | 847 +++++++++++++++++- couchpotato/static/scripts/page/settings.js | 78 +- couchpotato/static/scripts/page/wanted.js | 281 +++++- couchpotato/static/scripts/quality.js | 134 ++- couchpotato/static/scripts/status.js | 11 + couchpotato/static/style/main.css | 6 + .../static/style/{ => plugin}/movie_add.css | 6 +- couchpotato/static/style/plugin/quality.css | 21 + couchpotato/templates/_desktop.html | 49 +- libs/axl/axel.py | 4 +- setup.py | 30 - 67 files changed, 3270 insertions(+), 400 deletions(-) create mode 100644 couchpotato/core/downloaders/__init__.py create mode 100644 couchpotato/core/downloaders/base.py create mode 100644 couchpotato/core/downloaders/blackhole/__init__.py create mode 100644 couchpotato/core/downloaders/blackhole/main.py create mode 100644 couchpotato/core/downloaders/sabnzbd/__init__.py create mode 100644 couchpotato/core/downloaders/sabnzbd/main.py create mode 100644 couchpotato/core/helpers/variable.py create mode 100644 couchpotato/core/notifications/__init__.py create mode 100644 couchpotato/core/notifications/base.py create mode 100644 couchpotato/core/notifications/growl/__init__.py create mode 100644 couchpotato/core/notifications/growl/growl.py create mode 100644 couchpotato/core/notifications/growl/main.py create mode 100644 couchpotato/core/notifications/nmj/__init__.py create mode 100644 couchpotato/core/notifications/nmj/main.py create mode 100644 couchpotato/core/notifications/notifo/__init__.py create mode 100644 couchpotato/core/notifications/notifo/main.py create mode 100644 couchpotato/core/notifications/plex/__init__.py create mode 100644 couchpotato/core/notifications/plex/main.py create mode 100644 couchpotato/core/notifications/prowl/__init__.py create mode 100644 couchpotato/core/notifications/prowl/main.py create mode 100644 couchpotato/core/notifications/xbmc/__init__.py create mode 100644 couchpotato/core/notifications/xbmc/main.py create mode 100644 couchpotato/core/plugins/file/__init__.py create mode 100644 couchpotato/core/plugins/file/main.py create mode 100644 couchpotato/core/plugins/quality/__init__.py create mode 100644 couchpotato/core/plugins/quality/main.py create mode 100644 couchpotato/core/plugins/status/__init__.py create mode 100644 couchpotato/core/plugins/status/main.py rename couchpotato/core/providers/{tmdb => themoviedb}/__init__.py (100%) create mode 100644 couchpotato/core/providers/themoviedb/main.py delete mode 100644 couchpotato/core/providers/tmdb/main.py create mode 100644 couchpotato/static/images/delete.png create mode 100644 couchpotato/static/images/handle.png create mode 100644 couchpotato/static/images/homescreen.png create mode 100644 couchpotato/static/scripts/file.js create mode 100644 couchpotato/static/scripts/status.js rename couchpotato/static/style/{ => plugin}/movie_add.css (93%) create mode 100644 couchpotato/static/style/plugin/quality.css delete mode 100755 setup.py 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 0000000000000000000000000000000000000000..276ea15f86fea8576ccc28c13f8644612a8338a3 GIT binary patch literal 228 zcmVU44nJ=}fNC^Pr&X4C4s%e4MDpy7tZEg0?*w2AC#;8e3URU?g>& zBy|U;q%4Ej_mUPg4HSiotlben-y1=myQrGX(rv7&@Mc>g*TlQvZ8+K(sQ(mP9?}iL ev#0#pZ~OoZJfo9~#x*4X0000 literal 0 HcmV?d00001 diff --git a/couchpotato/static/images/favicon.ico b/couchpotato/static/images/favicon.ico index 46a56a53d21eb2efc17249835c5d502ac8465c5a..f93f9fbe77b7025a5dd2ed49855fcdd36ee60fbe 100644 GIT binary patch literal 28858 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

>Go}A*4f^PT%R)F}P{wcGg%zn)ha# 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 K|ETS+Q~v{Ai5s^7 literal 1406 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 literal 0 HcmV?d00001 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 @@ + + +