diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 38b36174..7058facc 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -78,6 +78,7 @@ def page_not_found(error): r = '%s%s' % (request.url.rstrip('/'), index_url + '#' + url) return redirect(r) else: - time.sleep(0.1) + if not Env.get('dev'): + time.sleep(0.1) return 'Wrong API key used', 404 diff --git a/couchpotato/core/_base/_core/main.py b/couchpotato/core/_base/_core/main.py index 0423e666..c91140fa 100644 --- a/couchpotato/core/_base/_core/main.py +++ b/couchpotato/core/_base/_core/main.py @@ -179,7 +179,7 @@ class Core(Plugin): if Env.get('daemonized'): return def signal_handler(signal, frame): - fireEvent('app.shutdown') + fireEvent('app.shutdown', single = True) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index bb380be6..f2a30f6f 100644 --- a/couchpotato/core/_base/clientscript/main.py +++ b/couchpotato/core/_base/clientscript/main.py @@ -1,15 +1,60 @@ from couchpotato.core.event import addEvent +from couchpotato.core.helpers.variable import tryInt from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin +from couchpotato.environment import Env +from minify.cssmin import cssmin +from minify.jsmin import jsmin +import os +import traceback log = CPLog(__name__) class ClientScript(Plugin): - urls = { - 'style': {}, - 'script': {}, + core_static = { + 'style': [ + 'style/main.css', + 'style/uniform.generic.css', + 'style/uniform.css', + 'style/settings.css', + ], + 'script': [ + 'scripts/library/mootools.js', + 'scripts/library/mootools_more.js', + 'scripts/library/prefix_free.js', + 'scripts/library/uniform.js', + 'scripts/library/form_replacement/form_check.js', + 'scripts/library/form_replacement/form_radio.js', + 'scripts/library/form_replacement/form_dropdown.js', + 'scripts/library/form_replacement/form_selectoption.js', + 'scripts/library/question.js', + 'scripts/library/scrollspy.js', + 'scripts/library/spin.js', + 'scripts/couchpotato.js', + 'scripts/api.js', + 'scripts/library/history.js', + 'scripts/page.js', + 'scripts/block.js', + 'scripts/block/navigation.js', + 'scripts/block/footer.js', + 'scripts/block/menu.js', + 'scripts/page/home.js', + 'scripts/page/wanted.js', + 'scripts/page/settings.js', + 'scripts/page/about.js', + 'scripts/page/manage.js', + ], + } + + + urls = {'style': {}, 'script': {}, } + minified = {'style': {}, 'script': {}, } + paths = {'style': {}, 'script': {}, } + comment = { + 'style': '/*** %s:%d ***/\n', + 'script': '// %s:%d\n' } html = { @@ -24,6 +69,66 @@ class ClientScript(Plugin): addEvent('clientscript.get_styles', self.getStyles) addEvent('clientscript.get_scripts', self.getScripts) + addEvent('app.load', self.minify) + + self.addCore() + + def addCore(self): + + for static_type in self.core_static: + for rel_path in self.core_static.get(static_type): + file_path = os.path.join(Env.get('app_dir'), 'couchpotato', 'static', rel_path) + core_url = 'api/%s/static/%s?%s' % (Env.setting('api_key'), rel_path, tryInt(os.path.getmtime(file_path))) + + if static_type == 'script': + self.registerScript(core_url, file_path, position = 'front') + else: + self.registerStyle(core_url, file_path, position = 'front') + + + def minify(self): + + for file_type in ['style', 'script']: + ext = 'js' if file_type is 'script' else 'css' + positions = self.paths.get(file_type, {}) + for position in positions: + files = positions.get(position) + self._minify(file_type, files, position, position + '.' + ext) + + def _minify(self, file_type, files, position, out): + + cache = Env.get('cache_dir') + out_name = 'minified_' + out + out = os.path.join(cache, out_name) + + raw = [] + for file_path in files: + f = open(file_path, 'r').read() + + if file_type == 'script': + data = jsmin(f) + else: + data = cssmin(f) + data = data.replace('../images/', '../static/images/') + + raw.append({'file': file_path, 'date': int(os.path.getmtime(file_path)), 'data': data}) + + # Combine all files together with some comments + data = '' + for r in raw: + data += self.comment.get(file_type) % (r.get('file'), r.get('date')) + data += r.get('data') + '\n\n' + + self.createFile(out, data.strip()) + + if not self.minified.get(file_type): + self.minified[file_type] = {} + if not self.minified[file_type].get(position): + self.minified[file_type][position] = [] + + minified_url = 'api/%s/file.cache/%s?%s' % (Env.setting('api_key'), out_name, tryInt(os.path.getmtime(out))) + self.minified[file_type][position].append(minified_url) + def getStyles(self, *args, **kwargs): return self.get('style', *args, **kwargs) @@ -35,22 +140,30 @@ class ClientScript(Plugin): data = '' if as_html else [] try: + try: + if not Env.get('dev'): + return self.minified[type][location] + except: + pass + return self.urls[type][location] - except Exception, e: - log.error(e) + except: + log.error('Error getting minified %s, %s: %s', (type, location, traceback.format_exc())) return data - def registerStyle(self, path, position = 'head'): - self.register(path, 'style', position) + def registerStyle(self, api_path, file_path, position = 'head'): + self.register(api_path, file_path, 'style', position) - def registerScript(self, path, position = 'head'): - self.register(path, 'script', position) + def registerScript(self, api_path, file_path, position = 'head'): + self.register(api_path, file_path, 'script', position) - def register(self, filepath, type, location): + def register(self, api_path, file_path, type, location): if not self.urls[type].get(location): self.urls[type][location] = [] + self.urls[type][location].append(api_path) - filePath = filepath - self.urls[type][location].append(filePath) + if not self.paths[type].get(location): + self.paths[type][location] = [] + self.paths[type][location].append(file_path) diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index becfb6b5..aad9ea7f 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -1,6 +1,7 @@ from __future__ import with_statement from couchpotato.core.downloaders.base import Downloader from couchpotato.core.logger import CPLog +from couchpotato.environment import Env import os import traceback @@ -36,6 +37,7 @@ class Blackhole(Downloader): log.info('Downloading %s to %s.', (data.get('type'), fullPath)) with open(fullPath, 'wb') as f: f.write(filedata) + os.chmod(fullPath, Env.getPermission('file')) return True else: log.info('File %s already exists.', fullPath) diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py index 91302780..a287f119 100644 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -2,6 +2,7 @@ from couchpotato.core.downloaders.base import Downloader from couchpotato.core.helpers.encoding import tryUrlencode, ss from couchpotato.core.helpers.variable import cleanHost, mergeDicts from couchpotato.core.logger import CPLog +from couchpotato.environment import Env from urllib2 import URLError import json import traceback @@ -38,9 +39,9 @@ class Sabnzbd(Downloader): try: if params.get('mode') is 'addfile': - sab = self.urlopen(url, timeout = 60, params = {'nzbfile': (ss(nzb_filename), filedata)}, multipart = True, show_error = False) + sab = self.urlopen(url, timeout = 60, params = {'nzbfile': (ss(nzb_filename), filedata)}, multipart = True, show_error = False, headers = {'User-Agent': Env.getIdentifier()}) else: - sab = self.urlopen(url, timeout = 60, show_error = False) + sab = self.urlopen(url, timeout = 60, show_error = False, headers = {'User-Agent': Env.getIdentifier()}) except URLError: log.error('Failed sending release, probably wrong HOST: %s', traceback.format_exc(0)) return False @@ -139,7 +140,7 @@ class Sabnzbd(Downloader): 'output': 'json' })) - data = self.urlopen(url, timeout = 60, show_error = False) + data = self.urlopen(url, timeout = 60, show_error = False, headers = {'User-Agent': Env.getIdentifier()}) if use_json: d = json.loads(data) if d.get('error'): diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index e1e67de1..5953b117 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -1,3 +1,4 @@ +from base64 import b16encode, b32decode from bencode import bencode, bdecode from couchpotato.core.downloaders.base import Downloader from couchpotato.core.helpers.encoding import isInt, ss @@ -6,6 +7,7 @@ from hashlib import sha1 from multipartpost import MultipartPostHandler import cookielib import httplib +import json import re import time import urllib @@ -37,6 +39,7 @@ class uTorrent(Downloader): if not filedata and data.get('type') == 'torrent': log.error('Failed sending torrent, no data') return False + if data.get('type') == 'torrent_magnet': torrent_hash = re.findall('urn:btih:([\w]{32,40})', data.get('url'))[0].upper() torrent_params['trackers'] = '%0D%0A%0D%0A'.join(self.torrent_trackers) @@ -45,6 +48,10 @@ class uTorrent(Downloader): torrent_hash = sha1(bencode(info)).hexdigest().upper() torrent_filename = self.createFileName(data, filedata, movie) + # Convert base 32 to hex + if len(torrent_hash) == 32: + torrent_hash = b16encode(b32decode(torrent_hash)) + # Send request to uTorrent try: if not self.utorrent_api: @@ -64,6 +71,59 @@ class uTorrent(Downloader): log.error('Failed to send torrent to uTorrent: %s', err) return False + def getAllDownloadStatus(self): + + log.debug('Checking uTorrent download status.') + + # Load host from config and split out port. + host = self.conf('host').split(':') + if not isInt(host[1]): + log.error('Config properties are not filled in correctly, port is missing.') + return False + + try: + self.utorrent_api = uTorrentAPI(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) + except Exception, err: + log.error('Failed to get uTorrent object: %s', err) + return False + + data = '' + try: + data = self.utorrent_api.get_status() + queue = json.loads(data) + if queue.get('error'): + log.error('Error getting data from uTorrent: %s', queue.get('error')) + return False + + except Exception, err: + log.error('Failed to get status from uTorrent: %s', err) + return False + + if queue.get('torrents', []) == []: + log.debug('Nothing in queue') + return False + + statuses = [] + + # Get torrents + for item in queue.get('torrents', []): + + # item[21] = Paused | Downloading | Seeding | Finished + status = 'busy' + if item[21] == 'Finished' or item[21] == 'Seeding': + status = 'completed' + + statuses.append({ + 'id': item[0], + 'name': item[2], + 'status': status, + 'original_status': item[1], + 'timeleft': item[10], + }) + + return statuses + + class uTorrentAPI(object): @@ -94,9 +154,7 @@ class uTorrentAPI(object): try: open_request = self.opener.open(request) response = open_request.read() - log.debug('response: %s', response) if response: - log.debug('uTorrent action successfull') return response else: log.debug('Unknown failure sending command to uTorrent. Return text is: %s', response) @@ -133,3 +191,7 @@ class uTorrentAPI(object): def pause_torrent(self, hash): action = "action=pause&hash=%s" % hash return self._request(action) + + def get_status(self): + action = "list=1" + return self._request(action) diff --git a/couchpotato/core/event.py b/couchpotato/core/event.py index aa05ce0f..bd704f5c 100644 --- a/couchpotato/core/event.py +++ b/couchpotato/core/event.py @@ -104,6 +104,8 @@ def fireEvent(name, *args, **kwargs): # Merge if options['merge'] and len(results) > 0: + results.reverse() # Priority 1 is higher then 100 + # Dict if isinstance(results[0], dict): merged = {} diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index 1ecb35bf..82bf88f7 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -168,4 +168,4 @@ def randomString(size = 8, chars = string.ascii_uppercase + string.digits): return ''.join(random.choice(chars) for x in range(size)) def splitString(str, split_on = ','): - return [x.strip() for x in str.split(split_on)] + return [x.strip() for x in str.split(split_on)] if str else [] diff --git a/couchpotato/core/migration/versions/001_Releases_last_edit.py b/couchpotato/core/migration/versions/001_Releases_last_edit.py new file mode 100644 index 00000000..d4b12080 --- /dev/null +++ b/couchpotato/core/migration/versions/001_Releases_last_edit.py @@ -0,0 +1,25 @@ +from migrate.changeset.schema import create_column +from sqlalchemy.schema import MetaData, Column, Table, Index +from sqlalchemy.types import Integer + +meta = MetaData() + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + + # Change release, add last_edit and index + last_edit_column = Column('last_edit', Integer) + release = Table('release', meta, last_edit_column) + + create_column(last_edit_column, release) + Index('ix_release_last_edit', release.c.last_edit).create() + + # Change movie last_edit + last_edit_column = Column('last_edit', Integer) + movie = Table('movie', meta, last_edit_column) + Index('ix_movie_last_edit', movie.c.last_edit).create() + + +def downgrade(migrate_engine): + pass diff --git a/couchpotato/core/notifications/core/static/notification.js b/couchpotato/core/notifications/core/static/notification.js index db9db843..52062e93 100644 --- a/couchpotato/core/notifications/core/static/notification.js +++ b/couchpotato/core/notifications/core/static/notification.js @@ -178,11 +178,14 @@ var NotificationBase = new Class({ }, addTestButton: function(fieldset, plugin_name){ - var self = this; + var self = this, + button_name = self.testButtonName(fieldset); + + if(button_name.contains('Notifications')) return; new Element('.ctrlHolder.test_button').adopt( new Element('a.button', { - 'text': self.testButtonName(fieldset), + 'text': button_name, 'events': { 'click': function(){ var button = fieldset.getElement('.test_button .button'); @@ -191,7 +194,7 @@ var NotificationBase = new Class({ Api.request('notify.'+plugin_name+'.test', { 'onComplete': function(json){ - button.set('text', self.testButtonName(fieldset)); + button.set('text', button_name); if(json.success){ var message = new Element('span.success', { diff --git a/couchpotato/core/notifications/email/main.py b/couchpotato/core/notifications/email/main.py index be61e944..014b5856 100644 --- a/couchpotato/core/notifications/email/main.py +++ b/couchpotato/core/notifications/email/main.py @@ -1,4 +1,5 @@ from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.helpers.variable import splitString from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification from email.mime.text import MIMEText @@ -39,7 +40,7 @@ class Email(Notification): # Send the e-mail log.debug("Sending the email") - mailserver.sendmail(from_address, to_address, message.as_string()) + mailserver.sendmail(from_address, splitString(to_address), message.as_string()) # Close the SMTP connection mailserver.quit() diff --git a/couchpotato/core/notifications/pushalot/__init__.py b/couchpotato/core/notifications/pushalot/__init__.py new file mode 100644 index 00000000..a2a297a3 --- /dev/null +++ b/couchpotato/core/notifications/pushalot/__init__.py @@ -0,0 +1,48 @@ +from .main import Pushalot + +def start(): + return Pushalot() + +config = [{ + 'name': 'pushalot', + 'groups': [ + { + 'tab': 'notifications', + 'list': 'notification_providers', + 'name': 'pushalot', + 'description': 'for Windows Phone and Windows 8', + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + }, + { + 'name': 'auth_token', + 'label': 'Auth Token', + }, + { + 'name': 'silent', + 'label': 'Silent', + 'default': 0, + 'type': 'bool', + 'description': 'Don\'t send Toast notifications. Only update Live Tile', + }, + { + 'name': 'important', + 'label': 'High Priority', + 'default': 0, + 'type': 'bool', + 'description': 'Send message with High priority.', + }, + { + 'name': 'on_snatch', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Also send message when movie is snatched.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/notifications/pushalot/main.py b/couchpotato/core/notifications/pushalot/main.py new file mode 100644 index 00000000..3b113311 --- /dev/null +++ b/couchpotato/core/notifications/pushalot/main.py @@ -0,0 +1,37 @@ +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +import traceback + +log = CPLog(__name__) + +class Pushalot(Notification): + + urls = { + 'api': 'https://pushalot.com/api/sendmessage' + } + + def notify(self, message = '', data = {}, listener = None): + if self.isDisabled(): return + + data = { + 'AuthorizationToken': self.conf('auth_token'), + 'Title': self.default_title, + 'Body': toUnicode(message), + 'LinkTitle': toUnicode("CouchPotato"), + 'link': toUnicode("https://couchpota.to/"), + 'IsImportant': self.conf('important'), + 'IsSilent': self.conf('silent'), + } + + headers = { + 'Content-type': 'application/x-www-form-urlencoded' + } + + try: + self.urlopen(self.urls['api'], headers = headers, params = data, multipart = True, show_error = False) + return True + except: + log.error('PushAlot failed: %s', traceback.format_exc()) + + return False diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 73a2c306..9330631d 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -64,7 +64,7 @@ class Plugin(object): for f in glob.glob(os.path.join(self.plugin_path, 'static', '*')): ext = getExt(f) if ext in ['js', 'css']: - fireEvent('register_%s' % ('script' if ext in 'js' else 'style'), path + os.path.basename(f)) + fireEvent('register_%s' % ('script' if ext in 'js' else 'style'), path + os.path.basename(f), f) def showStatic(self, filename): d = os.path.join(self.plugin_path, 'static') @@ -240,7 +240,6 @@ class Plugin(object): del kwargs['cache_timeout'] data = self.urlopen(url, **kwargs) - if data: self.setCache(cache_key, data, timeout = cache_timeout) return data diff --git a/couchpotato/core/plugins/browser/main.py b/couchpotato/core/plugins/browser/main.py index b5839e73..3eee85bb 100644 --- a/couchpotato/core/plugins/browser/main.py +++ b/couchpotato/core/plugins/browser/main.py @@ -15,7 +15,7 @@ if os.name == 'nt': raise ImportError("Missing the win32file module, which is a part of the prerequisite \ pywin32 package. You can get it from http://sourceforge.net/projects/pywin32/files/pywin32/"); else: - import win32file + import win32file #@UnresolvedImport class FileBrowser(Plugin): @@ -98,7 +98,7 @@ class FileBrowser(Plugin): def has_hidden_attribute(self, filepath): try: - attrs = ctypes.windll.kernel32.GetFileAttributesW(unicode(filepath)) + attrs = ctypes.windll.kernel32.GetFileAttributesW(unicode(filepath)) #@UndefinedVariable assert attrs != -1 result = bool(attrs & 2) except (AttributeError, AssertionError): diff --git a/couchpotato/core/plugins/dashboard/__init__.py b/couchpotato/core/plugins/dashboard/__init__.py new file mode 100644 index 00000000..81279291 --- /dev/null +++ b/couchpotato/core/plugins/dashboard/__init__.py @@ -0,0 +1,6 @@ +from .main import Dashboard + +def start(): + return Dashboard() + +config = [] diff --git a/couchpotato/core/plugins/dashboard/main.py b/couchpotato/core/plugins/dashboard/main.py new file mode 100644 index 00000000..d14eb5fb --- /dev/null +++ b/couchpotato/core/plugins/dashboard/main.py @@ -0,0 +1,134 @@ +from couchpotato import get_session +from couchpotato.api import addApiView +from couchpotato.core.event import fireEvent +from couchpotato.core.helpers.request import jsonified, getParams +from couchpotato.core.helpers.variable import splitString, tryInt +from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin +from couchpotato.core.settings.model import Movie +from sqlalchemy.orm import joinedload_all +import random +import time + +log = CPLog(__name__) + + +class Dashboard(Plugin): + + def __init__(self): + + addApiView('dashboard.suggestions', self.suggestView) + addApiView('dashboard.soon', self.getSoonView) + + def newSuggestions(self): + + movies = fireEvent('movie.list', status = ['active', 'done'], limit_offset = (20, 0), single = True) + movie_identifiers = [m['library']['identifier'] for m in movies[1]] + + ignored_movies = fireEvent('movie.list', status = ['ignored', 'deleted'], limit_offset = (100, 0), single = True) + ignored_identifiers = [m['library']['identifier'] for m in ignored_movies[1]] + + suggestions = fireEvent('movie.suggest', movies = movie_identifiers, ignore = ignored_identifiers, single = True) + suggest_status = fireEvent('status.get', 'suggest', single = True) + + for suggestion in suggestions: + fireEvent('movie.add', params = {'identifier': suggestion}, force_readd = False, search_after = False, status_id = suggest_status.get('id')) + + def suggestView(self): + + db = get_session() + + movies = db.query(Movie).limit(20).all() + identifiers = [m.library.identifier for m in movies] + + suggestions = fireEvent('movie.suggest', movies = identifiers, single = True) + + return jsonified({ + 'result': True, + 'suggestions': suggestions + }) + + def getSoonView(self): + + params = getParams() + db = get_session() + now = time.time() + + # Get profiles first, determine pre or post theater + profiles = fireEvent('profile.all', single = True) + qualities = fireEvent('quality.all', single = True) + pre_releases = fireEvent('quality.pre_releases', single = True) + + id_pre = {} + for quality in qualities: + id_pre[quality.get('id')] = quality.get('identifier') in pre_releases + + # See what the profile contain and cache it + profile_pre = {} + for profile in profiles: + contains = {} + for profile_type in profile.get('types', []): + contains['theater' if id_pre.get(profile_type.get('quality_id')) else 'dvd'] = True + + profile_pre[profile.get('id')] = contains + + # Get all active movies + active_status = fireEvent('status.get', 'active', single = True) + subq = db.query(Movie).filter(Movie.status_id == active_status.get('id')).subquery() + + q = db.query(Movie).join((subq, subq.c.id == Movie.id)) \ + .options(joinedload_all('releases')) \ + .options(joinedload_all('profile.types')) \ + .options(joinedload_all('library.titles')) \ + .options(joinedload_all('library.files')) \ + .options(joinedload_all('status')) \ + .options(joinedload_all('files')) + + # Add limit + limit_offset = params.get('limit_offset') + limit = 12 + if limit_offset: + splt = splitString(limit_offset) if isinstance(limit_offset, (str, unicode)) else limit_offset + limit = tryInt(splt[0]) + + all_movies = q.all() + + if params.get('random', False): + random.shuffle(all_movies) + + movies = [] + for movie in all_movies: + pp = profile_pre.get(movie.profile.id) + eta = movie.library.info.get('release_date', {}) or {} + coming_soon = False + + # Theater quality + if pp.get('theater') and fireEvent('searcher.could_be_released', True, eta, single = True): + coming_soon = True + if pp.get('dvd') and fireEvent('searcher.could_be_released', False, eta, single = True): + coming_soon = True + + + if coming_soon: + temp = movie.to_dict({ + 'profile': {'types': {}}, + 'releases': {'files':{}, 'info': {}}, + 'library': {'titles': {}, 'files':{}}, + 'files': {}, + }) + + # Don't list older movies + if ((not params.get('late') and (not eta.get('dvd') or (eta.get('dvd') and eta.get('dvd') > (now - 2419200)))) or \ + (params.get('late') and eta.get('dvd') and eta.get('dvd') < (now - 2419200))): + movies.append(temp) + + if len(movies) >= limit: + break + + return jsonified({ + 'success': True, + 'empty': len(movies) == 0, + 'movies': movies, + }) + + getLateView = getSoonView diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py index a9eab33d..0dc01783 100644 --- a/couchpotato/core/plugins/file/main.py +++ b/couchpotato/core/plugins/file/main.py @@ -71,7 +71,7 @@ class FileManager(Plugin): db = get_session() for root, dirs, walk_files in os.walk(Env.get('cache_dir')): for filename in walk_files: - if root == python_cache: continue + if root == python_cache or 'minified' in filename: continue file_path = os.path.join(root, filename) f = db.query(File).filter(File.path == toUnicode(file_path)).first() if not f: diff --git a/couchpotato/core/plugins/file/static/file.js b/couchpotato/core/plugins/file/static/file.js index 2093e2fe..7b893e88 100644 --- a/couchpotato/core/plugins/file/static/file.js +++ b/couchpotato/core/plugins/file/static/file.js @@ -4,6 +4,7 @@ var File = new Class({ var self = this; if(!file){ + self.empty = true; self.el = new Element('div'); return } diff --git a/couchpotato/core/plugins/library/main.py b/couchpotato/core/plugins/library/main.py index aa1611dd..b91ebe1e 100644 --- a/couchpotato/core/plugins/library/main.py +++ b/couchpotato/core/plugins/library/main.py @@ -38,7 +38,7 @@ class LibraryPlugin(Plugin): title = LibraryTitle( title = toUnicode(attrs.get('title')), - simple_title = self.simplifyTitle(attrs.get('title')) + simple_title = self.simplifyTitle(attrs.get('title')), ) l.titles.append(title) @@ -96,6 +96,7 @@ class LibraryPlugin(Plugin): titles = info.get('titles', []) log.debug('Adding titles: %s', titles) + counter = 0 for title in titles: if not title: continue @@ -103,9 +104,10 @@ class LibraryPlugin(Plugin): t = LibraryTitle( title = title, simple_title = self.simplifyTitle(title), - default = title.lower() == toUnicode(default_title.lower()) or (toUnicode(default_title) == u'' and toUnicode(titles[0]) == title) + default = (len(default_title) == 0 and counter == 0) or len(titles) == 1 or title.lower() == toUnicode(default_title.lower()) or (toUnicode(default_title) == u'' and toUnicode(titles[0]) == title) ) library.titles.append(t) + counter += 1 db.commit() diff --git a/couchpotato/core/plugins/manage/main.py b/couchpotato/core/plugins/manage/main.py index f80b80a8..51094899 100644 --- a/couchpotato/core/plugins/manage/main.py +++ b/couchpotato/core/plugins/manage/main.py @@ -2,11 +2,13 @@ from couchpotato.api import addApiView from couchpotato.core.event import fireEvent, addEvent, fireEventAsync from couchpotato.core.helpers.encoding import ss from couchpotato.core.helpers.request import jsonified, getParam -from couchpotato.core.helpers.variable import getTitle, splitString +from couchpotato.core.helpers.variable import splitString, getTitle from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.environment import Env +import ctypes import os +import sys import time import traceback @@ -22,6 +24,7 @@ class Manage(Plugin): fireEvent('scheduler.interval', identifier = 'manage.update_library', handle = self.updateLibrary, hours = 2) addEvent('manage.update', self.updateLibrary) + addEvent('manage.diskspace', self.getDiskSpace) # Add files after renaming def after_rename(message = None, group = {}): @@ -192,6 +195,7 @@ class Manage(Plugin): self.in_progress[folder]['to_go'] = self.in_progress[folder]['to_go'] - 1 total = self.in_progress[folder]['total'] movie_dict = fireEvent('movie.get', identifier, single = True) + fireEvent('notify.frontend', type = 'movie.added', data = movie_dict, message = None if total > 5 else 'Added "%s" to manage.' % getTitle(movie_dict['library'])) return afterUpdate @@ -214,3 +218,31 @@ class Manage(Plugin): for group in groups.itervalues(): if group['library'] and group['library'].get('identifier'): fireEvent('release.add', group = group) + + def getDiskSpace(self): + + free_space = {} + for folder in self.directories(): + + size = None + if os.path.isdir(folder): + if os.name == 'nt': + _, total, free = ctypes.c_ulonglong(), ctypes.c_ulonglong(), \ + ctypes.c_ulonglong() + if sys.version_info >= (3,) or isinstance(folder, unicode): + fun = ctypes.windll.kernel32.GetDiskFreeSpaceExW #@UndefinedVariable + else: + fun = ctypes.windll.kernel32.GetDiskFreeSpaceExA #@UndefinedVariable + ret = fun(folder, ctypes.byref(_), ctypes.byref(total), ctypes.byref(free)) + if ret == 0: + raise ctypes.WinError() + used = total.value - free.value + return [total.value, used, free.value] + else: + s = os.statvfs(folder) + size = [s.f_blocks * s.f_frsize / (1024 * 1024), (s.f_bavail * s.f_frsize) / (1024 * 1024)] + + free_space[folder] = size + + return free_space + diff --git a/couchpotato/core/plugins/movie/main.py b/couchpotato/core/plugins/movie/main.py index 8b0761c1..5ec968c3 100644 --- a/couchpotato/core/plugins/movie/main.py +++ b/couchpotato/core/plugins/movie/main.py @@ -6,11 +6,13 @@ from couchpotato.core.helpers.request import getParams, jsonified, getParam from couchpotato.core.helpers.variable import getImdb, splitString from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin -from couchpotato.core.settings.model import Library, LibraryTitle, Movie +from couchpotato.core.settings.model import Library, LibraryTitle, Movie, \ + Release from couchpotato.environment import Env from sqlalchemy.orm import joinedload_all -from sqlalchemy.sql.expression import or_, asc, not_ +from sqlalchemy.sql.expression import or_, asc, not_, desc from string import ascii_lowercase +import time log = CPLog(__name__) @@ -41,6 +43,7 @@ class MoviePlugin(Plugin): 'desc': 'List movies in wanted list', 'params': { 'status': {'type': 'array or csv', 'desc': 'Filter movie by status. Example:"active,done"'}, + 'release_status': {'type': 'array or csv', 'desc': 'Filter movie by status of its releases. Example:"snatched,available"'}, 'limit_offset': {'desc': 'Limit and offset the movie list. Examples: "50" or "50,30"'}, 'starts_with': {'desc': 'Starts with these characters. Example: "a" returns all movies starting with the letter "a"'}, 'search': {'desc': 'Search movie title'}, @@ -94,6 +97,34 @@ class MoviePlugin(Plugin): addEvent('movie.list', self.list) addEvent('movie.restatus', self.restatus) + # Clean releases that didn't have activity in the last week + addEvent('app.load', self.cleanReleases) + fireEvent('schedule.interval', 'movie.clean_releases', self.cleanReleases, hours = 4) + + def cleanReleases(self): + + log.debug('Removing releases from dashboard') + + now = time.time() + week = 262080 + + done_status = fireEvent('status.get', 'done', single = True) + available_status = fireEvent('status.get', 'available', single = True) + snatched_status = fireEvent('status.get', 'snatched', single = True) + + db = get_session() + + # get movies last_edit more than a week ago + movies = db.query(Movie) \ + .filter(Movie.status_id == done_status.get('id'), Movie.last_edit < (now - week)) \ + .all() + + # + for movie in movies: + for rel in movie.releases: + if rel.status_id in [available_status.get('id'), snatched_status.get('id')]: + fireEvent('release.delete', id = rel.id, single = True) + def getView(self): movie_id = getParam('id') @@ -121,20 +152,29 @@ class MoviePlugin(Plugin): return results - def list(self, status = ['active'], limit_offset = None, starts_with = None, search = None): + def list(self, status = None, release_status = None, limit_offset = None, starts_with = None, search = None, order = None): db = get_session() # Make a list from string - if not isinstance(status, (list, tuple)): + if status and not isinstance(status, (list, tuple)): status = [status] + if release_status and not isinstance(release_status, (list, tuple)): + release_status = [release_status] q = db.query(Movie) \ - .join(Movie.library, Library.titles) \ + .outerjoin(Movie.releases, Movie.library, Library.titles) \ .filter(LibraryTitle.default == True) \ - .filter(or_(*[Movie.status.has(identifier = s) for s in status])) \ .group_by(Movie.id) + # Filter on movie status + if status and len(status) > 0: + q = q.filter(or_(*[Movie.status.has(identifier = s) for s in status])) + + # Filter on release status + if release_status and len(release_status) > 0: + q = q.filter(or_(*[Release.status.has(identifier = s) for s in release_status])) + total_count = q.count() filter_or = [] @@ -154,7 +194,10 @@ class MoviePlugin(Plugin): if filter_or: q = q.filter(or_(*filter_or)) - q = q.order_by(asc(LibraryTitle.simple_title)) + if order == 'release_order': + q = q.order_by(desc(Release.last_edit)) + else: + q = q.order_by(asc(LibraryTitle.simple_title)) q = q.subquery() q2 = db.query(Movie).join((q, q.c.id == Movie.id)) \ @@ -166,7 +209,7 @@ class MoviePlugin(Plugin): .options(joinedload_all('files')) if limit_offset: - splt = splitString(limit_offset) + splt = splitString(limit_offset) if isinstance(limit_offset, (str, unicode)) else limit_offset limit = splt[0] offset = 0 if len(splt) is 1 else splt[1] q2 = q2.limit(limit).offset(offset) @@ -185,7 +228,7 @@ class MoviePlugin(Plugin): #db.close() return (total_count, movies) - def availableChars(self, status = ['active']): + def availableChars(self, status = None, release_status = None): chars = '' @@ -194,11 +237,20 @@ class MoviePlugin(Plugin): # Make a list from string if not isinstance(status, (list, tuple)): status = [status] + if release_status and not isinstance(release_status, (list, tuple)): + release_status = [release_status] q = db.query(Movie) \ - .join(Movie.library, Library.titles, Movie.status) \ - .options(joinedload_all('library.titles')) \ - .filter(or_(*[Movie.status.has(identifier = s) for s in status])) + .outerjoin(Movie.releases, Movie.library, Library.titles, Movie.status) \ + .options(joinedload_all('library.titles')) + + # Filter on movie status + if status and len(status) > 0: + q = q.filter(or_(*[Movie.status.has(identifier = s) for s in status])) + + # Filter on release status + if release_status and len(release_status) > 0: + q = q.filter(or_(*[Release.status.has(identifier = s) for s in release_status])) results = q.all() @@ -206,20 +258,29 @@ class MoviePlugin(Plugin): char = movie.library.titles[0].simple_title[0] char = char if char in ascii_lowercase else '#' if char not in chars: - chars += char + chars += str(char) #db.close() - return chars + return ''.join(sorted(chars, key = str.lower)) def listView(self): params = getParams() - status = params.get('status', ['active']) + status = splitString(params.get('status', None)) + release_status = splitString(params.get('release_status', None)) limit_offset = params.get('limit_offset', None) starts_with = params.get('starts_with', None) search = params.get('search', None) + order = params.get('order', None) - total_movies, movies = self.list(status = status, limit_offset = limit_offset, starts_with = starts_with, search = search) + total_movies, movies = self.list( + status = status, + release_status = release_status, + limit_offset = limit_offset, + starts_with = starts_with, + search = search, + order = order + ) return jsonified({ 'success': True, @@ -231,8 +292,9 @@ class MoviePlugin(Plugin): def charView(self): params = getParams() - status = params.get('status', ['active']) - chars = self.availableChars(status) + status = splitString(params.get('status', None)) + release_status = splitString(params.get('release_status', None)) + chars = self.availableChars(status, release_status) return jsonified({ 'success': True, @@ -283,7 +345,7 @@ class MoviePlugin(Plugin): 'movies': movies, }) - def add(self, params = {}, force_readd = True, search_after = True, update_library = False): + def add(self, params = {}, force_readd = True, search_after = True, update_library = False, status_id = None): if not params.get('identifier'): msg = 'Can\'t add movie without imdb identifier.' @@ -292,9 +354,8 @@ class MoviePlugin(Plugin): return False else: try: - url = 'http://thetvdb.com/api/GetSeriesByRemoteID.php?imdbid=%s' % params.get('identifier') - tvdb = self.getCache('thetvdb.%s' % params.get('identifier'), url = url, show_error = False) - if tvdb and 'series' in tvdb.lower(): + is_movie = fireEvent('movie.is_movie', identifier = params.get('identifier'), single = True) + if not is_movie: msg = 'Can\'t add movie, seems to be a TV show.' log.error(msg) fireEvent('notify.frontend', type = 'movie.is_tvshow', message = msg) @@ -307,7 +368,9 @@ class MoviePlugin(Plugin): # Status status_active = fireEvent('status.add', 'active', single = True) - status_snatched = fireEvent('status.add', 'snatched', single = True) + snatched_status = fireEvent('status.add', 'snatched', single = True) + ignored_status = fireEvent('status.add', 'ignored', single = True) + downloaded_status = fireEvent('status.add', 'downloaded', single = True) default_profile = fireEvent('profile.default', single = True) @@ -319,7 +382,7 @@ class MoviePlugin(Plugin): m = Movie( library_id = library.get('id'), profile_id = params.get('profile_id', default_profile.get('id')), - status_id = status_active.get('id'), + status_id = status_id if status_id else status_active.get('id'), ) db.add(m) db.commit() @@ -331,10 +394,14 @@ class MoviePlugin(Plugin): fireEventAsync('library.update', params.get('identifier'), default_title = params.get('title', ''), on_complete = onComplete) search_after = False elif force_readd: + # Clean snatched history for release in m.releases: - if release.status_id == status_snatched.get('id'): - release.delete() + if release.status_id in [downloaded_status.get('id'), snatched_status.get('id')]: + if params.get('ignore_previous', False): + release.status_id = ignored_status.get('id') + else: + fireEvent('release.delete', release.id, single = True) m.profile_id = params.get('profile_id', default_profile.get('id')) else: @@ -342,7 +409,8 @@ class MoviePlugin(Plugin): added = False if force_readd: - m.status_id = status_active.get('id') + m.status_id = status_id if status_id else status_active.get('id') + m.last_edit = int(time.time()) do_search = True db.commit() @@ -448,7 +516,7 @@ class MoviePlugin(Plugin): total_deleted = 0 new_movie_status = None for release in movie.releases: - if delete_from == 'wanted': + if delete_from in ['wanted', 'snatched']: if release.status_id != done_status.get('id'): db.delete(release) total_deleted += 1 diff --git a/couchpotato/core/plugins/movie/static/list.js b/couchpotato/core/plugins/movie/static/list.js index b61ea7e0..d8c7fa88 100644 --- a/couchpotato/core/plugins/movie/static/list.js +++ b/couchpotato/core/plugins/movie/static/list.js @@ -5,6 +5,7 @@ var MovieList = new Class({ options: { navigation: true, limit: 50, + load_more: true, menu: [], add_new: false }, @@ -12,25 +13,37 @@ var MovieList = new Class({ movies: [], movies_added: {}, letters: {}, - filter: { - 'startswith': null, - 'search': null - }, + filter: null, initialize: function(options){ var self = this; self.setOptions(options); self.offset = 0; + self.filter = self.options.filter || { + 'startswith': null, + 'search': null + } self.el = new Element('div.movies').adopt( + self.title = self.options.title ? new Element('h2', { + 'text': self.options.title, + 'styles': {'display': 'none'} + }) : null, + self.description = self.options.description ? new Element('div.description', { + 'html': self.options.description, + 'styles': {'display': 'none'} + }) : null, self.movie_list = new Element('div'), - self.load_more = new Element('a.load_more', { + self.load_more = self.options.load_more ? new Element('a.load_more', { 'events': { 'click': self.loadMore.bind(self) } - }) + }) : null ); + + self.changeView(self.getSavedView() || self.options.view || 'details'); + self.getMovies(); App.addEvent('movie.added', self.movieAdded.bind(self)) @@ -70,22 +83,14 @@ var MovieList = new Class({ if(self.options.navigation) self.createNavigation(); - self.movie_list.addEvents({ - 'mouseenter:relay(.movie)': function(e, el){ - el.addClass('hover'); - }, - 'mouseleave:relay(.movie)': function(e, el){ - el.removeClass('hover'); - } - }); - - self.scrollspy = new ScrollSpy({ - min: function(){ - var c = self.load_more.getCoordinates() - return c.top - window.document.getSize().y - 300 - }, - onEnter: self.loadMore.bind(self) - }); + if(self.options.load_more) + self.scrollspy = new ScrollSpy({ + min: function(){ + var c = self.load_more.getCoordinates() + return c.top - window.document.getSize().y - 300 + }, + onEnter: self.loadMore.bind(self) + }); self.created = true; }, @@ -96,7 +101,7 @@ var MovieList = new Class({ if(!self.created) self.create(); // do scrollspy - if(movies.length < self.options.limit){ + if(movies.length < self.options.limit && self.scrollspy){ self.load_more.hide(); self.scrollspy.stop(); } @@ -121,18 +126,14 @@ var MovieList = new Class({ createMovie: function(movie, inject_at){ var self = this; - - // Attach proper actions - var a = self.options.actions, - status = Status.get(movie.status_id); - var actions = a[status.identifier.capitalize()] || a.Wanted || {}; - var m = new Movie(self, { - 'actions': actions, + 'actions': self.options.actions, 'view': self.current_view, 'onSelect': self.calculateSelected.bind(self) }, movie); + $(m).inject(self.movie_list, inject_at || 'bottom'); + m.fireEvent('injected'); self.movies.include(m) @@ -216,7 +217,7 @@ var MovieList = new Class({ }); // Actions - ['mass_edit', 'thumbs', 'list'].each(function(view){ + ['mass_edit', 'details', 'list'].each(function(view){ self.navigation_actions.adopt( new Element('li.'+view+(self.current_view == view ? '.active' : '')+'[data-view='+view+']', { 'events': { @@ -398,11 +399,16 @@ var MovieList = new Class({ var self = this; self.movies = [] - self.calculateSelected() - self.navigation_alpha.getElements('.active').removeClass('active') + if(self.mass_edit_select) + self.calculateSelected() + if(self.navigation_alpha) + self.navigation_alpha.getElements('.active').removeClass('active') + self.offset = 0; - self.load_more.show(); - self.scrollspy.start(); + if(self.scrollspy){ + self.load_more.show(); + self.scrollspy.start(); + } }, activateLetter: function(letter){ @@ -418,10 +424,6 @@ var MovieList = new Class({ changeView: function(new_view){ var self = this; - self.movies.each(function(movie){ - movie.changeView(new_view) - }); - self.el .removeClass(self.current_view+'_list') .addClass(new_view+'_list') @@ -432,7 +434,7 @@ var MovieList = new Class({ getSavedView: function(){ var self = this; - return Cookie.read(self.options.identifier+'_view') || 'thumbs'; + return Cookie.read(self.options.identifier+'_view') || 'details'; }, search: function(){ @@ -468,9 +470,12 @@ var MovieList = new Class({ getMovies: function(){ var self = this; - if(self.scrollspy) self.scrollspy.stop(); - self.load_more.set('text', 'loading...'); - Api.request('movie.list', { + if(self.scrollspy){ + self.scrollspy.stop(); + self.load_more.set('text', 'loading...'); + } + + Api.request(self.options.api_call || 'movie.list', { 'data': Object.merge({ 'status': self.options.status, 'limit_offset': self.options.limit + ',' + self.offset @@ -478,8 +483,10 @@ var MovieList = new Class({ 'onComplete': function(json){ self.store(json.movies); self.addMovies(json.movies, json.total); - self.load_more.set('text', 'load more movies'); - if(self.scrollspy) self.scrollspy.start(); + if(self.scrollspy) { + self.load_more.set('text', 'load more movies'); + self.scrollspy.start(); + } self.checkIfEmpty() } @@ -502,7 +509,13 @@ var MovieList = new Class({ checkIfEmpty: function(){ var self = this; - var is_empty = self.movies.length == 0 && self.total_movies == 0; + var is_empty = self.movies.length == 0 && (self.total_movies == 0 || self.total_movies === undefined); + + if(self.title) + self.title[is_empty ? 'hide' : 'show']() + + if(self.description) + self.description[is_empty ? 'hide' : 'show']() if(is_empty && self.options.on_empty_element){ self.el.grab(self.options.on_empty_element); diff --git a/couchpotato/core/plugins/movie/static/movie.actions.js b/couchpotato/core/plugins/movie/static/movie.actions.js new file mode 100644 index 00000000..a56e9abd --- /dev/null +++ b/couchpotato/core/plugins/movie/static/movie.actions.js @@ -0,0 +1,699 @@ +var MovieAction = new Class({ + + class_name: 'action icon', + + initialize: function(movie){ + var self = this; + self.movie = movie; + + self.create(); + if(self.el) + self.el.addClass(self.class_name) + }, + + create: function(){}, + + disable: function(){ + this.el.addClass('disable') + }, + + enable: function(){ + this.el.removeClass('disable') + }, + + createMask: function(){ + var self = this; + self.mask = new Element('div.mask', { + 'styles': { + 'z-index': '1' + } + }).inject(self.movie, 'top').fade('hide'); + //self.positionMask(); + }, + + positionMask: function(){ + var self = this, + movie = $(self.movie), + s = movie.getSize() + + return; + + return self.mask.setStyles({ + 'width': s.x, + 'height': s.y + }).position({ + 'relativeTo': movie + }) + }, + + toElement: function(){ + return this.el || null + } + +}); + +var MA = {}; + +MA.IMDB = new Class({ + + Extends: MovieAction, + id: null, + + create: function(){ + var self = this; + + self.id = self.movie.get('identifier'); + + self.el = new Element('a.imdb', { + 'title': 'Go to the IMDB page of ' + self.movie.getTitle(), + 'href': 'http://www.imdb.com/title/'+self.id+'/', + 'target': '_blank' + }); + + if(!self.id) self.disable(); + } + +}); + +MA.Release = new Class({ + + Extends: MovieAction, + + create: function(){ + var self = this; + + self.el = new Element('a.releases.icon.download', { + 'title': 'Show the releases that are available for ' + self.movie.getTitle(), + 'events': { + 'click': self.show.bind(self) + } + }); + + if(self.movie.data.releases.length == 0){ + self.el.hide() + } + else { + + var buttons_done = false; + + self.movie.data.releases.sortBy('-info.score').each(function(release){ + if(buttons_done) return; + + var status = Status.get(release.status_id); + + if((self.next_release && (status.identifier == 'ignored' || status.identifier == 'failed')) || (!self.next_release && status.identifier == 'available')){ + self.hide_on_click = false; + self.show(); + buttons_done = true; + } + + }); + + } + + }, + + show: function(e){ + var self = this; + if(e) + (e).preventDefault(); + + if(!self.options_container){ + self.options_container = new Element('div.options').adopt( + self.release_container = new Element('div.releases.table').adopt( + self.trynext_container = new Element('div.buttons.try_container') + ) + ).inject(self.movie, 'top'); + + // Header + new Element('div.item.head').adopt( + new Element('span.name', {'text': 'Release name'}), + new Element('span.status', {'text': 'Status'}), + new Element('span.quality', {'text': 'Quality'}), + new Element('span.size', {'text': 'Size'}), + new Element('span.age', {'text': 'Age'}), + new Element('span.score', {'text': 'Score'}), + new Element('span.provider', {'text': 'Provider'}) + ).inject(self.release_container) + + self.movie.data.releases.sortBy('-info.score').each(function(release){ + + var status = Status.get(release.status_id), + quality = Quality.getProfile(release.quality_id) || {}, + info = release.info, + provider = self.get(release, 'provider') + (release.info['provider_extra'] ? self.get(release, 'provider_extra') : ''); + release.status = status; + + var release_name = self.get(release, 'name'); + if(release.files && release.files.length > 0){ + try { + var movie_file = release.files.filter(function(file){ + var type = File.Type.get(file.type_id); + return type && type.identifier == 'movie' + }).pick(); + release_name = movie_file.path.split(Api.getOption('path_sep')).getLast(); + } + catch(e){} + } + + // Create release + new Element('div', { + 'class': 'item '+status.identifier, + 'id': 'release_'+release.id + }).adopt( + new Element('span.name', {'text': release_name, 'title': release_name}), + new Element('span.status', {'text': status.identifier, 'class': 'release_status '+status.identifier}), + new Element('span.quality', {'text': quality.get('label') || 'n/a'}), + new Element('span.size', {'text': release.info['size'] ? Math.floor(self.get(release, 'size')) : 'n/a'}), + new Element('span.age', {'text': self.get(release, 'age')}), + new Element('span.score', {'text': self.get(release, 'score')}), + new Element('span.provider', { 'text': provider, 'title': provider }), + release.info['detail_url'] ? new Element('a.info.icon', { + 'href': release.info['detail_url'], + 'target': '_blank' + }) : null, + new Element('a.download.icon', { + 'events': { + 'click': function(e){ + (e).preventDefault(); + if(!this.hasClass('completed')) + self.download(release); + } + } + }), + new Element('a.delete.icon', { + 'events': { + 'click': function(e){ + (e).preventDefault(); + self.ignore(release); + this.getParent('.item').toggleClass('ignored') + } + } + }) + ).inject(self.release_container) + + if(status.identifier == 'ignored' || status.identifier == 'failed' || status.identifier == 'snatched'){ + if(!self.last_release || (self.last_release && self.last_release.status.identifier != 'snatched' && status.identifier == 'snatched')) + self.last_release = release; + } + else if(!self.next_release && status.identifier == 'available'){ + self.next_release = release; + } + }); + + if(self.last_release){ + self.release_container.getElement('#release_'+self.last_release.id).addClass('last_release'); + } + + if(self.next_release){ + self.release_container.getElement('#release_'+self.next_release.id).addClass('next_release'); + } + + if(self.next_release || self.last_release){ + + self.trynext_container.adopt( + new Element('span.or', { + 'text': 'This movie is snatched, if anything went wrong, download' + }), + self.last_release ? new Element('a.button.orange', { + 'text': 'the same release again', + 'events': { + 'click': self.trySameRelease.bind(self) + } + }) : null, + self.next_release && self.last_release ? new Element('span.or', { + 'text': ',' + }) : null, + self.next_release ? [new Element('a.button.green', { + 'text': self.last_release ? 'another release' : 'the best release', + 'events': { + 'click': self.tryNextRelease.bind(self) + } + }), + new Element('span.or', { + 'text': 'or pick one below' + })] : null + ) + } + + } + + self.movie.slide('in', self.options_container); + }, + + get: function(release, type){ + return release.info[type] || 'n/a' + }, + + download: function(release){ + var self = this; + + var release_el = self.release_container.getElement('#release_'+release.id), + icon = release_el.getElement('.download.icon'); + + icon.addClass('spinner'); + + Api.request('release.download', { + 'data': { + 'id': release.id + }, + 'onComplete': function(json){ + icon.removeClass('spinner') + if(json.success) + icon.addClass('completed'); + else + icon.addClass('attention').set('title', 'Something went wrong when downloading, please check logs.'); + } + }); + }, + + ignore: function(release){ + var self = this; + + Api.request('release.ignore', { + 'data': { + 'id': release.id + } + }) + + }, + + tryNextRelease: function(movie_id){ + var self = this; + + if(self.last_release) + self.ignore(self.last_release); + + if(self.next_release) + self.download(self.next_release); + + }, + + trySameRelease: function(movie_id){ + var self = this; + + if(self.last_release) + self.download(self.last_release); + + } + +}); + +MA.Trailer = new Class({ + + Extends: MovieAction, + id: null, + + create: function(){ + var self = this; + + self.el = new Element('a.trailer', { + 'title': 'Watch the trailer of ' + self.movie.getTitle(), + 'events': { + 'click': self.watch.bind(self) + } + }); + + }, + + watch: function(offset){ + var self = this; + + var data_url = 'http://gdata.youtube.com/feeds/videos?vq="{title}" {year} trailer&max-results=1&alt=json-in-script&orderby=relevance&sortorder=descending&format=5&fmt=18' + var url = data_url.substitute({ + 'title': encodeURI(self.movie.getTitle()), + 'year': self.movie.get('year'), + 'offset': offset || 1 + }), + size = $(self.movie).getSize(), + height = (size.x/16)*9, + id = 'trailer-'+randomString(); + + self.player_container = new Element('div[id='+id+']'); + self.container = new Element('div.hide.trailer_container') + .adopt(self.player_container) + .inject($(self.movie), 'top'); + + self.container.setStyle('height', 0); + self.container.removeClass('hide'); + + self.close_button = new Element('a.hide.hide_trailer', { + 'text': 'Hide trailer', + 'events': { + 'click': self.stop.bind(self) + } + }).inject(self.movie); + + self.container.setStyle('height', height); + $(self.movie).setStyle('height', height); + + new Request.JSONP({ + 'url': url, + 'onComplete': function(json){ + var video_url = json.feed.entry[0].id.$t.split('/'), + video_id = video_url[video_url.length-1]; + + self.player = new YT.Player(id, { + 'height': height, + 'width': size.x, + 'videoId': video_id, + 'playerVars': { + 'autoplay': 1, + 'showsearch': 0, + 'wmode': 'transparent', + 'iv_load_policy': 3 + } + }); + + self.close_button.removeClass('hide'); + + var quality_set = false; + var change_quality = function(state){ + if(!quality_set && (state.data == 1 || state.data || 2)){ + try { + self.player.setPlaybackQuality('hd720'); + quality_set = true; + } + catch(e){ + + } + } + } + self.player.addEventListener('onStateChange', change_quality); + + } + }).send() + + }, + + stop: function(){ + var self = this; + + self.player.stopVideo(); + self.container.addClass('hide'); + self.close_button.addClass('hide'); + $(self.movie).setStyle('height', null); + + setTimeout(function(){ + self.container.destroy() + self.close_button.destroy(); + }, 1800) + } + + +}); + +MA.Edit = new Class({ + + Extends: MovieAction, + + create: function(){ + var self = this; + + self.el = new Element('a.edit', { + 'title': 'Change movie information, like title and quality.', + 'events': { + 'click': self.editMovie.bind(self) + } + }); + + }, + + editMovie: function(e){ + var self = this; + (e).preventDefault(); + + if(!self.options_container){ + self.options_container = new Element('div.options').adopt( + new Element('div.form').adopt( + self.title_select = new Element('select', { + 'name': 'title' + }), + self.profile_select = new Element('select', { + 'name': 'profile' + }), + new Element('a.button.edit', { + 'text': 'Save & Search', + 'events': { + 'click': self.save.bind(self) + } + }) + ) + ).inject(self.movie, 'top'); + + Array.each(self.movie.data.library.titles, function(alt){ + new Element('option', { + 'text': alt.title + }).inject(self.title_select); + + if(alt['default']) + self.title_select.set('value', alt.title); + }); + + + Quality.getActiveProfiles().each(function(profile){ + + var profile_id = profile.id ? profile.id : profile.data.id; + + new Element('option', { + 'value': profile_id, + 'text': profile.label ? profile.label : profile.data.label + }).inject(self.profile_select); + + if(self.movie.profile && self.movie.profile.data && self.movie.profile.data.id == profile_id) + self.profile_select.set('value', profile_id); + }); + + } + + self.movie.slide('in', self.options_container); + }, + + save: function(e){ + (e).preventDefault(); + var self = this; + + Api.request('movie.edit', { + 'data': { + 'id': self.movie.get('id'), + 'default_title': self.title_select.get('value'), + 'profile_id': self.profile_select.get('value') + }, + 'useSpinner': true, + 'spinnerTarget': $(self.movie), + 'onComplete': function(){ + self.movie.quality.set('text', self.profile_select.getSelected()[0].get('text')); + self.movie.title.set('text', self.title_select.getSelected()[0].get('text')); + } + }); + + self.movie.slide('out'); + } + +}) + +MA.Refresh = new Class({ + + Extends: MovieAction, + + create: function(){ + var self = this; + + self.el = new Element('a.refresh', { + 'title': 'Refresh the movie info and do a forced search', + 'events': { + 'click': self.doRefresh.bind(self) + } + }); + + }, + + doRefresh: function(e){ + var self = this; + (e).preventDefault(); + + Api.request('movie.refresh', { + 'data': { + 'id': self.movie.get('id') + } + }); + } + +}); + +MA.Readd = new Class({ + + Extends: MovieAction, + + create: function(){ + var self = this; + + var movie_done = Status.get(self.movie.data.status_id).identifier == 'done'; + if(!movie_done) + var snatched = self.movie.data.releases.filter(function(release){ + return release.status && (release.status.identifier == 'snatched' || release.status.identifier == 'downloaded' || release.status.identifier == 'done'); + }).length; + + if(movie_done || snatched && snatched > 0) + self.el = new Element('a.readd', { + 'title': 'Readd the movie and mark all previous snatched/downloaded as ignored', + 'events': { + 'click': self.doReadd.bind(self) + } + }); + + }, + + doReadd: function(e){ + var self = this; + (e).preventDefault(); + + Api.request('movie.add', { + 'data': { + 'identifier': self.movie.get('identifier'), + 'ignore_previous': 1 + } + }); + } + +}); + +MA.Delete = new Class({ + + Extends: MovieAction, + + Implements: [Chain], + + create: function(){ + var self = this; + + self.el = new Element('a.delete', { + 'title': 'Remove the movie from this CP list', + 'events': { + 'click': self.showConfirm.bind(self) + } + }); + + }, + + showConfirm: function(e){ + var self = this; + (e).preventDefault(); + + if(!self.delete_container){ + self.delete_container = new Element('div.buttons.delete_container').adopt( + new Element('a.cancel', { + 'text': 'Cancel', + 'events': { + 'click': self.hideConfirm.bind(self) + } + }), + new Element('span.or', { + 'text': 'or' + }), + new Element('a.button.delete', { + 'text': 'Delete ' + self.movie.title.get('text'), + 'events': { + 'click': self.del.bind(self) + } + }) + ).inject(self.movie, 'top'); + } + + self.movie.slide('in', self.delete_container); + + }, + + hideConfirm: function(e){ + var self = this; + (e).preventDefault(); + + self.movie.slide('out'); + }, + + del: function(e){ + (e).preventDefault(); + var self = this; + + var movie = $(self.movie); + + self.chain( + function(){ + self.callChain(); + }, + function(){ + Api.request('movie.delete', { + 'data': { + 'id': self.movie.get('id'), + 'delete_from': self.movie.list.options.identifier + }, + 'onComplete': function(){ + movie.set('tween', { + 'duration': 300, + 'onComplete': function(){ + self.movie.destroy() + } + }); + movie.tween('height', 0); + } + }); + } + ); + + self.callChain(); + + } + +}); + +MA.Files = new Class({ + + Extends: MovieAction, + + create: function(){ + var self = this; + + self.el = new Element('a.directory', { + 'title': 'Available files', + 'events': { + 'click': self.showFiles.bind(self) + } + }); + + }, + + showFiles: function(e){ + var self = this; + (e).preventDefault(); + + if(!self.options_container){ + self.options_container = new Element('div.options').adopt( + self.files_container = new Element('div.files.table') + ).inject(self.movie, 'top'); + + // Header + new Element('div.item.head').adopt( + new Element('span.name', {'text': 'File'}), + new Element('span.type', {'text': 'Type'}), + new Element('span.is_available', {'text': 'Available'}) + ).inject(self.files_container) + + Array.each(self.movie.data.releases, function(release){ + + var rel = new Element('div.release').inject(self.files_container); + + Array.each(release.files, function(file){ + new Element('div.file.item').adopt( + new Element('span.name', {'text': file.path}), + new Element('span.type', {'text': File.Type.get(file.type_id).name}), + new Element('span.available', {'text': file.available}) + ).inject(rel) + }); + }); + + } + + self.movie.slide('in', self.options_container); + }, + +}); \ No newline at end of file diff --git a/couchpotato/core/plugins/movie/static/movie.css b/couchpotato/core/plugins/movie/static/movie.css index 49958496..65fe5209 100644 --- a/couchpotato/core/plugins/movie/static/movie.css +++ b/couchpotato/core/plugins/movie/static/movie.css @@ -1,7 +1,33 @@ .movies { padding: 60px 0 20px; + position: relative; + z-index: 3; } + .movies h2 { + margin-bottom: 20px; + } + + .movies > .description { + position: absolute; + top: 30px; + right: 0; + font-style: italic; + text-shadow: none; + opacity: 0.8; + } + .movies:hover > .description { + opacity: 1; + } + + .movies.thumbs_list { + padding: 20px 0 20px; + } + + .home .movies { + padding-top: 6px; + } + .movies.mass_edit_list { padding-top: 90px; } @@ -12,33 +38,58 @@ margin: 10px 0; overflow: hidden; width: 100%; + height: 180px; transition: all 0.2s linear; } - .movies .movie.list_view, .movies .movie.mass_edit_view { + + .movies.list_list .movie:not(.details_view), + .movies.mass_edit_list .movie { + height: 32px; + } + + .movies.thumbs_list .movie { + width: 153px; + height: 230px; + display: inline-block; + margin: 0 8px 0 0; + } + .movies.thumbs_list .movie:nth-child(6n+6) { + margin: 0; + } + + .movies .movie .mask { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + } + + .movies.list_list .movie:not(.details_view), + .movies.mass_edit_list .movie { margin: 1px 0; border-radius: 0; background: no-repeat; box-shadow: none; border-bottom: 1px solid rgba(255,255,255,0.05); } - .movies .movie.list_view:hover, .movies .movie.mass_edit_view:hover { - background: rgba(255,255,255,0.03); - } - .movies .movie_container { - overflow: hidden; + .movies.list_list .movie:hover:not(.details_view), + .movies.mass_edit_list .movie { + background: rgba(255,255,255,0.03); } .movies .data { padding: 20px; - height: 180px; + height: 100%; width: 840px; - position: relative; - float: right; + position: absolute; + right: 0; border-radius: 0; - transition: all 0.2s linear; + transition: all .6s cubic-bezier(0.9,0,0.1,1); } - .movies .list_view .data, .movies .mass_edit_view .data { + .movies.list_list .movie:not(.details_view) .data, + .movies.mass_edit_list .movie .data { height: 30px; padding: 3px 0 3px 10px; width: 938px; @@ -46,79 +97,148 @@ border: 0; background: none; } + + .movies.thumbs_list .data { + left: 0; + width: 100%; + padding: 10px; + height: 100%; + background: none; + transition: none; + } + + .movies.thumbs_list .movie.no_thumbnail .data { background-image: linear-gradient(-30deg, rgba(255, 0, 85, .2) 0,rgba(125, 185, 235, .2) 100%); + } + .movies.thumbs_list .movie.no_thumbnail:nth-child(2n+6) .data { background-image: linear-gradient(-20deg, rgba(125, 0, 215, .2) 0, rgba(4, 55, 5, .7) 100%); } + .movies.thumbs_list .movie.no_thumbnail:nth-child(3n+6) .data { background-image: linear-gradient(-30deg, rgba(155, 0, 85, .2) 0,rgba(25, 185, 235, .7) 100%); } + .movies.thumbs_list .movie.no_thumbnail:nth-child(4n+6) .data { background-image: linear-gradient(-30deg, rgba(115, 5, 235, .2) 0, rgba(55, 180, 5, .7) 100%); } + .movies.thumbs_list .movie.no_thumbnail:nth-child(5n+6) .data { background-image: linear-gradient(-30deg, rgba(35, 15, 215, .2) 0, rgba(135, 215, 115, .7) 100%); } + .movies.thumbs_list .movie.no_thumbnail:nth-child(6n+6) .data { background-image: linear-gradient(-30deg, rgba(35, 15, 215, .2) 0, rgba(135, 15, 115, .7) 100%); } + + .movies.thumbs_list .movie:hover .data { + background: rgba(0,0,0,0.9); + } + + .movies .data.hide_right { + right: -100%; + } .movies .movie .check { display: none; } .movies.mass_edit_list .movie .check { - float: left; + position: absolute; + left: 0; + top: 0; display: block; margin: 7px 0 0 5px; } .movies .poster { - float: left; + position: absolute; + left: 0; width: 120px; line-height: 0; overflow: hidden; - height: 180px; + height: 100%; border-radius: 4px 0 0 4px; - transition: all 0.2s linear; + transition: all .6s cubic-bezier(0.9,0,0.1,1); } - .movies .list_view .poster, .movies .mass_edit_view .poster { + .movies.list_list .movie:not(.details_view) .poster, + .movies.mass_edit_list .poster { width: 20px; height: 30px; + border-radius: 1px 0 0 1px; } .movies.mass_edit_list .poster { display: none; } + + .movies.thumbs_list .poster { + width: 100%; + height: 100%; + } - .movies .poster img, .options .poster img { + .movies .poster img, + .options .poster img { width: 101%; height: 101%; } + + .movies .info { + position: relative; + height: 100%; + } .movies .info .title { - font-size: 30px; + display: inline; + position: absolute; + font-size: 28px; font-weight: bold; margin-bottom: 10px; - float: left; + left: 0; + top: 0; width: 90%; transition: all 0.2s linear; } - .movies .list_view .info .title, .movies .mass_edit_view .info .title { + .movies.list_list .movie:not(.details_view) .info .title, + .movies.mass_edit_list .info .title { font-size: 16px; font-weight: normal; text-overflow: ellipsis; width: auto; + overflow: hidden; + } + + .movies.thumbs_list .movie:not(.no_thumbnail) .info { + display: none; + } + .movies.thumbs_list .movie:hover .info { + display: block; + } + + .movies.thumbs_list .info .title { + font-size: 21px; + text-shadow: 0 0 10px #000; + word-wrap: break-word; + } .movies .info .year { + position: absolute; font-size: 30px; margin-bottom: 10px; - float: right; color: #bbb; width: 10%; + right: 0; + top: 0; text-align: right; transition: all 0.2s linear; } - .movies .list_view .info .year, .movies .mass_edit_view .info .year { + .movies.list_list .movie:not(.details_view) .info .year, + .movies.mass_edit_list .info .year { font-size: 16px; width: 6%; + right: 10px; + } + + .movies.thumbs_list .info .year { + font-size: 23px; + margin: 0; + bottom: 0; + left: 0; + top: auto; + right: auto; + color: #FFF; + text-shadow: none; + text-shadow: 0 0 6px #000; } - .movies .info .rating { - font-size: 30px; - margin-bottom: 10px; - color: #444; - float: left; - width: 5%; - padding: 0 0 0 3%; - } - .movies .info .description { + position: absolute; + top: 30px; clear: both; height: 80px; overflow: hidden; @@ -126,63 +246,82 @@ .movies .data:hover .description { overflow: auto; } - .movies .list_view .info .description, .movies .mass_edit_view .info .description { + .movies.list_list .movie:not(.details_view) .info .description, + .movies.mass_edit_list .info .description, + .movies.thumbs_list .info .description { display: none; } .movies .data .quality { + position: absolute; + bottom: 0; display: block; min-height: 20px; vertical-align: mid; } - - .movies .data .quality span { - padding: 2px 3px; - font-weight: bold; - opacity: 0.5; - font-size: 10px; - height: 16px; - line-height: 12px; - vertical-align: middle; - display: inline-block; - text-transform: uppercase; - text-shadow: none; - font-weight: normal; - margin: 0 2px; - border-radius: 2px; - background-color: rgba(255,255,255,0.1); - } - .movies .list_view .data .quality, .movies .mass_edit_view .data .quality { - text-align: right; - float: right; + + .movies .status_suggest .data .quality, + .movies.thumbs_list .data .quality { + display: none; } - .movies .data .quality .available, .movies .data .quality .snatched { - opacity: 1; - box-shadow: 1px 1px 0 rgba(0,0,0,0.2); - cursor: pointer; - } - - .movies .data .quality .available { background-color: #578bc3; } - .movies .data .quality .snatched { background-color: #369545; } - .movies .data .quality .done { - background-color: #369545; - opacity: 1; - } - .movies .data .quality .finish { - background-image: url('../images/sprite.png'); - background-repeat: no-repeat; - background-position: 0 2px; - padding-left: 14px; - background-size: 14px - } + .movies .data .quality span { + padding: 2px 3px; + font-weight: bold; + opacity: 0.5; + font-size: 10px; + height: 16px; + line-height: 12px; + vertical-align: middle; + display: inline-block; + text-transform: uppercase; + text-shadow: none; + font-weight: normal; + margin: 0 2px; + border-radius: 2px; + background-color: rgba(255,255,255,0.1); + } + .movies.list_list .data .quality, + .movies.mass_edit_list .data .quality { + text-align: right; + right: 0; + margin-right: 50px; + z-index: 1; + } + + .movies .data .quality .available, + .movies .data .quality .snatched { + opacity: 1; + box-shadow: 1px 1px 0 rgba(0,0,0,0.2); + cursor: pointer; + } + + .movies .data .quality .available { background-color: #578bc3; } + .movies .data .quality .snatched { background-color: #369545; } + .movies .data .quality .done { + background-color: #369545; + opacity: 1; + } + .movies .data .quality .finish { + background-image: url('../images/sprite.png'); + background-repeat: no-repeat; + background-position: 0 2px; + padding-left: 14px; + background-size: 14px + } .movies .data .actions { + position: absolute; + bottom: 20px; + right: 20px; line-height: 0; - clear: both; - float: right; margin-top: -25px; } + .movies.thumbs_list .data .actions { + bottom: 8px; + right: 10px; + } + .movies .data:hover .action { opacity: 0.6; } .movies .data:hover .action:hover { opacity: 1; } .movies.mass_edit_list .data .actions { @@ -199,10 +338,14 @@ opacity: 0; } - .movies .list_view .data:hover .actions, .movies .mass_edit_view .data:hover .actions { - margin: -34px 2px 0 0; + .movies.list_list .movie:not(.details_view) .data:hover .actions, + .movies.mass_edit_list .data:hover .actions { + margin: 0; background: #4e5969; - position: relative; + top: 2px; + bottom: 2px; + right: 5px; + z-index: 3; } .movies .delete_container { @@ -284,6 +427,7 @@ .movies .options .table .provider { width: 120px; text-overflow: ellipsis; + overflow: hidden; } .movies .options .table .name { width: 350px; @@ -335,11 +479,11 @@ padding: 3px 10px; background: #4e5969; border-radius: 0 0 2px 2px; - transition: all .6s cubic-bezier(0.9,0,0.1,1) .2s; - } - .movies .movie .hide_trailer.hide { - top: -30px; + transition: all .2s cubic-bezier(0.9,0,0.1,1) .2s; } + .movies .movie .hide_trailer.hide { + top: -30px; + } .movies .movie .try_container { padding: 5px 10px; @@ -380,7 +524,7 @@ .movies .alph_nav { transition: box-shadow .4s linear; position: fixed; - z-index: 2; + z-index: 4; top: 0; padding: 100px 60px 7px; width: 1080px; @@ -409,7 +553,8 @@ text-align: center; } - .movies .alph_nav .numbers li, .movies .alph_nav .actions li { + .movies .alph_nav .numbers li, + .movies .alph_nav .actions li { display: inline-block; vertical-align: top; width: 20px; @@ -472,7 +617,7 @@ background-position: 3px -95px; } - .movies .alph_nav .actions li.thumbs span { + .movies .alph_nav .actions li.details span { background-position: 3px -74px; } diff --git a/couchpotato/core/plugins/movie/static/movie.js b/couchpotato/core/plugins/movie/static/movie.js index 5a49e416..ffdd14d1 100644 --- a/couchpotato/core/plugins/movie/static/movie.js +++ b/couchpotato/core/plugins/movie/static/movie.js @@ -8,7 +8,7 @@ var Movie = new Class({ var self = this; self.data = data; - self.view = options.view || 'thumbs'; + self.view = options.view || 'details'; self.list = list; self.el = new Element('div.movie.inlay'); @@ -72,7 +72,6 @@ var Movie = new Class({ else if(!self.spinner) { self.createMask(); self.spinner = createSpinner(self.mask); - self.positionMask(); self.mask.fade('in'); } }, @@ -81,10 +80,9 @@ var Movie = new Class({ var self = this; self.mask = new Element('div.mask', { 'styles': { - 'z-index': '1' + 'z-index': 4 } }).inject(self.el, 'top').fade('hide'); - self.positionMask(); }, positionMask: function(){ @@ -103,7 +101,7 @@ var Movie = new Class({ var self = this; self.data = notification.data; - self.container.destroy(); + self.el.empty(); self.profile = Quality.getProfile(self.data.profile_id) || {}; self.create(); @@ -114,52 +112,50 @@ var Movie = new Class({ create: function(){ var self = this; + var s = Status.get(self.get('status_id')); + self.el.addClass('status_'+s.identifier); + self.el.adopt( - self.container = new Element('div.movie_container').adopt( - self.select_checkbox = new Element('input[type=checkbox].inlay', { - 'events': { - 'change': function(){ - self.fireEvent('select') - } + self.select_checkbox = new Element('input[type=checkbox].inlay', { + 'events': { + 'change': function(){ + self.fireEvent('select') } - }), - self.thumbnail = File.Select.single('poster', self.data.library.files), - self.data_container = new Element('div.data.inlay.light', { - 'tween': { - duration: 400, - transition: 'quint:in:out', - onComplete: self.fireEvent.bind(self, 'slideEnd') - } - }).adopt( - self.info_container = new Element('div.info').adopt( - self.title = new Element('div.title', { - 'text': self.getTitle() || 'n/a' - }), - self.year = new Element('div.year', { - 'text': self.data.library.year || 'n/a' - }), - self.rating = new Element('div.rating.icon', { - 'text': self.data.library.rating - }), - self.description = new Element('div.description', { - 'text': self.data.library.plot - }), - self.quality = new Element('div.quality', { - 'events': { - 'click': function(e){ - var releases = self.el.getElement('.actions .releases'); - if(releases) - releases.fireEvent('click', [e]) - } + } + }), + self.thumbnail = File.Select.single('poster', self.data.library.files), + self.data_container = new Element('div.data.inlay.light').adopt( + self.info_container = new Element('div.info').adopt( + self.title = new Element('div.title', { + 'text': self.getTitle() || 'n/a' + }), + self.year = new Element('div.year', { + 'text': self.data.library.year || 'n/a' + }), + self.rating = new Element('div.rating.icon', { + 'text': self.data.library.rating + }), + self.description = new Element('div.description', { + 'text': self.data.library.plot + }), + self.quality = new Element('div.quality', { + 'events': { + 'click': function(e){ + var releases = self.el.getElement('.actions .releases'); + if(releases) + releases.fireEvent('click', [e]) } - }) - ), - self.actions = new Element('div.actions') - ) + } + }) + ), + self.actions = new Element('div.actions') ) ); - self.changeView(self.view); + if(self.thumbnail.empty) + self.el.addClass('no_thumbnail'); + + //self.changeView(self.view); self.select_checkbox_class = new Form.Check(self.select_checkbox); // Add profile @@ -174,7 +170,7 @@ var Movie = new Class({ }); - // Add done releases + // Add releases self.data.releases.each(function(release){ var q = self.quality.getElement('.q_id'+ release.quality_id), @@ -241,23 +237,23 @@ var Movie = new Class({ if(direction == 'in'){ self.temp_view = self.view; - self.changeView('thumbs') + self.changeView('details') self.el.addEvent('outerClick', function(){ - self.changeView(self.temp_view) + self.removeView() self.slide('out') }) el.show(); - self.data_container.tween('right', 0, -840); + self.data_container.addClass('hide_right'); } else { self.el.removeEvents('outerClick') - self.addEvent('slideEnd:once', function(){ + setTimeout(function(){ self.el.getElements('> :not(.data):not(.poster):not(.movie_container)').hide(); - }); + }, 600); - self.data_container.tween('right', -840, 0); + self.data_container.removeClass('hide_right'); } }, @@ -271,6 +267,12 @@ var Movie = new Class({ self.view = new_view; }, + removeView: function(){ + var self = this; + + self.el.removeClass(self.view+'_view') + }, + get: function(attr){ return this.data[attr] || this.data.library[attr] }, @@ -288,388 +290,4 @@ var Movie = new Class({ return this.el; } -}); - -var MovieAction = new Class({ - - class_name: 'action icon', - - initialize: function(movie){ - var self = this; - self.movie = movie; - - self.create(); - if(self.el) - self.el.addClass(self.class_name) - }, - - create: function(){}, - - disable: function(){ - this.el.addClass('disable') - }, - - enable: function(){ - this.el.removeClass('disable') - }, - - createMask: function(){ - var self = this; - self.mask = new Element('div.mask', { - 'styles': { - 'z-index': '1' - } - }).inject(self.movie, 'top').fade('hide'); - self.positionMask(); - }, - - positionMask: function(){ - var self = this, - movie = $(self.movie), - s = movie.getSize() - - return; - - return self.mask.setStyles({ - 'width': s.x, - 'height': s.y - }).position({ - 'relativeTo': movie - }) - }, - - toElement: function(){ - return this.el || null - } - -}); - -var IMDBAction = new Class({ - - Extends: MovieAction, - id: null, - - create: function(){ - var self = this; - - self.id = self.movie.get('identifier'); - - self.el = new Element('a.imdb', { - 'title': 'Go to the IMDB page of ' + self.movie.getTitle(), - 'href': 'http://www.imdb.com/title/'+self.id+'/', - 'target': '_blank' - }); - - if(!self.id) self.disable(); - } - -}); - -var ReleaseAction = new Class({ - - Extends: MovieAction, - - create: function(){ - var self = this; - - self.el = new Element('a.releases.icon.download', { - 'title': 'Show the releases that are available for ' + self.movie.getTitle(), - 'events': { - 'click': self.show.bind(self) - } - }); - - var buttons_done = false; - - self.movie.data.releases.sortBy('-info.score').each(function(release){ - if(buttons_done) return; - - var status = Status.get(release.status_id); - - if((self.next_release && (status.identifier == 'ignored' || status.identifier == 'failed')) || (!self.next_release && status.identifier == 'available')){ - self.hide_on_click = false; - self.show(); - buttons_done = true; - } - - }); - - }, - - show: function(e){ - var self = this; - if(e) - (e).preventDefault(); - - if(!self.options_container){ - self.options_container = new Element('div.options').adopt( - self.release_container = new Element('div.releases.table').adopt( - self.trynext_container = new Element('div.buttons.try_container') - ) - ).inject(self.movie, 'top'); - - // Header - new Element('div.item.head').adopt( - new Element('span.name', {'text': 'Release name'}), - new Element('span.status', {'text': 'Status'}), - new Element('span.quality', {'text': 'Quality'}), - new Element('span.size', {'text': 'Size'}), - new Element('span.age', {'text': 'Age'}), - new Element('span.score', {'text': 'Score'}), - new Element('span.provider', {'text': 'Provider'}) - ).inject(self.release_container) - - self.movie.data.releases.sortBy('-info.score').each(function(release){ - - var status = Status.get(release.status_id), - quality = Quality.getProfile(release.quality_id) || {}, - info = release.info; - release.status = status; - - // Create release - new Element('div', { - 'class': 'item '+status.identifier, - 'id': 'release_'+release.id - }).adopt( - new Element('span.name', {'text': self.get(release, 'name'), 'title': self.get(release, 'name')}), - new Element('span.status', {'text': status.identifier, 'class': 'release_status '+status.identifier}), - new Element('span.quality', {'text': quality.get('label') || 'n/a'}), - new Element('span.size', {'text': release.info['size'] ? Math.floor(self.get(release, 'size')) : 'n/a'}), - new Element('span.age', {'text': self.get(release, 'age')}), - new Element('span.score', {'text': self.get(release, 'score')}), - new Element('span.provider', {'text': self.get(release, 'provider')}), - release.info['detail_url'] ? new Element('a.info.icon', { - 'href': release.info['detail_url'], - 'target': '_blank' - }) : null, - new Element('a.download.icon', { - 'events': { - 'click': function(e){ - (e).preventDefault(); - if(!this.hasClass('completed')) - self.download(release); - } - } - }), - new Element('a.delete.icon', { - 'events': { - 'click': function(e){ - (e).preventDefault(); - self.ignore(release); - this.getParent('.item').toggleClass('ignored') - } - } - }) - ).inject(self.release_container) - - if(status.identifier == 'ignored' || status.identifier == 'failed' || status.identifier == 'snatched'){ - if(!self.last_release || (self.last_release && self.last_release.status.identifier != 'snatched' && status.identifier == 'snatched')) - self.last_release = release; - } - else if(!self.next_release && status.identifier == 'available'){ - self.next_release = release; - } - }); - - if(self.last_release){ - self.release_container.getElement('#release_'+self.last_release.id).addClass('last_release'); - } - - if(self.next_release){ - self.release_container.getElement('#release_'+self.next_release.id).addClass('next_release'); - } - - if(self.next_release || self.last_release){ - - self.trynext_container.adopt( - new Element('span.or', { - 'text': 'This movie is snatched, if anything went wrong, download' - }), - self.last_release ? new Element('a.button.orange', { - 'text': 'the same release again', - 'events': { - 'click': self.trySameRelease.bind(self) - } - }) : null, - self.next_release && self.last_release ? new Element('span.or', { - 'text': ',' - }) : null, - self.next_release ? [new Element('a.button.green', { - 'text': self.last_release ? 'another release' : 'the best release', - 'events': { - 'click': self.tryNextRelease.bind(self) - } - }), - new Element('span.or', { - 'text': 'or pick one below' - })] : null - ) - } - - } - - self.movie.slide('in', self.options_container); - }, - - get: function(release, type){ - return release.info[type] || 'n/a' - }, - - download: function(release){ - var self = this; - - var release_el = self.release_container.getElement('#release_'+release.id), - icon = release_el.getElement('.download.icon'); - - icon.addClass('spinner'); - - Api.request('release.download', { - 'data': { - 'id': release.id - }, - 'onComplete': function(json){ - icon.removeClass('spinner') - if(json.success) - icon.addClass('completed'); - else - icon.addClass('attention').set('title', 'Something went wrong when downloading, please check logs.'); - } - }); - }, - - ignore: function(release){ - var self = this; - - Api.request('release.ignore', { - 'data': { - 'id': release.id - } - }) - - }, - - tryNextRelease: function(movie_id){ - var self = this; - - if(self.last_release) - self.ignore(self.last_release); - - if(self.next_release) - self.download(self.next_release); - - }, - - trySameRelease: function(movie_id){ - var self = this; - - if(self.last_release) - self.download(self.last_release); - - } - -}); - -var TrailerAction = new Class({ - - Extends: MovieAction, - id: null, - - create: function(){ - var self = this; - - self.el = new Element('a.trailer', { - 'title': 'Watch the trailer of ' + self.movie.getTitle(), - 'events': { - 'click': self.watch.bind(self) - } - }); - - }, - - watch: function(offset){ - var self = this; - - var data_url = 'http://gdata.youtube.com/feeds/videos?vq="{title}" {year} trailer&max-results=1&alt=json-in-script&orderby=relevance&sortorder=descending&format=5&fmt=18' - var url = data_url.substitute({ - 'title': encodeURI(self.movie.getTitle()), - 'year': self.movie.get('year'), - 'offset': offset || 1 - }), - size = $(self.movie).getSize(), - height = (size.x/16)*9, - id = 'trailer-'+randomString(); - - self.player_container = new Element('div[id='+id+']'); - self.container = new Element('div.hide.trailer_container') - .adopt(self.player_container) - .inject(self.movie.container, 'top'); - - self.container.setStyle('height', 0); - self.container.removeClass('hide'); - - self.close_button = new Element('a.hide.hide_trailer', { - 'text': 'Hide trailer', - 'events': { - 'click': self.stop.bind(self) - } - }).inject(self.movie); - - setTimeout(function(){ - $(self.movie).setStyle('max-height', height); - self.container.setStyle('height', height); - }, 100) - - new Request.JSONP({ - 'url': url, - 'onComplete': function(json){ - var video_url = json.feed.entry[0].id.$t.split('/'), - video_id = video_url[video_url.length-1]; - - self.player = new YT.Player(id, { - 'height': height, - 'width': size.x, - 'videoId': video_id, - 'playerVars': { - 'autoplay': 1, - 'showsearch': 0, - 'wmode': 'transparent', - 'iv_load_policy': 3 - } - }); - - self.close_button.removeClass('hide'); - - var quality_set = false; - var change_quality = function(state){ - if(!quality_set && (state.data == 1 || state.data || 2)){ - try { - self.player.setPlaybackQuality('hd720'); - quality_set = true; - } - catch(e){ - - } - } - } - self.player.addEventListener('onStateChange', change_quality); - - } - }).send() - - }, - - stop: function(){ - var self = this; - - self.player.stopVideo(); - self.container.addClass('hide'); - self.close_button.addClass('hide'); - - setTimeout(function(){ - self.container.destroy() - self.close_button.destroy(); - }, 1800) - } - - }); \ No newline at end of file diff --git a/couchpotato/core/plugins/movie/static/search.css b/couchpotato/core/plugins/movie/static/search.css index af942295..23d87b46 100644 --- a/couchpotato/core/plugins/movie/static/search.css +++ b/couchpotato/core/plugins/movie/static/search.css @@ -191,6 +191,8 @@ .movie_result .info h2 { margin: 0; + font-size: 17px; + line-height: 20px; } .movie_result .info h2 span { @@ -200,7 +202,8 @@ .movie_result .info h2 span:before { content: "("; } .movie_result .info h2 span:after { content: ")"; } -.search_form .mask { +.search_form .mask, +.movie_result .mask { border-radius: 3px; position: absolute; height: 100%; diff --git a/couchpotato/core/plugins/movie/static/search.js b/couchpotato/core/plugins/movie/static/search.js index ba8b547e..dd8e7b04 100644 --- a/couchpotato/core/plugins/movie/static/search.js +++ b/couchpotato/core/plugins/movie/static/search.js @@ -366,7 +366,7 @@ Block.Search.Item = new Class({ loadingMask: function(){ var self = this; - self.mask = new Element('span.mask').inject(self.el).fade('hide') + self.mask = new Element('div.mask').inject(self.el).fade('hide') createSpinner(self.mask) self.mask.fade('in') diff --git a/couchpotato/core/plugins/renamer/__init__.py b/couchpotato/core/plugins/renamer/__init__.py index 88989695..6ce21922 100644 --- a/couchpotato/core/plugins/renamer/__init__.py +++ b/couchpotato/core/plugins/renamer/__init__.py @@ -1,4 +1,5 @@ from couchpotato.core.plugins.renamer.main import Renamer +import os def start(): return Renamer() @@ -111,6 +112,15 @@ config = [{ 'label': 'Separator', 'description': 'Replace all the spaces with a character. Example: ".", "-" (without quotes). Leave empty to use spaces.', }, + { + 'advanced': True, + 'name': 'ntfs_permission', + 'label': 'NTFS Permission', + 'type': 'bool', + 'hidden': os.name != 'nt', + 'description': 'Set permission of moved files to that of destination folder (Windows NTFS only).', + 'default': False, + }, ], }, { 'tab': 'renamer', diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index 91dfb339..b7062157 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -13,6 +13,7 @@ import errno import os import re import shutil +import time import traceback log = CPLog(__name__) @@ -170,15 +171,15 @@ class Renamer(Plugin): replacements['cd_nr'] = cd if multiple else '' # Naming - final_folder_name = self.doReplace(folder_name, replacements) - final_file_name = self.doReplace(file_name, replacements) + final_folder_name = self.doReplace(folder_name, replacements).lstrip('. ') + final_file_name = self.doReplace(file_name, replacements).lstrip('. ') replacements['filename'] = final_file_name[:-(len(getExt(final_file_name)) + 1)] # Meta naming if file_type is 'trailer': - final_file_name = self.doReplace(trailer_name, replacements, remove_multiple = True) + final_file_name = self.doReplace(trailer_name, replacements, remove_multiple = True).lstrip('. ') elif file_type is 'nfo': - final_file_name = self.doReplace(nfo_name, replacements, remove_multiple = True) + final_file_name = self.doReplace(nfo_name, replacements, remove_multiple = True).lstrip('. ') # Seperator replace if separator: @@ -275,6 +276,7 @@ class Renamer(Plugin): for profile_type in movie.profile.types: if profile_type.quality_id == group['meta_data']['quality']['id'] and profile_type.finish: movie.status_id = done_status.get('id') + movie.last_edit = int(time.time()) db.commit() except Exception, e: log.error('Failed marking movie finished: %s %s', (e, traceback.format_exc())) @@ -316,8 +318,10 @@ class Renamer(Plugin): log.debug('Marking release as downloaded') try: release.status_id = downloaded_status.get('id') + release.last_edit = int(time.time()) except Exception, e: log.error('Failed marking release as finished: %s %s', (e, traceback.format_exc())) + db.commit() # Remove leftover files @@ -455,6 +459,8 @@ class Renamer(Plugin): try: os.chmod(dest, Env.getPermission('file')) + if os.name == 'nt' and self.conf('ntfs_permission'): + os.popen('icacls "' + dest + '"* /reset /T') except: log.error('Failed setting permissions for file: %s, %s', (dest, traceback.format_exc(1))) @@ -468,7 +474,7 @@ class Renamer(Plugin): except: log.error('Couldn\'t move file "%s" to "%s": %s', (old, dest, traceback.format_exc())) - raise Exception + raise return True @@ -554,6 +560,7 @@ class Renamer(Plugin): if rel.movie.status_id == done_status.get('id'): log.debug('Found a completed movie with a snatched release : %s. Setting release status to ignored...' , default_title) rel.status_id = ignored_status.get('id') + rel.last_edit = int(time.time()) db.commit() continue @@ -564,7 +571,7 @@ class Renamer(Plugin): found = False for item in statuses: - if item['name'] == nzbname or getImdb(item['name']) == movie_dict['library']['identifier']: + if item['name'] == nzbname or rel_dict['info']['name'] in item['name'] or getImdb(item['name']) == movie_dict['library']['identifier']: timeleft = 'N/A' if item['timeleft'] == -1 else item['timeleft'] log.debug('Found %s: %s, time to go: %s', (item['name'], item['status'].upper(), timeleft)) @@ -578,6 +585,7 @@ class Renamer(Plugin): fireEvent('searcher.try_next_release', movie_id = rel.movie_id) else: rel.status_id = failed_status.get('id') + rel.last_edit = int(time.time()) db.commit() elif item['status'] == 'completed': log.info('Download of %s completed!', item['name']) diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index 684f6821..b822bc0f 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -23,7 +23,7 @@ class Scanner(Plugin): 'media': 314572800, # 300MB 'trailer': 1048576, # 1MB } - ignored_in_path = ['extracting', '_unpack', '_failed_', '_unknown_', '_exists_', '_failed_remove_', '_failed_rename_', '.appledouble', '.appledb', '.appledesktop', os.path.sep + '._', '.ds_store', 'cp.cpnfo'] #unpacking, smb-crap, hidden files + ignored_in_path = [os.path.sep + 'extracted' + os.path.sep, 'extracting', '_unpack', '_failed_', '_unknown_', '_exists_', '_failed_remove_', '_failed_rename_', '.appledouble', '.appledb', '.appledesktop', os.path.sep + '._', '.ds_store', 'cp.cpnfo'] #unpacking, smb-crap, hidden files ignore_names = ['extract', 'extracting', 'extracted', 'movie', 'movies', 'film', 'films', 'download', 'downloads', 'video_ts', 'audio_ts', 'bdmv', 'certificate'] extensions = { 'movie': ['mkv', 'wmv', 'avi', 'mpg', 'mpeg', 'mp4', 'm2ts', 'iso', 'img', 'mdf', 'ts', 'm4v'], diff --git a/couchpotato/core/plugins/searcher/main.py b/couchpotato/core/plugins/searcher/main.py index 8c1929af..0285917f 100644 --- a/couchpotato/core/plugins/searcher/main.py +++ b/couchpotato/core/plugins/searcher/main.py @@ -30,6 +30,7 @@ class Searcher(Plugin): addEvent('searcher.correct_movie', self.correctMovie) addEvent('searcher.download', self.download) addEvent('searcher.try_next_release', self.tryNextRelease) + addEvent('searcher.could_be_released', self.couldBeReleased) addApiView('searcher.try_next', self.tryNextReleaseView, docs = { 'desc': 'Marks the snatched results as ignored and try the next best release', @@ -156,7 +157,7 @@ class Searcher(Plugin): ret = False for quality_type in movie['profile']['types']: - if not self.couldBeReleased(quality_type['quality']['identifier'], release_dates, pre_releases): + if not self.couldBeReleased(quality_type['quality']['identifier'] in pre_releases, release_dates): log.info('Too early to search for %s, %s', (quality_type['quality']['identifier'], default_title)) continue @@ -164,7 +165,7 @@ class Searcher(Plugin): # See if better quality is available for release in movie['releases']: - if release['quality']['order'] <= quality_type['quality']['order'] and release['status_id'] not in [available_status.get('id'), ignored_status.get('id')]: + if release['quality']['order'] < quality_type['quality']['order'] and release['status_id'] not in [available_status.get('id'), ignored_status.get('id')]: has_better_quality += 1 # Don't search for quality lower then already available. @@ -208,6 +209,7 @@ class Searcher(Plugin): db.add(rls) else: [db.delete(old_info) for old_info in rls.info] + rls.last_edit = int(time.time()) db.commit() @@ -292,7 +294,10 @@ class Searcher(Plugin): db = get_session() rls = db.query(Release).filter_by(identifier = md5(data['url'])).first() if rls: - rls.status_id = snatched_status.get('id') + renamer_enabled = Env.setting('enabled', 'renamer') + + done_status = fireEvent('status.get', 'done', single = True) + rls.status_id = done_status.get('id') if not renamer_enabled else snatched_status.get('id') db.commit() log_movie = '%s (%s) in %s' % (getTitle(movie['library']), movie['library']['year'], rls.quality.label) @@ -300,26 +305,28 @@ class Searcher(Plugin): log.info(snatch_message) fireEvent('movie.snatched', message = snatch_message, data = rls.to_dict()) - # If renamer isn't used, mark movie done - if not Env.setting('enabled', 'renamer'): - active_status = fireEvent('status.get', 'active', single = True) - done_status = fireEvent('status.get', 'done', single = True) - try: - if movie['status_id'] == active_status.get('id'): - for profile_type in movie['profile']['types']: - if rls and profile_type['quality_id'] == rls.quality.id and profile_type['finish']: - log.info('Renamer disabled, marking movie as finished: %s', log_movie) + # If renamer isn't used, mark movie done + if not renamer_enabled: + active_status = fireEvent('status.get', 'active', single = True) + done_status = fireEvent('status.get', 'done', single = True) + try: + if movie['status_id'] == active_status.get('id'): + for profile_type in movie['profile']['types']: + if profile_type['quality_id'] == rls.quality.id and profile_type['finish']: + log.info('Renamer disabled, marking movie as finished: %s', log_movie) - # Mark release done - rls.status_id = done_status.get('id') - db.commit() + # Mark release done + rls.status_id = done_status.get('id') + rls.last_edit = int(time.time()) + db.commit() - # Mark movie done - mvie = db.query(Movie).filter_by(id = movie['id']).first() - mvie.status_id = done_status.get('id') - db.commit() - except: - log.error('Failed marking movie finished, renamer disabled: %s', traceback.format_exc()) + # Mark movie done + mvie = db.query(Movie).filter_by(id = movie['id']).first() + mvie.status_id = done_status.get('id') + mvie.last_edit = int(time.time()) + db.commit() + except: + log.error('Failed marking movie finished, renamer disabled: %s', traceback.format_exc()) except: log.error('Failed marking movie finished: %s', traceback.format_exc()) @@ -526,7 +533,7 @@ class Searcher(Plugin): return False - def couldBeReleased(self, wanted_quality, dates, pre_releases): + def couldBeReleased(self, is_pre_release, dates): now = int(time.time()) @@ -538,7 +545,7 @@ class Searcher(Plugin): if dates.get('theater', 0) < 0 or dates.get('dvd', 0) < 0: return True - if wanted_quality in pre_releases: + if is_pre_release: # Prerelease 1 week before theaters if dates.get('theater') - 604800 < now: return True diff --git a/couchpotato/core/plugins/status/main.py b/couchpotato/core/plugins/status/main.py index 338749d6..c01caef5 100644 --- a/couchpotato/core/plugins/status/main.py +++ b/couchpotato/core/plugins/status/main.py @@ -23,6 +23,7 @@ class StatusPlugin(Plugin): 'deleted': 'Deleted', 'ignored': 'Ignored', 'available': 'Available', + 'suggest': 'Suggest', } def __init__(self): diff --git a/couchpotato/core/plugins/suggestion/main.py b/couchpotato/core/plugins/suggestion/main.py index 7a65a015..2c31ca32 100644 --- a/couchpotato/core/plugins/suggestion/main.py +++ b/couchpotato/core/plugins/suggestion/main.py @@ -1,6 +1,22 @@ +from couchpotato.api import addApiView +from couchpotato.core.event import fireEvent +from couchpotato.core.helpers.request import jsonified, getParam from couchpotato.core.plugins.base import Plugin class Suggestion(Plugin): - pass + def __init__(self): + addApiView('suggestion.view', self.getView) + + def getView(self): + + limit_offset = getParam('limit_offset', None) + total_movies, movies = fireEvent('movie.list', status = 'suggest', limit_offset = limit_offset, single = True) + + return jsonified({ + 'success': True, + 'empty': len(movies) == 0, + 'total': total_movies, + 'movies': movies, + }) diff --git a/couchpotato/core/plugins/wizard/static/wizard.js b/couchpotato/core/plugins/wizard/static/wizard.js index eb41cb59..12ae4771 100644 --- a/couchpotato/core/plugins/wizard/static/wizard.js +++ b/couchpotato/core/plugins/wizard/static/wizard.js @@ -82,7 +82,7 @@ Page.Wizard = new Class({ 'target': self.el }, 'onComplete': function(){ - window.location = App.createUrl(); + window.location = App.createUrl('wanted'); } }); } diff --git a/couchpotato/core/providers/automation/goodfilms/__init__.py b/couchpotato/core/providers/automation/goodfilms/__init__.py new file mode 100644 index 00000000..795e21da --- /dev/null +++ b/couchpotato/core/providers/automation/goodfilms/__init__.py @@ -0,0 +1,28 @@ +from .main import Goodfilms + +def start(): + return Goodfilms() + +config = [{ + 'name': 'goodfilms', + 'groups': [ + { + 'tab': 'automation', + 'list': 'watchlist_providers', + 'name': 'goodfilms_automation', + 'label': 'Goodfilms', + 'description': 'import movies from your Goodfilms queue', + 'options': [ + { + 'name': 'automation_enabled', + 'default': False, + 'type': 'enabler', + }, + { + 'name': 'automation_username', + 'label': 'Username', + }, + ], + }, + ], +}] \ No newline at end of file diff --git a/couchpotato/core/providers/automation/goodfilms/main.py b/couchpotato/core/providers/automation/goodfilms/main.py new file mode 100644 index 00000000..dd4b1aef --- /dev/null +++ b/couchpotato/core/providers/automation/goodfilms/main.py @@ -0,0 +1,36 @@ +from couchpotato.core.logger import CPLog +from couchpotato.core.providers.automation.base import Automation +from bs4 import BeautifulSoup + +log = CPLog(__name__) + + +class Goodfilms(Automation): + + url = 'http://goodfil.ms/%s/queue' + + def getIMDBids(self): + + if not self.conf('automation_username'): + log.error('Please fill in your username') + return [] + + movies = [] + + for movie in self.getWatchlist(): + imdb_id = self.search(movie.get('title'), movie.get('year'), imdb_only = True) + movies.append(imdb_id) + + return movies + + def getWatchlist(self): + + url = self.url % self.conf('automation_username') + soup = BeautifulSoup(self.getHTMLData(url)) + + movies = [] + + for movie in soup.find_all('div', attrs = { 'class': 'movie', 'data-film-title': True }): + movies.append({ 'title': movie['data-film-title'], 'year': movie['data-film-year'] }) + + return movies diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index 12d77740..855f3c72 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -46,7 +46,8 @@ class Provider(Plugin): def getJsonData(self, url, **kwargs): - data = self.getCache(md5(url), url, **kwargs) + cache_key = '%s%s' % (md5(url), md5('%s' % kwargs.get('params', {}))) + data = self.getCache(cache_key, url, **kwargs) if data: try: @@ -58,7 +59,8 @@ class Provider(Plugin): def getRSSData(self, url, item_path = 'channel/item', **kwargs): - data = self.getCache(md5(url), url, **kwargs) + cache_key = '%s%s' % (md5(url), md5('%s' % kwargs.get('params', {}))) + data = self.getCache(cache_key, url, **kwargs) if data: try: @@ -70,7 +72,9 @@ class Provider(Plugin): return [] def getHTMLData(self, url, **kwargs): - return self.getCache(md5(url), url, **kwargs) + + cache_key = '%s%s' % (md5(url), md5('%s' % kwargs.get('params', {}))) + return self.getCache(cache_key, url, **kwargs) class YarrProvider(Provider): diff --git a/couchpotato/core/providers/movie/couchpotatoapi/main.py b/couchpotato/core/providers/movie/couchpotatoapi/main.py index 88883d79..fc16e8ab 100644 --- a/couchpotato/core/providers/movie/couchpotatoapi/main.py +++ b/couchpotato/core/providers/movie/couchpotatoapi/main.py @@ -5,9 +5,7 @@ from couchpotato.core.helpers.request import jsonified, getParams from couchpotato.core.logger import CPLog from couchpotato.core.providers.movie.base import MovieProvider from couchpotato.core.settings.model import Movie -from flask.helpers import json import time -import traceback log = CPLog(__name__) @@ -17,8 +15,9 @@ class CouchPotatoApi(MovieProvider): urls = { 'search': 'https://couchpota.to/api/search/%s/', 'info': 'https://couchpota.to/api/info/%s/', + 'is_movie': 'https://couchpota.to/api/ismovie/%s/', 'eta': 'https://couchpota.to/api/eta/%s/', - 'suggest': 'https://couchpota.to/api/suggest/%s/%s/', + 'suggest': 'https://couchpota.to/api/suggest/', } http_time_between_calls = 0 api_version = 1 @@ -29,58 +28,47 @@ class CouchPotatoApi(MovieProvider): addEvent('movie.info', self.getInfo, priority = 1) addEvent('movie.search', self.search, priority = 1) addEvent('movie.release_date', self.getReleaseDate) + addEvent('movie.suggest', self.suggest) + addEvent('movie.is_movie', self.isMovie) def search(self, q, limit = 12): + return self.getJsonData(self.urls['search'] % tryUrlencode(q), headers = self.getRequestHeaders()) - cache_key = 'cpapi.cache.%s' % q - cached = self.getCache(cache_key, self.urls['search'] % tryUrlencode(q), headers = self.getRequestHeaders()) + def isMovie(self, identifier = None): - if cached: - try: - movies = json.loads(cached) - return movies - except: - log.error('Failed parsing search results: %s', traceback.format_exc()) + if not identifier: + return - return [] + data = self.getJsonData(self.urls['is_movie'] % identifier, headers = self.getRequestHeaders()) + if data: + return data.get('is_movie', True) + + return True def getInfo(self, identifier = None): if not identifier: return - cache_key = 'cpapi.cache.info.%s' % identifier - cached = self.getCache(cache_key, self.urls['info'] % identifier, headers = self.getRequestHeaders()) - - if cached: - try: - movie = json.loads(cached) - return movie - except: - log.error('Failed parsing info results: %s', traceback.format_exc()) + result = self.getJsonData(self.urls['info'] % identifier, headers = self.getRequestHeaders()) + if result: return result return {} def getReleaseDate(self, identifier = None): - if identifier is None: return {} - try: - data = self.urlopen(self.urls['eta'] % identifier, headers = self.getRequestHeaders()) - dates = json.loads(data) - log.debug('Found ETA for %s: %s', (identifier, dates)) - return dates - except Exception, e: - log.error('Error getting ETA for %s: %s', (identifier, e)) - return {} + dates = self.getJsonData(self.urls['eta'] % identifier, headers = self.getRequestHeaders()) + log.debug('Found ETA for %s: %s', (identifier, dates)) + + return dates def suggest(self, movies = [], ignore = []): - try: - data = self.urlopen(self.urls['suggest'] % (','.join(movies), ','.join(ignore))) - suggestions = json.loads(data) - log.info('Found Suggestions for %s', (suggestions)) - except Exception, e: - log.error('Error getting suggestions for %s: %s', (movies, e)) + suggestions = self.getJsonData(self.urls['suggest'], params = { + 'movies': ','.join(movies), + #'ignore': ','.join(ignore), + }) + log.info('Found Suggestions for %s', (suggestions)) return suggestions diff --git a/couchpotato/core/providers/nzb/newznab/main.py b/couchpotato/core/providers/nzb/newznab/main.py index f80e07cc..414e7112 100644 --- a/couchpotato/core/providers/nzb/newznab/main.py +++ b/couchpotato/core/providers/nzb/newznab/main.py @@ -69,7 +69,7 @@ class Newznab(NZBProvider, RSS): results.append({ 'id': nzb_id, - 'provider_extra': host['host'], + 'provider_extra': urlparse(host['host']).hostname or host['host'], 'name': self.getTextElement(nzb, 'title'), 'age': self.calculateAge(int(time.mktime(parse(date).timetuple()))), 'size': int(self.getElement(nzb, 'enclosure').attrib['length']) / 1024 / 1024, diff --git a/couchpotato/core/providers/torrent/iptorrents/__init__.py b/couchpotato/core/providers/torrent/iptorrents/__init__.py new file mode 100644 index 00000000..bca8ce69 --- /dev/null +++ b/couchpotato/core/providers/torrent/iptorrents/__init__.py @@ -0,0 +1,40 @@ +from .main import IPTorrents + +def start(): + return IPTorrents() + +config = [{ + 'name': 'iptorrents', + 'groups': [ + { + 'tab': 'searcher', + 'subtab': 'providers', + 'list': 'torrent_providers', + 'name': 'IPTorrents', + 'description': 'See IPTorrents', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'type': 'enabler', + 'default': False, + }, + { + 'name': 'username', + 'default': '', + }, + { + 'name': 'password', + 'default': '', + 'type': 'password', + }, + { + 'name': 'freeleech', + 'default': 0, + 'type': 'bool', + 'description': 'Only search for [FreeLeech] torrents.', + }, + ], + }, + ], +}] diff --git a/couchpotato/core/providers/torrent/iptorrents/main.py b/couchpotato/core/providers/torrent/iptorrents/main.py new file mode 100644 index 00000000..75dc6f12 --- /dev/null +++ b/couchpotato/core/providers/torrent/iptorrents/main.py @@ -0,0 +1,83 @@ +from bs4 import BeautifulSoup +from couchpotato.core.helpers.encoding import tryUrlencode +from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.logger import CPLog +from couchpotato.core.providers.torrent.base import TorrentProvider +import traceback + + +log = CPLog(__name__) + + +class IPTorrents(TorrentProvider): + + urls = { + 'test' : 'http://www.iptorrents.com/', + 'base_url' : 'http://www.iptorrents.com', + 'login' : 'http://www.iptorrents.com/torrents/', + 'search' : 'http://www.iptorrents.com/torrents/?l%d=1%s&q=%s&qf=ti', + } + + cat_ids = [ + ([48], ['720p', '1080p', 'bd50']), + ([72], ['cam', 'ts', 'tc', 'r5', 'scr']), + ([7], ['dvdrip', 'brrip']), + ([6], ['dvdr']), + ] + + http_time_between_calls = 1 #seconds + cat_backup_id = None + + def _searchOnTitle(self, title, movie, quality, results): + + freeleech = '' if not self.conf('freeleech') else '&free=on' + + url = self.urls['search'] % (self.getCatId(quality['identifier'])[0], freeleech, tryUrlencode('%s %s' % (title.replace(':', ''), movie['library']['year']))) + data = self.getHTMLData(url, opener = self.login_opener) + + if data: + html = BeautifulSoup(data) + + try: + result_table = html.find('table', attrs = {'class' : 'torrents'}) + + if not result_table or 'nothing found!' in data.lower(): + return + + entries = result_table.find_all('tr') + + for result in entries[1:]: + + torrent = result.find_all('td')[1].find('a') + + torrent_id = torrent['href'].replace('/details.php?id=', '') + torrent_name = torrent.string + torrent_download_url = self.urls['base_url'] + (result.find_all('td')[3].find('a'))['href'].replace(' ', '.') + torrent_details_url = self.urls['base_url'] + torrent['href'] + torrent_size = self.parseSize(result.find_all('td')[5].string) + torrent_seeders = tryInt(result.find('td', attrs = {'class' : 'ac t_seeders'}).string) + torrent_leechers = tryInt(result.find('td', attrs = {'class' : 'ac t_leechers'}).string) + + results.append({ + 'id': torrent_id, + 'name': torrent_name, + 'url': torrent_download_url, + 'detail_url': torrent_details_url, + 'download': self.loginDownload, + 'size': torrent_size, + 'seeders': torrent_seeders, + 'leechers': torrent_leechers, + }) + + except: + log.error('Failed to parsing %s: %s', (self.getName(), traceback.format_exc())) + + def loginSuccess(self, output): + return 'don\'t have an account' not in output.lower() + + def getLoginParams(self): + return tryUrlencode({ + 'username': self.conf('username'), + 'password': self.conf('password'), + 'login': 'submit', + }) diff --git a/couchpotato/core/providers/torrent/thepiratebay/main.py b/couchpotato/core/providers/torrent/thepiratebay/main.py index 2a2433f0..1e47a710 100644 --- a/couchpotato/core/providers/torrent/thepiratebay/main.py +++ b/couchpotato/core/providers/torrent/thepiratebay/main.py @@ -68,8 +68,6 @@ class ThePirateBay(TorrentMagnetProvider): except: pass - print total_pages, page - entries = results_table.find_all('tr') for result in entries[2:]: link = result.find(href = re.compile('torrent\/\d+\/')) diff --git a/couchpotato/core/settings/model.py b/couchpotato/core/settings/model.py index 64117fb4..141ee4fe 100644 --- a/couchpotato/core/settings/model.py +++ b/couchpotato/core/settings/model.py @@ -45,7 +45,7 @@ class Movie(Entity): The files belonging to the movie object are global for the whole movie such as trailers, nfo, thumbnails""" - last_edit = Field(Integer, default = lambda: int(time.time())) + last_edit = Field(Integer, default = lambda: int(time.time()), index = True) library = ManyToOne('Library', cascade = 'delete, delete-orphan', single_parent = True) status = ManyToOne('Status') @@ -95,6 +95,7 @@ class Release(Entity): """Logically groups all files that belong to a certain release, such as parts of a movie, subtitles.""" + last_edit = Field(Integer, default = lambda: int(time.time()), index = True) identifier = Field(String(100), index = True) movie = ManyToOne('Movie') diff --git a/couchpotato/environment.py b/couchpotato/environment.py index d8c03c7c..bd637ad2 100644 --- a/couchpotato/environment.py +++ b/couchpotato/environment.py @@ -20,7 +20,7 @@ class Env(object): _options = None _args = None _quiet = False - _deamonize = False + _daemonized = False _desktop = None _session = None diff --git a/couchpotato/runner.py b/couchpotato/runner.py index 37152582..de538485 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -209,11 +209,12 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En # Basic config app.secret_key = api_key + host = Env.setting('host', default = '0.0.0.0') # app.debug = development config = { 'use_reloader': reloader, 'port': tryInt(Env.setting('port', default = 5000)), - 'host': Env.setting('host', default = ''), + 'host': host if host and len(host) > 0 else '0.0.0.0', 'ssl_cert': Env.setting('ssl_cert', default = None), 'ssl_key': Env.setting('ssl_key', default = None), } @@ -244,7 +245,8 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En (r'.*', FallbackHandler, dict(fallback = web_container)), ], log_function = lambda x : None, - debug = config['use_reloader'] + debug = config['use_reloader'], + gzip = True, ) if config['ssl_cert'] and config['ssl_key']: diff --git a/couchpotato/static/images/icon.readd.png b/couchpotato/static/images/icon.readd.png new file mode 100644 index 00000000..dacb432f Binary files /dev/null and b/couchpotato/static/images/icon.readd.png differ diff --git a/couchpotato/static/images/xbmc-notify.png b/couchpotato/static/images/xbmc-notify.png index 6b7959f4..5454fcd6 100644 Binary files a/couchpotato/static/images/xbmc-notify.png and b/couchpotato/static/images/xbmc-notify.png differ diff --git a/couchpotato/static/scripts/couchpotato.js b/couchpotato/static/scripts/couchpotato.js index a94f5d4e..d2e8fa00 100644 --- a/couchpotato/static/scripts/couchpotato.js +++ b/couchpotato/static/scripts/couchpotato.js @@ -3,7 +3,7 @@ var CouchPotato = new Class({ Implements: [Events, Options], defaults: { - page: 'wanted', + page: 'home', action: 'index', params: {} }, @@ -24,8 +24,8 @@ var CouchPotato = new Class({ if(window.location.hash) History.handleInitialState(); - - self.openPage(window.location.pathname); + else + self.openPage(window.location.pathname); History.addEvent('change', self.openPage.bind(self)); self.c.addEvent('click:relay(a[href^=/]:not([target]))', self.pushState.bind(self)); @@ -135,7 +135,7 @@ var CouchPotato = new Class({ self.current_page.hide() try { - var page = self.pages[page_name] || self.pages.Wanted; + var page = self.pages[page_name] || self.pages.Home; page.open(action, params, current_url); page.show(); } @@ -342,7 +342,14 @@ var Route = new Class({ parse: function(){ var self = this; - var path = History.getPath().replace(Api.getOption('url'), '/').replace(App.getOption('base_url'), '/') + var rep = function(pa){ + return pa.replace(Api.getOption('url'), '/').replace(App.getOption('base_url'), '/') + } + + var path = rep(History.getPath()) + if(path == '/' && location.hash){ + path = rep(location.hash.replace('#', '/')) + } self.current = path.replace(/^\/+|\/+$/g, '') var url = self.current.split('/') diff --git a/couchpotato/static/scripts/library/prefix_free.js b/couchpotato/static/scripts/library/prefix_free.js index 8dd99e2e..b6d9812a 100644 --- a/couchpotato/static/scripts/library/prefix_free.js +++ b/couchpotato/static/scripts/library/prefix_free.js @@ -24,6 +24,9 @@ var self = window.StyleFix = { var url = link.href || link.getAttribute('data-href'), base = url.replace(/[^\/]+$/, ''), + base_scheme = (/^[a-z]{3,10}:/.exec(base) || [''])[0], + base_domain = (/^[a-z]{3,10}:\/\/[^\/]+/.exec(base) || [''])[0], + base_query = /^([^?]*)\??/.exec(url)[1], parent = link.parentNode, xhr = new XMLHttpRequest(), process; @@ -43,12 +46,23 @@ var self = window.StyleFix = { // Convert relative URLs to absolute, if needed if(base) { css = css.replace(/url\(\s*?((?:"|')?)(.+?)\1\s*?\)/gi, function($0, quote, url) { - if(!/^([a-z]{3,10}:|\/|#)/i.test(url)) { // If url not absolute & not a hash + if(/^([a-z]{3,10}:|#)/i.test(url)) { // Absolute & or hash-relative + return $0; + } + else if(/^\/\//.test(url)) { // Scheme-relative // May contain sequences like /../ and /./ but those DO work + return 'url("' + base_scheme + url + '")'; + } + else if(/^\//.test(url)) { // Domain-relative + return 'url("' + base_domain + url + '")'; + } + else if(/^\?/.test(url)) { // Query-relative + return 'url("' + base_query + url + '")'; + } + else { + // Path-relative return 'url("' + base + url + '")'; } - - return $0; }); // behavior URLs shoudn’t be converted (Issue #19) @@ -470,4 +484,4 @@ root.className += ' ' + self.prefix; StyleFix.register(self.prefixCSS); -})(document.documentElement); +})(document.documentElement); \ No newline at end of file diff --git a/couchpotato/static/scripts/page/home.js b/couchpotato/static/scripts/page/home.js new file mode 100644 index 00000000..e3c1ae09 --- /dev/null +++ b/couchpotato/static/scripts/page/home.js @@ -0,0 +1,104 @@ +Page.Home = new Class({ + + Extends: PageBase, + + name: 'home', + title: 'Manage new stuff for things and such', + + indexAction: function(param){ + var self = this; + + if(self.soon_list){ + + // Reset lists + self.available_list.update(); + self.late_list.update(); + + return + } + + // Snatched + self.available_list = new MovieList({ + 'navigation': false, + 'identifier': 'snatched', + 'load_more': false, + 'view': 'list', + 'actions': [MA.IMDB, MA.Trailer, MA.Files, MA.Release, MA.Edit, MA.Readd, MA.Refresh, MA.Delete], + 'title': 'Snatched & Available', + 'on_empty_element': new Element('div'), + 'filter': { + 'release_status': 'snatched,available' + } + }); + + // Coming Soon + self.soon_list = new MovieList({ + 'navigation': false, + 'identifier': 'soon', + 'limit': 18, + 'title': 'Available soon', + 'description': 'These are being searches for and should be available soon as they will be released on DVD in the next few weeks.', + 'on_empty_element': new Element('div').adopt( + new Element('h1', {'text': 'Available soon'}), + new Element('span', {'text': 'There are no movies available soon. Add some movies, so you have something to watch later.'}) + ), + 'filter': { + 'random': true + }, + 'actions': [MA.IMDB, MA.Refresh], + 'load_more': false, + 'view': 'thumbs', + 'api_call': 'dashboard.soon' + }); + + // Still not available + self.late_list = new MovieList({ + 'navigation': false, + 'identifier': 'late', + 'limit': 50, + 'title': 'Still not available', + 'description': 'Try another quality profile or maybe add more providers in Settings.', + 'on_empty_element': new Element('div'), + 'filter': { + 'late': true + }, + 'load_more': false, + 'view': 'list', + 'actions': [MA.IMDB, MA.Trailer, MA.Edit, MA.Refresh, MA.Delete], + 'api_call': 'dashboard.soon' + }); + + self.el.adopt( + $(self.available_list), + $(self.soon_list), + $(self.late_list) + ); + + // Suggest + // self.suggestion_list = new MovieList({ + // 'navigation': false, + // 'identifier': 'suggestions', + // 'limit': 6, + // 'load_more': false, + // 'view': 'thumbs', + // 'api_call': 'suggestion.suggest' + // }); + // self.el.adopt( + // new Element('h2', { + // 'text': 'You might like' + // }), + // $(self.suggestion_list) + // ); + + // Recent + // Snatched + // Renamed + // Added + + // Free space + + // Shortcuts + + } + +}) \ No newline at end of file diff --git a/couchpotato/static/scripts/page/manage.js b/couchpotato/static/scripts/page/manage.js index c06c655b..aef1f3c7 100644 --- a/couchpotato/static/scripts/page/manage.js +++ b/couchpotato/static/scripts/page/manage.js @@ -27,8 +27,10 @@ Page.Manage = new Class({ self.list = new MovieList({ 'identifier': 'manage', - 'status': 'done', - 'actions': MovieActions, + 'filter': { + 'release_status': 'done' + }, + 'actions': [MA.IMDB, MA.Trailer, MA.Files, MA.Readd, MA.Edit, MA.Delete], 'menu': [self.refresh_button, self.refresh_quick], 'on_empty_element': new Element('div.empty_manage').adopt( new Element('div', { @@ -88,7 +90,7 @@ Page.Manage = new Class({ 'onComplete': function(json){ self.update_in_progress = true; - if(!json.progress){ + if(!json || !json.progress){ clearInterval(self.progress_interval); self.update_in_progress = false; if(self.progress_container){ diff --git a/couchpotato/static/scripts/page/soon.js b/couchpotato/static/scripts/page/soon.js deleted file mode 100644 index eeef446c..00000000 --- a/couchpotato/static/scripts/page/soon.js +++ /dev/null @@ -1,8 +0,0 @@ -Page.Soon = new Class({ - - Extends: PageBase, - - name: 'soon', - title: 'Which wanted movies are released soon?' - -}) \ No newline at end of file diff --git a/couchpotato/static/scripts/page/wanted.js b/couchpotato/static/scripts/page/wanted.js index 3f6e065b..6e329973 100644 --- a/couchpotato/static/scripts/page/wanted.js +++ b/couchpotato/static/scripts/page/wanted.js @@ -22,7 +22,7 @@ Page.Wanted = new Class({ self.wanted = new MovieList({ 'identifier': 'wanted', 'status': 'active', - 'actions': MovieActions, + 'actions': [MA.IMDB, MA.Trailer, MA.Release, MA.Edit, MA.Refresh, MA.Readd, MA.Delete], 'add_new': true, 'menu': [self.manual_search], 'on_empty_element': App.createUserscriptButtons().addClass('empty_wanted') @@ -52,7 +52,8 @@ Page.Wanted = new Class({ var start_text = self.manual_search.get('text'); self.progress_interval = setInterval(function(){ - Api.request('searcher.progress', { + if(self.search_progress && self.search_progress.running) return; + self.search_progress = Api.request('searcher.progress', { 'onComplete': function(json){ self.search_in_progress = true; if(!json.progress){ @@ -65,288 +66,9 @@ Page.Wanted = new Class({ self.manual_search.set('text', 'Searching.. (' + (((progress.total-progress.to_go)/progress.total)*100).round() + '%)'); } } - }) + }); }, 1000); } -}); - -var MovieActions = {}; -window.addEvent('domready', function(){ - - MovieActions.Wanted = { - 'IMDB': IMDBAction - ,'Trailer': TrailerAction - ,'Releases': ReleaseAction - ,'Edit': new Class({ - - Extends: MovieAction, - - create: function(){ - var self = this; - - self.el = new Element('a.edit', { - 'title': 'Change movie information, like title and quality.', - 'events': { - 'click': self.editMovie.bind(self) - } - }); - - }, - - editMovie: function(e){ - var self = this; - (e).preventDefault(); - - if(!self.options_container){ - self.options_container = new Element('div.options').adopt( - new Element('div.form').adopt( - self.title_select = new Element('select', { - 'name': 'title' - }), - self.profile_select = new Element('select', { - 'name': 'profile' - }), - new Element('a.button.edit', { - 'text': 'Save & Search', - 'events': { - 'click': self.save.bind(self) - } - }) - ) - ).inject(self.movie, 'top'); - - Array.each(self.movie.data.library.titles, function(alt){ - new Element('option', { - 'text': alt.title - }).inject(self.title_select); - - if(alt['default']) - self.title_select.set('value', alt.title); - }); - - - Quality.getActiveProfiles().each(function(profile){ - - var profile_id = profile.id ? profile.id : profile.data.id; - - new Element('option', { - 'value': profile_id, - 'text': profile.label ? profile.label : profile.data.label - }).inject(self.profile_select); - - if(self.movie.profile && self.movie.profile.data && self.movie.profile.data.id == profile_id) - self.profile_select.set('value', profile_id); - }); - - } - - self.movie.slide('in', self.options_container); - }, - - save: function(e){ - (e).preventDefault(); - var self = this; - - Api.request('movie.edit', { - 'data': { - 'id': self.movie.get('id'), - 'default_title': self.title_select.get('value'), - 'profile_id': self.profile_select.get('value') - }, - 'useSpinner': true, - 'spinnerTarget': $(self.movie), - 'onComplete': function(){ - self.movie.quality.set('text', self.profile_select.getSelected()[0].get('text')); - self.movie.title.set('text', self.title_select.getSelected()[0].get('text')); - } - }); - - self.movie.slide('out'); - } - - }) - - ,'Refresh': new Class({ - - Extends: MovieAction, - - create: function(){ - var self = this; - - self.el = new Element('a.refresh', { - 'title': 'Refresh the movie info and do a forced search', - 'events': { - 'click': self.doRefresh.bind(self) - } - }); - - }, - - doRefresh: function(e){ - var self = this; - (e).preventDefault(); - - Api.request('movie.refresh', { - 'data': { - 'id': self.movie.get('id') - } - }); - } - - }) - - ,'Delete': new Class({ - - Extends: MovieAction, - - Implements: [Chain], - - create: function(){ - var self = this; - - self.el = new Element('a.delete', { - 'title': 'Remove the movie from this CP list', - 'events': { - 'click': self.showConfirm.bind(self) - } - }); - - }, - - showConfirm: function(e){ - var self = this; - (e).preventDefault(); - - if(!self.delete_container){ - self.delete_container = new Element('div.buttons.delete_container').adopt( - new Element('a.cancel', { - 'text': 'Cancel', - 'events': { - 'click': self.hideConfirm.bind(self) - } - }), - new Element('span.or', { - 'text': 'or' - }), - new Element('a.button.delete', { - 'text': 'Delete ' + self.movie.title.get('text'), - 'events': { - 'click': self.del.bind(self) - } - }) - ).inject(self.movie, 'top'); - } - - self.movie.slide('in', self.delete_container); - - }, - - hideConfirm: function(e){ - var self = this; - (e).preventDefault(); - - self.movie.slide('out'); - }, - - del: function(e){ - (e).preventDefault(); - var self = this; - - var movie = $(self.movie); - - self.chain( - function(){ - self.callChain(); - }, - function(){ - Api.request('movie.delete', { - 'data': { - 'id': self.movie.get('id'), - 'delete_from': self.movie.list.options.identifier - }, - 'onComplete': function(){ - movie.set('tween', { - 'duration': 300, - 'onComplete': function(){ - self.movie.destroy() - } - }); - movie.tween('height', 0); - } - }); - } - ); - - self.callChain(); - - } - - }) - }; - - MovieActions.Snatched = { - 'IMDB': IMDBAction - ,'Delete': MovieActions.Wanted.Delete - }; - - MovieActions.Done = { - 'IMDB': IMDBAction - ,'Edit': MovieActions.Wanted.Edit - ,'Trailer': TrailerAction - ,'Files': new Class({ - - Extends: MovieAction, - - create: function(){ - var self = this; - - self.el = new Element('a.directory', { - 'title': 'Available files', - 'events': { - 'click': self.showFiles.bind(self) - } - }); - - }, - - showFiles: function(e){ - var self = this; - (e).preventDefault(); - - if(!self.options_container){ - self.options_container = new Element('div.options').adopt( - self.files_container = new Element('div.files.table') - ).inject(self.movie, 'top'); - - // Header - new Element('div.item.head').adopt( - new Element('span.name', {'text': 'File'}), - new Element('span.type', {'text': 'Type'}), - new Element('span.is_available', {'text': 'Available'}) - ).inject(self.files_container) - - Array.each(self.movie.data.releases, function(release){ - - var rel = new Element('div.release').inject(self.files_container); - - Array.each(release.files, function(file){ - new Element('div.file.item').adopt( - new Element('span.name', {'text': file.path}), - new Element('span.type', {'text': File.Type.get(file.type_id).name}), - new Element('span.available', {'text': file.available}) - ).inject(rel) - }); - }); - - } - - self.movie.slide('in', self.options_container); - }, - - }) - ,'Delete': MovieActions.Wanted.Delete - }; - -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index 97ca1837..3ad502b9 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -80,6 +80,12 @@ a:hover { color: #f3f3f3; } padding: 80px 0 10px; } +h2 { + font-size: 30px; + padding: 0; + margin: 20px 0 0 0; +} + .footer { text-align:center; padding: 50px 0 0 0; @@ -151,6 +157,7 @@ body > .spinner, .mask{ .icon.folder { background-image: url('../images/icon.folder.png'); } .icon.imdb { background-image: url('../images/icon.imdb.png'); } .icon.refresh { background-image: url('../images/icon.refresh.png'); } +.icon.readd { background-image: url('../images/icon.readd.png'); } .icon.rating { background-image: url('../images/icon.rating.png'); } .icon.files { background-image: url('../images/icon.files.png'); } .icon.info { background-image: url('../images/icon.info.png'); } @@ -578,7 +585,7 @@ body > .spinner, .mask{ bottom: 0; padding: 2px; width: 240px; - z-index: 2; + z-index: 20; overflow: hidden; font-size: 14px; font-weight: bold; diff --git a/couchpotato/static/style/page/settings.css b/couchpotato/static/style/settings.css similarity index 96% rename from couchpotato/static/style/page/settings.css rename to couchpotato/static/style/settings.css index bcd0b774..3af7dba7 100644 --- a/couchpotato/static/style/page/settings.css +++ b/couchpotato/static/style/settings.css @@ -118,7 +118,7 @@ border: 0; } .page .ctrlHolder.save_success:not(:first-child) { - background: url('../../images/icon.check.png') no-repeat 7px center; + background: url('../images/icon.check.png') no-repeat 7px center; } .page .ctrlHolder:last-child { border: none; } .page .ctrlHolder:hover { background-color: rgba(255,255,255,0.05); } @@ -250,7 +250,7 @@ padding: 0 4% 0 4px; font-size: 13px; width: 30%; - background-image: url('../../images/icon.folder.gif'); + background-image: url('../images/icon.folder.gif'); background-repeat: no-repeat; background-position: 97% center; overflow: hidden; @@ -298,7 +298,7 @@ cursor: pointer; margin: 0 !important; border-top: 1px solid rgba(255,255,255,0.1); - background: url('../../images/right.arrow.png') no-repeat 98% center; + background: url('../images/right.arrow.png') no-repeat 98% center; } .page .directory_list li:last-child { border-bottom: 1px solid rgba(255,255,255,0.1); @@ -484,7 +484,7 @@ margin: -9px 0 0 -16px; border-radius: 30px 30px 0 0; cursor: pointer; - background: url('../../images/icon.delete.png') no-repeat center 2px, -*-linear-gradient( + background: url('../images/icon.delete.png') no-repeat center 2px, -*-linear-gradient( 270deg, #5b9bd1 0%, #5b9bd1 100% @@ -558,7 +558,7 @@ } .page .tab_about .usenet li { - background: url('../../images/icon.check.png') no-repeat left center; + background: url('../images/icon.check.png') no-repeat left center; padding: 0 0 0 25px; } @@ -646,6 +646,6 @@ } .active .group_imdb_automation:not(.disabled) { - background: url('../../images/imdb_watchlist.png') no-repeat right 50px; + background: url('../images/imdb_watchlist.png') no-repeat right 50px; min-height: 210px; } \ No newline at end of file diff --git a/couchpotato/templates/_desktop.html b/couchpotato/templates/_desktop.html index 8689b666..1d618066 100644 --- a/couchpotato/templates/_desktop.html +++ b/couchpotato/templates/_desktop.html @@ -1,43 +1,14 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for url in fireEvent('clientscript.get_scripts', as_html = True, single = True) %} + {% for url in fireEvent('clientscript.get_styles', as_html = True, location = 'front', single = True) %} + {% endfor %} + {% for url in fireEvent('clientscript.get_scripts', as_html = True, location = 'front', single = True) %} {% endfor %} - {% for url in fireEvent('clientscript.get_styles', as_html = True, single = True) %} + + {% for url in fireEvent('clientscript.get_scripts', as_html = True, location = 'head', single = True) %} + {% endfor %} + {% for url in fireEvent('clientscript.get_styles', as_html = True, location = 'head', single = True) %} {% endfor %} diff --git a/init/fedora b/init/fedora index 791b676d..47352471 100644 --- a/init/fedora +++ b/init/fedora @@ -14,6 +14,11 @@ prog=couchpotato lockfile=/var/lock/subsys/$prog +# Source couchpotato configuration +if [ -f /etc/sysconfig/couchpotato ]; then + . /etc/sysconfig/couchpotato +fi + ## Edit user configuation in /etc/sysconfig/couchpotato to change ## the defaults username=${CP_USER-couchpotato} @@ -22,11 +27,6 @@ datadir=${CP_DATA-~/.couchpotato} pidfile=${CP_PIDFILE-/var/run/couchpotato/couchpotato.pid} ## -# Source couchpotato configuration -if [ -f /etc/sysconfig/couchpotato ]; then - . /etc/sysconfig/couchpotato -fi - pidpath=`dirname ${pidfile}` options=" --daemon --pid_file=${pidfile} --data_dir=${datadir}" @@ -87,4 +87,4 @@ case "$1" in *) echo $"Usage: $0 {start|stop|status|restart|try-restart|force-reload}" exit 2 -esac \ No newline at end of file +esac diff --git a/init/ubuntu b/init/ubuntu index d6af148a..376c001f 100644 --- a/init/ubuntu +++ b/init/ubuntu @@ -12,51 +12,56 @@ # Description: starts instance of CouchPotato using start-stop-daemon ### END INIT INFO -############### EDIT ME ################## -# path to app -APP_PATH=/usr/local/sbin/CouchPotatoServer/ +# Check for existance of defaults file +# and utilze if available +if [ -f /etc/default/couchpotato ]; then + . /etc/default/couchpotato +else + echo "/etc/default/couchpotato not found using default settings."; +fi -# user -RUN_AS=YOUR_USERNAME_HERE - -# path to python bin -DAEMON=/usr/bin/python - -# Path to store PID file -PID_FILE=/var/run/couchpotato/server.pid -PID_PATH=$(dirname $PID_FILE) - -# script name +# Script name NAME=couchpotato -# app name +# App name DESC=CouchPotato -# startup args -DAEMON_OPTS=" CouchPotato.py --daemon --pid_file=${PID_FILE}" +# Path to app root +CP_APP_PATH=${APP_PATH-/usr/local/sbin/CouchPotatoServer/} -############### END EDIT ME ################## +# User to run CP as +CP_RUN_AS=${RUN_AS-root} -test -x $DAEMON || exit 0 +# Path to python bin +CP_DAEMON=${DAEMON_PATH-/usr/bin/python} + +# Path to store PID file +CP_PID_FILE=${PID_FILE-/var/run/couchpotato.pid} + +# Other startup args +CP_DAEMON_OPTS=" CouchPotato.py --daemon --pid_file=${CP_PID_FILE}" + +test -x $CP_DAEMON || exit 0 set -e case "$1" in start) echo "Starting $DESC" - rm -rf $PID_PATH || return 1 - install -d --mode=0755 -o $RUN_AS $PID_PATH || return 1 - start-stop-daemon -d $APP_PATH -c $RUN_AS --start --background --pidfile $PID_FILE --exec $DAEMON -- $DAEMON_OPTS + rm -rf $CP_PID_FILE || return 1 + touch $CP_PID_FILE + chown $CP_RUN_AS $CP_PID_FILE + start-stop-daemon -d $CP_APP_PATH -c $CP_RUN_AS --start --background --pidfile $CP_PID_FILE --exec $CP_DAEMON -- $CP_DAEMON_OPTS ;; stop) echo "Stopping $DESC" - start-stop-daemon --stop --pidfile $PID_FILE --retry 15 + start-stop-daemon --stop --pidfile $CP_PID_FILE --retry 15 ;; restart|force-reload) echo "Restarting $DESC" - start-stop-daemon --stop --pidfile $PID_FILE --retry 15 - start-stop-daemon -d $APP_PATH -c $RUN_AS --start --background --pidfile $PID_FILE --exec $DAEMON -- $DAEMON_OPTS + start-stop-daemon --stop --pidfile $CP_PID_FILE --retry 15 + start-stop-daemon -d $CP_APP_PATH -c $CP_RUN_AS --start --background --pidfile $CP_PID_FILE --exec $CP_DAEMON -- $CP_DAEMON_OPTS ;; *) N=/etc/init.d/$NAME diff --git a/init/ubuntu.default b/init/ubuntu.default new file mode 100644 index 00000000..0d1e7128 --- /dev/null +++ b/init/ubuntu.default @@ -0,0 +1,5 @@ +# COPY THIS FILE TO /etc/default/couchpotato +# OPTIONS: APP_PATH, RUN_AS, DAEMON_PATH, CP_PID_FILE + +APP_PATH= +RUN_AS=root \ No newline at end of file diff --git a/libs/daemon.py b/libs/daemon.py index 0e3d0d63..805cfa24 100644 --- a/libs/daemon.py +++ b/libs/daemon.py @@ -92,6 +92,7 @@ class Daemon(): """ Stop the daemon """ + # Get the pid from the pidfile try: pf = file(self.pidfile, 'r') @@ -115,7 +116,6 @@ class Daemon(): if err.find("No such process") > 0: self.delpid() else: - print str(err) sys.exit(1) def restart(self): diff --git a/libs/minify/__init__.py b/libs/minify/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/minify/cssmin.py b/libs/minify/cssmin.py new file mode 100644 index 00000000..c29cb83b --- /dev/null +++ b/libs/minify/cssmin.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# `cssmin.py` - A Python port of the YUI CSS compressor. + + +from StringIO import StringIO # The pure-Python StringIO supports unicode. +import re + + +__version__ = '0.1.1' + + +def remove_comments(css): + """Remove all CSS comment blocks.""" + + iemac = False + preserve = False + comment_start = css.find("/*") + while comment_start >= 0: + # Preserve comments that look like `/*!...*/`. + # Slicing is used to make sure we don"t get an IndexError. + preserve = css[comment_start + 2:comment_start + 3] == "!" + + comment_end = css.find("*/", comment_start + 2) + if comment_end < 0: + if not preserve: + css = css[:comment_start] + break + elif comment_end >= (comment_start + 2): + if css[comment_end - 1] == "\\": + # This is an IE Mac-specific comment; leave this one and the + # following one alone. + comment_start = comment_end + 2 + iemac = True + elif iemac: + comment_start = comment_end + 2 + iemac = False + elif not preserve: + css = css[:comment_start] + css[comment_end + 2:] + else: + comment_start = comment_end + 2 + comment_start = css.find("/*", comment_start) + + return css + + +def remove_unnecessary_whitespace(css): + """Remove unnecessary whitespace characters.""" + + def pseudoclasscolon(css): + + """ + Prevents 'p :link' from becoming 'p:link'. + + Translates 'p :link' into 'p ___PSEUDOCLASSCOLON___link'; this is + translated back again later. + """ + + regex = re.compile(r"(^|\})(([^\{\:])+\:)+([^\{]*\{)") + match = regex.search(css) + while match: + css = ''.join([ + css[:match.start()], + match.group().replace(":", "___PSEUDOCLASSCOLON___"), + css[match.end():]]) + match = regex.search(css) + return css + + css = pseudoclasscolon(css) + # Remove spaces from before things. + css = re.sub(r"\s+([!{};:>+\(\)\],])", r"\1", css) + + # If there is a `@charset`, then only allow one, and move to the beginning. + css = re.sub(r"^(.*)(@charset \"[^\"]*\";)", r"\2\1", css) + css = re.sub(r"^(\s*@charset [^;]+;\s*)+", r"\1", css) + + # Put the space back in for a few cases, such as `@media screen` and + # `(-webkit-min-device-pixel-ratio:0)`. + css = re.sub(r"\band\(", "and (", css) + + # Put the colons back. + css = css.replace('___PSEUDOCLASSCOLON___', ':') + + # Remove spaces from after things. + css = re.sub(r"([!{}:;>+\(\[,])\s+", r"\1", css) + + return css + + +def remove_unnecessary_semicolons(css): + """Remove unnecessary semicolons.""" + + return re.sub(r";+\}", "}", css) + + +def remove_empty_rules(css): + """Remove empty rules.""" + + return re.sub(r"[^\}\{]+\{\}", "", css) + + +def normalize_rgb_colors_to_hex(css): + """Convert `rgb(51,102,153)` to `#336699`.""" + + regex = re.compile(r"rgb\s*\(\s*([0-9,\s]+)\s*\)") + match = regex.search(css) + while match: + colors = match.group(1).split(",") + hexcolor = '#%.2x%.2x%.2x' % tuple(map(int, colors)) + css = css.replace(match.group(), hexcolor) + match = regex.search(css) + return css + + +def condense_zero_units(css): + """Replace `0(px, em, %, etc)` with `0`.""" + + return re.sub(r"([\s:])(0)(px|em|%|in|cm|mm|pc|pt|ex)", r"\1\2", css) + + +def condense_multidimensional_zeros(css): + """Replace `:0 0 0 0;`, `:0 0 0;` etc. with `:0;`.""" + + css = css.replace(":0 0 0 0;", ":0;") + css = css.replace(":0 0 0;", ":0;") + css = css.replace(":0 0;", ":0;") + + # Revert `background-position:0;` to the valid `background-position:0 0;`. + css = css.replace("background-position:0;", "background-position:0 0;") + + return css + + +def condense_floating_points(css): + """Replace `0.6` with `.6` where possible.""" + + return re.sub(r"(:|\s)0+\.(\d+)", r"\1.\2", css) + + +def condense_hex_colors(css): + """Shorten colors from #AABBCC to #ABC where possible.""" + + regex = re.compile(r"([^\"'=\s])(\s*)#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])") + match = regex.search(css) + while match: + first = match.group(3) + match.group(5) + match.group(7) + second = match.group(4) + match.group(6) + match.group(8) + if first.lower() == second.lower(): + css = css.replace(match.group(), match.group(1) + match.group(2) + '#' + first) + match = regex.search(css, match.end() - 3) + else: + match = regex.search(css, match.end()) + return css + + +def condense_whitespace(css): + """Condense multiple adjacent whitespace characters into one.""" + + return re.sub(r"\s+", " ", css) + + +def condense_semicolons(css): + """Condense multiple adjacent semicolon characters into one.""" + + return re.sub(r";;+", ";", css) + + +def wrap_css_lines(css, line_length): + """Wrap the lines of the given CSS to an approximate length.""" + + lines = [] + line_start = 0 + for i, char in enumerate(css): + # It's safe to break after `}` characters. + if char == '}' and (i - line_start >= line_length): + lines.append(css[line_start:i + 1]) + line_start = i + 1 + + if line_start < len(css): + lines.append(css[line_start:]) + return '\n'.join(lines) + + +def cssmin(css, wrap = None): + css = remove_comments(css) + css = condense_whitespace(css) + # A pseudo class for the Box Model Hack + # (see http://tantek.com/CSS/Examples/boxmodelhack.html) + css = css.replace('"\\"}\\""', "___PSEUDOCLASSBMH___") + #css = remove_unnecessary_whitespace(css) + css = remove_unnecessary_semicolons(css) + css = condense_zero_units(css) + css = condense_multidimensional_zeros(css) + css = condense_floating_points(css) + css = normalize_rgb_colors_to_hex(css) + css = condense_hex_colors(css) + if wrap is not None: + css = wrap_css_lines(css, wrap) + css = css.replace("___PSEUDOCLASSBMH___", '"\\"}\\""') + css = condense_semicolons(css) + return css.strip() + + +def main(): + import optparse + import sys + + p = optparse.OptionParser( + prog = "cssmin", version = __version__, + usage = "%prog [--wrap N]", + description = """Reads raw CSS from stdin, and writes compressed CSS to stdout.""") + + p.add_option( + '-w', '--wrap', type = 'int', default = None, metavar = 'N', + help = "Wrap output to approximately N chars per line.") + + options, args = p.parse_args() + sys.stdout.write(cssmin(sys.stdin.read(), wrap = options.wrap)) + + +if __name__ == '__main__': + main() diff --git a/libs/minify/jsmin.py b/libs/minify/jsmin.py new file mode 100644 index 00000000..a1b81f9a --- /dev/null +++ b/libs/minify/jsmin.py @@ -0,0 +1,218 @@ +#!/usr/bin/python + +# This code is original from jsmin by Douglas Crockford, it was translated to +# Python by Baruch Even. The original code had the following copyright and +# license. +# +# /* jsmin.c +# 2007-05-22 +# +# Copyright (c) 2002 Douglas Crockford (www.crockford.com) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# The Software shall be used for Good, not Evil. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# */ + +from StringIO import StringIO + +def jsmin(js): + ins = StringIO(js) + outs = StringIO() + JavascriptMinify().minify(ins, outs) + str = outs.getvalue() + if len(str) > 0 and str[0] == '\n': + str = str[1:] + return str + +def isAlphanum(c): + """return true if the character is a letter, digit, underscore, + dollar sign, or non-ASCII character. + """ + return ((c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or + (c >= 'A' and c <= 'Z') or c == '_' or c == '$' or c == '\\' or (c is not None and ord(c) > 126)); + +class UnterminatedComment(Exception): + pass + +class UnterminatedStringLiteral(Exception): + pass + +class UnterminatedRegularExpression(Exception): + pass + +class JavascriptMinify(object): + + def _outA(self): + self.outstream.write(self.theA) + def _outB(self): + self.outstream.write(self.theB) + + def _get(self): + """return the next character from stdin. Watch out for lookahead. If + the character is a control character, translate it to a space or + linefeed. + """ + c = self.theLookahead + self.theLookahead = None + if c == None: + c = self.instream.read(1) + if c >= ' ' or c == '\n': + return c + if c == '': # EOF + return '\000' + if c == '\r': + return '\n' + return ' ' + + def _peek(self): + self.theLookahead = self._get() + return self.theLookahead + + def _next(self): + """get the next character, excluding comments. peek() is used to see + if a '/' is followed by a '/' or '*'. + """ + c = self._get() + if c == '/': + p = self._peek() + if p == '/': + c = self._get() + while c > '\n': + c = self._get() + return c + if p == '*': + c = self._get() + while 1: + c = self._get() + if c == '*': + if self._peek() == '/': + self._get() + return ' ' + if c == '\000': + raise UnterminatedComment() + + return c + + def _action(self, action): + """do something! What you do is determined by the argument: + 1 Output A. Copy B to A. Get the next B. + 2 Copy B to A. Get the next B. (Delete A). + 3 Get the next B. (Delete B). + action treats a string as a single character. Wow! + action recognizes a regular expression if it is preceded by ( or , or =. + """ + if action <= 1: + self._outA() + + if action <= 2: + self.theA = self.theB + if self.theA == "'" or self.theA == '"': + while 1: + self._outA() + self.theA = self._get() + if self.theA == self.theB: + break + if self.theA <= '\n': + raise UnterminatedStringLiteral() + if self.theA == '\\': + self._outA() + self.theA = self._get() + + + if action <= 3: + self.theB = self._next() + if self.theB == '/' and (self.theA == '(' or self.theA == ',' or + self.theA == '=' or self.theA == ':' or + self.theA == '[' or self.theA == '?' or + self.theA == '!' or self.theA == '&' or + self.theA == '|' or self.theA == ';' or + self.theA == '{' or self.theA == '}' or + self.theA == '\n'): + self._outA() + self._outB() + while 1: + self.theA = self._get() + if self.theA == '/': + break + elif self.theA == '\\': + self._outA() + self.theA = self._get() + elif self.theA <= '\n': + raise UnterminatedRegularExpression() + self._outA() + self.theB = self._next() + + + def _jsmin(self): + """Copy the input to the output, deleting the characters which are + insignificant to JavaScript. Comments will be removed. Tabs will be + replaced with spaces. Carriage returns will be replaced with linefeeds. + Most spaces and linefeeds will be removed. + """ + self.theA = '\n' + self._action(3) + + while self.theA != '\000': + if self.theA == ' ': + if isAlphanum(self.theB): + self._action(1) + else: + self._action(2) + elif self.theA == '\n': + if self.theB in ['{', '[', '(', '+', '-']: + self._action(1) + elif self.theB == ' ': + self._action(3) + else: + if isAlphanum(self.theB): + self._action(1) + else: + self._action(2) + else: + if self.theB == ' ': + if isAlphanum(self.theA): + self._action(1) + else: + self._action(3) + elif self.theB == '\n': + if self.theA in ['}', ']', ')', '+', '-', '"', '\'']: + self._action(1) + else: + if isAlphanum(self.theA): + self._action(1) + else: + self._action(3) + else: + self._action(1) + + def minify(self, instream, outstream): + self.instream = instream + self.outstream = outstream + self.theA = '\n' + self.theB = None + self.theLookahead = None + + self._jsmin() + self.instream.close() + +if __name__ == '__main__': + import sys + jsm = JavascriptMinify() + jsm.minify(sys.stdin, sys.stdout) diff --git a/libs/tornado/__init__.py b/libs/tornado/__init__.py index 2d1bba88..609e2c05 100755 --- a/libs/tornado/__init__.py +++ b/libs/tornado/__init__.py @@ -16,7 +16,7 @@ """The Tornado web server and tools.""" -from __future__ import absolute_import, division, with_statement +from __future__ import absolute_import, division, print_function, with_statement # version is a human-readable version number. @@ -25,5 +25,5 @@ from __future__ import absolute_import, division, with_statement # is zero for an official release, positive for a development branch, # or negative for a release candidate (after the base version number # has been incremented) -version = "2.4.post2" -version_info = (2, 4, 0, 2) +version = "2.4.post3" +version_info = (2, 4, 0, 3) diff --git a/libs/tornado/auth.py b/libs/tornado/auth.py index 964534fa..0ff32cb2 100755 --- a/libs/tornado/auth.py +++ b/libs/tornado/auth.py @@ -44,23 +44,63 @@ Example usage for Google OpenID:: # Save the user with, e.g., set_secure_cookie() """ -from __future__ import absolute_import, division, with_statement +from __future__ import absolute_import, division, print_function, with_statement import base64 import binascii +import functools import hashlib import hmac import time -import urllib -import urlparse import uuid +from tornado.concurrent import Future, chain_future, return_future +from tornado import gen from tornado import httpclient from tornado import escape from tornado.httputil import url_concat from tornado.log import gen_log -from tornado.util import bytes_type, b +from tornado.util import bytes_type, u, unicode_type, ArgReplacer +try: + import urlparse # py2 +except ImportError: + import urllib.parse as urlparse # py3 + +try: + import urllib.parse as urllib_parse # py3 +except ImportError: + import urllib as urllib_parse # py2 + +class AuthError(Exception): + pass + +def _auth_future_to_callback(callback, future): + try: + result = future.result() + except AuthError as e: + gen_log.warning(str(e)) + result = None + callback(result) + +def _auth_return_future(f): + """Similar to tornado.concurrent.return_future, but uses the auth + module's legacy callback interface. + + Note that when using this decorator the ``callback`` parameter + inside the function will actually be a future. + """ + replacer = ArgReplacer(f, 'callback') + @functools.wraps(f) + def wrapper(*args, **kwargs): + future = Future() + callback, args, kwargs = replacer.replace(future, args, kwargs) + if callback is not None: + future.add_done_callback( + functools.partial(_auth_future_to_callback, callback)) + f(*args, **kwargs) + return future + return wrapper class OpenIdMixin(object): """Abstract implementation of OpenID and Attribute Exchange. @@ -81,8 +121,9 @@ class OpenIdMixin(object): """ callback_uri = callback_uri or self.request.uri args = self._openid_args(callback_uri, ax_attrs=ax_attrs) - self.redirect(self._OPENID_ENDPOINT + "?" + urllib.urlencode(args)) + self.redirect(self._OPENID_ENDPOINT + "?" + urllib_parse.urlencode(args)) + @_auth_return_future def get_authenticated_user(self, callback, http_client=None): """Fetches the authenticated user data upon redirect. @@ -91,23 +132,23 @@ class OpenIdMixin(object): methods. """ # Verify the OpenID response via direct request to the OP - args = dict((k, v[-1]) for k, v in self.request.arguments.iteritems()) - args["openid.mode"] = u"check_authentication" + args = dict((k, v[-1]) for k, v in self.request.arguments.items()) + args["openid.mode"] = u("check_authentication") url = self._OPENID_ENDPOINT if http_client is None: http_client = self.get_auth_http_client() http_client.fetch(url, self.async_callback( self._on_authentication_verified, callback), - method="POST", body=urllib.urlencode(args)) + method="POST", body=urllib_parse.urlencode(args)) def _openid_args(self, callback_uri, ax_attrs=[], oauth_scope=None): url = urlparse.urljoin(self.request.full_url(), callback_uri) args = { "openid.ns": "http://specs.openid.net/auth/2.0", "openid.claimed_id": - "http://specs.openid.net/auth/2.0/identifier_select", + "http://specs.openid.net/auth/2.0/identifier_select", "openid.identity": - "http://specs.openid.net/auth/2.0/identifier_select", + "http://specs.openid.net/auth/2.0/identifier_select", "openid.return_to": url, "openid.realm": urlparse.urljoin(url, '/'), "openid.mode": "checkid_setup", @@ -124,11 +165,11 @@ class OpenIdMixin(object): required += ["firstname", "fullname", "lastname"] args.update({ "openid.ax.type.firstname": - "http://axschema.org/namePerson/first", + "http://axschema.org/namePerson/first", "openid.ax.type.fullname": - "http://axschema.org/namePerson", + "http://axschema.org/namePerson", "openid.ax.type.lastname": - "http://axschema.org/namePerson/last", + "http://axschema.org/namePerson/last", }) known_attrs = { "email": "http://axschema.org/contact/email", @@ -142,40 +183,40 @@ class OpenIdMixin(object): if oauth_scope: args.update({ "openid.ns.oauth": - "http://specs.openid.net/extensions/oauth/1.0", + "http://specs.openid.net/extensions/oauth/1.0", "openid.oauth.consumer": self.request.host.split(":")[0], "openid.oauth.scope": oauth_scope, }) return args - def _on_authentication_verified(self, callback, response): - if response.error or b("is_valid:true") not in response.body: - gen_log.warning("Invalid OpenID response: %s", response.error or - response.body) - callback(None) + def _on_authentication_verified(self, future, response): + if response.error or b"is_valid:true" not in response.body: + future.set_exception(AuthError( + "Invalid OpenID response: %s" % (response.error or + response.body))) return # Make sure we got back at least an email from attribute exchange ax_ns = None - for name in self.request.arguments.iterkeys(): + for name in self.request.arguments: if name.startswith("openid.ns.") and \ - self.get_argument(name) == u"http://openid.net/srv/ax/1.0": + self.get_argument(name) == u("http://openid.net/srv/ax/1.0"): ax_ns = name[10:] break def get_ax_arg(uri): if not ax_ns: - return u"" + return u("") prefix = "openid." + ax_ns + ".type." ax_name = None - for name in self.request.arguments.iterkeys(): + for name in self.request.arguments.keys(): if self.get_argument(name) == uri and name.startswith(prefix): part = name[len(prefix):] ax_name = "openid." + ax_ns + ".value." + part break if not ax_name: - return u"" - return self.get_argument(ax_name, u"") + return u("") + return self.get_argument(ax_name, u("")) email = get_ax_arg("http://axschema.org/contact/email") name = get_ax_arg("http://axschema.org/namePerson") @@ -194,7 +235,7 @@ class OpenIdMixin(object): if name: user["name"] = name elif name_parts: - user["name"] = u" ".join(name_parts) + user["name"] = u(" ").join(name_parts) elif email: user["name"] = email.split("@")[0] if email: @@ -206,7 +247,7 @@ class OpenIdMixin(object): claimed_id = self.get_argument("openid.claimed_id", None) if claimed_id: user["claimed_id"] = claimed_id - callback(user) + future.set_result(user) def get_auth_http_client(self): """Returns the AsyncHTTPClient instance to be used for auth requests. @@ -248,7 +289,7 @@ class OAuthMixin(object): self.async_callback( self._on_request_token, self._OAUTH_AUTHORIZE_URL, - callback_uri)) + callback_uri)) else: http_client.fetch( self._oauth_request_token_url(), @@ -256,6 +297,7 @@ class OAuthMixin(object): self._on_request_token, self._OAUTH_AUTHORIZE_URL, callback_uri)) + @_auth_return_future def get_authenticated_user(self, callback, http_client=None): """Gets the OAuth authorized user and access token on callback. @@ -267,19 +309,19 @@ class OAuthMixin(object): to this service on behalf of the user. """ + future = callback request_key = escape.utf8(self.get_argument("oauth_token")) oauth_verifier = self.get_argument("oauth_verifier", None) request_cookie = self.get_cookie("_oauth_request_token") if not request_cookie: - gen_log.warning("Missing OAuth request token cookie") - callback(None) + future.set_exception(AuthError( + "Missing OAuth request token cookie")) return self.clear_cookie("_oauth_request_token") cookie_key, cookie_secret = [base64.b64decode(escape.utf8(i)) for i in request_cookie.split("|")] if cookie_key != request_key: - gen_log.info((cookie_key, request_key, request_cookie)) - gen_log.warning("Request token does not match cookie") - callback(None) + future.set_exception(AuthError( + "Request token does not match cookie")) return token = dict(key=cookie_key, secret=cookie_secret) if oauth_verifier: @@ -312,23 +354,23 @@ class OAuthMixin(object): signature = _oauth_signature(consumer_token, "GET", url, args) args["oauth_signature"] = signature - return url + "?" + urllib.urlencode(args) + return url + "?" + urllib_parse.urlencode(args) def _on_request_token(self, authorize_url, callback_uri, response): if response.error: raise Exception("Could not get request token") request_token = _oauth_parse_response(response.body) - data = (base64.b64encode(request_token["key"]) + b("|") + + data = (base64.b64encode(request_token["key"]) + b"|" + base64.b64encode(request_token["secret"])) self.set_cookie("_oauth_request_token", data) args = dict(oauth_token=request_token["key"]) if callback_uri == "oob": - self.finish(authorize_url + "?" + urllib.urlencode(args)) + self.finish(authorize_url + "?" + urllib_parse.urlencode(args)) return elif callback_uri: args["oauth_callback"] = urlparse.urljoin( self.request.full_url(), callback_uri) - self.redirect(authorize_url + "?" + urllib.urlencode(args)) + self.redirect(authorize_url + "?" + urllib_parse.urlencode(args)) def _oauth_access_token_url(self, request_token): consumer_token = self._oauth_consumer_token() @@ -352,27 +394,36 @@ class OAuthMixin(object): request_token) args["oauth_signature"] = signature - return url + "?" + urllib.urlencode(args) + return url + "?" + urllib_parse.urlencode(args) - def _on_access_token(self, callback, response): + def _on_access_token(self, future, response): if response.error: - gen_log.warning("Could not fetch access token") - callback(None) + future.set_exception(AuthError("Could not fetch access token")) return access_token = _oauth_parse_response(response.body) - self._oauth_get_user(access_token, self.async_callback( - self._on_oauth_get_user, access_token, callback)) + self._oauth_get_user_future(access_token).add_done_callback( + self.async_callback(self._on_oauth_get_user, access_token, future)) + + @return_future + def _oauth_get_user_future(self, access_token, callback): + # By default, call the old-style _oauth_get_user, but new code + # should override this method instead. + self._oauth_get_user(access_token, callback) def _oauth_get_user(self, access_token, callback): raise NotImplementedError() - def _on_oauth_get_user(self, access_token, callback, user): + def _on_oauth_get_user(self, access_token, future, user_future): + if user_future.exception() is not None: + future.set_exception(user_future.exception()) + return + user = user_future.result() if not user: - callback(None) + future.set_exception(AuthError("Error getting user")) return user["access_token"] = access_token - callback(user) + future.set_result(user) def _oauth_request_parameters(self, url, access_token, parameters={}, method="GET"): @@ -395,7 +446,7 @@ class OAuthMixin(object): args.update(parameters) if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": signature = _oauth10a_signature(consumer_token, method, url, args, - access_token) + access_token) else: signature = _oauth_signature(consumer_token, method, url, args, access_token) @@ -425,13 +476,13 @@ class OAuth2Mixin(object): process. """ args = { - "redirect_uri": redirect_uri, - "client_id": client_id + "redirect_uri": redirect_uri, + "client_id": client_id } if extra_params: args.update(extra_params) self.redirect( - url_concat(self._OAUTH_AUTHORIZE_URL, args)) + url_concat(self._OAUTH_AUTHORIZE_URL, args)) def _oauth_request_token_url(self, redirect_uri=None, client_id=None, client_secret=None, code=None, @@ -442,7 +493,7 @@ class OAuth2Mixin(object): code=code, client_id=client_id, client_secret=client_secret, - ) + ) if extra_params: args.update(extra_params) return url_concat(url, args) @@ -499,8 +550,9 @@ class TwitterMixin(OAuthMixin): http.fetch(self._oauth_request_token_url(callback_uri=callback_uri), self.async_callback( self._on_request_token, self._OAUTH_AUTHENTICATE_URL, None)) - def twitter_request(self, path, callback, access_token=None, - post_args=None, **args): + @_auth_return_future + def twitter_request(self, path, callback=None, access_token=None, + post_args=None, **args): """Fetches the given API path, e.g., "/statuses/user_timeline/btaylor" The path should not include the format (we automatically append @@ -553,22 +605,22 @@ class TwitterMixin(OAuthMixin): url, access_token, all_args, method=method) args.update(oauth) if args: - url += "?" + urllib.urlencode(args) - callback = self.async_callback(self._on_twitter_request, callback) + url += "?" + urllib_parse.urlencode(args) http = self.get_auth_http_client() + http_callback = self.async_callback(self._on_twitter_request, callback) if post_args is not None: - http.fetch(url, method="POST", body=urllib.urlencode(post_args), - callback=callback) + http.fetch(url, method="POST", body=urllib_parse.urlencode(post_args), + callback=http_callback) else: - http.fetch(url, callback=callback) + http.fetch(url, callback=http_callback) - def _on_twitter_request(self, callback, response): + def _on_twitter_request(self, future, response): if response.error: - gen_log.warning("Error response %s fetching %s", response.error, - response.request.url) - callback(None) + future.set_exception(AuthError( + "Error response %s fetching %s" % (response.error, + response.request.url))) return - callback(escape.json_decode(response.body)) + future.set_result(escape.json_decode(response.body)) def _oauth_consumer_token(self): self.require_setting("twitter_consumer_key", "Twitter OAuth") @@ -577,13 +629,12 @@ class TwitterMixin(OAuthMixin): key=self.settings["twitter_consumer_key"], secret=self.settings["twitter_consumer_secret"]) - def _oauth_get_user(self, access_token, callback): - callback = self.async_callback(self._parse_user_response, callback) - self.twitter_request( - "/users/show/" + escape.native_str(access_token[b("screen_name")]), - access_token=access_token, callback=callback) - - def _parse_user_response(self, callback, user): + @return_future + @gen.engine + def _oauth_get_user_future(self, access_token, callback): + user = yield self.twitter_request( + "/users/show/" + escape.native_str(access_token[b"screen_name"]), + access_token=access_token) if user: user["username"] = user["screen_name"] callback(user) @@ -629,6 +680,7 @@ class FriendFeedMixin(OAuthMixin): _OAUTH_NO_CALLBACKS = True _OAUTH_VERSION = "1.0" + @_auth_return_future def friendfeed_request(self, path, callback, access_token=None, post_args=None, **args): """Fetches the given relative API path, e.g., "/bret/friends" @@ -675,22 +727,22 @@ class FriendFeedMixin(OAuthMixin): url, access_token, all_args, method=method) args.update(oauth) if args: - url += "?" + urllib.urlencode(args) + url += "?" + urllib_parse.urlencode(args) callback = self.async_callback(self._on_friendfeed_request, callback) http = self.get_auth_http_client() if post_args is not None: - http.fetch(url, method="POST", body=urllib.urlencode(post_args), + http.fetch(url, method="POST", body=urllib_parse.urlencode(post_args), callback=callback) else: http.fetch(url, callback=callback) - def _on_friendfeed_request(self, callback, response): + def _on_friendfeed_request(self, future, response): if response.error: - gen_log.warning("Error response %s fetching %s", response.error, - response.request.url) - callback(None) + future.set_exception(AuthError( + "Error response %s fetching %s" % (response.error, + response.request.url))) return - callback(escape.json_decode(response.body)) + future.set_result(escape.json_decode(response.body)) def _oauth_consumer_token(self): self.require_setting("friendfeed_consumer_key", "FriendFeed OAuth") @@ -699,12 +751,15 @@ class FriendFeedMixin(OAuthMixin): key=self.settings["friendfeed_consumer_key"], secret=self.settings["friendfeed_consumer_secret"]) + @return_future + @gen.engine def _oauth_get_user(self, access_token, callback): - callback = self.async_callback(self._parse_user_response, callback) - self.friendfeed_request( + user = yield self.friendfeed_request( "/feedinfo/" + access_token["username"], - include="id,name,description", access_token=access_token, - callback=callback) + include="id,name,description", access_token=access_token) + if user: + user["username"] = user["id"] + callback(user) def _parse_user_response(self, callback, user): if user: @@ -755,15 +810,16 @@ class GoogleMixin(OpenIdMixin, OAuthMixin): callback_uri = callback_uri or self.request.uri args = self._openid_args(callback_uri, ax_attrs=ax_attrs, oauth_scope=oauth_scope) - self.redirect(self._OPENID_ENDPOINT + "?" + urllib.urlencode(args)) + self.redirect(self._OPENID_ENDPOINT + "?" + urllib_parse.urlencode(args)) + @_auth_return_future def get_authenticated_user(self, callback): """Fetches the authenticated user data upon redirect.""" # Look to see if we are doing combined OpenID/OAuth oauth_ns = "" - for name, values in self.request.arguments.iteritems(): + for name, values in self.request.arguments.items(): if name.startswith("openid.ns.") and \ - values[-1] == u"http://specs.openid.net/extensions/oauth/1.0": + values[-1] == b"http://specs.openid.net/extensions/oauth/1.0": oauth_ns = name[10:] break token = self.get_argument("openid." + oauth_ns + ".request_token", "") @@ -773,7 +829,8 @@ class GoogleMixin(OpenIdMixin, OAuthMixin): http.fetch(self._oauth_access_token_url(token), self.async_callback(self._on_access_token, callback)) else: - OpenIdMixin.get_authenticated_user(self, callback) + chain_future(OpenIdMixin.get_authenticated_user(self), + callback) def _oauth_consumer_token(self): self.require_setting("google_consumer_key", "Google OAuth") @@ -782,15 +839,16 @@ class GoogleMixin(OpenIdMixin, OAuthMixin): key=self.settings["google_consumer_key"], secret=self.settings["google_consumer_secret"]) - def _oauth_get_user(self, access_token, callback): - OpenIdMixin.get_authenticated_user(self, callback) + def _oauth_get_user_future(self, access_token, callback): + return OpenIdMixin.get_authenticated_user(self) class FacebookMixin(object): """Facebook Connect authentication. - New applications should consider using `FacebookGraphMixin` below instead - of this class. + *Deprecated:* New applications should use `FacebookGraphMixin` + below instead of this class. This class does not support the + Future-based interface seen on other classes in this module. To authenticate with Facebook, register your application with Facebook at http://www.facebook.com/developers/apps.php. Then @@ -837,11 +895,11 @@ class FacebookMixin(object): args["cancel_url"] = urlparse.urljoin( self.request.full_url(), cancel_uri) if extended_permissions: - if isinstance(extended_permissions, (unicode, bytes_type)): + if isinstance(extended_permissions, (unicode_type, bytes_type)): extended_permissions = [extended_permissions] args["req_perms"] = ",".join(extended_permissions) self.redirect("http://www.facebook.com/login.php?" + - urllib.urlencode(args)) + urllib_parse.urlencode(args)) def authorize_redirect(self, extended_permissions, callback_uri=None, cancel_uri=None): @@ -923,7 +981,7 @@ class FacebookMixin(object): args["format"] = "json" args["sig"] = self._signature(args) url = "http://api.facebook.com/restserver.php?" + \ - urllib.urlencode(args) + urllib_parse.urlencode(args) http = self.get_auth_http_client() http.fetch(url, callback=self.async_callback( self._parse_response, callback)) @@ -966,7 +1024,7 @@ class FacebookMixin(object): def _signature(self, args): parts = ["%s=%s" % (n, args[n]) for n in sorted(args.keys())] body = "".join(parts) + self.settings["facebook_secret"] - if isinstance(body, unicode): + if isinstance(body, unicode_type): body = body.encode("utf-8") return hashlib.md5(body).hexdigest() @@ -986,7 +1044,7 @@ class FacebookGraphMixin(OAuth2Mixin): _OAUTH_NO_CALLBACKS = False def get_authenticated_user(self, redirect_uri, client_id, client_secret, - code, callback, extra_fields=None): + code, callback, extra_fields=None): """Handles the login for the Facebook user, returning a user object. Example usage:: @@ -1014,10 +1072,10 @@ class FacebookGraphMixin(OAuth2Mixin): """ http = self.get_auth_http_client() args = { - "redirect_uri": redirect_uri, - "code": code, - "client_id": client_id, - "client_secret": client_secret, + "redirect_uri": redirect_uri, + "code": code, + "client_id": client_id, + "client_secret": client_secret, } fields = set(['id', 'name', 'first_name', 'last_name', @@ -1026,11 +1084,11 @@ class FacebookGraphMixin(OAuth2Mixin): fields.update(extra_fields) http.fetch(self._oauth_request_token_url(**args), - self.async_callback(self._on_access_token, redirect_uri, client_id, - client_secret, callback, fields)) + self.async_callback(self._on_access_token, redirect_uri, client_id, + client_secret, callback, fields)) def _on_access_token(self, redirect_uri, client_id, client_secret, - callback, fields, response): + callback, fields, response): if response.error: gen_log.warning('Facebook auth error: %s' % str(response)) callback(None) @@ -1048,7 +1106,7 @@ class FacebookGraphMixin(OAuth2Mixin): self._on_get_user_info, callback, session, fields), access_token=session["access_token"], fields=",".join(fields) - ) + ) def _on_get_user_info(self, callback, session, fields, user): if user is None: @@ -1063,7 +1121,7 @@ class FacebookGraphMixin(OAuth2Mixin): callback(fieldmap) def facebook_request(self, path, callback, access_token=None, - post_args=None, **args): + post_args=None, **args): """Fetches the given relative API path, e.g., "/btaylor/picture" If the request is a POST, post_args should be provided. Query @@ -1104,11 +1162,11 @@ class FacebookGraphMixin(OAuth2Mixin): all_args.update(args) if all_args: - url += "?" + urllib.urlencode(all_args) + url += "?" + urllib_parse.urlencode(all_args) callback = self.async_callback(self._on_facebook_request, callback) http = self.get_auth_http_client() if post_args is not None: - http.fetch(url, method="POST", body=urllib.urlencode(post_args), + http.fetch(url, method="POST", body=urllib_parse.urlencode(post_args), callback=callback) else: http.fetch(url, callback=callback) @@ -1148,7 +1206,7 @@ def _oauth_signature(consumer_token, method, url, parameters={}, token=None): key_elems = [escape.utf8(consumer_token["secret"])] key_elems.append(escape.utf8(token["secret"] if token else "")) - key = b("&").join(key_elems) + key = b"&".join(key_elems) hash = hmac.new(key, escape.utf8(base_string), hashlib.sha1) return binascii.b2a_base64(hash.digest())[:-1] @@ -1170,25 +1228,25 @@ def _oauth10a_signature(consumer_token, method, url, parameters={}, token=None): for k, v in sorted(parameters.items()))) base_string = "&".join(_oauth_escape(e) for e in base_elems) - key_elems = [escape.utf8(urllib.quote(consumer_token["secret"], safe='~'))] - key_elems.append(escape.utf8(urllib.quote(token["secret"], safe='~') if token else "")) - key = b("&").join(key_elems) + key_elems = [escape.utf8(urllib_parse.quote(consumer_token["secret"], safe='~'))] + key_elems.append(escape.utf8(urllib_parse.quote(token["secret"], safe='~') if token else "")) + key = b"&".join(key_elems) hash = hmac.new(key, escape.utf8(base_string), hashlib.sha1) return binascii.b2a_base64(hash.digest())[:-1] def _oauth_escape(val): - if isinstance(val, unicode): + if isinstance(val, unicode_type): val = val.encode("utf-8") - return urllib.quote(val, safe="~") + return urllib_parse.quote(val, safe="~") def _oauth_parse_response(body): p = escape.parse_qs(body, keep_blank_values=False) - token = dict(key=p[b("oauth_token")][0], secret=p[b("oauth_token_secret")][0]) + token = dict(key=p[b"oauth_token"][0], secret=p[b"oauth_token_secret"][0]) # Add the extra parameters the Provider included to the token - special = (b("oauth_token"), b("oauth_token_secret")) + special = (b"oauth_token", b"oauth_token_secret") token.update((k, p[k][0]) for k in p if k not in special) return token diff --git a/libs/tornado/autoreload.py b/libs/tornado/autoreload.py index 62af0f3b..4e424878 100755 --- a/libs/tornado/autoreload.py +++ b/libs/tornado/autoreload.py @@ -31,7 +31,7 @@ Additionally, modifying these variables will cause reloading to behave incorrectly. """ -from __future__ import absolute_import, division, with_statement +from __future__ import absolute_import, division, print_function, with_statement import os import sys @@ -79,6 +79,7 @@ import weakref from tornado import ioloop from tornado.log import gen_log from tornado import process +from tornado.util import exec_in try: import signal @@ -91,6 +92,7 @@ _reload_hooks = [] _reload_attempted = False _io_loops = weakref.WeakKeyDictionary() + def start(io_loop=None, check_time=500): """Restarts the process automatically when a module is modified. @@ -103,7 +105,7 @@ def start(io_loop=None, check_time=500): _io_loops[io_loop] = True if len(_io_loops) > 1: gen_log.warning("tornado.autoreload started more than once in the same process") - add_reload_hook(functools.partial(_close_all_fds, io_loop)) + add_reload_hook(functools.partial(io_loop.close, all_fds=True)) modify_times = {} callback = functools.partial(_reload_on_update, modify_times) scheduler = ioloop.PeriodicCallback(callback, check_time, io_loop=io_loop) @@ -141,14 +143,6 @@ def add_reload_hook(fn): _reload_hooks.append(fn) -def _close_all_fds(io_loop): - for fd in io_loop._handlers.keys(): - try: - os.close(fd) - except Exception: - pass - - def _reload_on_update(modify_times): if _reload_attempted: # We already tried to reload and it didn't work, so don't try again. @@ -204,7 +198,7 @@ def _reload(): # to ensure that the new process sees the same path we did. path_prefix = '.' + os.pathsep if (sys.path[0] == '' and - not os.environ.get("PYTHONPATH", "").startswith(path_prefix)): + not os.environ.get("PYTHONPATH", "").startswith(path_prefix)): os.environ["PYTHONPATH"] = (path_prefix + os.environ.get("PYTHONPATH", "")) if sys.platform == 'win32': @@ -263,7 +257,7 @@ def main(): script = sys.argv[1] sys.argv = sys.argv[1:] else: - print >>sys.stderr, _USAGE + print(_USAGE, file=sys.stderr) sys.exit(1) try: @@ -277,11 +271,11 @@ def main(): # Use globals as our "locals" dictionary so that # something that tries to import __main__ (e.g. the unittest # module) will see the right things. - exec f.read() in globals(), globals() - except SystemExit, e: + exec_in(f.read(), globals(), globals()) + except SystemExit as e: logging.basicConfig() gen_log.info("Script exited with status %s", e.code) - except Exception, e: + except Exception as e: logging.basicConfig() gen_log.warning("Script exited with uncaught exception", exc_info=True) # If an exception occurred at import time, the file with the error diff --git a/libs/tornado/concurrent.py b/libs/tornado/concurrent.py index 80596844..59075a3a 100755 --- a/libs/tornado/concurrent.py +++ b/libs/tornado/concurrent.py @@ -13,19 +13,21 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -from __future__ import absolute_import, division, with_statement +from __future__ import absolute_import, division, print_function, with_statement import functools import sys from tornado.stack_context import ExceptionStackContext -from tornado.util import raise_exc_info +from tornado.util import raise_exc_info, ArgReplacer try: from concurrent import futures except ImportError: futures = None +class ReturnValueIgnoredError(Exception): + pass class DummyFuture(object): def __init__(self): @@ -89,39 +91,99 @@ if futures is None: else: Future = futures.Future + class DummyExecutor(object): def submit(self, fn, *args, **kwargs): future = Future() try: future.set_result(fn(*args, **kwargs)) - except Exception, e: + except Exception as e: future.set_exception(e) return future dummy_executor = DummyExecutor() + def run_on_executor(fn): @functools.wraps(fn) def wrapper(self, *args, **kwargs): - callback = kwargs.pop("callback") + callback = kwargs.pop("callback", None) future = self.executor.submit(fn, self, *args, **kwargs) if callback: self.io_loop.add_future(future, callback) return future return wrapper -# TODO: this needs a better name -def future_wrap(f): + +def return_future(f): + """Decorator to make a function that returns via callback return a `Future`. + + The wrapped function should take a ``callback`` keyword argument + and invoke it with one argument when it has finished. To signal failure, + the function can simply raise an exception (which will be + captured by the `stack_context` and passed along to the `Future`). + + From the caller's perspective, the callback argument is optional. + If one is given, it will be invoked when the function is complete + with the `Future` as an argument. If no callback is given, the caller + should use the `Future` to wait for the function to complete + (perhaps by yielding it in a `gen.engine` function, or passing it + to `IOLoop.add_future`). + + Usage:: + @return_future + def future_func(arg1, arg2, callback): + # Do stuff (possibly asynchronous) + callback(result) + + @gen.engine + def caller(callback): + yield future_func(arg1, arg2) + callback() + + Note that ``@return_future`` and ``@gen.engine`` can be applied to the + same function, provided ``@return_future`` appears first. + """ + replacer = ArgReplacer(f, 'callback') @functools.wraps(f) def wrapper(*args, **kwargs): future = Future() - if kwargs.get('callback') is not None: - future.add_done_callback(kwargs.pop('callback')) - kwargs['callback'] = future.set_result + callback, args, kwargs = replacer.replace(future.set_result, + args, kwargs) + if callback is not None: + future.add_done_callback(callback) + def handle_error(typ, value, tb): future.set_exception(value) return True + exc_info = None with ExceptionStackContext(handle_error): - f(*args, **kwargs) + try: + result = f(*args, **kwargs) + if result is not None: + raise ReturnValueIgnoredError( + "@return_future should not be used with functions " + "that return values") + except: + exc_info = sys.exc_info() + raise + if exc_info is not None: + # If the initial synchronous part of f() raised an exception, + # go ahead and raise it to the caller directly without waiting + # for them to inspect the Future. + raise_exc_info(exc_info) return future return wrapper + +def chain_future(a, b): + """Chain two futures together so that when one completes, so does the other. + + The result (success or failure) of ``a`` will be copied to ``b``. + """ + def copy(future): + assert future is a + if a.exception() is not None: + b.set_exception(a.exception()) + else: + b.set_result(a.result()) + a.add_done_callback(copy) diff --git a/libs/tornado/curl_httpclient.py b/libs/tornado/curl_httpclient.py index 52350d24..f46ea7b8 100755 --- a/libs/tornado/curl_httpclient.py +++ b/libs/tornado/curl_httpclient.py @@ -16,9 +16,8 @@ """Blocking and non-blocking HTTP client implementations using pycurl.""" -from __future__ import absolute_import, division, with_statement +from __future__ import absolute_import, division, print_function, with_statement -import cStringIO import collections import logging import pycurl @@ -30,20 +29,22 @@ from tornado import ioloop from tornado.log import gen_log from tornado import stack_context -from tornado.escape import utf8 +from tornado.escape import utf8, native_str from tornado.httpclient import HTTPRequest, HTTPResponse, HTTPError, AsyncHTTPClient, main, _RequestProxy +try: + from io import BytesIO # py3 +except ImportError: + from cStringIO import StringIO as BytesIO # py2 + class CurlAsyncHTTPClient(AsyncHTTPClient): - def initialize(self, io_loop=None, max_clients=10, defaults=None): - self.io_loop = io_loop - self.defaults = dict(HTTPRequest._DEFAULTS) - if defaults is not None: - self.defaults.update(defaults) + def initialize(self, io_loop, max_clients=10, defaults=None): + super(CurlAsyncHTTPClient, self).initialize(io_loop, defaults=defaults) self._multi = pycurl.CurlMulti() self._multi.setopt(pycurl.M_TIMERFUNCTION, self._set_timeout) self._multi.setopt(pycurl.M_SOCKETFUNCTION, self._handle_socket) - self._curls = [_curl_create() for i in xrange(max_clients)] + self._curls = [_curl_create() for i in range(max_clients)] self._free_list = self._curls[:] self._requests = collections.deque() self._fds = {} @@ -69,19 +70,27 @@ class CurlAsyncHTTPClient(AsyncHTTPClient): self._handle_force_timeout, 1000, io_loop=io_loop) self._force_timeout_callback.start() + # Work around a bug in libcurl 7.29.0: Some fields in the curl + # multi object are initialized lazily, and its destructor will + # segfault if it is destroyed without having been used. Add + # and remove a dummy handle to make sure everything is + # initialized. + dummy_curl_handle = pycurl.Curl() + self._multi.add_handle(dummy_curl_handle) + self._multi.remove_handle(dummy_curl_handle) + def close(self): self._force_timeout_callback.stop() + if self._timeout is not None: + self.io_loop.remove_timeout(self._timeout) for curl in self._curls: curl.close() self._multi.close() self._closed = True super(CurlAsyncHTTPClient, self).close() - def fetch(self, request, callback, **kwargs): - if not isinstance(request, HTTPRequest): - request = HTTPRequest(url=request, **kwargs) - request = _RequestProxy(request, self.defaults) - self._requests.append((request, stack_context.wrap(callback))) + def fetch_impl(self, request, callback): + self._requests.append((request, callback)) self._process_queue() self._set_timeout(0) @@ -128,7 +137,7 @@ class CurlAsyncHTTPClient(AsyncHTTPClient): while True: try: ret, num_handles = self._socket_action(fd, action) - except pycurl.error, e: + except pycurl.error as e: ret = e.args[0] if ret != pycurl.E_CALL_MULTI_PERFORM: break @@ -142,7 +151,7 @@ class CurlAsyncHTTPClient(AsyncHTTPClient): try: ret, num_handles = self._socket_action( pycurl.SOCKET_TIMEOUT, 0) - except pycurl.error, e: + except pycurl.error as e: ret = e.args[0] if ret != pycurl.E_CALL_MULTI_PERFORM: break @@ -173,7 +182,7 @@ class CurlAsyncHTTPClient(AsyncHTTPClient): while True: try: ret, num_handles = self._multi.socket_all() - except pycurl.error, e: + except pycurl.error as e: ret = e.args[0] if ret != pycurl.E_CALL_MULTI_PERFORM: break @@ -203,7 +212,7 @@ class CurlAsyncHTTPClient(AsyncHTTPClient): (request, callback) = self._requests.popleft() curl.info = { "headers": httputil.HTTPHeaders(), - "buffer": cStringIO.StringIO(), + "buffer": BytesIO(), "request": request, "callback": callback, "curl_start_time": time.time(), @@ -247,7 +256,7 @@ class CurlAsyncHTTPClient(AsyncHTTPClient): starttransfer=curl.getinfo(pycurl.STARTTRANSFER_TIME), total=curl.getinfo(pycurl.TOTAL_TIME), redirect=curl.getinfo(pycurl.REDIRECT_TIME), - ) + ) try: info["callback"](HTTPResponse( request=info["request"], code=code, headers=info["headers"], @@ -276,7 +285,7 @@ def _curl_create(): def _curl_setup_request(curl, request, buffer, headers): - curl.setopt(pycurl.URL, utf8(request.url)) + curl.setopt(pycurl.URL, native_str(request.url)) # libcurl's magic "Expect: 100-continue" behavior causes delays # with servers that don't support it (which include, among others, @@ -296,10 +305,10 @@ def _curl_setup_request(curl, request, buffer, headers): # Request headers may be either a regular dict or HTTPHeaders object if isinstance(request.headers, httputil.HTTPHeaders): curl.setopt(pycurl.HTTPHEADER, - [utf8("%s: %s" % i) for i in request.headers.get_all()]) + [native_str("%s: %s" % i) for i in request.headers.get_all()]) else: curl.setopt(pycurl.HTTPHEADER, - [utf8("%s: %s" % i) for i in request.headers.iteritems()]) + [native_str("%s: %s" % i) for i in request.headers.items()]) if request.header_callback: curl.setopt(pycurl.HEADERFUNCTION, request.header_callback) @@ -307,15 +316,26 @@ def _curl_setup_request(curl, request, buffer, headers): curl.setopt(pycurl.HEADERFUNCTION, lambda line: _curl_header_callback(headers, line)) if request.streaming_callback: - curl.setopt(pycurl.WRITEFUNCTION, request.streaming_callback) + write_function = request.streaming_callback else: - curl.setopt(pycurl.WRITEFUNCTION, buffer.write) + write_function = buffer.write + if type(b'') is type(''): # py2 + curl.setopt(pycurl.WRITEFUNCTION, write_function) + else: # py3 + # Upstream pycurl doesn't support py3, but ubuntu 12.10 includes + # a fork/port. That version has a bug in which it passes unicode + # strings instead of bytes to the WRITEFUNCTION. This means that + # if you use a WRITEFUNCTION (which tornado always does), you cannot + # download arbitrary binary data. This needs to be fixed in the + # ported pycurl package, but in the meantime this lambda will + # make it work for downloading (utf8) text. + curl.setopt(pycurl.WRITEFUNCTION, lambda s: write_function(utf8(s))) curl.setopt(pycurl.FOLLOWLOCATION, request.follow_redirects) curl.setopt(pycurl.MAXREDIRS, request.max_redirects) curl.setopt(pycurl.CONNECTTIMEOUT_MS, int(1000 * request.connect_timeout)) curl.setopt(pycurl.TIMEOUT_MS, int(1000 * request.request_timeout)) if request.user_agent: - curl.setopt(pycurl.USERAGENT, utf8(request.user_agent)) + curl.setopt(pycurl.USERAGENT, native_str(request.user_agent)) else: curl.setopt(pycurl.USERAGENT, "Mozilla/5.0 (compatible; pycurl)") if request.network_interface: @@ -329,7 +349,7 @@ def _curl_setup_request(curl, request, buffer, headers): curl.setopt(pycurl.PROXYPORT, request.proxy_port) if request.proxy_username: credentials = '%s:%s' % (request.proxy_username, - request.proxy_password) + request.proxy_password) curl.setopt(pycurl.PROXYUSERPWD, credentials) else: curl.setopt(pycurl.PROXY, '') @@ -377,7 +397,7 @@ def _curl_setup_request(curl, request, buffer, headers): # Handle curl's cryptic options for every individual HTTP method if request.method in ("POST", "PUT"): - request_buffer = cStringIO.StringIO(utf8(request.body)) + request_buffer = BytesIO(utf8(request.body)) curl.setopt(pycurl.READFUNCTION, request_buffer.read) if request.method == "POST": def ioctl(cmd): @@ -391,7 +411,7 @@ def _curl_setup_request(curl, request, buffer, headers): if request.auth_username is not None: userpwd = "%s:%s" % (request.auth_username, request.auth_password or '') curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC) - curl.setopt(pycurl.USERPWD, utf8(userpwd)) + curl.setopt(pycurl.USERPWD, native_str(userpwd)) gen_log.debug("%s %s (username: %r)", request.method, request.url, request.auth_username) else: diff --git a/libs/tornado/epoll.c b/libs/tornado/epoll.c deleted file mode 100755 index 9a2e3a37..00000000 --- a/libs/tornado/epoll.c +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2009 Facebook - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -#include "Python.h" -#include