diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 8dc691da..089ecd48 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -4,16 +4,15 @@ from couchpotato.core.event import fireEvent from couchpotato.core.helpers.variable import md5 from couchpotato.core.logger import CPLog from couchpotato.environment import Env -from sqlalchemy.engine import create_engine -from sqlalchemy.orm import scoped_session -from sqlalchemy.orm.session import sessionmaker from tornado import template from tornado.web import RequestHandler import os import time +import traceback log = CPLog(__name__) + views = {} template_loader = template.Loader(os.path.join(os.path.dirname(__file__), 'templates')) @@ -25,7 +24,12 @@ class WebHandler(RequestHandler): if not views.get(route): page_not_found(self) return - self.write(views[route]()) + + try: + self.write(views[route]()) + except: + log.error('Failed doing web request "%s": %s', (route, traceback.format_exc())) + self.write({'success': False, 'error': 'Failed returning results'}) def addView(route, func, static = False): views[route] = func @@ -58,16 +62,22 @@ addView('docs', apiDocs) class KeyHandler(RequestHandler): def get(self, *args, **kwargs): api = None - username = Env.setting('username') - password = Env.setting('password') - if (self.get_argument('u') == md5(username) or not username) and (self.get_argument('p') == password or not password): - api = Env.setting('api_key') + try: + username = Env.setting('username') + password = Env.setting('password') + + if (self.get_argument('u') == md5(username) or not username) and (self.get_argument('p') == password or not password): + api = Env.setting('api_key') + + self.write({ + 'success': api is not None, + 'api_key': api + }) + except: + log.error('Failed doing key request: %s', (traceback.format_exc())) + self.write({'success': False, 'error': 'Failed returning results'}) - self.write({ - 'success': api is not None, - 'api_key': api - }) def page_not_found(rh): index_url = Env.get('web_base') diff --git a/couchpotato/api.py b/couchpotato/api.py index 029ebce2..a9f449be 100644 --- a/couchpotato/api.py +++ b/couchpotato/api.py @@ -1,38 +1,63 @@ from couchpotato.core.helpers.request import getParams +from couchpotato.core.logger import CPLog +from functools import wraps +from threading import Thread +from tornado.gen import coroutine from tornado.web import RequestHandler, asynchronous import json +import threading +import tornado +import traceback import urllib +log = CPLog(__name__) + + api = {} +api_locks = {} api_nonblock = {} api_docs = {} api_docs_missing = [] +def run_async(func): + @wraps(func) + def async_func(*args, **kwargs): + func_hl = Thread(target = func, args = args, kwargs = kwargs) + func_hl.start() + return func_hl + + return async_func + # NonBlock API handler class NonBlockHandler(RequestHandler): - stoppers = [] + stopper = None @asynchronous def get(self, route, *args, **kwargs): route = route.strip('/') start, stop = api_nonblock[route] - self.stoppers.append(stop) + self.stopper = stop - start(self.onNewMessage, last_id = self.get_argument("last_id", None)) + start(self.onNewMessage, last_id = self.get_argument('last_id', None)) def onNewMessage(self, response): if self.request.connection.stream.closed(): return - self.finish(response) + + try: + self.finish(response) + except: + log.error('Failed doing nonblock request: %s', (traceback.format_exc())) + self.finish({'success': False, 'error': 'Failed returning results'}) def on_connection_close(self): - for stop in self.stoppers: - stop(self.onNewMessage) + if self.stopper: + self.stopper(self.onNewMessage) - self.stoppers = [] + self.stopper = None def addNonBlockApiView(route, func_tuple, docs = None, **kwargs): api_nonblock[route] = func_tuple @@ -45,38 +70,61 @@ def addNonBlockApiView(route, func_tuple, docs = None, **kwargs): # Blocking API handler class ApiHandler(RequestHandler): + @coroutine def get(self, route, *args, **kwargs): route = route.strip('/') if not api.get(route): self.write('API call doesn\'t seem to exist') return - kwargs = {} - for x in self.request.arguments: - kwargs[x] = urllib.unquote(self.get_argument(x)) + api_locks[route].acquire() - # Split array arguments - kwargs = getParams(kwargs) + try: - # Remove t random string - try: del kwargs['t'] - except: pass + kwargs = {} + for x in self.request.arguments: + kwargs[x] = urllib.unquote(self.get_argument(x)) - # Check JSONP callback - result = api[route](**kwargs) - jsonp_callback = self.get_argument('callback_func', default = None) + # Split array arguments + kwargs = getParams(kwargs) - if jsonp_callback: - self.write(str(jsonp_callback) + '(' + json.dumps(result) + ')') - elif isinstance(result, (tuple)) and result[0] == 'redirect': - self.redirect(result[1]) - else: - self.write(result) + # Remove t random string + try: del kwargs['t'] + except: pass + + # Add async callback handler + @run_async + def run_handler(callback): + try: + result = api[route](**kwargs) + callback(result) + except: + log.error('Failed doing api request "%s": %s', (route, traceback.format_exc())) + callback({'success': False, 'error': 'Failed returning results'}) + result = yield tornado.gen.Task(run_handler) + + # Check JSONP callback + jsonp_callback = self.get_argument('callback_func', default = None) + + if jsonp_callback: + self.write(str(jsonp_callback) + '(' + json.dumps(result) + ')') + elif isinstance(result, tuple) and result[0] == 'redirect': + self.redirect(result[1]) + else: + self.write(result) + + except: + log.error('Failed doing api request "%s": %s', (route, traceback.format_exc())) + self.write({'success': False, 'error': 'Failed returning results'}) + + api_locks[route].release() def addApiView(route, func, static = False, docs = None, **kwargs): if static: func(route) - else: api[route] = func + else: + api[route] = func + api_locks[route] = threading.Lock() if docs: api_docs[route[4:] if route[0:4] == 'api.' else route] = docs diff --git a/couchpotato/core/_base/_core/main.py b/couchpotato/core/_base/_core/main.py index 4ad37d6c..9647d959 100644 --- a/couchpotato/core/_base/_core/main.py +++ b/couchpotato/core/_base/_core/main.py @@ -124,7 +124,7 @@ class Core(Plugin): time.sleep(1) - log.debug('Save to shutdown/restart') + log.debug('Safe to shutdown/restart') try: IOLoop.current().stop() diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index fece6fa4..efbaa643 100644 --- a/couchpotato/core/_base/clientscript/main.py +++ b/couchpotato/core/_base/clientscript/main.py @@ -80,7 +80,7 @@ class ClientScript(Plugin): 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))) + core_url = 'api/%s/static/%s' % (Env.setting('api_key'), rel_path) if static_type == 'script': self.registerScript(core_url, file_path, position = 'front') @@ -111,7 +111,7 @@ class ClientScript(Plugin): data = jsmin(f) else: data = self.prefix(f) - data = cssmin(f) + data = cssmin(data) data = data.replace('../images/', '../static/images/') data = data.replace('../fonts/', '../static/fonts/') data = data.replace('../../static/', '../static/') # Replace inside plugins @@ -165,6 +165,8 @@ class ClientScript(Plugin): def register(self, api_path, file_path, type, location): + api_path = '%s?%s' % (api_path, tryInt(os.path.getmtime(file_path))) + if not self.urls[type].get(location): self.urls[type][location] = [] self.urls[type][location].append(api_path) diff --git a/couchpotato/core/_base/updater/main.py b/couchpotato/core/_base/updater/main.py index 38b7d36e..f3b4b19c 100644 --- a/couchpotato/core/_base/updater/main.py +++ b/couchpotato/core/_base/updater/main.py @@ -132,6 +132,7 @@ class BaseUpdater(Plugin): update_failed = False update_version = None last_check = 0 + auto_register_static = False def doUpdate(self): pass diff --git a/couchpotato/core/auth.py b/couchpotato/core/auth.py index e58016bd..e8778601 100644 --- a/couchpotato/core/auth.py +++ b/couchpotato/core/auth.py @@ -10,10 +10,15 @@ def requires_auth(handler_class): def wrap_execute(handler_execute): def require_basic_auth(handler, kwargs): + if Env.setting('username') and Env.setting('password'): auth_header = handler.request.headers.get('Authorization') auth_decoded = base64.decodestring(auth_header[6:]) if auth_header else None + + username = '' + password = '' + if auth_decoded: username, password = auth_decoded.split(':', 2) diff --git a/couchpotato/core/downloaders/base.py b/couchpotato/core/downloaders/base.py index 900fd8c0..08be4bd0 100644 --- a/couchpotato/core/downloaders/base.py +++ b/couchpotato/core/downloaders/base.py @@ -11,7 +11,7 @@ log = CPLog(__name__) class Downloader(Provider): - type = [] + protocol = [] http_time_between_calls = 0 torrent_sources = [ @@ -36,18 +36,23 @@ class Downloader(Provider): def __init__(self): addEvent('download', self._download) addEvent('download.enabled', self._isEnabled) - addEvent('download.enabled_types', self.getEnabledDownloadType) + addEvent('download.enabled_protocols', self.getEnabledProtocol) addEvent('download.status', self._getAllDownloadStatus) addEvent('download.remove_failed', self._removeFailed) + addEvent('download.pause', self._pause) + addEvent('download.process_complete', self._processComplete) - def getEnabledDownloadType(self): - for download_type in self.type: - if self.isEnabled(manual = True, data = {'type': download_type}): - return self.type + def getEnabledProtocol(self): + for download_protocol in self.protocol: + if self.isEnabled(manual = True, data = {'protocol': download_protocol}): + return self.protocol return [] - def _download(self, data = {}, movie = {}, manual = False, filedata = None): + def _download(self, data = None, movie = None, manual = False, filedata = None): + if not movie: movie = {} + if not data: data = {} + if self.isDisabled(manual, data): return return self.download(data = data, movie = movie, filedata = filedata) @@ -65,19 +70,35 @@ class Downloader(Provider): if self.isDisabled(manual = True, data = {}): return - if self.conf('delete_failed', default = True): - return self.removeFailed(item) + if item and item.get('downloader') == self.getName(): + if self.conf('delete_failed'): + return self.removeFailed(item) - return False + return False + return def removeFailed(self, item): return - def isCorrectType(self, item_type): - is_correct = item_type in self.type + def _processComplete(self, item): + if self.isDisabled(manual = True, data = {}): + return + + if item and item.get('downloader') == self.getName(): + if self.conf('remove_complete', default = False): + return self.processComplete(item = item, delete_files = self.conf('delete_files', default = False)) + + return False + return + + def processComplete(self, item, delete_files): + return + + def isCorrectProtocol(self, item_protocol): + is_correct = item_protocol in self.protocol if not is_correct: - log.debug("Downloader doesn't support this type") + log.debug("Downloader doesn't support this protocol") return is_correct @@ -101,7 +122,7 @@ class Downloader(Provider): except: log.debug('Torrent hash "%s" wasn\'t found on: %s', (torrent_hash, source)) - log.error('Failed converting magnet url to torrent: %s', (torrent_hash)) + log.error('Failed converting magnet url to torrent: %s', torrent_hash) return False def downloadReturnId(self, download_id): @@ -110,20 +131,38 @@ class Downloader(Provider): 'id': download_id } - def isDisabled(self, manual, data): + def isDisabled(self, manual = False, data = None): + if not data: data = {} + return not self.isEnabled(manual, data) - def _isEnabled(self, manual, data = {}): + def _isEnabled(self, manual, data = None): + if not data: data = {} + if not self.isEnabled(manual, data): return return True - def isEnabled(self, manual, data = {}): + def isEnabled(self, manual = False, data = None): + if not data: data = {} + d_manual = self.conf('manual', default = False) return super(Downloader, self).isEnabled() and \ - ((d_manual and manual) or (d_manual is False)) and \ - (not data or self.isCorrectType(data.get('type'))) + (d_manual and manual or d_manual is False) and \ + (not data or self.isCorrectProtocol(data.get('protocol'))) + def _pause(self, item, pause = True): + if self.isDisabled(manual = True, data = {}): + return + + if item and item.get('downloader') == self.getName(): + self.pause(item, pause) + return True + + return False + + def pause(self, item, pause): + return class StatusList(list): diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index aad9ea7f..9a5a6217 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -7,22 +7,25 @@ import traceback log = CPLog(__name__) + class Blackhole(Downloader): - type = ['nzb', 'torrent', 'torrent_magnet'] + protocol = ['nzb', 'torrent', 'torrent_magnet'] - def download(self, data = {}, movie = {}, filedata = None): + def download(self, data = None, movie = None, filedata = None): + if not movie: movie = {} + if not data: data = {} directory = self.conf('directory') if not directory or not os.path.isdir(directory): - log.error('No directory set for blackhole %s download.', data.get('type')) + log.error('No directory set for blackhole %s download.', data.get('protocol')) else: try: if not filedata or len(filedata) < 50: try: - if data.get('type') == 'torrent_magnet': + if data.get('protocol') == 'torrent_magnet': filedata = self.magnetToTorrent(data.get('url')) - data['type'] = 'torrent' + data['protocol'] = 'torrent' except: log.error('Failed download torrent via magnet url: %s', traceback.format_exc()) @@ -34,7 +37,7 @@ class Blackhole(Downloader): try: if not os.path.isfile(fullPath): - log.info('Downloading %s to %s.', (data.get('type'), fullPath)) + log.info('Downloading %s to %s.', (data.get('protocol'), fullPath)) with open(fullPath, 'wb') as f: f.write(filedata) os.chmod(fullPath, Env.getPermission('file')) @@ -53,20 +56,21 @@ class Blackhole(Downloader): return False - def getEnabledDownloadType(self): + def getEnabledProtocol(self): if self.conf('use_for') == 'both': - return super(Blackhole, self).getEnabledDownloadType() + return super(Blackhole, self).getEnabledProtocol() elif self.conf('use_for') == 'torrent': return ['torrent', 'torrent_magnet'] else: return ['nzb'] - def isEnabled(self, manual, data = {}): - for_type = ['both'] - if data and 'torrent' in data.get('type'): - for_type.append('torrent') + def isEnabled(self, manual = False, data = None): + if not data: data = {} + for_protocol = ['both'] + if data and 'torrent' in data.get('protocol'): + for_protocol.append('torrent') elif data: - for_type.append(data.get('type')) + for_protocol.append(data.get('protocol')) return super(Blackhole, self).isEnabled(manual, data) and \ - ((self.conf('use_for') in for_type)) + ((self.conf('use_for') in for_protocol)) diff --git a/couchpotato/core/downloaders/deluge/__init__.py b/couchpotato/core/downloaders/deluge/__init__.py new file mode 100644 index 00000000..c7aa26e6 --- /dev/null +++ b/couchpotato/core/downloaders/deluge/__init__.py @@ -0,0 +1,90 @@ +from .main import Deluge + +def start(): + return Deluge() + +config = [{ + 'name': 'deluge', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'deluge', + 'label': 'Deluge', + 'description': 'Use Deluge to download torrents.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'host', + 'default': 'localhost:58846', + 'description': 'Hostname with port. Usually localhost:58846', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Download to this directory. Keep empty for default Deluge download directory.', + }, + { + 'name': 'completed_directory', + 'type': 'directory', + 'description': 'Move completed torrent to this directory. Keep empty for default Deluge options.', + 'advanced': True, + }, + { + 'name': 'label', + 'description': 'Label to add to torrents in the Deluge UI.', + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'type': 'bool', + 'default': True, + 'advanced': True, + 'description': 'Remove the torrent from Deluge after it has finished seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'advanced': True, + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/deluge/main.py b/couchpotato/core/downloaders/deluge/main.py new file mode 100644 index 00000000..580ed7ff --- /dev/null +++ b/couchpotato/core/downloaders/deluge/main.py @@ -0,0 +1,244 @@ +from base64 import b64encode +from couchpotato.core.downloaders.base import Downloader, StatusList +from couchpotato.core.helpers.encoding import isInt, ss +from couchpotato.core.helpers.variable import tryFloat +from couchpotato.core.logger import CPLog +from couchpotato.environment import Env +from datetime import timedelta +from synchronousdeluge import DelugeClient +import os.path +import traceback + +log = CPLog(__name__) + + +class Deluge(Downloader): + + protocol = ['torrent', 'torrent_magnet'] + log = CPLog(__name__) + drpc = None + + def connect(self): + # 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 + + if not self.drpc: + self.drpc = DelugeRPC(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) + + return self.drpc + + def download(self, data, movie, filedata = None): + log.info('Sending "%s" (%s) to Deluge.', (data.get('name'), data.get('protocol'))) + + if not self.connect(): + return False + + if not filedata and data.get('protocol') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + # Set parameters for Deluge + options = { + 'add_paused': self.conf('paused', default = 0), + 'label': self.conf('label') + } + + if self.conf('directory'): + if os.path.isdir(self.conf('directory')): + options['download_location'] = self.conf('directory') + else: + log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) + + if self.conf('completed_directory'): + if os.path.isdir(self.conf('completed_directory')): + options['move_completed'] = 1 + options['move_completed_path'] = self.conf('completed_directory') + else: + log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) + + if data.get('seed_ratio'): + options['stop_at_ratio'] = 1 + options['stop_ratio'] = tryFloat(data.get('seed_ratio')) + +# Deluge only has seed time as a global option. Might be added in +# in a future API release. +# if data.get('seed_time'): + + # Send request to Deluge + if data.get('protocol') == 'torrent_magnet': + remote_torrent = self.drpc.add_torrent_magnet(data.get('url'), options) + else: + filename = self.createFileName(data, filedata, movie) + remote_torrent = self.drpc.add_torrent_file(filename, b64encode(filedata), options) + + if not remote_torrent: + log.error('Failed sending torrent to Deluge') + return False + + log.info('Torrent sent to Deluge successfully.') + return self.downloadReturnId(remote_torrent) + + def getAllDownloadStatus(self): + + log.debug('Checking Deluge download status.') + + if not os.path.isdir(Env.setting('from', 'renamer')): + log.error('Renamer "from" folder doesn\'t to exist.') + return + + if not self.connect(): + return False + + statuses = StatusList(self) + + queue = self.drpc.get_alltorrents() + + if not queue: + log.debug('Nothing in queue or error') + return False + + for torrent_id in queue: + item = queue[torrent_id] + log.debug('name=%s / id=%s / save_path=%s / move_completed_path=%s / hash=%s / progress=%s / state=%s / eta=%s / ratio=%s / stop_ratio=%s / is_seed=%s / is_finished=%s / paused=%s', (item['name'], item['hash'], item['save_path'], item['move_completed_path'], item['hash'], item['progress'], item['state'], item['eta'], item['ratio'], item['stop_ratio'], item['is_seed'], item['is_finished'], item['paused'])) + + # Deluge has no easy way to work out if a torrent is stalled or failing. + #status = 'failed' + status = 'busy' + if item['is_seed'] and tryFloat(item['ratio']) < tryFloat(item['stop_ratio']): + # We have item['seeding_time'] to work out what the seeding time is, but we do not + # have access to the downloader seed_time, as with deluge we have no way to pass it + # when the torrent is added. So Deluge will only look at the ratio. + # See above comment in download(). + status = 'seeding' + elif item['is_seed'] and item['is_finished'] and item['paused'] and item['state'] == 'Paused': + status = 'completed' + + download_dir = item['save_path'] + if item['move_on_completed']: + download_dir = item['move_completed_path'] + + statuses.append({ + 'id': item['hash'], + 'name': item['name'], + 'status': status, + 'original_status': item['state'], + 'seed_ratio': item['ratio'], + 'timeleft': str(timedelta(seconds = item['eta'])), + 'folder': ss(os.path.join(download_dir, item['name'])), + }) + + return statuses + + def pause(self, item, pause = True): + if pause: + return self.drpc.pause_torrent([item['id']]) + else: + return self.drpc.resume_torrent([item['id']]) + + def removeFailed(self, item): + log.info('%s failed downloading, deleting...', item['name']) + return self.drpc.remove_torrent(item['id'], True) + + def processComplete(self, item, delete_files = False): + log.debug('Requesting Deluge to remove the torrent %s%s.', (item['name'], ' and cleanup the downloaded files' if delete_files else '')) + return self.drpc.remove_torrent(item['id'], remove_local_data = delete_files) + +class DelugeRPC(object): + + host = 'localhost' + port = 58846 + username = None + password = None + client = None + + def __init__(self, host = 'localhost', port = 58846, username = None, password = None): + super(DelugeRPC, self).__init__() + + self.host = host + self.port = port + self.username = username + self.password = password + + def connect(self): + self.client = DelugeClient() + self.client.connect(self.host, int(self.port), self.username, self.password) + + def add_torrent_magnet(self, torrent, options): + torrent_id = False + try: + self.connect() + torrent_id = self.client.core.add_torrent_magnet(torrent, options).get() + if options['label']: + self.client.label.set_torrent(torrent_id, options['label']).get() + except Exception, err: + log.error('Failed to add torrent magnet %s: %s %s', (torrent, err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + return torrent_id + + def add_torrent_file(self, filename, torrent, options): + torrent_id = False + try: + self.connect() + torrent_id = self.client.core.add_torrent_file(filename, torrent, options).get() + if options['label']: + self.client.label.set_torrent(torrent_id, options['label']).get() + except Exception, err: + log.error('Failed to add torrent file %s: %s %s', (filename, err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + return torrent_id + + def get_alltorrents(self): + ret = False + try: + self.connect() + ret = self.client.core.get_torrents_status({}, {}).get() + except Exception, err: + log.error('Failed to get all torrents: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + return ret + + def pause_torrent(self, torrent_ids): + try: + self.connect() + self.client.core.pause_torrent(torrent_ids).get() + except Exception, err: + log.error('Failed to pause torrent: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + def resume_torrent(self, torrent_ids): + try: + self.connect() + self.client.core.resume_torrent(torrent_ids).get() + except Exception, err: + log.error('Failed to resume torrent: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + + def remove_torrent(self, torrent_id, remove_local_data): + ret = False + try: + self.connect() + ret = self.client.core.remove_torrent(torrent_id, remove_local_data).get() + except Exception, err: + log.error('Failed to remove torrent: %s %s', (err, traceback.format_exc())) + finally: + if self.client: + self.disconnect() + return ret + + def disconnect(self): + self.client.disconnect() diff --git a/couchpotato/core/downloaders/nzbget/__init__.py b/couchpotato/core/downloaders/nzbget/__init__.py index a17b2dc5..19483713 100644 --- a/couchpotato/core/downloaders/nzbget/__init__.py +++ b/couchpotato/core/downloaders/nzbget/__init__.py @@ -42,6 +42,7 @@ config = [{ }, { 'name': 'priority', + 'advanced': True, 'default': '0', 'type': 'dropdown', 'values': [('Very Low', -100), ('Low', -50), ('Normal', 0), ('High', 50), ('Very High', 100)], @@ -57,6 +58,7 @@ config = [{ { 'name': 'delete_failed', 'default': True, + 'advanced': True, 'type': 'bool', 'description': 'Delete a release after the download has failed.', }, diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index 43061e17..35d47de5 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -12,13 +12,16 @@ import xmlrpclib log = CPLog(__name__) + class NZBGet(Downloader): - type = ['nzb'] + protocol = ['nzb'] url = 'http://%(username)s:%(password)s@%(host)s/xmlrpc' - def download(self, data = {}, movie = {}, filedata = None): + def download(self, data = None, movie = None, filedata = None): + if not movie: movie = {} + if not data: data = {} if not filedata: log.error('Unable to get NZB file: %s', traceback.format_exc()) @@ -32,7 +35,7 @@ class NZBGet(Downloader): rpc = xmlrpclib.ServerProxy(url) try: if rpc.writelog('INFO', 'CouchPotato connected to drop off %s.' % nzb_name): - log.info('Successfully connected to NZBGet') + log.debug('Successfully connected to NZBGet') else: log.info('Successfully connected to NZBGet, but unable to send a message') except socket.error: @@ -73,7 +76,7 @@ class NZBGet(Downloader): rpc = xmlrpclib.ServerProxy(url) try: if rpc.writelog('INFO', 'CouchPotato connected to check status'): - log.info('Successfully connected to NZBGet') + log.debug('Successfully connected to NZBGet') else: log.info('Successfully connected to NZBGet, but unable to send a message') except socket.error: @@ -142,7 +145,7 @@ class NZBGet(Downloader): 'status': 'completed' if item['ParStatus'] == 'SUCCESS' and item['ScriptStatus'] == 'SUCCESS' else 'failed', 'original_status': item['ParStatus'] + ', ' + item['ScriptStatus'], 'timeleft': str(timedelta(seconds = 0)), - 'folder': item['DestDir'] + 'folder': ss(item['DestDir']) }) return statuses @@ -151,12 +154,12 @@ class NZBGet(Downloader): log.info('%s failed downloading, deleting...', item['name']) - url = self.url % {'host': self.conf('host'), 'password': self.conf('password')} + url = self.url % {'host': self.conf('host'), 'username': self.conf('username'), 'password': self.conf('password')} rpc = xmlrpclib.ServerProxy(url) try: if rpc.writelog('INFO', 'CouchPotato connected to delete some history'): - log.info('Successfully connected to NZBGet') + log.debug('Successfully connected to NZBGet') else: log.info('Successfully connected to NZBGet, but unable to send a message') except socket.error: @@ -171,11 +174,15 @@ class NZBGet(Downloader): try: history = rpc.history() + nzb_id = None + path = None + for hist in history: if hist['Parameters'] and hist['Parameters']['couchpotato'] and hist['Parameters']['couchpotato'] == item['id']: nzb_id = hist['ID'] path = hist['DestDir'] - if rpc.editqueue('HistoryDelete', 0, "", [tryInt(nzb_id)]): + + if nzb_id and path and rpc.editqueue('HistoryDelete', 0, "", [tryInt(nzb_id)]): shutil.rmtree(path, True) except: log.error('Failed deleting: %s', traceback.format_exc(0)) diff --git a/couchpotato/core/downloaders/nzbvortex/__init__.py b/couchpotato/core/downloaders/nzbvortex/__init__.py index f1604ea8..3b95698e 100644 --- a/couchpotato/core/downloaders/nzbvortex/__init__.py +++ b/couchpotato/core/downloaders/nzbvortex/__init__.py @@ -38,6 +38,7 @@ config = [{ { 'name': 'delete_failed', 'default': True, + 'advanced': True, 'type': 'bool', 'description': 'Delete a release after the download has failed.', }, diff --git a/couchpotato/core/downloaders/nzbvortex/main.py b/couchpotato/core/downloaders/nzbvortex/main.py index f1f8acc6..a652f110 100644 --- a/couchpotato/core/downloaders/nzbvortex/main.py +++ b/couchpotato/core/downloaders/nzbvortex/main.py @@ -16,13 +16,16 @@ import urllib2 log = CPLog(__name__) + class NZBVortex(Downloader): - type = ['nzb'] + protocol = ['nzb'] api_level = None session_id = None - def download(self, data = {}, movie = {}, filedata = None): + def download(self, data = None, movie = None, filedata = None): + if not movie: movie = {} + if not data: data = {} # Send the nzb try: @@ -55,8 +58,8 @@ class NZBVortex(Downloader): 'name': item['uiTitle'], 'status': status, 'original_status': item['state'], - 'timeleft': -1, - 'folder': item['destinationPath'], + 'timeleft':-1, + 'folder': ss(item['destinationPath']), }) return statuses @@ -96,9 +99,10 @@ class NZBVortex(Downloader): return False - def call(self, call, parameters = {}, repeat = False, auth = True, *args, **kwargs): + def call(self, call, parameters = None, repeat = False, auth = True, *args, **kwargs): # Login first + if not parameters: parameters = {} if not self.session_id and auth: self.login() @@ -121,7 +125,7 @@ class NZBVortex(Downloader): # Try login and do again if not repeat: self.login() - return self.call(call, parameters = parameters, repeat = True, *args, **kwargs) + return self.call(call, parameters = parameters, repeat = True, **kwargs) log.error('Failed to parsing %s: %s', (self.getName(), traceback.format_exc())) except: @@ -147,7 +151,8 @@ class NZBVortex(Downloader): return self.api_level - def isEnabled(self, manual, data): + def isEnabled(self, manual = False, data = None): + if not data: data = {} return super(NZBVortex, self).isEnabled(manual, data) and self.getApiLevel() diff --git a/couchpotato/core/downloaders/pneumatic/main.py b/couchpotato/core/downloaders/pneumatic/main.py index 5e2b7854..643350e1 100644 --- a/couchpotato/core/downloaders/pneumatic/main.py +++ b/couchpotato/core/downloaders/pneumatic/main.py @@ -6,12 +6,15 @@ import traceback log = CPLog(__name__) + class Pneumatic(Downloader): - type = ['nzb'] + protocol = ['nzb'] strm_syntax = 'plugin://plugin.program.pneumatic/?mode=strm&type=add_file&nzb=%s&nzbname=%s' - def download(self, data = {}, movie = {}, filedata = None): + def download(self, data = None, movie = None, filedata = None): + if not movie: movie = {} + if not data: data = {} directory = self.conf('directory') if not directory or not os.path.isdir(directory): @@ -26,7 +29,7 @@ class Pneumatic(Downloader): try: if not os.path.isfile(fullPath): - log.info('Downloading %s to %s.', (data.get('type'), fullPath)) + log.info('Downloading %s to %s.', (data.get('protocol'), fullPath)) with open(fullPath, 'wb') as f: f.write(filedata) diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py new file mode 100755 index 00000000..efc2234b --- /dev/null +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -0,0 +1,71 @@ +from .main import rTorrent + +def start(): + return rTorrent() + +config = [{ + 'name': 'rtorrent', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'rtorrent', + 'label': 'rTorrent', + 'description': '', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'url', + 'default': 'http://localhost:80/RPC2', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'label', + 'description': 'Label to apply on added torrents.', + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'default': False, + 'advanced': True, + 'type': 'bool', + 'description': 'Remove the torrent after it finishes seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'advanced': True, + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py new file mode 100755 index 00000000..680c44aa --- /dev/null +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -0,0 +1,202 @@ +from base64 import b16encode, b32decode +from datetime import timedelta +from hashlib import sha1 +import shutil +from couchpotato.core.helpers.encoding import ss +from rtorrent.err import MethodError + +from bencode import bencode, bdecode +from couchpotato.core.downloaders.base import Downloader, StatusList +from couchpotato.core.logger import CPLog +from rtorrent import RTorrent + + +log = CPLog(__name__) + + +class rTorrent(Downloader): + protocol = ['torrent', 'torrent_magnet'] + rt = None + + def connect(self): + # Already connected? + if self.rt is not None: + return self.rt + + # Ensure url is set + if not self.conf('url'): + log.error('Config properties are not filled in correctly, url is missing.') + return False + + if self.conf('username') and self.conf('password'): + self.rt = RTorrent( + self.conf('url'), + self.conf('username'), + self.conf('password') + ) + else: + self.rt = RTorrent(self.conf('url')) + + return self.rt + + def _update_provider_group(self, name, data): + if data.get('seed_time'): + log.info('seeding time ignored, not supported') + + if not name: + return False + + if not self.connect(): + return False + + views = self.rt.get_views() + + if name not in views: + self.rt.create_group(name) + + group = self.rt.get_group(name) + + try: + if data.get('seed_ratio'): + ratio = int(float(data.get('seed_ratio')) * 100) + log.debug('Updating provider ratio to %s, group name: %s', (ratio, name)) + + # Explicitly set all group options to ensure it is setup correctly + group.set_upload('1M') + group.set_min(ratio) + group.set_max(ratio) + group.set_command('d.stop') + group.enable() + else: + # Reset group action and disable it + group.set_command() + group.disable() + except MethodError, err: + log.error('Unable to set group options: %s', err.message) + return False + + return True + + + def download(self, data, movie, filedata = None): + log.debug('Sending "%s" to rTorrent.', (data.get('name'))) + + if not self.connect(): + return False + + group_name = 'cp_' + data.get('provider').lower() + if not self._update_provider_group(group_name, data): + return False + + torrent_params = {} + if self.conf('label'): + torrent_params['label'] = self.conf('label') + + if not filedata and data.get('protocol') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + # Try download magnet torrents + if data.get('protocol') == 'torrent_magnet': + filedata = self.magnetToTorrent(data.get('url')) + + if filedata is False: + return False + + data['protocol'] = 'torrent' + + info = bdecode(filedata)["info"] + torrent_hash = sha1(bencode(info)).hexdigest().upper() + + # Convert base 32 to hex + if len(torrent_hash) == 32: + torrent_hash = b16encode(b32decode(torrent_hash)) + + # Send request to rTorrent + try: + # Send torrent to rTorrent + torrent = self.rt.load_torrent(filedata) + + # Set label + if self.conf('label'): + torrent.set_custom(1, self.conf('label')) + + # Set Ratio Group + torrent.set_visible(group_name) + + # Start torrent + if not self.conf('paused', default = 0): + torrent.start() + + return self.downloadReturnId(torrent_hash) + except Exception, err: + log.error('Failed to send torrent to rTorrent: %s', err) + return False + + def getAllDownloadStatus(self): + log.debug('Checking rTorrent download status.') + + if not self.connect(): + return False + + try: + torrents = self.rt.get_torrents() + + statuses = StatusList(self) + + for item in torrents: + status = 'busy' + if item.complete: + if item.active: + status = 'seeding' + else: + status = 'completed' + + statuses.append({ + 'id': item.info_hash, + 'name': item.name, + 'status': status, + 'seed_ratio': item.ratio, + 'original_status': item.state, + 'timeleft': str(timedelta(seconds = float(item.left_bytes) / item.down_rate)) if item.down_rate > 0 else -1, + 'folder': ss(item.directory) + }) + + return statuses + + except Exception, err: + log.error('Failed to get status from rTorrent: %s', err) + return False + + def pause(self, download_info, pause = True): + if not self.connect(): + return False + + torrent = self.rt.find_torrent(download_info['id']) + if torrent is None: + return False + + if pause: + return torrent.pause() + return torrent.resume() + + def removeFailed(self, item): + log.info('%s failed downloading, deleting...', item['name']) + return self.processComplete(item, delete_files = True) + + def processComplete(self, item, delete_files): + log.debug('Requesting rTorrent to remove the torrent %s%s.', + (item['name'], ' and cleanup the downloaded files' if delete_files else '')) + if not self.connect(): + return False + + torrent = self.rt.find_torrent(item['id']) + if torrent is None: + return False + + torrent.erase() # just removes the torrent, doesn't delete data + + if delete_files: + shutil.rmtree(item['folder'], True) + + return True diff --git a/couchpotato/core/downloaders/sabnzbd/__init__.py b/couchpotato/core/downloaders/sabnzbd/__init__.py index f17db9c1..48692dae 100644 --- a/couchpotato/core/downloaders/sabnzbd/__init__.py +++ b/couchpotato/core/downloaders/sabnzbd/__init__.py @@ -34,6 +34,15 @@ config = [{ 'label': 'Category', 'description': 'The category CP places the nzb in. Like movies or couchpotato', }, + { + 'name': 'priority', + 'label': 'Priority', + 'type': 'dropdown', + 'default': '0', + 'advanced': True, + 'values': [('Paused', -2), ('Low', -1), ('Normal', 0), ('High', 1), ('Forced', 2)], + 'description': 'Add to the queue with this priority.', + }, { 'name': 'manual', 'default': False, @@ -41,9 +50,18 @@ config = [{ 'advanced': True, 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', }, + { + 'name': 'remove_complete', + 'advanced': True, + 'label': 'Remove NZB', + 'default': False, + 'type': 'bool', + 'description': 'Remove the NZB from history after it completed.', + }, { 'name': 'delete_failed', 'default': True, + 'advanced': True, 'type': 'bool', 'description': 'Delete a release after the download has failed.', }, diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py index f2f217a1..08ee409c 100644 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -10,11 +10,14 @@ import traceback log = CPLog(__name__) + class Sabnzbd(Downloader): - type = ['nzb'] + protocol = ['nzb'] - def download(self, data = {}, movie = {}, filedata = None): + def download(self, data = None, movie = None, filedata = None): + if not movie: movie = {} + if not data: data = {} log.info('Sending "%s" to SABnzbd.', data.get('name')) @@ -22,11 +25,13 @@ class Sabnzbd(Downloader): 'cat': self.conf('category'), 'mode': 'addurl', 'nzbname': self.createNzbName(data, movie), + 'priority': self.conf('priority'), } + nzb_filename = None if filedata: if len(filedata) < 50: - log.error('No proper nzb available: %s', (filedata)) + log.error('No proper nzb available: %s', filedata) return False # If it's a .rar, it adds the .rar extension, otherwise it stays .nzb @@ -36,7 +41,7 @@ class Sabnzbd(Downloader): req_params['name'] = data.get('url') try: - if req_params.get('mode') is 'addfile': + if nzb_filename and req_params.get('mode') is 'addfile': sab_data = self.call(req_params, params = {'nzbfile': (ss(nzb_filename), filedata)}, multipart = True) else: sab_data = self.call(req_params) @@ -107,7 +112,7 @@ class Sabnzbd(Downloader): 'status': status, 'original_status': item['status'], 'timeleft': str(timedelta(seconds = 0)), - 'folder': item['storage'], + 'folder': ss(item['storage']), }) return statuses @@ -129,6 +134,22 @@ class Sabnzbd(Downloader): return True + def processComplete(self, item, delete_files = False): + log.debug('Requesting SabNZBd to remove the NZB %s.', item['name']) + + try: + self.call({ + 'mode': 'history', + 'name': 'delete', + 'del_files': '0', + 'value': item['id'] + }, use_json = False) + except: + log.error('Failed removing: %s', traceback.format_exc(0)) + return False + + return True + def call(self, request_params, use_json = True, **kwargs): url = cleanHost(self.conf('host')) + 'api?' + tryUrlencode(mergeDicts(request_params, { diff --git a/couchpotato/core/downloaders/synology/main.py b/couchpotato/core/downloaders/synology/main.py index 87212749..d5082c77 100644 --- a/couchpotato/core/downloaders/synology/main.py +++ b/couchpotato/core/downloaders/synology/main.py @@ -9,13 +9,15 @@ log = CPLog(__name__) class Synology(Downloader): - type = ['nzb', 'torrent', 'torrent_magnet'] + protocol = ['nzb', 'torrent', 'torrent_magnet'] log = CPLog(__name__) - def download(self, data, movie, filedata = None): + def download(self, data = None, movie = None, filedata = None): + if not movie: movie = {} + if not data: data = {} response = False - log.error('Sending "%s" (%s) to Synology.', (data['name'], data['type'])) + log.error('Sending "%s" (%s) to Synology.', (data['name'], data['protocol'])) # Load host from config and split out port. host = self.conf('host').split(':') @@ -26,42 +28,44 @@ class Synology(Downloader): try: # Send request to Synology srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) - if data['type'] == 'torrent_magnet': + if data['protocol'] == 'torrent_magnet': log.info('Adding torrent URL %s', data['url']) response = srpc.create_task(url = data['url']) - elif data['type'] in ['nzb', 'torrent']: - log.info('Adding %s' % data['type']) + elif data['protocol'] in ['nzb', 'torrent']: + log.info('Adding %s' % data['protocol']) if not filedata: - log.error('No %s data found' % data['type']) + log.error('No %s data found' % data['protocol']) else: - filename = data['name'] + '.' + data['type'] + filename = data['name'] + '.' + data['protocol'] response = srpc.create_task(filename = filename, filedata = filedata) except Exception, err: log.error('Exception while adding torrent: %s', err) finally: return response - def getEnabledDownloadType(self): + def getEnabledProtocol(self): if self.conf('use_for') == 'both': - return super(Synology, self).getEnabledDownloadType() + return super(Synology, self).getEnabledProtocol() elif self.conf('use_for') == 'torrent': return ['torrent', 'torrent_magnet'] else: return ['nzb'] - def isEnabled(self, manual, data = {}): - for_type = ['both'] - if data and 'torrent' in data.get('type'): - for_type.append('torrent') + def isEnabled(self, manual = False, data = None): + if not data: data = {} + + for_protocol = ['both'] + if data and 'torrent' in data.get('protocol'): + for_protocol.append('torrent') elif data: - for_type.append(data.get('type')) + for_protocol.append(data.get('protocol')) return super(Synology, self).isEnabled(manual, data) and\ - ((self.conf('use_for') in for_type)) + ((self.conf('use_for') in for_protocol)) class SynologyRPC(object): - '''SynologyRPC lite library''' + """SynologyRPC lite library""" def __init__(self, host = 'localhost', port = 5000, username = None, password = None): @@ -98,7 +102,7 @@ class SynologyRPC(object): req = requests.post(url, data = args, files = files) req.raise_for_status() response = json.loads(req.text) - if response['success'] == True: + if response['success']: log.info('Synology action successfull') return response except requests.ConnectionError, err: @@ -111,11 +115,11 @@ class SynologyRPC(object): return response def create_task(self, url = None, filename = None, filedata = None): - ''' Creates new download task in Synology DownloadStation. Either specify + """ Creates new download task in Synology DownloadStation. Either specify url or pair (filename, filedata). Returns True if task was created, False otherwise - ''' + """ result = False # login if self._login(): diff --git a/couchpotato/core/downloaders/transmission/__init__.py b/couchpotato/core/downloaders/transmission/__init__.py index 6dfbd3f9..f96e628e 100644 --- a/couchpotato/core/downloaders/transmission/__init__.py +++ b/couchpotato/core/downloaders/transmission/__init__.py @@ -25,6 +25,13 @@ config = [{ 'default': 'localhost:9091', 'description': 'Hostname with port. Usually localhost:9091', }, + { + 'name': 'rpc_url', + 'type': 'string', + 'default': 'transmission', + 'advanced': True, + 'description': 'Change if you don\'t run Transmission RPC at the default url.', + }, { 'name': 'username', }, @@ -32,30 +39,33 @@ config = [{ 'name': 'password', 'type': 'password', }, - { - 'name': 'paused', - 'type': 'bool', - 'default': False, - 'description': 'Add the torrent paused.', - }, { 'name': 'directory', 'type': 'directory', 'description': 'Download to this directory. Keep empty for default Transmission download directory.', }, { - 'name': 'ratio', - 'default': 10, - 'type': 'float', + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'default': True, 'advanced': True, - 'description': 'Stop transfer when reaching ratio', + 'type': 'bool', + 'description': 'Remove the torrent from Transmission after it finished seeding.', }, { - 'name': 'ratiomode', - 'default': 0, - 'type': 'int', + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', 'advanced': True, - 'description': '0 = Use session limit, 1 = Use transfer limit, 2 = Disable limit.', + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'advanced': True, + 'default': False, + 'description': 'Add the torrent paused.', }, { 'name': 'manual', @@ -64,6 +74,20 @@ config = [{ 'advanced': True, 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', }, + { + 'name': 'stalled_as_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Consider a stalled torrent as failed', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, ], } ], diff --git a/couchpotato/core/downloaders/transmission/main.py b/couchpotato/core/downloaders/transmission/main.py index 6094e89b..5ff33c05 100644 --- a/couchpotato/core/downloaders/transmission/main.py +++ b/couchpotato/core/downloaders/transmission/main.py @@ -1,6 +1,7 @@ from base64 import b64encode from couchpotato.core.downloaders.base import Downloader, StatusList -from couchpotato.core.helpers.encoding import isInt +from couchpotato.core.helpers.encoding import isInt, ss +from couchpotato.core.helpers.variable import tryInt, tryFloat from couchpotato.core.logger import CPLog from couchpotato.environment import Env from datetime import timedelta @@ -8,7 +9,6 @@ import httplib import json import os.path import re -import traceback import urllib2 log = CPLog(__name__) @@ -16,151 +16,140 @@ log = CPLog(__name__) class Transmission(Downloader): - type = ['torrent', 'torrent_magnet'] + protocol = ['torrent', 'torrent_magnet'] log = CPLog(__name__) + trpc = None - def download(self, data, movie, filedata = None): - - log.info('Sending "%s" (%s) to Transmission.', (data.get('name'), data.get('type'))) - + def connect(self): # 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 - # Set parameters for Transmission - params = { - 'paused': self.conf('paused', default = 0), - } + if not self.trpc: + self.trpc = TransmissionRPC(host[0], port = host[1], rpc_url = self.conf('rpc_url'), username = self.conf('username'), password = self.conf('password')) - if len(self.conf('directory', default = '')) > 0: - folder_name = self.createFileName(data, filedata, movie)[:-len(data.get('type')) - 1] - params['download-dir'] = os.path.join(self.conf('directory', default = ''), folder_name).rstrip(os.path.sep) + return self.trpc - torrent_params = {} - if self.conf('ratio'): - torrent_params = { - 'seedRatioLimit': self.conf('ratio'), - 'seedRatioMode': self.conf('ratiomode') - } + def download(self, data, movie, filedata = None): - if not filedata and data.get('type') == 'torrent': + log.info('Sending "%s" (%s) to Transmission.', (data.get('name'), data.get('protocol'))) + + if not self.connect(): + return False + + if not filedata and data.get('protocol') == 'torrent': log.error('Failed sending torrent, no data') return False - # Send request to Transmission - try: - trpc = TransmissionRPC(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) - if data.get('type') == 'torrent_magnet': - remote_torrent = trpc.add_torrent_uri(data.get('url'), arguments = params) - torrent_params['trackerAdd'] = self.torrent_trackers + # Set parameters for adding torrent + params = { + 'paused': self.conf('paused', default = False) + } + + if self.conf('directory'): + if os.path.isdir(self.conf('directory')): + params['download-dir'] = self.conf('directory') else: - remote_torrent = trpc.add_torrent_file(b64encode(filedata), arguments = params) + log.error('Download directory from Transmission settings: %s doesn\'t exist', self.conf('directory')) - if not remote_torrent: - return False + # Change parameters of torrent + torrent_params = {} + if data.get('seed_ratio'): + torrent_params['seedRatioLimit'] = tryFloat(data.get('seed_ratio')) + torrent_params['seedRatioMode'] = 1 - # Change settings of added torrents - elif torrent_params: - trpc.set_torrent(remote_torrent['torrent-added']['hashString'], torrent_params) + if data.get('seed_time'): + torrent_params['seedIdleLimit'] = tryInt(data.get('seed_time')) * 60 + torrent_params['seedIdleMode'] = 1 - log.info('Torrent sent to Transmission successfully.') - return self.downloadReturnId(remote_torrent['torrent-added']['hashString']) - except: - log.error('Failed to change settings for transfer: %s', traceback.format_exc()) + # Send request to Transmission + if data.get('protocol') == 'torrent_magnet': + remote_torrent = self.trpc.add_torrent_uri(data.get('url'), arguments = params) + torrent_params['trackerAdd'] = self.torrent_trackers + else: + remote_torrent = self.trpc.add_torrent_file(b64encode(filedata), arguments = params) + + if not remote_torrent: + log.error('Failed sending torrent to Transmission') return False + # Change settings of added torrents + if torrent_params: + self.trpc.set_torrent(remote_torrent['torrent-added']['hashString'], torrent_params) + + log.info('Torrent sent to Transmission successfully.') + return self.downloadReturnId(remote_torrent['torrent-added']['hashString']) + def getAllDownloadStatus(self): log.debug('Checking Transmission 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.') + if not self.connect(): return False - # Go through Queue - try: - trpc = TransmissionRPC(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) - return_params = { - 'fields': ['id', 'name', 'hashString', 'percentDone', 'status', 'eta', 'isFinished', 'downloadDir', 'uploadRatio'] - } - queue = trpc.get_alltorrents(return_params) - except Exception, err: - log.error('Failed getting queue: %s', err) - return False - - if not queue: - return [] - statuses = StatusList(self) - # Get torrents status - # CouchPotato Status - #status = 'busy' - #status = 'failed' - #status = 'completed' - # Transmission Status - #status = 0 => "Torrent is stopped" - #status = 1 => "Queued to check files" - #status = 2 => "Checking files" - #status = 3 => "Queued to download" - #status = 4 => "Downloading" - #status = 4 => "Queued to seed" - #status = 6 => "Seeding" - #To do : - # add checking file - # manage no peer in a range time => fail + return_params = { + 'fields': ['id', 'name', 'hashString', 'percentDone', 'status', 'eta', 'isStalled', 'isFinished', 'downloadDir', 'uploadRatio', 'secondsSeeding', 'seedIdleLimit'] + } + + queue = self.trpc.get_alltorrents(return_params) + if not (queue and queue.get('torrents')): + log.debug('Nothing in queue or error') + return False for item in queue['torrents']: - log.debug('name=%s / id=%s / downloadDir=%s / hashString=%s / percentDone=%s / status=%s / eta=%s / uploadRatio=%s / confRatio=%s / isFinished=%s', (item['name'], item['id'], item['downloadDir'], item['hashString'], item['percentDone'], item['status'], item['eta'], item['uploadRatio'], self.conf('ratio'), item['isFinished'])) + log.debug('name=%s / id=%s / downloadDir=%s / hashString=%s / percentDone=%s / status=%s / eta=%s / uploadRatio=%s / isFinished=%s', + (item['name'], item['id'], item['downloadDir'], item['hashString'], item['percentDone'], item['status'], item['eta'], item['uploadRatio'], item['isFinished'])) if not os.path.isdir(Env.setting('from', 'renamer')): log.error('Renamer "from" folder doesn\'t to exist.') return - if (item['percentDone'] * 100) >= 100 and (item['status'] == 6 or item['status'] == 0) and item['uploadRatio'] > self.conf('ratio'): - try: - trpc.stop_torrent(item['hashString'], {}) - statuses.append({ - 'id': item['hashString'], - 'name': item['name'], - 'status': 'completed', - 'original_status': item['status'], - 'timeleft': str(timedelta(seconds = 0)), - 'folder': os.path.join(item['downloadDir'], item['name']), - }) - except Exception, err: - log.error('Failed to stop and remove torrent "%s" with error: %s', (item['name'], err)) - statuses.append({ - 'id': item['hashString'], - 'name': item['name'], - 'status': 'failed', - 'original_status': item['status'], - 'timeleft': str(timedelta(seconds = 0)), - }) - else: - statuses.append({ - 'id': item['hashString'], - 'name': item['name'], - 'status': 'busy', - 'original_status': item['status'], - 'timeleft': str(timedelta(seconds = item['eta'])), # Is ETA in seconds?? - }) + status = 'busy' + if item['isStalled'] and self.conf('stalled_as_failed'): + status = 'failed' + elif item['status'] == 0 and item['percentDone'] == 1: + status = 'completed' + elif item['status'] in [5, 6]: + status = 'seeding' + + statuses.append({ + 'id': item['hashString'], + 'name': item['name'], + 'status': status, + 'original_status': item['status'], + 'seed_ratio': item['uploadRatio'], + 'timeleft': str(timedelta(seconds = item['eta'])), + 'folder': ss(os.path.join(item['downloadDir'], item['name'])), + }) return statuses + def pause(self, item, pause = True): + if pause: + return self.trpc.stop_torrent(item['id']) + else: + return self.trpc.start_torrent(item['id']) + + def removeFailed(self, item): + log.info('%s failed downloading, deleting...', item['name']) + return self.trpc.remove_torrent(item['hashString'], True) + + def processComplete(self, item, delete_files = False): + log.debug('Requesting Transmission to remove the torrent %s%s.', (item['name'], ' and cleanup the downloaded files' if delete_files else '')) + return self.trpc.remove_torrent(item['hashString'], delete_files) + class TransmissionRPC(object): """TransmissionRPC lite library""" - - def __init__(self, host = 'localhost', port = 9091, username = None, password = None): + def __init__(self, host = 'localhost', port = 9091, rpc_url = 'transmission', username = None, password = None): super(TransmissionRPC, self).__init__() - self.url = 'http://' + host + ':' + str(port) + '/transmission/rpc' + self.url = 'http://' + host + ':' + str(port) + '/' + rpc_url + '/rpc' self.tag = 0 self.session_id = 0 self.session = {} @@ -184,7 +173,7 @@ class TransmissionRPC(object): log.debug('request: %s', json.dumps(ojson)) log.debug('response: %s', json.dumps(response)) if response['result'] == 'success': - log.debug('Transmission action successfull') + log.debug('Transmission action successful') return response['arguments'] else: log.debug('Unknown failure sending command to Transmission. Return text is: %s', response['result']) @@ -236,13 +225,15 @@ class TransmissionRPC(object): post_data = {'arguments': arguments, 'method': 'torrent-get', 'tag': self.tag} return self._request(post_data) - def stop_torrent(self, torrent_id, arguments): - arguments['ids'] = torrent_id - post_data = {'arguments': arguments, 'method': 'torrent-stop', 'tag': self.tag} + def stop_torrent(self, torrent_id): + post_data = {'arguments': {'ids': torrent_id}, 'method': 'torrent-stop', 'tag': self.tag} return self._request(post_data) - def remove_torrent(self, torrent_id, remove_local_data, arguments): - arguments['ids'] = torrent_id - arguments['delete-local-data'] = remove_local_data - post_data = {'arguments': arguments, 'method': 'torrent-remove', 'tag': self.tag} + def start_torrent(self, torrent_id): + post_data = {'arguments': {'ids': torrent_id}, 'method': 'torrent-start', 'tag': self.tag} return self._request(post_data) + + def remove_torrent(self, torrent_id, delete_local_data): + post_data = {'arguments': {'ids': torrent_id, 'delete-local-data': delete_local_data}, 'method': 'torrent-remove', 'tag': self.tag} + return self._request(post_data) + diff --git a/couchpotato/core/downloaders/utorrent/__init__.py b/couchpotato/core/downloaders/utorrent/__init__.py index 2c494eb2..d45e2e6c 100644 --- a/couchpotato/core/downloaders/utorrent/__init__.py +++ b/couchpotato/core/downloaders/utorrent/__init__.py @@ -11,7 +11,7 @@ config = [{ 'list': 'download_providers', 'name': 'utorrent', 'label': 'uTorrent', - 'description': 'Use uTorrent to download torrents.', + 'description': 'Use uTorrent (3.0+) to download torrents.', 'wizard': True, 'options': [ { @@ -36,9 +36,26 @@ config = [{ 'name': 'label', 'description': 'Label to add torrent as.', }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Remove the torrent from uTorrent after it finished seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, { 'name': 'paused', 'type': 'bool', + 'advanced': True, 'default': False, 'description': 'Add the torrent paused.', }, @@ -49,6 +66,13 @@ config = [{ 'advanced': True, 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, ], } ], diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index f49859ab..d9330076 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -1,7 +1,8 @@ from base64 import b16encode, b32decode -from bencode import bencode, bdecode +from bencode import bencode as benc, bdecode from couchpotato.core.downloaders.base import Downloader, StatusList from couchpotato.core.helpers.encoding import isInt, ss +from couchpotato.core.helpers.variable import tryInt, tryFloat from couchpotato.core.logger import CPLog from datetime import timedelta from hashlib import sha1 @@ -9,123 +10,170 @@ from multipartpost import MultipartPostHandler import cookielib import httplib import json +import os import re +import stat import time import urllib import urllib2 - log = CPLog(__name__) class uTorrent(Downloader): - type = ['torrent', 'torrent_magnet'] + protocol = ['torrent', 'torrent_magnet'] utorrent_api = None - def download(self, data, movie, filedata = None): - - log.debug('Sending "%s" (%s) to uTorrent.', (data.get('name'), data.get('type'))) - + def connect(self): # 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 + self.utorrent_api = uTorrentAPI(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) + + return self.utorrent_api + + def download(self, data = None, movie = None, filedata = None): + if not movie: movie = {} + if not data: data = {} + + log.debug('Sending "%s" (%s) to uTorrent.', (data.get('name'), data.get('protocol'))) + + if not self.connect(): + return False + + settings = self.utorrent_api.get_settings() + if not settings: + return False + + #Fix settings in case they are not set for CPS compatibility + new_settings = {} + if not (settings.get('seed_prio_limitul') == 0 and settings['seed_prio_limitul_flag']): + new_settings['seed_prio_limitul'] = 0 + new_settings['seed_prio_limitul_flag'] = True + log.info('Updated uTorrent settings to set a torrent to complete after it the seeding requirements are met.') + + if settings.get('bt.read_only_on_complete'): #This doesn't work as this option seems to be not available through the api. Mitigated with removeReadOnly function + new_settings['bt.read_only_on_complete'] = False + log.info('Updated uTorrent settings to not set the files to read only after completing.') + + if new_settings: + self.utorrent_api.set_settings(new_settings) + torrent_params = {} if self.conf('label'): torrent_params['label'] = self.conf('label') - if not filedata and data.get('type') == 'torrent': + if not filedata and data.get('protocol') == 'torrent': log.error('Failed sending torrent, no data') return False - if data.get('type') == 'torrent_magnet': + if data.get('protocol') == '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) else: info = bdecode(filedata)["info"] - torrent_hash = sha1(bencode(info)).hexdigest().upper() + torrent_hash = sha1(benc(info)).hexdigest().upper() torrent_filename = self.createFileName(data, filedata, movie) + if data.get('seed_ratio'): + torrent_params['seed_override'] = 1 + torrent_params['seed_ratio'] = tryInt(tryFloat(data['seed_ratio']) * 1000) + + if data.get('seed_time'): + torrent_params['seed_override'] = 1 + torrent_params['seed_time'] = tryInt(data['seed_time']) * 3600 + # 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: - self.utorrent_api = uTorrentAPI(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) + if data.get('protocol') == 'torrent_magnet': + self.utorrent_api.add_torrent_uri(data.get('url')) + else: + self.utorrent_api.add_torrent_file(torrent_filename, filedata) - if data.get('type') == 'torrent_magnet': - self.utorrent_api.add_torrent_uri(data.get('url')) - else: - self.utorrent_api.add_torrent_file(torrent_filename, filedata) + # Change settings of added torrent + self.utorrent_api.set_torrent(torrent_hash, torrent_params) + if self.conf('paused', default = 0): + self.utorrent_api.pause_torrent(torrent_hash) - # Change settings of added torrents - self.utorrent_api.set_torrent(torrent_hash, torrent_params) - if self.conf('paused', default = 0): - self.utorrent_api.pause_torrent(torrent_hash) - return self.downloadReturnId(torrent_hash) - except Exception, err: - log.error('Failed to send torrent to uTorrent: %s', err) - return False + return self.downloadReturnId(torrent_hash) 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') + if not self.connect(): return False statuses = StatusList(self) + data = self.utorrent_api.get_status() + if not data: + log.error('Error getting data from uTorrent') + return False + + queue = json.loads(data) + if queue.get('error'): + log.error('Error getting data from uTorrent: %s', queue.get('error')) + return False + + if not queue.get('torrents'): + log.debug('Nothing in queue') + return False + # Get torrents - for item in queue.get('torrents', []): + for item in queue['torrents']: # item[21] = Paused | Downloading | Seeding | Finished status = 'busy' - if item[21] == 'Finished' or item[21] == 'Seeding': + if 'Finished' in item[21]: status = 'completed' + self.removeReadOnly(item[26]) + elif 'Seeding' in item[21]: + status = 'seeding' + self.removeReadOnly(item[26]) statuses.append({ 'id': item[0], 'name': item[2], 'status': status, + 'seed_ratio': float(item[7]) / 1000, 'original_status': item[1], 'timeleft': str(timedelta(seconds = item[10])), - 'folder': item[26], + 'folder': ss(item[26]), }) return statuses + def pause(self, item, pause = True): + if not self.connect(): + return False + return self.utorrent_api.pause_torrent(item['id'], pause) + def removeFailed(self, item): + log.info('%s failed downloading, deleting...', item['name']) + if not self.connect(): + return False + return self.utorrent_api.remove_torrent(item['id'], remove_data = True) + + def processComplete(self, item, delete_files = False): + log.debug('Requesting uTorrent to remove the torrent %s%s.', (item['name'], ' and cleanup the downloaded files' if delete_files else '')) + if not self.connect(): + return False + return self.utorrent_api.remove_torrent(item['id'], remove_data = delete_files) + + def removeReadOnly(self, folder): + #Removes all read-only flags in a folder + if folder and os.path.isdir(folder): + for root, folders, filenames in os.walk(folder): + for filename in filenames: + os.chmod(os.path.join(root, filename), stat.S_IWRITE) class uTorrentAPI(object): @@ -190,8 +238,22 @@ class uTorrentAPI(object): action += "&s=%s&v=%s" % (k, v) return self._request(action) - def pause_torrent(self, hash): - action = "action=pause&hash=%s" % hash + def pause_torrent(self, hash, pause = True): + if pause: + action = "action=pause&hash=%s" % hash + else: + action = "action=unpause&hash=%s" % hash + return self._request(action) + + def stop_torrent(self, hash): + action = "action=stop&hash=%s" % hash + return self._request(action) + + def remove_torrent(self, hash, remove_data = False): + if remove_data: + action = "action=removedata&hash=%s" % hash + else: + action = "action=remove&hash=%s" % hash return self._request(action) def get_status(self): @@ -219,3 +281,13 @@ class uTorrentAPI(object): log.error('Failed to get settings from uTorrent: %s', err) return settings_dict + + def set_settings(self, settings_dict = None): + if not settings_dict: settings_dict = {} + + for key in settings_dict: + if isinstance(settings_dict[key], bool): + settings_dict[key] = 1 if settings_dict[key] else 0 + + action = 'action=setsetting' + ''.join(['&s=%s&v=%s' % (key, value) for (key, value) in settings_dict.items()]) + return self._request(action) diff --git a/couchpotato/core/event.py b/couchpotato/core/event.py index 0e0b4a70..7b01fbd8 100644 --- a/couchpotato/core/event.py +++ b/couchpotato/core/event.py @@ -21,9 +21,11 @@ def addEvent(name, handler, priority = 100): def createHandle(*args, **kwargs): + h = None try: # Open handler has_parent = hasattr(handler, 'im_self') + parent = None if has_parent: parent = handler.im_self bc = hasattr(parent, 'beforeCall') @@ -33,7 +35,7 @@ def addEvent(name, handler, priority = 100): h = runHandler(name, handler, *args, **kwargs) # Close handler - if has_parent: + if parent and has_parent: ac = hasattr(parent, 'afterCall') if ac: parent.afterCall(handler) except: @@ -53,11 +55,6 @@ def removeEvent(name, handler): def fireEvent(name, *args, **kwargs): if not events.has_key(name): return - e = Event(name = name, threads = 10, asynch = kwargs.get('async', False), exc_info = True, traceback = True, lock = threading.RLock()) - - for event in events[name]: - e.handle(event['handler'], priority = event['priority']) - #log.debug('Firing event %s', name) try: @@ -67,7 +64,6 @@ def fireEvent(name, *args, **kwargs): 'single': False, # Return single handler 'merge': False, # Merge items 'in_order': False, # Fire them in specific order, waits for the other to finish - 'async': False } # Do options @@ -78,12 +74,32 @@ def fireEvent(name, *args, **kwargs): options[x] = val except: pass - # Make sure only 1 event is fired at a time when order is wanted - kwargs['event_order_lock'] = threading.RLock() if options['in_order'] or options['single'] else None - kwargs['event_return_on_result'] = options['single'] + if len(events[name]) == 1: - # Fire - result = e(*args, **kwargs) + single = None + try: + single = events[name][0]['handler'](*args, **kwargs) + except: + log.error('Failed running single event: %s', traceback.format_exc()) + + # Don't load thread for single event + result = { + 'single': (single is not None, single), + } + + else: + + e = Event(name = name, threads = 10, exc_info = True, traceback = True, lock = threading.RLock()) + + for event in events[name]: + e.handle(event['handler'], priority = event['priority']) + + # Make sure only 1 event is fired at a time when order is wanted + kwargs['event_order_lock'] = threading.RLock() if options['in_order'] or options['single'] else None + kwargs['event_return_on_result'] = options['single'] + + # Fire + result = e(*args, **kwargs) if options['single'] and not options['merge']: results = None diff --git a/couchpotato/core/helpers/encoding.py b/couchpotato/core/helpers/encoding.py index a11dd88b..6e864446 100644 --- a/couchpotato/core/helpers/encoding.py +++ b/couchpotato/core/helpers/encoding.py @@ -11,7 +11,8 @@ log = CPLog(__name__) def toSafeString(original): valid_chars = "-_.() %s%s" % (ascii_letters, digits) cleanedFilename = unicodedata.normalize('NFKD', toUnicode(original)).encode('ASCII', 'ignore') - return ''.join(c for c in cleanedFilename if c in valid_chars) + valid_string = ''.join(c for c in cleanedFilename if c in valid_chars) + return ' '.join(valid_string.split()) def simplifyString(original): string = stripAccents(original.lower()) @@ -62,7 +63,7 @@ def stripAccents(s): def tryUrlencode(s): new = u'' - if isinstance(s, (dict)): + if isinstance(s, dict): for key, value in s.iteritems(): new += u'&%s=%s' % (key, tryUrlencode(value)) diff --git a/couchpotato/core/helpers/request.py b/couchpotato/core/helpers/request.py index c2249796..888e63fd 100644 --- a/couchpotato/core/helpers/request.py +++ b/couchpotato/core/helpers/request.py @@ -8,7 +8,7 @@ def getParams(params): reg = re.compile('^[a-z0-9_\.]+$') - current = temp = {} + temp = {} for param, value in sorted(params.iteritems()): nest = re.split("([\[\]]+)", param) diff --git a/couchpotato/core/helpers/rss.py b/couchpotato/core/helpers/rss.py index d88fdb53..b840d862 100644 --- a/couchpotato/core/helpers/rss.py +++ b/couchpotato/core/helpers/rss.py @@ -6,7 +6,7 @@ log = CPLog(__name__) class RSS(object): def getTextElements(self, xml, path): - ''' Find elements and return tree''' + """ Find elements and return tree""" textelements = [] try: @@ -28,7 +28,7 @@ class RSS(object): return elements def getElement(self, xml, path): - ''' Find element and return text''' + """ Find element and return text""" try: return xml.find(path) @@ -36,7 +36,7 @@ class RSS(object): return def getTextElement(self, xml, path): - ''' Find element and return text''' + """ Find element and return text""" try: return xml.find(path).text diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index fa8a8b51..8f393d0a 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -106,6 +106,11 @@ def md5(text): def sha1(text): return hashlib.sha1(text).hexdigest() +def isLocalIP(ip): + ip = ip.lstrip('htps:/') + regex = '/(^127\.)|(^192\.168\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^::1)$/' + return re.search(regex, ip) is not None or 'localhost' in ip or ip[:4] == '127.' + def getExt(filename): return os.path.splitext(filename)[1][1:] @@ -113,8 +118,8 @@ def cleanHost(host): if not host.startswith(('http://', 'https://')): host = 'http://' + host - if not host.endswith('/'): - host += '/' + host = host.rstrip('/') + host += '/' return host @@ -128,7 +133,7 @@ def getImdb(txt, check_inside = True, multiple = False): try: ids = re.findall('(tt\d{7})', txt) if multiple: - return ids if len(ids) > 0 else [] + return list(set(ids)) if len(ids) > 0 else [] return ids[0] except IndexError: pass @@ -140,7 +145,11 @@ def tryInt(s): except: return 0 def tryFloat(s): - try: return float(s) if '.' in s else tryInt(s) + try: + if isinstance(s, str): + return float(s) if '.' in s else tryInt(s) + else: + return float(s) except: return 0 def natsortKey(s): @@ -170,11 +179,15 @@ def getTitle(library_dict): def possibleTitles(raw_title): - titles = [] + titles = [ + toSafeString(raw_title).lower(), + raw_title.lower(), + simplifyString(raw_title) + ] - titles.append(toSafeString(raw_title).lower()) - titles.append(raw_title.lower()) - titles.append(simplifyString(raw_title)) + # replace some chars + new_title = raw_title.replace('&', 'and') + titles.append(simplifyString(new_title)) return list(set(titles)) diff --git a/couchpotato/core/loader.py b/couchpotato/core/loader.py index a97437a2..2016d287 100644 --- a/couchpotato/core/loader.py +++ b/couchpotato/core/loader.py @@ -6,15 +6,24 @@ import traceback log = CPLog(__name__) -class Loader(object): +class Loader(object): plugins = {} providers = {} - modules = {} - def preload(self, root = ''): + def addPath(self, root, base_path, priority, recursive = False): + for filename in os.listdir(os.path.join(root, *base_path)): + path = os.path.join(os.path.join(root, *base_path), filename) + if os.path.isdir(path) and filename[:2] != '__': + if u'__init__.py' in os.listdir(path): + new_base_path = ''.join(s + '.' for s in base_path) + filename + self.paths[new_base_path.replace('.', '_')] = (priority, new_base_path, path) + if recursive: + self.addPath(root, base_path + [filename], priority, recursive = True) + + def preload(self, root = ''): core = os.path.join(root, 'couchpotato', 'core') self.paths = { @@ -25,12 +34,10 @@ class Loader(object): } # Add providers to loader - provider_dir = os.path.join(root, 'couchpotato', 'core', 'providers') - for provider in os.listdir(provider_dir): - path = os.path.join(provider_dir, provider) - if os.path.isdir(path): - self.paths[provider + '_provider'] = (25, 'couchpotato.core.providers.' + provider, path) + self.addPath(root, ['couchpotato', 'core', 'providers'], 25, recursive = False) + # Add media to loader + self.addPath(root, ['couchpotato', 'core', 'media'], 25, recursive = True) for plugin_type, plugin_tuple in self.paths.iteritems(): priority, module, dir_name = plugin_tuple @@ -43,7 +50,13 @@ class Loader(object): for module_name, plugin in sorted(self.modules[priority].iteritems()): # Load module try: - m = getattr(self.loadModule(module_name), plugin.get('name')) + if plugin.get('name')[:2] == '__': + continue + + m = self.loadModule(module_name) + if m is None: + continue + m = getattr(m, plugin.get('name')) log.info('Loading %s: %s', (plugin['type'], plugin['name'])) @@ -53,7 +66,7 @@ class Loader(object): self.loadPlugins(m, plugin.get('name')) except ImportError as e: # todo:: subclass ImportError for missing requirements. - if (e.message.lower().startswith("missing")): + if e.message.lower().startswith("missing"): log.error(e.message) pass # todo:: this needs to be more descriptive. @@ -73,19 +86,21 @@ class Loader(object): splitted = module.split('.') for sub in splitted[1:]: m = getattr(m, sub) - - if hasattr(m, 'config'): - fireEvent('settings.options', splitted[-1] + '_config', getattr(m, 'config')) except: raise for cur_file in glob.glob(os.path.join(dir_name, '*')): name = os.path.basename(cur_file) - if os.path.isdir(os.path.join(dir_name, name)): + if os.path.isdir(os.path.join(dir_name, name)) and name != 'static' and os.path.isfile(os.path.join(cur_file, '__init__.py')): module_name = '%s.%s' % (module, name) self.addModule(priority, plugin_type, module_name, name) def loadSettings(self, module, name, save = True): + + if not hasattr(module, 'config'): + log.debug('Skip loading settings for plugin %s as it has no config section' % module.__file__) + return False + try: for section in module.config: fireEvent('settings.options', section['name'], section) @@ -100,15 +115,14 @@ class Loader(object): return False def loadPlugins(self, module, name): + + if not hasattr(module, 'start'): + log.debug('Skip startup for plugin %s as it has no start section' % module.__file__) + return False try: - klass = module.start() - klass.registerPlugin() - - if klass and getattr(klass, 'auto_register_static'): - klass.registerStatic(module.__file__) - + module.start() return True - except Exception, e: + except: log.error('Failed loading plugin "%s": %s', (module.__file__, traceback.format_exc())) return False @@ -131,5 +145,8 @@ class Loader(object): for sub in splitted[1:-1]: m = getattr(m, sub) return m + except ImportError: + log.debug('Skip loading module plugin %s: %s', (name, traceback.format_exc())) + return None except: raise diff --git a/couchpotato/core/media/__init__.py b/couchpotato/core/media/__init__.py new file mode 100644 index 00000000..1cef967b --- /dev/null +++ b/couchpotato/core/media/__init__.py @@ -0,0 +1,13 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.plugins.base import Plugin + + +class MediaBase(Plugin): + + _type = None + + def initType(self): + addEvent('media.types', self.getType) + + def getType(self): + return self._type diff --git a/couchpotato/core/providers/movie/__init__.py b/couchpotato/core/media/_base/__init__.py similarity index 100% rename from couchpotato/core/providers/movie/__init__.py rename to couchpotato/core/media/_base/__init__.py diff --git a/couchpotato/core/media/_base/library/__init__.py b/couchpotato/core/media/_base/library/__init__.py new file mode 100644 index 00000000..553eff5a --- /dev/null +++ b/couchpotato/core/media/_base/library/__init__.py @@ -0,0 +1,13 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.plugins.base import Plugin + + +class LibraryBase(Plugin): + + _type = None + + def initType(self): + addEvent('library.types', self.getType) + + def getType(self): + return self._type diff --git a/couchpotato/core/media/_base/searcher/__init__.py b/couchpotato/core/media/_base/searcher/__init__.py new file mode 100644 index 00000000..0fb6cc09 --- /dev/null +++ b/couchpotato/core/media/_base/searcher/__init__.py @@ -0,0 +1,75 @@ +from .main import Searcher + +def start(): + return Searcher() + +config = [{ + 'name': 'searcher', + 'order': 20, + 'groups': [ + { + 'tab': 'searcher', + 'name': 'searcher', + 'label': 'Basics', + 'description': 'General search options', + 'options': [ + { + 'name': 'preferred_method', + 'label': 'First search', + 'description': 'Which of the methods do you prefer', + 'default': 'both', + 'type': 'dropdown', + 'values': [('usenet & torrents', 'both'), ('usenet', 'nzb'), ('torrents', 'torrent')], + }, + ], + }, { + 'tab': 'searcher', + 'subtab': 'category', + 'subtab_label': 'Categories', + 'name': 'filter', + 'label': 'Global filters', + 'description': 'Prefer, ignore & required words in release names', + 'options': [ + { + 'name': 'preferred_words', + 'label': 'Preferred', + 'default': '', + 'placeholder': 'Example: CtrlHD, Amiable, Wiki', + 'description': 'Words that give the releases a higher score.' + }, + { + 'name': 'required_words', + 'label': 'Required', + 'default': '', + 'placeholder': 'Example: DTS, AC3 & English', + 'description': 'Release should contain at least one set of words. Sets are separated by "," and each word within a set must be separated with "&"' + }, + { + 'name': 'ignored_words', + 'label': 'Ignored', + 'default': 'german, dutch, french, truefrench, danish, swedish, spanish, italian, korean, dubbed, swesub, korsub, dksubs', + 'description': 'Ignores releases that match any of these sets. (Works like explained above)' + }, + ], + }, + ], +}, { + 'name': 'nzb', + 'groups': [ + { + 'tab': 'searcher', + 'name': 'searcher', + 'label': 'NZB', + 'wizard': True, + 'options': [ + { + 'name': 'retention', + 'label': 'Usenet Retention', + 'default': 1500, + 'type': 'int', + 'unit': 'days' + }, + ], + }, + ], +}] diff --git a/couchpotato/core/media/_base/searcher/base.py b/couchpotato/core/media/_base/searcher/base.py new file mode 100644 index 00000000..368c6e2d --- /dev/null +++ b/couchpotato/core/media/_base/searcher/base.py @@ -0,0 +1,45 @@ +from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin + +log = CPLog(__name__) + + +class SearcherBase(Plugin): + + in_progress = False + + def __init__(self): + super(SearcherBase, self).__init__() + + + addEvent('searcher.progress', self.getProgress) + addEvent('%s.searcher.progress' % self.getType(), self.getProgress) + + self.initCron() + + def initCron(self): + """ Set the searcher cronjob + Make sure to reset cronjob after setting has changed + """ + + _type = self.getType() + + def setCrons(): + + fireEvent('schedule.cron', '%s.searcher.all' % _type, self.searchAll, + day = self.conf('cron_day'), hour = self.conf('cron_hour'), minute = self.conf('cron_minute')) + + addEvent('app.load', setCrons) + addEvent('setting.save.%s_searcher.cron_day.after' % _type, setCrons) + addEvent('setting.save.%s_searcher.cron_hour.after' % _type, setCrons) + addEvent('setting.save.%s_searcher.cron_minute.after' % _type, setCrons) + + def getProgress(self, **kwargs): + """ Return progress of current searcher""" + + progress = { + self.getType(): self.in_progress + } + + return progress diff --git a/couchpotato/core/media/_base/searcher/main.py b/couchpotato/core/media/_base/searcher/main.py new file mode 100644 index 00000000..89afd756 --- /dev/null +++ b/couchpotato/core/media/_base/searcher/main.py @@ -0,0 +1,234 @@ +from couchpotato import get_session +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.helpers.encoding import simplifyString, toUnicode +from couchpotato.core.helpers.variable import md5, getTitle +from couchpotato.core.logger import CPLog +from couchpotato.core.media._base.searcher.base import SearcherBase +from couchpotato.core.settings.model import Movie, Release, ReleaseInfo +from couchpotato.environment import Env +from inspect import ismethod, isfunction +import datetime +import re +import time +import traceback + +log = CPLog(__name__) + + +class Searcher(SearcherBase): + + def __init__(self): + addEvent('searcher.protocols', self.getSearchProtocols) + addEvent('searcher.contains_other_quality', self.containsOtherQuality) + addEvent('searcher.correct_year', self.correctYear) + addEvent('searcher.correct_name', self.correctName) + addEvent('searcher.download', self.download) + + addApiView('searcher.full_search', self.searchAllView, docs = { + 'desc': 'Starts a full search for all media', + }) + + addApiView('searcher.progress', self.getProgressForAll, docs = { + 'desc': 'Get the progress of all media searches', + 'return': {'type': 'object', 'example': """{ + 'movie': False || object, total & to_go, + 'show': False || object, total & to_go, +}"""}, + }) + + def searchAllView(self): + + results = {} + for _type in fireEvent('media.types'): + results[_type] = fireEvent('%s.searcher.all_view' % _type) + + return results + + def getProgressForAll(self): + progress = fireEvent('searcher.progress', merge = True) + return progress + + def download(self, data, movie, manual = False): + + # Test to see if any downloaders are enabled for this type + downloader_enabled = fireEvent('download.enabled', manual, data, single = True) + + if downloader_enabled: + + snatched_status = fireEvent('status.get', 'snatched', single = True) + + # Download movie to temp + filedata = None + if data.get('download') and (ismethod(data.get('download')) or isfunction(data.get('download'))): + filedata = data.get('download')(url = data.get('url'), nzb_id = data.get('id')) + if filedata == 'try_next': + return filedata + + download_result = fireEvent('download', data = data, movie = movie, manual = manual, filedata = filedata, single = True) + log.debug('Downloader result: %s', download_result) + + if download_result: + try: + # Mark release as snatched + db = get_session() + rls = db.query(Release).filter_by(identifier = md5(data['url'])).first() + if rls: + 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') + + # Save download-id info if returned + if isinstance(download_result, dict): + for key in download_result: + rls_info = ReleaseInfo( + identifier = 'download_%s' % key, + value = toUnicode(download_result.get(key)) + ) + rls.info.append(rls_info) + db.commit() + + log_movie = '%s (%s) in %s' % (getTitle(movie['library']), movie['library']['year'], rls.quality.label) + snatch_message = 'Snatched "%s": %s' % (data.get('name'), log_movie) + log.info(snatch_message) + fireEvent('movie.snatched', message = snatch_message, data = rls.to_dict()) + + # 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') + 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') + 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()) + + return True + + log.info('Tried to download, but none of the "%s" downloaders are enabled or gave an error', (data.get('protocol', ''))) + + return False + + def getSearchProtocols(self): + + download_protocols = fireEvent('download.enabled_protocols', merge = True) + provider_protocols = fireEvent('provider.enabled_protocols', merge = True) + + if download_protocols and len(list(set(provider_protocols) & set(download_protocols))) == 0: + log.error('There aren\'t any providers enabled for your downloader (%s). Check your settings.', ','.join(download_protocols)) + return [] + + for useless_provider in list(set(provider_protocols) - set(download_protocols)): + log.debug('Provider for "%s" enabled, but no downloader.', useless_provider) + + search_protocols = download_protocols + + if len(search_protocols) == 0: + log.error('There aren\'t any downloaders enabled. Please pick one in settings.') + return [] + + return search_protocols + + def containsOtherQuality(self, nzb, movie_year = None, preferred_quality = None): + if not preferred_quality: preferred_quality = {} + + name = nzb['name'] + size = nzb.get('size', 0) + nzb_words = re.split('\W+', simplifyString(name)) + + qualities = fireEvent('quality.all', single = True) + + found = {} + for quality in qualities: + # Main in words + if quality['identifier'] in nzb_words: + found[quality['identifier']] = True + + # Alt in words + if list(set(nzb_words) & set(quality['alternative'])): + found[quality['identifier']] = True + + # Try guessing via quality tags + guess = fireEvent('quality.guess', [nzb.get('name')], single = True) + if guess: + found[guess['identifier']] = True + + # Hack for older movies that don't contain quality tag + year_name = fireEvent('scanner.name_year', name, single = True) + if len(found) == 0 and movie_year < datetime.datetime.now().year - 3 and not year_name.get('year', None): + if size > 3000: # Assume dvdr + log.info('Quality was missing in name, assuming it\'s a DVD-R based on the size: %s', size) + found['dvdr'] = True + else: # Assume dvdrip + log.info('Quality was missing in name, assuming it\'s a DVD-Rip based on the size: %s', size) + found['dvdrip'] = True + + # Allow other qualities + for allowed in preferred_quality.get('allow'): + if found.get(allowed): + del found[allowed] + + return not (found.get(preferred_quality['identifier']) and len(found) == 1) + + def correctYear(self, haystack, year, year_range): + + if not isinstance(haystack, (list, tuple, set)): + haystack = [haystack] + + year_name = {} + for string in haystack: + + year_name = fireEvent('scanner.name_year', string, single = True) + + if year_name and ((year - year_range) <= year_name.get('year') <= (year + year_range)): + log.debug('Movie year matches range: %s looking for %s', (year_name.get('year'), year)) + return True + + log.debug('Movie year doesn\'t matche range: %s looking for %s', (year_name.get('year'), year)) + return False + + def correctName(self, check_name, movie_name): + + check_names = [check_name] + + # Match names between " + try: check_names.append(re.search(r'([\'"])[^\1]*\1', check_name).group(0)) + except: pass + + # Match longest name between [] + try: check_names.append(max(check_name.split('['), key = len)) + except: pass + + for check_name in list(set(check_names)): + check_movie = fireEvent('scanner.name_year', check_name, single = True) + + try: + check_words = filter(None, re.split('\W+', check_movie.get('name', ''))) + movie_words = filter(None, re.split('\W+', simplifyString(movie_name))) + + if len(check_words) > 0 and len(movie_words) > 0 and len(list(set(check_words) - set(movie_words))) == 0: + return True + except: + pass + + return False + +class SearchSetupError(Exception): + pass diff --git a/couchpotato/core/media/movie/__init__.py b/couchpotato/core/media/movie/__init__.py new file mode 100644 index 00000000..898529c1 --- /dev/null +++ b/couchpotato/core/media/movie/__init__.py @@ -0,0 +1,6 @@ +from couchpotato.core.media import MediaBase + + +class MovieTypeBase(MediaBase): + + _type = 'movie' diff --git a/couchpotato/core/media/movie/_base/__init__.py b/couchpotato/core/media/movie/_base/__init__.py new file mode 100644 index 00000000..4be3b127 --- /dev/null +++ b/couchpotato/core/media/movie/_base/__init__.py @@ -0,0 +1,6 @@ +from .main import MovieBase + +def start(): + return MovieBase() + +config = [] diff --git a/couchpotato/core/plugins/movie/main.py b/couchpotato/core/media/movie/_base/main.py similarity index 77% rename from couchpotato/core/plugins/movie/main.py rename to couchpotato/core/media/movie/_base/main.py index 0cc98fd3..e6f66e26 100644 --- a/couchpotato/core/plugins/movie/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -2,9 +2,10 @@ from couchpotato import get_session from couchpotato.api import addApiView from couchpotato.core.event import fireEvent, fireEventAsync, addEvent from couchpotato.core.helpers.encoding import toUnicode, simplifyString -from couchpotato.core.helpers.variable import getImdb, splitString +from couchpotato.core.helpers.variable import getImdb, splitString, tryInt, \ + mergeDicts from couchpotato.core.logger import CPLog -from couchpotato.core.plugins.base import Plugin +from couchpotato.core.media.movie import MovieTypeBase from couchpotato.core.settings.model import Library, LibraryTitle, Movie, \ Release from couchpotato.environment import Env @@ -16,17 +17,23 @@ import time log = CPLog(__name__) -class MoviePlugin(Plugin): +class MovieBase(MovieTypeBase): default_dict = { 'profile': {'types': {'quality': {}}}, 'releases': {'status': {}, 'quality': {}, 'files':{}, 'info': {}}, 'library': {'titles': {}, 'files':{}}, 'files': {}, - 'status': {} + 'status': {}, + 'category': {}, } def __init__(self): + + # Initialize this type + super(MovieBase, self).__init__() + self.initType() + addApiView('movie.search', self.search, docs = { 'desc': 'Search the movie providers for a movie', 'params': { @@ -139,7 +146,7 @@ class MoviePlugin(Plugin): imdb_id = getImdb(str(movie_id)) - if(imdb_id): + if imdb_id: m = db.query(Movie).filter(Movie.library.has(identifier = imdb_id)).first() else: m = db.query(Movie).filter_by(id = movie_id).first() @@ -161,19 +168,33 @@ class MoviePlugin(Plugin): if release_status and not isinstance(release_status, (list, tuple)): release_status = [release_status] + # query movie ids q = db.query(Movie) \ - .outerjoin(Movie.releases, Movie.library, Library.titles) \ - .filter(LibraryTitle.default == True) \ + .with_entities(Movie.id) \ .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])) + statuses = fireEvent('status.get', status, single = len(status) > 1) + statuses = [s.get('id') for s in statuses] + + q = q.filter(Movie.status_id.in_(statuses)) # 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])) + q = q.join(Movie.releases) + statuses = fireEvent('status.get', release_status, single = len(release_status) > 1) + statuses = [s.get('id') for s in statuses] + + q = q.filter(Release.status_id.in_(statuses)) + + # Only join when searching / ordering + if starts_with or search or order != 'release_order': + q = q.join(Movie.library, Library.titles) \ + .filter(LibraryTitle.default == True) + + # Add search filters filter_or = [] if starts_with: starts_with = toUnicode(starts_with.lower()) @@ -188,47 +209,79 @@ class MoviePlugin(Plugin): if search: filter_or.append(LibraryTitle.simple_title.like('%%' + search + '%%')) - if filter_or: + if len(filter_or) > 0: q = q.filter(or_(*filter_or)) total_count = q.count() + if total_count == 0: + return 0, [] 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)) \ - .options(joinedload_all('releases')) \ - .options(joinedload_all('profile.types')) \ + if 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] + q = q.limit(limit).offset(offset) + + # Get all movie_ids in sorted order + movie_ids = [m.id for m in q.all()] + + # List release statuses + releases = db.query(Release) \ + .filter(Release.movie_id.in_(movie_ids)) \ + .all() + + release_statuses = dict((m, set()) for m in movie_ids) + releases_count = dict((m, 0) for m in movie_ids) + for release in releases: + release_statuses[release.movie_id].add('%d,%d' % (release.status_id, release.quality_id)) + releases_count[release.movie_id] += 1 + + # Get main movie data + q2 = db.query(Movie) \ .options(joinedload_all('library.titles')) \ .options(joinedload_all('library.files')) \ .options(joinedload_all('status')) \ .options(joinedload_all('files')) - if 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) + q2 = q2.filter(Movie.id.in_(movie_ids)) results = q2.all() - movies = [] + + # Create dict by movie id + movie_dict = {} for movie in results: - movies.append(movie.to_dict({ - 'profile': {'types': {}}, - 'releases': {'files':{}, 'info': {}}, + movie_dict[movie.id] = movie + + # List movies based on movie_ids order + movies = [] + for movie_id in movie_ids: + + releases = [] + for r in release_statuses.get(movie_id): + x = splitString(r) + releases.append({'status_id': x[0], 'quality_id': x[1]}) + + # Merge releases with movie dict + movies.append(mergeDicts(movie_dict[movie_id].to_dict({ 'library': {'titles': {}, 'files':{}}, 'files': {}, + }), { + 'releases': releases, + 'releases_count': releases_count.get(movie_id), })) db.expire_all() - return (total_count, movies) + return total_count, movies def availableChars(self, status = None, release_status = None): - chars = '' + status = status or [] + release_status = release_status or [] db = get_session() @@ -238,37 +291,53 @@ class MoviePlugin(Plugin): if release_status and not isinstance(release_status, (list, tuple)): release_status = [release_status] - q = db.query(Movie) \ - .outerjoin(Movie.releases, Movie.library, Library.titles, Movie.status) \ - .options(joinedload_all('library.titles')) + q = db.query(Movie) # Filter on movie status if status and len(status) > 0: - q = q.filter(or_(*[Movie.status.has(identifier = s) for s in status])) + statuses = fireEvent('status.get', status, single = len(release_status) > 1) + statuses = [s.get('id') for s in statuses] + + q = q.filter(Movie.status_id.in_(statuses)) # 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() + statuses = fireEvent('status.get', release_status, single = len(release_status) > 1) + statuses = [s.get('id') for s in statuses] - for movie in results: - char = movie.library.titles[0].simple_title[0] - char = char if char in ascii_lowercase else '#' - if char not in chars: - chars += str(char) + q = q.join(Movie.releases) \ + .filter(Release.status_id.in_(statuses)) + + q = q.join(Library, LibraryTitle) \ + .with_entities(LibraryTitle.simple_title) \ + .filter(LibraryTitle.default == True) + + titles = q.all() + + chars = set() + for title in titles: + try: + char = title[0][0] + char = char if char in ascii_lowercase else '#' + chars.add(str(char)) + except: + log.error('Failed getting title for %s', title.libraries_id) + + if len(chars) == 25: + break db.expire_all() - return ''.join(sorted(chars, key = str.lower)) + return ''.join(sorted(chars)) def listView(self, **kwargs): - status = splitString(kwargs.get('status', None)) - release_status = splitString(kwargs.get('release_status', None)) - limit_offset = kwargs.get('limit_offset', None) - starts_with = kwargs.get('starts_with', None) - search = kwargs.get('search', None) - order = kwargs.get('order', None) + status = splitString(kwargs.get('status')) + release_status = splitString(kwargs.get('release_status')) + limit_offset = kwargs.get('limit_offset') + starts_with = kwargs.get('starts_with') + search = kwargs.get('search') + order = kwargs.get('order') total_movies, movies = self.list( status = status, @@ -313,7 +382,7 @@ class MoviePlugin(Plugin): if title.default: default_title = title.title fireEvent('notify.frontend', type = 'movie.busy.%s' % x, data = True) - fireEventAsync('library.update', identifier = movie.library.identifier, default_title = default_title, force = True, on_complete = self.createOnComplete(x)) + fireEventAsync('library.update.movie', identifier = movie.library.identifier, default_title = default_title, force = True, on_complete = self.createOnComplete(x)) db.expire_all() return { @@ -339,7 +408,8 @@ class MoviePlugin(Plugin): 'movies': movies, } - def add(self, params = {}, force_readd = True, search_after = True, update_library = False, status_id = None): + def add(self, params = None, force_readd = True, search_after = True, update_library = False, status_id = None): + if not params: params = {} if not params.get('identifier'): msg = 'Can\'t add movie without imdb identifier.' @@ -358,23 +428,26 @@ class MoviePlugin(Plugin): pass - library = fireEvent('library.add', single = True, attrs = params, update_after = update_library) + library = fireEvent('library.add.movie', single = True, attrs = params, update_after = update_library) # Status status_active, snatched_status, ignored_status, done_status, downloaded_status = \ fireEvent('status.get', ['active', 'snatched', 'ignored', 'done', 'downloaded'], single = True) default_profile = fireEvent('profile.default', single = True) + cat_id = params.get('category_id') db = get_session() m = db.query(Movie).filter_by(library_id = library.get('id')).first() added = True do_search = False + search_after = search_after and self.conf('search_on_add', section = 'moviesearcher') if not m: m = Movie( library_id = library.get('id'), profile_id = params.get('profile_id', default_profile.get('id')), status_id = status_id if status_id else status_active.get('id'), + category_id = tryInt(cat_id) if cat_id is not None and tryInt(cat_id) > 0 else None, ) db.add(m) db.commit() @@ -383,7 +456,7 @@ class MoviePlugin(Plugin): if search_after: onComplete = self.createOnComplete(m.id) - fireEventAsync('library.update', params.get('identifier'), default_title = params.get('title', ''), on_complete = onComplete) + fireEventAsync('library.update.movie', params.get('identifier'), default_title = params.get('title', ''), on_complete = onComplete) search_after = False elif force_readd: @@ -396,6 +469,7 @@ class MoviePlugin(Plugin): fireEvent('release.delete', release.id, single = True) m.profile_id = params.get('profile_id', default_profile.get('id')) + m.category_id = tryInt(cat_id) if cat_id is not None and tryInt(cat_id) > 0 else None else: log.debug('Movie already exists, not updating: %s', params) added = False @@ -452,6 +526,10 @@ class MoviePlugin(Plugin): m.profile_id = kwargs.get('profile_id') + cat_id = kwargs.get('category_id') + if cat_id is not None: + m.category_id = tryInt(cat_id) if tryInt(cat_id) > 0 else None + # Remove releases for rel in m.releases: if rel.status_id is available_status.get('id'): @@ -468,7 +546,7 @@ class MoviePlugin(Plugin): fireEvent('movie.restatus', m.id) movie_dict = m.to_dict(self.default_dict) - fireEventAsync('searcher.single', movie_dict, on_complete = self.createNotifyFront(movie_id)) + fireEventAsync('movie.searcher.single', movie_dict, on_complete = self.createNotifyFront(movie_id)) db.expire_all() return { @@ -503,7 +581,7 @@ class MoviePlugin(Plugin): total_deleted = 0 new_movie_status = None for release in movie.releases: - if delete_from in ['wanted', 'snatched']: + if delete_from in ['wanted', 'snatched', 'late']: if release.status_id != done_status.get('id'): db.delete(release) total_deleted += 1 @@ -544,7 +622,7 @@ class MoviePlugin(Plugin): log.debug('Can\'t restatus movie, doesn\'t seem to exist.') return False - log.debug('Changing status for %s', (m.library.titles[0].title)) + log.debug('Changing status for %s', m.library.titles[0].title) if not m.profile: m.status_id = done_status.get('id') else: @@ -566,7 +644,7 @@ class MoviePlugin(Plugin): def onComplete(): db = get_session() movie = db.query(Movie).filter_by(id = movie_id).first() - fireEventAsync('searcher.single', movie.to_dict(self.default_dict), on_complete = self.createNotifyFront(movie_id)) + fireEventAsync('movie.searcher.single', movie.to_dict(self.default_dict), on_complete = self.createNotifyFront(movie_id)) db.expire_all() return onComplete diff --git a/couchpotato/core/plugins/movie/static/list.js b/couchpotato/core/media/movie/_base/static/list.js similarity index 96% rename from couchpotato/core/plugins/movie/static/list.js rename to couchpotato/core/media/movie/_base/static/list.js index 1b11fab5..341d2348 100644 --- a/couchpotato/core/plugins/movie/static/list.js +++ b/couchpotato/core/media/movie/_base/static/list.js @@ -273,8 +273,25 @@ var MovieList = new Class({ }) ).addClass('search'); + var available_chars; self.filter_menu.addEvent('open', function(){ self.navigation_search_input.focus(); + + // Get available chars and highlight + if(!available_chars && (self.navigation.isDisplayed() || self.navigation.isVisible())) + Api.request('movie.available_chars', { + 'data': Object.merge({ + 'status': self.options.status + }, self.filter), + 'onSuccess': function(json){ + available_chars = json.chars + + json.chars.split('').each(function(c){ + self.letters[c.capitalize()].addClass('available') + }) + + } + }); }); self.filter_menu.addLink( @@ -311,21 +328,6 @@ var MovieList = new Class({ }).inject(self.navigation_alpha); }); - // Get available chars and highlight - if(self.navigation.isDisplayed() || self.navigation.isVisible()) - Api.request('movie.available_chars', { - 'data': Object.merge({ - 'status': self.options.status - }, self.filter), - 'onSuccess': function(json){ - - json.chars.split('').each(function(c){ - self.letters[c.capitalize()].addClass('available') - }) - - } - }); - // Add menu or hide if (self.options.menu.length > 0) self.options.menu.each(function(menu_item){ @@ -566,7 +568,7 @@ var MovieList = new Class({ } self.store(json.movies); - self.addMovies(json.movies, json.total); + self.addMovies(json.movies, json.total || json.movies.length); if(self.scrollspy) { self.load_more.set('text', 'load more movies'); self.scrollspy.start(); diff --git a/couchpotato/core/plugins/movie/static/movie.actions.js b/couchpotato/core/media/movie/_base/static/movie.actions.js similarity index 80% rename from couchpotato/core/plugins/movie/static/movie.actions.js rename to couchpotato/core/media/movie/_base/static/movie.actions.js index a0f7bad5..7d8c37fd 100644 --- a/couchpotato/core/plugins/movie/static/movie.actions.js +++ b/couchpotato/core/media/movie/_base/static/movie.actions.js @@ -1,5 +1,5 @@ var MovieAction = new Class({ - + Implements: [Options], class_name: 'action icon2', @@ -124,6 +124,46 @@ MA.Release = new Class({ else self.showHelper(); + App.addEvent('movie.searcher.ended.'+self.movie.data.id, function(notification){ + self.releases = null; + if(self.options_container){ + self.options_container.destroy(); + self.options_container = null; + } + }); + + }, + + show: function(e){ + var self = this; + if(e) + (e).preventDefault(); + + if(self.releases) + self.createReleases(); + else { + + self.movie.busy(true); + + Api.request('release.for_movie', { + 'data': { + 'id': self.movie.data.id + }, + 'onComplete': function(json){ + self.movie.busy(false, 1); + + if(json && json.releases){ + self.releases = json.releases; + self.createReleases(); + } + else + alert('Something went wrong, check the logs.'); + } + }); + + } + + }, createReleases: function(){ @@ -145,7 +185,7 @@ MA.Release = new Class({ new Element('span.provider', {'text': 'Provider'}) ).inject(self.release_container) - self.movie.data.releases.sortBy('-info.score').each(function(release){ + self.releases.each(function(release){ var status = Status.get(release.status_id), quality = Quality.getProfile(release.quality_id) || {}, @@ -211,13 +251,11 @@ MA.Release = new Class({ } }); - if(self.last_release){ + if(self.last_release) self.release_container.getElement('#release_'+self.last_release.id).addClass('last_release'); - } - if(self.next_release){ + if(self.next_release) self.release_container.getElement('#release_'+self.next_release.id).addClass('next_release'); - } if(self.next_release || (self.last_release && ['ignored', 'failed'].indexOf(self.last_release.status.identifier) === false)){ @@ -230,7 +268,9 @@ MA.Release = new Class({ self.last_release ? new Element('a.button.orange', { 'text': 'the same release again', 'events': { - 'click': self.trySameRelease.bind(self) + 'click': function(){ + self.download(self.last_release); + } } }) : null, self.next_release && self.last_release ? new Element('span.or', { @@ -239,7 +279,9 @@ MA.Release = new Class({ self.next_release ? [new Element('a.button.green', { 'text': self.last_release ? 'another release' : 'the best release', 'events': { - 'click': self.tryNextRelease.bind(self) + 'click': function(){ + self.download(self.next_release); + } } }), new Element('span.or', { @@ -248,18 +290,15 @@ MA.Release = new Class({ ) } + self.last_release = null; + self.next_release = null; + } - }, - - show: function(e){ - var self = this; - if(e) - (e).preventDefault(); - - self.createReleases(); + // Show it self.options_container.inject(self.movie, 'top'); self.movie.slide('in', self.options_container); + }, showHelper: function(e){ @@ -267,15 +306,29 @@ MA.Release = new Class({ if(e) (e).preventDefault(); - self.createReleases(); + var has_available = false, + has_snatched = false; - if(self.next_release || (self.last_release && ['ignored', 'failed'].indexOf(self.last_release.status.identifier) === false)){ + self.movie.data.releases.each(function(release){ + if(has_available && has_snatched) return; + + var status = Status.get(release.status_id); + + if(['snatched', 'downloaded', 'seeding'].contains(status.identifier)) + has_snatched = true; + + if(['available'].contains(status.identifier)) + has_available = true; + + }); + + if(has_available || has_snatched){ self.trynext_container = new Element('div.buttons.trynext').inject(self.movie.info_container); self.trynext_container.adopt( - self.next_release ? [new Element('a.icon2.readd', { - 'text': self.last_release ? 'Download another release' : 'Download the best release', + has_available ? [new Element('a.icon2.readd', { + 'text': has_snatched ? 'Download another release' : 'Download the best release', 'events': { 'click': self.tryNextRelease.bind(self) } @@ -291,24 +344,7 @@ MA.Release = new Class({ new Element('a.icon2.completed', { 'text': 'mark this movie done', 'events': { - 'click': function(){ - Api.request('movie.delete', { - 'data': { - 'id': self.movie.get('id'), - 'delete_from': 'wanted' - }, - 'onComplete': function(){ - var movie = $(self.movie); - movie.set('tween', { - 'duration': 300, - 'onComplete': function(){ - self.movie.destroy() - } - }); - movie.tween('height', 0); - } - }); - } + 'click': self.markMovieDone.bind(self) } }) ) @@ -326,14 +362,14 @@ MA.Release = new Class({ var release_el = self.release_container.getElement('#release_'+release.id), icon = release_el.getElement('.download.icon2'); - self.movie.busy(true); + icon.addClass('icon spinner').removeClass('download'); Api.request('release.download', { 'data': { 'id': release.id }, 'onComplete': function(json){ - self.movie.busy(false); + icon.removeClass('icon spinner'); if(json.success) icon.addClass('completed'); @@ -365,24 +401,36 @@ MA.Release = new Class({ }, - tryNextRelease: function(movie_id){ + markMovieDone: function(){ var self = this; - self.createReleases(); - - if(self.last_release) - self.ignore(self.last_release); - - if(self.next_release) - self.download(self.next_release); + Api.request('movie.delete', { + 'data': { + 'id': self.movie.get('id'), + 'delete_from': 'wanted' + }, + 'onComplete': function(){ + var movie = $(self.movie); + movie.set('tween', { + 'duration': 300, + 'onComplete': function(){ + self.movie.destroy() + } + }); + movie.tween('height', 0); + } + }); }, - trySameRelease: function(movie_id){ + tryNextRelease: function(movie_id){ var self = this; - if(self.last_release) - self.download(self.last_release); + Api.request('movie.searcher.try_next', { + 'data': { + 'id': self.movie.get('id') + } + }); } @@ -408,7 +456,7 @@ MA.Trailer = new Class({ 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 data_url = 'https://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.getTitle()), 'year': self.get('year'), @@ -521,6 +569,11 @@ MA.Edit = new Class({ self.profile_select = new Element('select', { 'name': 'profile' }), + self.category_select = new Element('select', { + 'name': 'category' + }).grab( + new Element('option', {'value': -1, 'text': 'None'}) + ), new Element('a.button.edit', { 'text': 'Save & Search', 'events': { @@ -540,7 +593,34 @@ MA.Edit = new Class({ }); - Quality.getActiveProfiles().each(function(profile){ + // Fill categories + var categories = CategoryList.getAll(); + + if(categories.length == 0) + self.category_select.hide(); + else { + self.category_select.show(); + categories.each(function(category){ + + var category_id = category.data.id; + + new Element('option', { + 'value': category_id, + 'text': category.data.label + }).inject(self.category_select); + + if(self.movie.category && self.movie.category.data && self.movie.category.data.id == category_id) + self.category_select.set('value', category_id); + + }); + } + + // Fill profiles + var profiles = Quality.getActiveProfiles(); + if(profiles.length == 1) + self.profile_select.hide(); + + profiles.each(function(profile){ var profile_id = profile.id ? profile.id : profile.data.id; @@ -549,8 +629,9 @@ MA.Edit = new Class({ '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) + if(self.movie.get('profile_id') == profile_id) self.profile_select.set('value', profile_id); + }); } @@ -566,7 +647,8 @@ MA.Edit = new Class({ 'data': { 'id': self.movie.get('id'), 'default_title': self.title_select.get('value'), - 'profile_id': self.profile_select.get('value') + 'profile_id': self.profile_select.get('value'), + 'category_id': self.category_select.get('value') }, 'useSpinner': true, 'spinnerTarget': $(self.movie), @@ -697,6 +779,7 @@ MA.Delete = new Class({ var self = this; (e).preventDefault(); + self.movie.removeView(); self.movie.slide('out'); }, @@ -745,16 +828,45 @@ MA.Files = new Class({ self.el = new Element('a.directory', { 'title': 'Available files', 'events': { - 'click': self.showFiles.bind(self) + 'click': self.show.bind(self) } }); }, - showFiles: function(e){ + show: function(e){ var self = this; (e).preventDefault(); + if(self.releases) + self.showFiles(); + else { + + self.movie.busy(true); + + Api.request('release.for_movie', { + 'data': { + 'id': self.movie.data.id + }, + 'onComplete': function(json){ + self.movie.busy(false, 1); + + if(json && json.releases){ + self.releases = json.releases; + self.showFiles(); + } + else + alert('Something went wrong, check the logs.'); + } + }); + + } + + }, + + showFiles: function(){ + var self = this; + if(!self.options_container){ self.options_container = new Element('div.options').adopt( self.files_container = new Element('div.files.table') @@ -767,7 +879,7 @@ MA.Files = new Class({ new Element('span.is_available', {'text': 'Available'}) ).inject(self.files_container) - Array.each(self.movie.data.releases, function(release){ + Array.each(self.releases, function(release){ var rel = new Element('div.release').inject(self.files_container); diff --git a/couchpotato/core/plugins/movie/static/movie.css b/couchpotato/core/media/movie/_base/static/movie.css similarity index 98% rename from couchpotato/core/plugins/movie/static/movie.css rename to couchpotato/core/media/movie/_base/static/movie.css index 60ab96b2..0200417c 100644 --- a/couchpotato/core/plugins/movie/static/movie.css +++ b/couchpotato/core/media/movie/_base/static/movie.css @@ -425,7 +425,9 @@ } .movies .data .quality .available { background-color: #578bc3; } - .movies .data .quality .snatched { background-color: #369545; } + .movies .data .quality .failed { background-color: #a43d34; } + .movies .data .quality .snatched { background-color: #a2a232; } + .movies .data .quality .seeding { background-color: #0a6819; } .movies .data .quality .done { background-color: #369545; opacity: 1; @@ -639,6 +641,12 @@ position: absolute; z-index: 10; } + @media only screen and (device-width: 768px) { + .trailer_container iframe { + margin-top: 25px; + } + } + .trailer_container.hide { height: 0 !important; } diff --git a/couchpotato/core/plugins/movie/static/movie.js b/couchpotato/core/media/movie/_base/static/movie.js similarity index 85% rename from couchpotato/core/plugins/movie/static/movie.js rename to couchpotato/core/media/movie/_base/static/movie.js index 5ca36c9d..363d860c 100644 --- a/couchpotato/core/plugins/movie/static/movie.js +++ b/couchpotato/core/media/movie/_base/static/movie.js @@ -14,6 +14,7 @@ var Movie = new Class({ self.el = new Element('div.movie'); self.profile = Quality.getProfile(data.profile_id) || {}; + self.category = CategoryList.getCategory(data.category_id) || {}; self.parent(self, options); self.addEvents(); @@ -28,14 +29,14 @@ var Movie = new Class({ self.update.delay(2000, self, notification); }); - ['movie.busy', 'searcher.started'].each(function(listener){ + ['movie.busy', 'movie.searcher.started'].each(function(listener){ App.addEvent(listener+'.'+self.data.id, function(notification){ if(notification.data) self.busy(true) }); }) - App.addEvent('searcher.ended.'+self.data.id, function(notification){ + App.addEvent('movie.searcher.ended.'+self.data.id, function(notification){ if(notification.data) self.busy(false) }); @@ -52,12 +53,12 @@ var Movie = new Class({ // Remove events App.removeEvents('movie.update.'+self.data.id); - ['movie.busy', 'searcher.started'].each(function(listener){ + ['movie.busy', 'movie.searcher.started'].each(function(listener){ App.removeEvents(listener+'.'+self.data.id); }) }, - busy: function(set_busy){ + busy: function(set_busy, timeout){ var self = this; if(!set_busy){ @@ -71,9 +72,9 @@ var Movie = new Class({ self.spinner.el.destroy(); self.spinner = null; self.mask = null; - }, 400); + }, timeout || 400); } - }, 1000) + }, timeout || 1000) } else if(!self.spinner) { self.createMask(); @@ -111,6 +112,7 @@ var Movie = new Class({ self.removeView(); self.profile = Quality.getProfile(self.data.profile_id) || {}; + self.category = CategoryList.getCategory(self.data.category_id) || {}; self.create(); self.busy(false); @@ -177,20 +179,21 @@ var Movie = new Class({ }); // Add releases - self.data.releases.each(function(release){ - - var q = self.quality.getElement('.q_id'+ release.quality_id), - status = Status.get(release.status_id); - - if(!q && (status.identifier == 'snatched' || status.identifier == 'done')) - var q = self.addQuality(release.quality_id) - - if (status && q && !q.hasClass(status.identifier)){ - q.addClass(status.identifier); - q.set('title', (q.get('title') ? q.get('title') : '') + ' status: '+ status.label) - } - - }); + if(self.data.releases) + self.data.releases.each(function(release){ + + var q = self.quality.getElement('.q_id'+ release.quality_id), + status = Status.get(release.status_id); + + if(!q && (status.identifier == 'snatched' || status.identifier == 'done')) + var q = self.addQuality(release.quality_id) + + if (status && q && !q.hasClass(status.identifier)){ + q.addClass(status.identifier); + q.set('title', (q.get('title') ? q.get('title') : '') + ' status: '+ status.label) + } + + }); Object.each(self.options.actions, function(action, key){ self.action[key.toLowerCase()] = action = new self.options.actions[key](self) diff --git a/couchpotato/core/plugins/movie/static/search.css b/couchpotato/core/media/movie/_base/static/search.css similarity index 84% rename from couchpotato/core/plugins/movie/static/search.css rename to couchpotato/core/media/movie/_base/static/search.css index e2aa0a47..dc747346 100644 --- a/couchpotato/core/plugins/movie/static/search.css +++ b/couchpotato/core/media/movie/_base/static/search.css @@ -159,13 +159,15 @@ display: inline-block; margin-right: 10px; } - .movie_result .options select[name=title] { width: 180px; } + .movie_result .options select[name=title] { width: 170px; } .movie_result .options select[name=profile] { width: 90px; } + .movie_result .options select[name=category] { width: 80px; } @media all and (max-width: 480px) { .movie_result .options select[name=title] { width: 90px; } - .movie_result .options select[name=profile] { width: 60px; } + .movie_result .options select[name=profile] { width: 50px; } + .movie_result .options select[name=category] { width: 50px; } } @@ -217,26 +219,51 @@ position: absolute; top: 20%; left: 15px; - right: 60px; + right: 7px; vertical-align: middle; } - + .movie_result .info h2 { + margin: 0; font-weight: normal; font-size: 20px; + padding: 0; + } + + .search_form .info h2 { + position: absolute; + width: 100%; + } + + .movie_result .info h2 .title { display: block; margin: 0; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; - width: 100%; - } - - .movie_result .info h2 span { - padding: 0 5px; - position: absolute; - right: -60px; } + + .search_form .info h2 .title { + position: absolute; + width: 88%; + } + + .movie_result .info h2 .year { + padding: 0 5px; + text-align: center; + position: absolute; + width: 12%; + right: 0; + } + + @media all and (max-width: 480px) { + + .search_form .info h2 .year { + font-size: 12px; + margin-top: 7px; + } + + } .search_form .mask, .movie_result .mask { diff --git a/couchpotato/core/plugins/movie/static/search.js b/couchpotato/core/media/movie/_base/static/search.js similarity index 82% rename from couchpotato/core/plugins/movie/static/search.js rename to couchpotato/core/media/movie/_base/static/search.js index 5530505f..7332381b 100644 --- a/couchpotato/core/plugins/movie/static/search.js +++ b/couchpotato/core/media/movie/_base/static/search.js @@ -215,10 +215,11 @@ Block.Search.Item = new Class({ 'click': self.showOptions.bind(self) } }).adopt( - new Element('div.info').adopt( - self.title = new Element('h2', { - 'text': info.titles && info.titles.length > 0 ? info.titles[0] : 'Unknown' - }).adopt( + self.info_container = new Element('div.info').adopt( + new Element('h2').adopt( + self.title = new Element('span.title', { + 'text': info.titles && info.titles.length > 0 ? info.titles[0] : 'Unknown' + }), self.year = info.year ? new Element('span.year', { 'text': info.year }) : null @@ -274,7 +275,9 @@ Block.Search.Item = new Class({ add: function(e){ var self = this; - (e).preventDefault(); + + if(e) + (e).preventDefault(); self.loadingMask(); @@ -282,7 +285,8 @@ Block.Search.Item = new Class({ 'data': { 'identifier': self.info.imdb, 'title': self.title_select.get('value'), - 'profile_id': self.profile_select.get('value') + 'profile_id': self.profile_select.get('value'), + 'category_id': self.category_select.get('value') }, 'onComplete': function(json){ self.options_el.empty(); @@ -322,10 +326,10 @@ Block.Search.Item = new Class({ self.options_el.grab( new Element('div', { - 'class': self.info.in_wanted && self.info.in_wanted.profile || in_library ? 'in_library_wanted' : '' + 'class': self.info.in_wanted && self.info.in_wanted.profile_id || in_library ? 'in_library_wanted' : '' }).adopt( - self.info.in_wanted && self.info.in_wanted.profile ? new Element('span.in_wanted', { - 'text': 'Already in wanted list: ' + self.info.in_wanted.profile.label + self.info.in_wanted && self.info.in_wanted.profile_id ? new Element('span.in_wanted', { + 'text': 'Already in wanted list: ' + Quality.getProfile(self.info.in_wanted.profile_id).get('label') }) : (in_library ? new Element('span.in_library', { 'text': 'Already in library: ' + in_library.join(', ') }) : null), @@ -335,7 +339,12 @@ Block.Search.Item = new Class({ self.profile_select = new Element('select', { 'name': 'profile' }), - new Element('a.button', { + self.category_select = new Element('select', { + 'name': 'category' + }).grab( + new Element('option', {'value': -1, 'text': 'None'}) + ), + self.add_button = new Element('a.button', { 'text': 'Add', 'events': { 'click': self.add.bind(self) @@ -350,7 +359,28 @@ Block.Search.Item = new Class({ }).inject(self.title_select) }) - Quality.getActiveProfiles().each(function(profile){ + + // Fill categories + var categories = CategoryList.getAll(); + + if(categories.length == 0) + self.category_select.hide(); + else { + self.category_select.show(); + categories.each(function(category){ + new Element('option', { + 'value': category.data.id, + 'text': category.data.label + }).inject(self.category_select); + }); + } + + // Fill profiles + var profiles = Quality.getActiveProfiles(); + if(profiles.length == 1) + self.profile_select.hide(); + + profiles.each(function(profile){ new Element('option', { 'value': profile.id ? profile.id : profile.data.id, 'text': profile.label ? profile.label : profile.data.label @@ -358,6 +388,11 @@ Block.Search.Item = new Class({ }); self.options_el.addClass('set'); + + if(categories.length == 0 && self.title_select.getElements('option').length == 1 && profiles.length == 1 && + !(self.info.in_wanted && self.info.in_wanted.profile_id || in_library)) + self.add(); + } }, diff --git a/libs/themoviedb/__init__.py b/couchpotato/core/media/movie/library/__init__.py similarity index 100% rename from libs/themoviedb/__init__.py rename to couchpotato/core/media/movie/library/__init__.py diff --git a/couchpotato/core/media/movie/library/movie/__init__.py b/couchpotato/core/media/movie/library/movie/__init__.py new file mode 100644 index 00000000..03494a11 --- /dev/null +++ b/couchpotato/core/media/movie/library/movie/__init__.py @@ -0,0 +1,6 @@ +from .main import MovieLibraryPlugin + +def start(): + return MovieLibraryPlugin() + +config = [] diff --git a/couchpotato/core/plugins/library/main.py b/couchpotato/core/media/movie/library/movie/main.py similarity index 82% rename from couchpotato/core/plugins/library/main.py rename to couchpotato/core/media/movie/library/movie/main.py index b463abfd..718e7390 100644 --- a/couchpotato/core/plugins/library/main.py +++ b/couchpotato/core/media/movie/library/movie/main.py @@ -2,7 +2,7 @@ from couchpotato import get_session from couchpotato.core.event import addEvent, fireEventAsync, fireEvent from couchpotato.core.helpers.encoding import toUnicode, simplifyString from couchpotato.core.logger import CPLog -from couchpotato.core.plugins.base import Plugin +from couchpotato.core.media._base.library import LibraryBase from couchpotato.core.settings.model import Library, LibraryTitle, File from string import ascii_letters import time @@ -10,16 +10,20 @@ import traceback log = CPLog(__name__) -class LibraryPlugin(Plugin): + +class MovieLibraryPlugin(LibraryBase): default_dict = {'titles': {}, 'files':{}} def __init__(self): - addEvent('library.add', self.add) - addEvent('library.update', self.update) - addEvent('library.update_release_date', self.updateReleaseDate) + addEvent('library.add.movie', self.add) + addEvent('library.update.movie', self.update) + addEvent('library.update.movie.release_date', self.updateReleaseDate) - def add(self, attrs = {}, update_after = True): + def add(self, attrs = None, update_after = True): + if not attrs: attrs = {} + + primary_provider = attrs.get('primary_provider', 'imdb') db = get_session() @@ -32,7 +36,7 @@ class LibraryPlugin(Plugin): plot = toUnicode(attrs.get('plot')), tagline = toUnicode(attrs.get('tagline')), status_id = status.get('id'), - info = {}, + info = {} ) title = LibraryTitle( @@ -48,7 +52,7 @@ class LibraryPlugin(Plugin): # Update library info if update_after is not False: handle = fireEventAsync if update_after is 'async' else fireEvent - handle('library.update', identifier = l.identifier, default_title = toUnicode(attrs.get('title', ''))) + handle('library.update.movie', identifier = l.identifier, default_title = toUnicode(attrs.get('title', ''))) library_dict = l.to_dict(self.default_dict) @@ -57,29 +61,30 @@ class LibraryPlugin(Plugin): def update(self, identifier, default_title = '', force = False): + if self.shuttingDown(): + return + db = get_session() library = db.query(Library).filter_by(identifier = identifier).first() done_status = fireEvent('status.get', 'done', single = True) + library_dict = None if library: library_dict = library.to_dict(self.default_dict) do_update = True - if library.status_id == done_status.get('id') and not force: - do_update = False - else: - info = fireEvent('movie.info', merge = True, identifier = identifier) + info = fireEvent('movie.info', merge = True, identifier = identifier) - # Don't need those here - try: del info['in_wanted'] - except: pass - try: del info['in_library'] - except: pass + # Don't need those here + try: del info['in_wanted'] + except: pass + try: del info['in_library'] + except: pass - if not info or len(info) == 0: - log.error('Could not update, no movie info to work with: %s', identifier) - return False + if not info or len(info) == 0: + log.error('Could not update, no movie info to work with: %s', identifier) + return False # Main info if do_update: diff --git a/couchpotato/core/media/movie/searcher/__init__.py b/couchpotato/core/media/movie/searcher/__init__.py new file mode 100644 index 00000000..bae18902 --- /dev/null +++ b/couchpotato/core/media/movie/searcher/__init__.py @@ -0,0 +1,73 @@ +from .main import MovieSearcher +import random + +def start(): + return MovieSearcher() + +config = [{ + 'name': 'moviesearcher', + 'order': 20, + 'groups': [ + { + 'tab': 'searcher', + 'name': 'movie_searcher', + 'label': 'Movie search', + 'description': 'Search options for movies', + 'advanced': True, + 'options': [ + { + 'name': 'always_search', + 'default': False, + 'migrate_from': 'searcher', + 'type': 'bool', + 'label': 'Always search', + 'description': 'Search for movies even before there is a ETA. Enabling this will probably get you a lot of fakes.', + }, + { + 'name': 'run_on_launch', + 'migrate_from': 'searcher', + 'label': 'Run on launch', + 'advanced': True, + 'default': 0, + 'type': 'bool', + 'description': 'Force run the searcher after (re)start.', + }, + { + 'name': 'search_on_add', + 'label': 'Search after add', + 'advanced': True, + 'default': 1, + 'type': 'bool', + 'description': 'Disable this to only search for movies on cron.', + }, + { + 'name': 'cron_day', + 'migrate_from': 'searcher', + 'label': 'Day', + 'advanced': True, + 'default': '*', + 'type': 'string', + 'description': '*: Every day, */2: Every 2 days, 1: Every first of the month. See APScheduler for details.', + }, + { + 'name': 'cron_hour', + 'migrate_from': 'searcher', + 'label': 'Hour', + 'advanced': True, + 'default': random.randint(0, 23), + 'type': 'string', + 'description': '*: Every hour, */8: Every 8 hours, 3: At 3, midnight.', + }, + { + 'name': 'cron_minute', + 'migrate_from': 'searcher', + 'label': 'Minute', + 'advanced': True, + 'default': random.randint(0, 59), + 'type': 'string', + 'description': "Just keep it random, so the providers don't get DDOSed by every CP user on a 'full' hour." + }, + ], + }, + ], +}] diff --git a/couchpotato/core/plugins/searcher/main.py b/couchpotato/core/media/movie/searcher/main.py similarity index 52% rename from couchpotato/core/plugins/searcher/main.py rename to couchpotato/core/media/movie/searcher/main.py index cc2774ac..fdf3460d 100644 --- a/couchpotato/core/plugins/searcher/main.py +++ b/couchpotato/core/media/movie/searcher/main.py @@ -3,15 +3,14 @@ from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent, fireEventAsync from couchpotato.core.helpers.encoding import simplifyString, toUnicode from couchpotato.core.helpers.variable import md5, getTitle, splitString, \ - possibleTitles + possibleTitles, getImdb from couchpotato.core.logger import CPLog -from couchpotato.core.plugins.base import Plugin +from couchpotato.core.media._base.searcher.base import SearcherBase +from couchpotato.core.media.movie import MovieTypeBase from couchpotato.core.settings.model import Movie, Release, ReleaseInfo from couchpotato.environment import Env from datetime import date -from inspect import ismethod, isfunction from sqlalchemy.exc import InterfaceError -import datetime import random import re import time @@ -20,30 +19,32 @@ import traceback log = CPLog(__name__) -class Searcher(Plugin): +class MovieSearcher(SearcherBase, MovieTypeBase): in_progress = False def __init__(self): - addEvent('searcher.all', self.allMovies) - addEvent('searcher.single', self.single) - 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) + super(MovieSearcher, self).__init__() - addApiView('searcher.try_next', self.tryNextReleaseView, docs = { + addEvent('movie.searcher.all', self.searchAll) + addEvent('movie.searcher.all_view', self.searchAllView) + addEvent('movie.searcher.single', self.single) + addEvent('movie.searcher.correct_movie', self.correctMovie) + addEvent('movie.searcher.try_next_release', self.tryNextRelease) + addEvent('movie.searcher.could_be_released', self.couldBeReleased) + + addApiView('movie.searcher.try_next', self.tryNextReleaseView, docs = { 'desc': 'Marks the snatched results as ignored and try the next best release', 'params': { 'id': {'desc': 'The id of the movie'}, }, }) - addApiView('searcher.full_search', self.allMoviesView, docs = { + addApiView('movie.searcher.full_search', self.searchAllView, docs = { 'desc': 'Starts a full search for all wanted movies', }) - addApiView('searcher.progress', self.getProgress, docs = { + addApiView('movie.searcher.progress', self.getProgress, docs = { 'desc': 'Get the progress of current full search', 'return': {'type': 'object', 'example': """{ 'progress': False || object, total & to_go, @@ -51,42 +52,25 @@ class Searcher(Plugin): }) if self.conf('run_on_launch'): - addEvent('app.load', self.allMovies) + addEvent('app.load', self.searchAll) - addEvent('app.load', self.setCrons) - addEvent('setting.save.searcher.cron_day.after', self.setCrons) - addEvent('setting.save.searcher.cron_hour.after', self.setCrons) - addEvent('setting.save.searcher.cron_minute.after', self.setCrons) + def searchAllView(self, **kwargs): - def setCrons(self): - fireEvent('schedule.cron', 'searcher.all', self.allMovies, day = self.conf('cron_day'), hour = self.conf('cron_hour'), minute = self.conf('cron_minute')) - - def allMoviesView(self, **kwargs): - - in_progress = self.in_progress - if not in_progress: - fireEventAsync('searcher.all') - fireEvent('notify.frontend', type = 'searcher.started', data = True, message = 'Full search started') - else: - fireEvent('notify.frontend', type = 'searcher.already_started', data = True, message = 'Full search already in progress') + fireEventAsync('movie.searcher.all') return { - 'success': not in_progress + 'success': not self.in_progress } - def getProgress(self, **kwargs): - - return { - 'progress': self.in_progress - } - - def allMovies(self): + def searchAll(self): if self.in_progress: log.info('Search already in progress') + fireEvent('notify.frontend', type = 'movie.searcher.already_started', data = True, message = 'Full search already in progress') return self.in_progress = True + fireEvent('notify.frontend', type = 'movie.searcher.started', data = True, message = 'Full search started') db = get_session() @@ -101,21 +85,22 @@ class Searcher(Plugin): } try: - search_types = self.getSearchTypes() + search_protocols = fireEvent('searcher.protocols', single = True) for movie in movies: movie_dict = movie.to_dict({ + 'category': {}, 'profile': {'types': {'quality': {}}}, 'releases': {'status': {}, 'quality': {}}, 'library': {'titles': {}, 'files':{}}, - 'files': {} + 'files': {}, }) try: - self.single(movie_dict, search_types) + self.single(movie_dict, search_protocols) except IndexError: log.error('Forcing library update for %s, if you see this often, please report: %s', (movie_dict['library']['identifier'], traceback.format_exc())) - fireEvent('library.update', movie_dict['library']['identifier'], force = True) + fireEvent('library.update.movie', movie_dict['library']['identifier'], force = True) except: log.error('Search failed for %s: %s', (movie_dict['library']['identifier'], traceback.format_exc())) @@ -130,25 +115,25 @@ class Searcher(Plugin): self.in_progress = False - def single(self, movie, search_types = None): + def single(self, movie, search_protocols = None, manual = False): # Find out search type try: - if not search_types: - search_types = self.getSearchTypes() + if not search_protocols: + search_protocols = fireEvent('searcher.protocols', single = True) except SearchSetupError: return done_status = fireEvent('status.get', 'done', single = True) - if not movie['profile'] or movie['status_id'] == done_status.get('id'): + if not movie['profile'] or (movie['status_id'] == done_status.get('id') and not manual): log.debug('Movie doesn\'t have a profile or already done, assuming in manage tab.') return db = get_session() pre_releases = fireEvent('quality.pre_releases', single = True) - release_dates = fireEvent('library.update_release_date', identifier = movie['library']['identifier'], merge = True) + release_dates = fireEvent('library.update.movie.release_date', identifier = movie['library']['identifier'], merge = True) available_status, ignored_status, failed_status = fireEvent('status.get', ['available', 'ignored', 'failed'], single = True) found_releases = [] @@ -160,7 +145,7 @@ class Searcher(Plugin): fireEvent('movie.delete', movie['id'], single = True) return - fireEvent('notify.frontend', type = 'searcher.started.%s' % movie['id'], data = True, message = 'Searching for "%s"' % default_title) + fireEvent('notify.frontend', type = 'movie.searcher.started.%s' % movie['id'], data = True, message = 'Searching for "%s"' % default_title) ret = False @@ -183,18 +168,18 @@ class Searcher(Plugin): quality = fireEvent('quality.single', identifier = quality_type['quality']['identifier'], single = True) results = [] - for search_type in search_types: - type_results = fireEvent('%s.search' % search_type, movie, quality, merge = True) - if type_results: - results += type_results + for search_protocol in search_protocols: + protocol_results = fireEvent('provider.search.%s.movie' % search_protocol, movie, quality, merge = True) + if protocol_results: + results += protocol_results sorted_results = sorted(results, key = lambda k: k['score'], reverse = True) if len(sorted_results) == 0: log.debug('Nothing found for %s in %s', (default_title, quality_type['quality']['label'])) - download_preference = self.conf('preferred_method') + download_preference = self.conf('preferred_method', section = 'searcher') if download_preference != 'both': - sorted_results = sorted(sorted_results, key = lambda k: k['type'], reverse = (download_preference == 'torrent')) + sorted_results = sorted(sorted_results, key = lambda k: k['protocol'][:3], reverse = (download_preference == 'torrent')) # Check if movie isn't deleted while searching if not db.query(Movie).filter_by(id = movie.get('id')).first(): @@ -252,7 +237,7 @@ class Searcher(Plugin): log.info('Ignored, score to low: %s', nzb['name']) continue - downloaded = self.download(data = nzb, movie = movie) + downloaded = fireEvent('searcher.download', data = nzb, movie = movie, manual = manual, single = True) if downloaded is True: ret = True break @@ -276,107 +261,10 @@ class Searcher(Plugin): if len(too_early_to_search) > 0: log.info2('Too early to search for %s, %s', (too_early_to_search, default_title)) - fireEvent('notify.frontend', type = 'searcher.ended.%s' % movie['id'], data = True) + fireEvent('notify.frontend', type = 'movie.searcher.ended.%s' % movie['id'], data = True) return ret - def download(self, data, movie, manual = False): - - # Test to see if any downloaders are enabled for this type - downloader_enabled = fireEvent('download.enabled', manual, data, single = True) - - if downloader_enabled: - - snatched_status = fireEvent('status.get', 'snatched', single = True) - - # Download movie to temp - filedata = None - if data.get('download') and (ismethod(data.get('download')) or isfunction(data.get('download'))): - filedata = data.get('download')(url = data.get('url'), nzb_id = data.get('id')) - if filedata == 'try_next': - return filedata - - download_result = fireEvent('download', data = data, movie = movie, manual = manual, filedata = filedata, single = True) - log.debug('Downloader result: %s', download_result) - - if download_result: - try: - # Mark release as snatched - db = get_session() - rls = db.query(Release).filter_by(identifier = md5(data['url'])).first() - if rls: - 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') - - # Save download-id info if returned - if isinstance(download_result, dict): - for key in download_result: - rls_info = ReleaseInfo( - identifier = 'download_%s' % key, - value = toUnicode(download_result.get(key)) - ) - rls.info.append(rls_info) - db.commit() - - log_movie = '%s (%s) in %s' % (getTitle(movie['library']), movie['library']['year'], rls.quality.label) - snatch_message = 'Snatched "%s": %s' % (data.get('name'), log_movie) - log.info(snatch_message) - fireEvent('movie.snatched', message = snatch_message, data = rls.to_dict()) - - # 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') - 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') - 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()) - - return True - - log.info('Tried to download, but none of the "%s" downloaders are enabled or gave an error', (data.get('type', ''))) - - return False - - def getSearchTypes(self): - - download_types = fireEvent('download.enabled_types', merge = True) - provider_types = fireEvent('provider.enabled_types', merge = True) - - if download_types and len(list(set(provider_types) & set(download_types))) == 0: - log.error('There aren\'t any providers enabled for your downloader (%s). Check your settings.', ','.join(download_types)) - raise NoProviders - - for useless_provider in list(set(provider_types) - set(download_types)): - log.debug('Provider for "%s" enabled, but no downloader.', useless_provider) - - search_types = download_types - - if len(search_types) == 0: - log.error('There aren\'t any downloaders enabled. Please pick one in settings.') - raise NoDownloaders - - return search_types - def correctMovie(self, nzb = None, movie = None, quality = None, **kwargs): imdb_results = kwargs.get('imdb_results', False) @@ -392,24 +280,30 @@ class Searcher(Plugin): nzb_words = re.split('\W+', nzb_name) # Make sure it has required words - required_words = splitString(self.conf('required_words').lower()) + required_words = splitString(self.conf('required_words', section = 'searcher').lower()) + try: required_words = list(set(required_words + splitString(movie['category']['required'].lower()))) + except: pass + req_match = 0 for req_set in required_words: req = splitString(req_set, '&') req_match += len(list(set(nzb_words) & set(req))) == len(req) - if self.conf('required_words') and req_match == 0: + if len(required_words) > 0 and req_match == 0: log.info2('Wrong: Required word missing: %s', nzb['name']) return False # Ignore releases - ignored_words = splitString(self.conf('ignored_words').lower()) + ignored_words = splitString(self.conf('ignored_words', section = 'searcher').lower()) + try: ignored_words = list(set(ignored_words + splitString(movie['category']['ignored'].lower()))) + except: pass + ignored_match = 0 for ignored_set in ignored_words: ignored = splitString(ignored_set, '&') ignored_match += len(list(set(nzb_words) & set(ignored))) == len(ignored) - if self.conf('ignored_words') and ignored_match: + if len(ignored_words) > 0 and ignored_match: log.info2("Wrong: '%s' contains 'ignored words'", (nzb['name'])) return False @@ -423,7 +317,7 @@ class Searcher(Plugin): preferred_quality = fireEvent('quality.single', identifier = quality['identifier'], single = True) # Contains lower quality string - if self.containsOtherQuality(nzb, movie_year = movie['library']['year'], preferred_quality = preferred_quality): + if fireEvent('searcher.contains_other_quality', nzb, movie_year = movie['library']['year'], preferred_quality = preferred_quality, single = True): log.info2('Wrong: %s, looking for %s', (nzb['name'], quality['label'])) return False @@ -453,112 +347,25 @@ class Searcher(Plugin): return True # Check if nzb contains imdb link - if self.checkIMDB([nzb.get('description', '')], movie['library']['identifier']): + if getImdb(nzb.get('description', '')) == movie['library']['identifier']: return True for raw_title in movie['library']['titles']: for movie_title in possibleTitles(raw_title['title']): movie_words = re.split('\W+', simplifyString(movie_title)) - if self.correctName(nzb['name'], movie_title): + if fireEvent('searcher.correct_name', nzb['name'], movie_title, single = True): # if no IMDB link, at least check year range 1 - if len(movie_words) > 2 and self.correctYear([nzb['name']], movie['library']['year'], 1): + if len(movie_words) > 2 and fireEvent('searcher.correct_year', nzb['name'], movie['library']['year'], 1, single = True): return True # if no IMDB link, at least check year - if len(movie_words) <= 2 and self.correctYear([nzb['name']], movie['library']['year'], 0): + if len(movie_words) <= 2 and fireEvent('searcher.correct_year', nzb['name'], movie['library']['year'], 0, single = True): return True log.info("Wrong: %s, undetermined naming. Looking for '%s (%s)'", (nzb['name'], movie_name, movie['library']['year'])) return False - def containsOtherQuality(self, nzb, movie_year = None, preferred_quality = {}): - - name = nzb['name'] - size = nzb.get('size', 0) - nzb_words = re.split('\W+', simplifyString(name)) - - qualities = fireEvent('quality.all', single = True) - - found = {} - for quality in qualities: - # Main in words - if quality['identifier'] in nzb_words: - found[quality['identifier']] = True - - # Alt in words - if list(set(nzb_words) & set(quality['alternative'])): - found[quality['identifier']] = True - - # Try guessing via quality tags - guess = fireEvent('quality.guess', [nzb.get('name')], single = True) - if guess: - found[guess['identifier']] = True - - # Hack for older movies that don't contain quality tag - year_name = fireEvent('scanner.name_year', name, single = True) - if len(found) == 0 and movie_year < datetime.datetime.now().year - 3 and not year_name.get('year', None): - if size > 3000: # Assume dvdr - log.info('Quality was missing in name, assuming it\'s a DVD-R based on the size: %s', (size)) - found['dvdr'] = True - else: # Assume dvdrip - log.info('Quality was missing in name, assuming it\'s a DVD-Rip based on the size: %s', (size)) - found['dvdrip'] = True - - # Allow other qualities - for allowed in preferred_quality.get('allow'): - if found.get(allowed): - del found[allowed] - - return not (found.get(preferred_quality['identifier']) and len(found) == 1) - - def checkIMDB(self, haystack, imdbId): - - for string in haystack: - if 'imdb.com/title/' + imdbId in string: - return True - - return False - - def correctYear(self, haystack, year, year_range): - - for string in haystack: - - year_name = fireEvent('scanner.name_year', string, single = True) - - if year_name and ((year - year_range) <= year_name.get('year') <= (year + year_range)): - log.debug('Movie year matches range: %s looking for %s', (year_name.get('year'), year)) - return True - - log.debug('Movie year doesn\'t matche range: %s looking for %s', (year_name.get('year'), year)) - return False - - def correctName(self, check_name, movie_name): - - check_names = [check_name] - - # Match names between " - try: check_names.append(re.search(r'([\'"])[^\1]*\1', check_name).group(0)) - except: pass - - # Match longest name between [] - try: check_names.append(max(check_name.split('['), key = len)) - except: pass - - for check_name in list(set(check_names)): - check_movie = fireEvent('scanner.name_year', check_name, single = True) - - try: - check_words = filter(None, re.split('\W+', check_movie.get('name', ''))) - movie_words = filter(None, re.split('\W+', simplifyString(movie_name))) - - if len(check_words) > 0 and len(movie_words) > 0 and len(list(set(check_words) - set(movie_words))) == 0: - return True - except: - pass - - return False - def couldBeReleased(self, is_pre_release, dates, year = None): now = int(time.time()) @@ -569,7 +376,7 @@ class Searcher(Plugin): else: # For movies before 1972 - if dates.get('theater', 0) < 0 or dates.get('dvd', 0) < 0: + if not dates or dates.get('theater', 0) < 0 or dates.get('dvd', 0) < 0: return True if is_pre_release: @@ -596,7 +403,7 @@ class Searcher(Plugin): def tryNextReleaseView(self, id = None, **kwargs): - trynext = self.tryNextRelease(id) + trynext = self.tryNextRelease(id, manual = True) return { 'success': trynext @@ -604,14 +411,14 @@ class Searcher(Plugin): def tryNextRelease(self, movie_id, manual = False): - snatched_status, ignored_status = fireEvent('status.get', ['snatched', 'ignored'], single = True) + snatched_status, done_status, ignored_status = fireEvent('status.get', ['snatched', 'done', 'ignored'], single = True) try: db = get_session() - rels = db.query(Release).filter_by( - status_id = snatched_status.get('id'), - movie_id = movie_id - ).all() + rels = db.query(Release) \ + .filter_by(movie_id = movie_id) \ + .filter(Release.status_id.in_([snatched_status.get('id'), done_status.get('id')])) \ + .all() for rel in rels: rel.status_id = ignored_status.get('id') @@ -619,7 +426,7 @@ class Searcher(Plugin): movie_dict = fireEvent('movie.get', movie_id, single = True) log.info('Trying next release for: %s', getTitle(movie_dict['library'])) - fireEvent('searcher.single', movie_dict) + fireEvent('movie.searcher.single', movie_dict, manual = manual) return True @@ -629,9 +436,3 @@ class Searcher(Plugin): class SearchSetupError(Exception): pass - -class NoDownloaders(SearchSetupError): - pass - -class NoProviders(SearchSetupError): - pass diff --git a/couchpotato/core/migration/versions/002_Movie_category.py b/couchpotato/core/migration/versions/002_Movie_category.py new file mode 100644 index 00000000..234e1136 --- /dev/null +++ b/couchpotato/core/migration/versions/002_Movie_category.py @@ -0,0 +1,17 @@ +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 + + category_column = Column('category_id', Integer) + movie = Table('movie', meta, category_column) + create_column(category_column, movie) + Index('ix_movie_category_id', movie.c.category_id).create() + +def downgrade(migrate_engine): + pass diff --git a/couchpotato/core/notifications/base.py b/couchpotato/core/notifications/base.py index 7418e1a4..4c0d0992 100644 --- a/couchpotato/core/notifications/base.py +++ b/couchpotato/core/notifications/base.py @@ -32,7 +32,9 @@ class Notification(Provider): addEvent(listener, self.createNotifyHandler(listener)) def createNotifyHandler(self, listener): - def notify(message = None, group = {}, data = None): + def notify(message = None, group = None, data = None): + if not group: group = {} + if not self.conf('on_snatch', default = True) and listener == 'movie.snatched': return return self._notify(message = message, data = data if data else group, listener = listener) @@ -45,9 +47,10 @@ class Notification(Provider): def _notify(self, *args, **kwargs): if self.isEnabled(): return self.notify(*args, **kwargs) + return False - def notify(self, message = '', data = {}, listener = None): - pass + def notify(self, message = '', data = None, listener = None): + if not data: data = {} def test(self, **kwargs): diff --git a/couchpotato/core/notifications/boxcar/main.py b/couchpotato/core/notifications/boxcar/main.py index b30d487a..0fca749f 100644 --- a/couchpotato/core/notifications/boxcar/main.py +++ b/couchpotato/core/notifications/boxcar/main.py @@ -10,7 +10,8 @@ class Boxcar(Notification): url = 'https://boxcar.io/devices/providers/7MNNXY3UIzVBwvzkKwkC/notifications' - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} try: message = message.strip() diff --git a/couchpotato/core/notifications/core/main.py b/couchpotato/core/notifications/core/main.py index b6c07f58..a9a20b0a 100644 --- a/couchpotato/core/notifications/core/main.py +++ b/couchpotato/core/notifications/core/main.py @@ -7,6 +7,7 @@ from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification from couchpotato.core.settings.model import Notification as Notif from couchpotato.environment import Env +from operator import itemgetter from sqlalchemy.sql.expression import or_ import threading import time @@ -18,9 +19,7 @@ log = CPLog(__name__) class CoreNotifier(Notification): - m_lock = threading.Lock() - messages = [] - listeners = [] + m_lock = None def __init__(self): super(CoreNotifier, self).__init__() @@ -51,10 +50,15 @@ class CoreNotifier(Notification): addApiView('notification.listener', self.listener) fireEvent('schedule.interval', 'core.check_messages', self.checkMessages, hours = 12, single = True) + fireEvent('schedule.interval', 'core.clean_messages', self.cleanMessages, seconds = 15, single = True) addEvent('app.load', self.clean) addEvent('app.load', self.checkMessages) + self.messages = [] + self.listeners = [] + self.m_lock = threading.Lock() + def clean(self): db = get_session() @@ -113,7 +117,7 @@ class CoreNotifier(Notification): prop_name = 'messages.last_check' last_check = tryInt(Env.prop(prop_name, default = 0)) - messages = fireEvent('cp.messages', last_check = last_check, single = True) + messages = fireEvent('cp.messages', last_check = last_check, single = True) or [] for message in messages: if message.get('time') > last_check: @@ -124,7 +128,8 @@ class CoreNotifier(Notification): Env.prop(prop_name, value = last_check) - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} db = get_session() @@ -145,7 +150,8 @@ class CoreNotifier(Notification): return True - def frontend(self, type = 'notification', data = {}, message = None): + def frontend(self, type = 'notification', data = None, message = None): + if not data: data = {} log.debug('Notifying frontend') @@ -169,8 +175,8 @@ class CoreNotifier(Notification): except: log.debug('Failed sending to listener: %s', traceback.format_exc()) + self.listeners = [] self.m_lock.release() - self.cleanMessages() log.debug('Done notifying frontend') @@ -184,11 +190,14 @@ class CoreNotifier(Notification): 'result': messages, }) + self.m_lock.acquire() self.listeners.append((callback, last_id)) + self.m_lock.release() def removeListener(self, callback): + self.m_lock.acquire() for list_tuple in self.listeners: try: listener, last_id = list_tuple @@ -196,15 +205,18 @@ class CoreNotifier(Notification): self.listeners.remove(list_tuple) except: log.debug('Failed removing listener: %s', traceback.format_exc()) + self.m_lock.release() def cleanMessages(self): + if len(self.messages) == 0: + return + log.debug('Cleaning messages') self.m_lock.acquire() - for message in self.messages: - if message['time'] < (time.time() - 15): - self.messages.remove(message) + time_ago = (time.time() - 15) + self.messages[:] = [m for m in self.messages if (m['time'] > time_ago)] self.m_lock.release() log.debug('Done cleaning messages') @@ -215,16 +227,16 @@ class CoreNotifier(Notification): self.m_lock.acquire() recent = [] - index = 0 - for i in xrange(len(self.messages)): - index = len(self.messages) - i - 1 - if self.messages[index]["message_id"] == last_id: break - recent = self.messages[index:] + try: + index = map(itemgetter('message_id'), self.messages).index(last_id) + recent = self.messages[index + 1:] + except: + pass self.m_lock.release() - log.debug('Returning for %s %s messages', (last_id, len(recent or []))) + log.debug('Returning for %s %s messages', (last_id, len(recent))) - return recent or [] + return recent def listener(self, init = False, **kwargs): @@ -237,6 +249,7 @@ class CoreNotifier(Notification): notifications = db.query(Notif) \ .filter(or_(Notif.read == False, Notif.added > (time.time() - 259200))) \ .all() + for n in notifications: ndict = n.to_dict() ndict['type'] = 'notification' diff --git a/couchpotato/core/notifications/email/main.py b/couchpotato/core/notifications/email/main.py index 21fcf157..f94688d5 100644 --- a/couchpotato/core/notifications/email/main.py +++ b/couchpotato/core/notifications/email/main.py @@ -11,7 +11,8 @@ log = CPLog(__name__) class Email(Notification): - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} # Extract all the settings from settings from_address = self.conf('from') diff --git a/couchpotato/core/notifications/growl/main.py b/couchpotato/core/notifications/growl/main.py index caad661b..dabeea01 100644 --- a/couchpotato/core/notifications/growl/main.py +++ b/couchpotato/core/notifications/growl/main.py @@ -43,7 +43,8 @@ class Growl(Notification): else: log.error('Failed register of growl: %s', traceback.format_exc()) - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} self.register() diff --git a/couchpotato/core/notifications/nmj/main.py b/couchpotato/core/notifications/nmj/main.py index 695f53be..1479fb1b 100644 --- a/couchpotato/core/notifications/nmj/main.py +++ b/couchpotato/core/notifications/nmj/main.py @@ -23,16 +23,15 @@ class NMJ(Notification): def autoConfig(self, host = 'localhost', **kwargs): - database = '' mount = '' try: terminal = telnetlib.Telnet(host) except Exception: - log.error('Warning: unable to get a telnet session to %s', (host)) + log.error('Warning: unable to get a telnet session to %s', host) return self.failed() - log.debug('Connected to %s via telnet', (host)) + log.debug('Connected to %s via telnet', host) terminal.read_until('sh-3.00# ') terminal.write('cat /tmp/source\n') terminal.write('cat /tmp/netshare\n') @@ -46,7 +45,7 @@ class NMJ(Notification): device = match.group(2) log.info('Found NMJ database %s on device %s', (database, device)) else: - log.error('Could not get current NMJ database on %s, NMJ is probably not running!', (host)) + log.error('Could not get current NMJ database on %s, NMJ is probably not running!', host) return self.failed() if device.startswith('NETWORK_SHARE/'): @@ -54,7 +53,7 @@ class NMJ(Notification): if match: mount = match.group().replace('127.0.0.1', host) - log.info('Found mounting url on the Popcorn Hour in configuration: %s', (mount)) + log.info('Found mounting url on the Popcorn Hour in configuration: %s', mount) else: log.error('Detected a network share on the Popcorn Hour, but could not get the mounting url') return self.failed() @@ -65,17 +64,18 @@ class NMJ(Notification): 'mount': mount, } - def addToLibrary(self, message = None, group = {}): + def addToLibrary(self, message = None, group = None): if self.isDisabled(): return + if not group: group = {} host = self.conf('host') mount = self.conf('mount') database = self.conf('database') if mount: - log.debug('Try to mount network drive via url: %s', (mount)) + log.debug('Try to mount network drive via url: %s', mount) try: - data = self.urlopen(mount) + self.urlopen(mount) except: return False @@ -98,11 +98,11 @@ class NMJ(Notification): et = etree.fromstring(response) result = et.findtext('returnValue') except SyntaxError, e: - log.error('Unable to parse XML returned from the Popcorn Hour: %s', (e)) + log.error('Unable to parse XML returned from the Popcorn Hour: %s', e) return False if int(result) > 0: - log.error('Popcorn Hour returned an errorcode: %s', (result)) + log.error('Popcorn Hour returned an errorcode: %s', result) return False else: log.info('NMJ started background scan') diff --git a/couchpotato/core/notifications/notifo/main.py b/couchpotato/core/notifications/notifo/main.py index 6e4d7adf..2d56ed71 100644 --- a/couchpotato/core/notifications/notifo/main.py +++ b/couchpotato/core/notifications/notifo/main.py @@ -12,7 +12,8 @@ class Notifo(Notification): url = 'https://api.notifo.com/v1/send_notification' - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} try: params = { diff --git a/couchpotato/core/notifications/notifymyandroid/main.py b/couchpotato/core/notifications/notifymyandroid/main.py index 2c4ac90c..92e59562 100644 --- a/couchpotato/core/notifications/notifymyandroid/main.py +++ b/couchpotato/core/notifications/notifymyandroid/main.py @@ -8,19 +8,17 @@ log = CPLog(__name__) class NotifyMyAndroid(Notification): - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} nma = pynma.PyNMA() keys = splitString(self.conf('api_key')) nma.addkey(keys) nma.developerkey(self.conf('dev_key')) - # hacky fix for the event type - # as it seems to be part of the message now - self.event = message.split(' ')[0] response = nma.push( application = self.default_title, - event = self.event, + event = message.split(' ')[0], description = message, priority = self.conf('priority'), batch_mode = len(keys) > 1 diff --git a/couchpotato/core/notifications/plex/main.py b/couchpotato/core/notifications/plex/main.py index 86da9cd5..f6088f5b 100644 --- a/couchpotato/core/notifications/plex/main.py +++ b/couchpotato/core/notifications/plex/main.py @@ -1,9 +1,10 @@ from couchpotato.core.event import addEvent from couchpotato.core.helpers.encoding import tryUrlencode -from couchpotato.core.helpers.variable import cleanHost +from couchpotato.core.helpers.variable import cleanHost, splitString from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification from urllib2 import URLError +from urlparse import urlparse from xml.dom import minidom import traceback @@ -16,16 +17,17 @@ class Plex(Notification): super(Plex, self).__init__() addEvent('renamer.after', self.addToLibrary) - def addToLibrary(self, message = None, group = {}): + def addToLibrary(self, message = None, group = None): if self.isDisabled(): return + if not group: group = {} log.info('Sending notification to Plex') - hosts = [cleanHost(x.strip() + ':32400') for x in self.conf('host').split(",")] + hosts = self.getHosts(port = 32400) for host in hosts: source_type = ['movie'] - base_url = '%slibrary/sections' % host + base_url = '%s/library/sections' % host refresh_url = '%s/%%s/refresh' % base_url try: @@ -36,7 +38,7 @@ class Plex(Notification): for s in sections: if s.getAttribute('type') in source_type: url = refresh_url % s.getAttribute('key') - x = self.urlopen(url) + self.urlopen(url) except: log.error('Plex library update failed for %s, Media Server not running: %s', (host, traceback.format_exc(1))) @@ -44,9 +46,10 @@ class Plex(Notification): return True - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} - hosts = [x.strip() + ':3000' for x in self.conf('host').split(",")] + hosts = self.getHosts(port = 3000) successful = 0 for host in hosts: if self.send({'command': 'ExecBuiltIn', 'parameter': 'Notification(CouchPotato, %s)' % message}, host): @@ -56,8 +59,7 @@ class Plex(Notification): def send(self, command, host): - url = 'http://%s/xbmcCmds/xbmcHttp/?%s' % (host, tryUrlencode(command)) - + url = '%s/xbmcCmds/xbmcHttp/?%s' % (host, tryUrlencode(command)) headers = {} try: @@ -88,3 +90,18 @@ class Plex(Notification): return { 'success': success or success2 } + + def getHosts(self, port = None): + + raw_hosts = splitString(self.conf('host')) + hosts = [] + + for h in raw_hosts: + h = cleanHost(h) + p = urlparse(h) + h = h.rstrip('/') + if port and not p.port: + h += ':%s' % port + hosts.append(h) + + return hosts diff --git a/couchpotato/core/notifications/prowl/main.py b/couchpotato/core/notifications/prowl/main.py index e5c4678b..a8a3dda2 100644 --- a/couchpotato/core/notifications/prowl/main.py +++ b/couchpotato/core/notifications/prowl/main.py @@ -12,7 +12,8 @@ class Prowl(Notification): 'api': 'https://api.prowlapp.com/publicapi/add' } - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} data = { 'apikey': self.conf('api_key'), diff --git a/couchpotato/core/notifications/pushalot/main.py b/couchpotato/core/notifications/pushalot/main.py index 4c5e76c3..4e3b6e76 100644 --- a/couchpotato/core/notifications/pushalot/main.py +++ b/couchpotato/core/notifications/pushalot/main.py @@ -11,7 +11,8 @@ class Pushalot(Notification): 'api': 'https://pushalot.com/api/sendmessage' } - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} data = { 'AuthorizationToken': self.conf('auth_token'), diff --git a/couchpotato/core/notifications/pushover/main.py b/couchpotato/core/notifications/pushover/main.py index ea5e7748..76f730b6 100644 --- a/couchpotato/core/notifications/pushover/main.py +++ b/couchpotato/core/notifications/pushover/main.py @@ -11,7 +11,8 @@ class Pushover(Notification): app_token = 'YkxHMYDZp285L265L3IwH3LmzkTaCy' - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} http_handler = HTTPSConnection("api.pushover.net:443") diff --git a/couchpotato/core/notifications/synoindex/main.py b/couchpotato/core/notifications/synoindex/main.py index 315520ef..0f7775d6 100644 --- a/couchpotato/core/notifications/synoindex/main.py +++ b/couchpotato/core/notifications/synoindex/main.py @@ -15,8 +15,9 @@ class Synoindex(Notification): super(Synoindex, self).__init__() addEvent('renamer.after', self.addToLibrary) - def addToLibrary(self, message = None, group = {}): + def addToLibrary(self, message = None, group = None): if self.isDisabled(): return + if not group: group = {} command = [self.index_path, '-A', group.get('destination_dir')] log.info('Executing synoindex command: %s ', command) @@ -27,9 +28,8 @@ class Synoindex(Notification): return True except OSError, e: log.error('Unable to run synoindex: %s', e) - return False - return True + return False def test(self, **kwargs): return { diff --git a/couchpotato/core/notifications/toasty/main.py b/couchpotato/core/notifications/toasty/main.py index 79b021e2..c65b6b42 100644 --- a/couchpotato/core/notifications/toasty/main.py +++ b/couchpotato/core/notifications/toasty/main.py @@ -11,7 +11,8 @@ class Toasty(Notification): 'api': 'http://api.supertoasty.com/notify/%s?%s' } - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} data = { 'title': self.default_title, diff --git a/couchpotato/core/notifications/trakt/main.py b/couchpotato/core/notifications/trakt/main.py index 86d47086..99d55530 100644 --- a/couchpotato/core/notifications/trakt/main.py +++ b/couchpotato/core/notifications/trakt/main.py @@ -13,7 +13,8 @@ class Trakt(Notification): listen_to = ['movie.downloaded'] - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} post_data = { 'username': self.conf('automation_username'), diff --git a/couchpotato/core/notifications/twitter/main.py b/couchpotato/core/notifications/twitter/main.py index 59fbb3a1..ad4fc315 100644 --- a/couchpotato/core/notifications/twitter/main.py +++ b/couchpotato/core/notifications/twitter/main.py @@ -4,7 +4,8 @@ from couchpotato.core.helpers.variable import cleanHost from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification from couchpotato.environment import Env -from pytwitter import Api, parse_qsl +from pytwitter import Api +from urlparse import parse_qsl import oauth2 log = CPLog(__name__) @@ -29,7 +30,8 @@ class Twitter(Notification): addApiView('notify.%s.auth_url' % self.getName().lower(), self.getAuthorizationUrl) addApiView('notify.%s.credentials' % self.getName().lower(), self.getCredentials) - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} api = Api(self.consumer_key, self.consumer_secret, self.conf('access_token_key'), self.conf('access_token_secret')) @@ -50,7 +52,7 @@ class Twitter(Notification): try: if direct_message: for user in direct_message_users.split(): - api.PostDirectMessage(user, '[%s] %s' % (self.default_title, message)) + api.PostDirectMessage('[%s] %s' % (self.default_title, message), screen_name = user) else: update_message = '[%s] %s' % (self.default_title, message) if len(update_message) > 140: diff --git a/couchpotato/core/notifications/xbmc/__init__.py b/couchpotato/core/notifications/xbmc/__init__.py index e3c467ce..dafa0f63 100644 --- a/couchpotato/core/notifications/xbmc/__init__.py +++ b/couchpotato/core/notifications/xbmc/__init__.py @@ -38,6 +38,14 @@ config = [{ 'advanced': True, 'description': 'Only update the first host when movie snatched, useful for synced XBMC', }, + { + 'name': 'remote_dir_scan', + 'label': 'Remote Folder Scan', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Only scan new movie folder at remote XBMC servers. Works if movie location is the same.', + }, { 'name': 'on_snatch', 'default': 0, diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index ad6fa605..dc185c41 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -13,11 +13,12 @@ log = CPLog(__name__) class XBMC(Notification): - listen_to = ['renamer.after'] + listen_to = ['renamer.after', 'movie.snatched'] use_json_notifications = {} http_time_between_calls = 0 - def notify(self, message = '', data = {}, listener = None): + def notify(self, message = '', data = None, listener = None): + if not data: data = {} hosts = splitString(self.conf('host')) @@ -33,15 +34,19 @@ class XBMC(Notification): ('GUI.ShowNotification', {'title': self.default_title, 'message': message, 'image': self.getNotificationImage('small')}), ] - if not self.conf('only_first') or hosts.index(host) == 0: - calls.append(('VideoLibrary.Scan', {})) + if data and data.get('destination_dir') and (not self.conf('only_first') or hosts.index(host) == 0): + param = {} + if self.conf('remote_dir_scan') or socket.getfqdn('localhost') == socket.getfqdn(host.split(':')[0]): + param = {'directory': data['destination_dir']} + + calls.append(('VideoLibrary.Scan', param)) max_successful += len(calls) response = self.request(host, calls) else: response = self.notifyXBMCnoJSON(host, {'title':self.default_title, 'message':message}) - if not self.conf('only_first') or hosts.index(host) == 0: + if data and data.get('destination_dir') and (not self.conf('only_first') or hosts.index(host) == 0): response += self.request(host, [('VideoLibrary.Scan', {})]) max_successful += 1 @@ -49,9 +54,9 @@ class XBMC(Notification): try: for result in response: - if (result.get('result') and result['result'] == 'OK'): + if result.get('result') and result['result'] == 'OK': successful += 1 - elif (result.get('error')): + elif result.get('error'): log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) except: @@ -68,7 +73,7 @@ class XBMC(Notification): ('JSONRPC.Version', {}) ]) for result in response: - if (result.get('result') and type(result['result']['version']).__name__ == 'int'): + if result.get('result') and type(result['result']['version']).__name__ == 'int': # only v2 and v4 return an int object # v6 (as of XBMC v12(Frodo)) is required to send notifications xbmc_rpc_version = str(result['result']['version']) @@ -81,15 +86,15 @@ class XBMC(Notification): # send the text message resp = self.notifyXBMCnoJSON(host, {'title':self.default_title, 'message':message}) for result in resp: - if (result.get('result') and result['result'] == 'OK'): + if result.get('result') and result['result'] == 'OK': log.debug('Message delivered successfully!') success = True break - elif (result.get('error')): + elif result.get('error'): log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) break - elif (result.get('result') and type(result['result']['version']).__name__ == 'dict'): + elif result.get('result') and type(result['result']['version']).__name__ == 'dict': # XBMC JSON-RPC v6 returns an array object containing # major, minor and patch number xbmc_rpc_version = str(result['result']['version']['major']) @@ -104,16 +109,16 @@ class XBMC(Notification): # send the text message resp = self.request(host, [('GUI.ShowNotification', {'title':self.default_title, 'message':message, 'image': self.getNotificationImage('small')})]) for result in resp: - if (result.get('result') and result['result'] == 'OK'): + if result.get('result') and result['result'] == 'OK': log.debug('Message delivered successfully!') success = True break - elif (result.get('error')): + elif result.get('error'): log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) break # error getting version info (we do have contact with XBMC though) - elif (result.get('error')): + elif result.get('error'): log.error('XBMC error; %s: %s (%s)', (result['id'], result['error']['message'], result['error']['code'])) log.debug('Use JSON notifications: %s ', self.use_json_notifications) diff --git a/couchpotato/core/plugins/automation/main.py b/couchpotato/core/plugins/automation/main.py index 67bae1d1..92547cb0 100644 --- a/couchpotato/core/plugins/automation/main.py +++ b/couchpotato/core/plugins/automation/main.py @@ -26,6 +26,10 @@ class Automation(Plugin): movie_ids = [] for imdb_id in movies: + + if self.shuttingDown(): + break + prop_name = 'automation.added.%s' % imdb_id added = Env.prop(prop_name, default = False) if not added: @@ -35,5 +39,11 @@ class Automation(Plugin): Env.prop(prop_name, True) for movie_id in movie_ids: + + if self.shuttingDown(): + break + movie_dict = fireEvent('movie.get', movie_id, single = True) - fireEvent('searcher.single', movie_dict) + fireEvent('movie.searcher.single', movie_dict) + + return True \ No newline at end of file diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index aa2a99e9..0c023a8e 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -2,7 +2,7 @@ from StringIO import StringIO from couchpotato.core.event import fireEvent, addEvent from couchpotato.core.helpers.encoding import tryUrlencode, ss, toSafeString, \ toUnicode -from couchpotato.core.helpers.variable import getExt, md5 +from couchpotato.core.helpers.variable import getExt, md5, isLocalIP from couchpotato.core.logger import CPLog from couchpotato.environment import Env from multipartpost import MultipartPostHandler @@ -12,6 +12,7 @@ from urlparse import urlparse import cookielib import glob import gzip +import inspect import math import os.path import re @@ -24,10 +25,14 @@ log = CPLog(__name__) class Plugin(object): + _class_name = None + plugin_path = None + enabled_option = 'enabled' auto_register_static = True _needs_shutdown = False + _running = None user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:24.0) Gecko/20130519 Firefox/24.0' http_last_use = {} @@ -35,16 +40,29 @@ class Plugin(object): http_failed_request = {} http_failed_disabled = {} + def __new__(typ, *args, **kwargs): + new_plugin = super(Plugin, typ).__new__(typ) + new_plugin.registerPlugin() + + return new_plugin + def registerPlugin(self): addEvent('app.do_shutdown', self.doShutdown) addEvent('plugin.running', self.isRunning) self._running = [] - def conf(self, attr, value = None, default = None): - return Env.setting(attr, self.getName().lower(), value = value, default = default) + if self.auto_register_static: + self.registerStatic(inspect.getfile(self.__class__)) + + def conf(self, attr, value = None, default = None, section = None): + class_name = self.getName().lower().split(':') + return Env.setting(attr, section = section if section else class_name[0].lower(), value = value, default = default) def getName(self): - return self.__class__.__name__ + return self._class_name or self.__class__.__name__ + + def setName(self, name): + self._class_name = name def renderTemplate(self, parent_file, templ, **params): @@ -124,7 +142,7 @@ class Plugin(object): if self.http_failed_disabled[host] > (time.time() - 900): log.info2('Disabled calls to %s for 15 minutes because so many failed requests.', host) if not show_error: - raise + raise Exception('Disabled calls to %s for 15 minutes because so many failed requests') else: return '' else: @@ -187,7 +205,7 @@ class Plugin(object): self.http_failed_request[host] += 1 # Disable temporarily - if self.http_failed_request[host] > 5: + if self.http_failed_request[host] > 5 and not isLocalIP(host): self.http_failed_disabled[host] = time.time() except: @@ -241,8 +259,8 @@ class Plugin(object): def getCache(self, cache_key, url = None, **kwargs): - cache_key = md5(ss(cache_key)) - cache = Env.get('cache').get(cache_key) + cache_key_md5 = md5(ss(cache_key)) + cache = Env.get('cache').get(cache_key_md5) if cache: if not Env.get('dev'): log.debug('Getting cache %s', cache_key) return cache @@ -266,8 +284,9 @@ class Plugin(object): return '' def setCache(self, cache_key, value, timeout = 300): + cache_key_md5 = md5(ss(cache_key)) log.debug('Setting cache %s', cache_key) - Env.get('cache').set(cache_key, value, timeout) + Env.get('cache').set(cache_key_md5, value, timeout) return value def createNzbName(self, data, movie): @@ -276,9 +295,9 @@ class Plugin(object): def createFileName(self, data, filedata, movie): name = os.path.join(self.createNzbName(data, movie)) - if data.get('type') == 'nzb' and 'DOCTYPE nzb' not in filedata and '' not in filedata: + if data.get('protocol', data.get('type')) == 'nzb' and 'DOCTYPE nzb' not in filedata and '' not in filedata: return '%s.%s' % (name, 'rar') - return '%s.%s' % (name, data.get('type')) + return '%s.%s' % (name, data.get('protocol')) def cpTag(self, movie): if Env.setting('enabled', 'renamer'): @@ -290,4 +309,4 @@ class Plugin(object): return not self.isEnabled() def isEnabled(self): - return self.conf(self.enabled_option) or self.conf(self.enabled_option) == None + return self.conf(self.enabled_option) or self.conf(self.enabled_option) is None diff --git a/couchpotato/core/plugins/browser/main.py b/couchpotato/core/plugins/browser/main.py index 6b989a08..380e6826 100644 --- a/couchpotato/core/plugins/browser/main.py +++ b/couchpotato/core/plugins/browser/main.py @@ -12,7 +12,7 @@ if os.name == 'nt': except: # todo:: subclass ImportError for missing dependencies, vs. broken plugins? 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/"); + pywin32 package. You can get it from http://sourceforge.net/projects/pywin32/files/pywin32/") else: import win32file #@UnresolvedImport diff --git a/couchpotato/core/plugins/category/__init__.py b/couchpotato/core/plugins/category/__init__.py new file mode 100644 index 00000000..6dc41df7 --- /dev/null +++ b/couchpotato/core/plugins/category/__init__.py @@ -0,0 +1,6 @@ +from .main import CategoryPlugin + +def start(): + return CategoryPlugin() + +config = [] diff --git a/couchpotato/core/plugins/category/main.py b/couchpotato/core/plugins/category/main.py new file mode 100644 index 00000000..6a60ae4e --- /dev/null +++ b/couchpotato/core/plugins/category/main.py @@ -0,0 +1,122 @@ +from couchpotato import get_session +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin +from couchpotato.core.settings.model import Movie, Category + +log = CPLog(__name__) + + +class CategoryPlugin(Plugin): + + def __init__(self): + addEvent('category.all', self.all) + + addApiView('category.save', self.save) + addApiView('category.save_order', self.saveOrder) + addApiView('category.delete', self.delete) + addApiView('category.list', self.allView, docs = { + 'desc': 'List all available categories', + 'return': {'type': 'object', 'example': """{ + 'success': True, + 'list': array, categories +}"""} + }) + + def allView(self, **kwargs): + + return { + 'success': True, + 'list': self.all() + } + + def all(self): + + db = get_session() + categories = db.query(Category).all() + + temp = [] + for category in categories: + temp.append(category.to_dict()) + + db.expire_all() + return temp + + def save(self, **kwargs): + + db = get_session() + + c = db.query(Category).filter_by(id = kwargs.get('id')).first() + if not c: + c = Category() + db.add(c) + + c.order = kwargs.get('order', c.order if c.order else 0) + c.label = toUnicode(kwargs.get('label')) + c.path = toUnicode(kwargs.get('path')) + c.ignored = toUnicode(kwargs.get('ignored')) + c.preferred = toUnicode(kwargs.get('preferred')) + c.required = toUnicode(kwargs.get('required')) + c.destination = toUnicode(kwargs.get('destination')) + + db.commit() + + category_dict = c.to_dict() + + return { + 'success': True, + 'category': category_dict + } + + def saveOrder(self, **kwargs): + + db = get_session() + + order = 0 + for category_id in kwargs.get('ids', []): + c = db.query(Category).filter_by(id = category_id).first() + c.order = order + + order += 1 + + db.commit() + + return { + 'success': True + } + + def delete(self, id = None, **kwargs): + + db = get_session() + + success = False + message = '' + try: + c = db.query(Category).filter_by(id = id).first() + db.delete(c) + db.commit() + + # Force defaults on all empty category movies + self.removeFromMovie(id) + + success = True + except Exception, e: + message = log.error('Failed deleting category: %s', e) + + db.expire_all() + return { + 'success': success, + 'message': message + } + + def removeFromMovie(self, category_id): + + db = get_session() + movies = db.query(Movie).filter(Movie.category_id == category_id).all() + + if len(movies) > 0: + for movie in movies: + movie.category_id = None + db.commit() diff --git a/couchpotato/core/plugins/category/static/category.css b/couchpotato/core/plugins/category/static/category.css new file mode 100644 index 00000000..0987c197 --- /dev/null +++ b/couchpotato/core/plugins/category/static/category.css @@ -0,0 +1,82 @@ +.add_new_category { + padding: 20px; + display: block; + text-align: center; + font-size: 20px; + border-bottom: 1px solid rgba(255,255,255,0.2); +} + +.category { + border-bottom: 1px solid rgba(255,255,255,0.2); + position: relative; +} + + .category > .delete { + position: absolute; + padding: 16px; + right: 0; + cursor: pointer; + opacity: 0.6; + color: #fd5353; + } + .category > .delete:hover { + opacity: 1; + } + + .category .ctrlHolder:hover { + background: none; + } + + .category .formHint { + width: 250px !important; + margin: 0 !important; + opacity: 0.1; + } + .category:hover .formHint { + opacity: 1; + } + +#category_ordering { + +} + + #category_ordering ul { + float: left; + margin: 0; + width: 275px; + padding: 0; + } + + #category_ordering li { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + border-bottom: 1px solid rgba(255,255,255,0.2); + padding: 0 5px; + } + #category_ordering li:last-child { border: 0; } + + #category_ordering li .check { + margin: 2px 10px 0 0; + vertical-align: top; + } + + #category_ordering li > span { + display: inline-block; + height: 20px; + vertical-align: top; + line-height: 20px; + } + + #category_ordering li .handle { + background: url('../../static/profile_plugin/handle.png') center; + width: 20px; + float: right; + } + + #category_ordering .formHint { + clear: none; + float: right; + width: 250px; + margin: 0; + } \ No newline at end of file diff --git a/couchpotato/core/plugins/category/static/category.js b/couchpotato/core/plugins/category/static/category.js new file mode 100644 index 00000000..168b70de --- /dev/null +++ b/couchpotato/core/plugins/category/static/category.js @@ -0,0 +1,332 @@ +var CategoryListBase = new Class({ + + initialize: function(){ + var self = this; + + App.addEvent('load', self.addSettings.bind(self)); + }, + + setup: function(categories){ + var self = this; + + self.categories = [] + Array.each(categories, self.createCategory.bind(self)); + + }, + + addSettings: function(){ + var self = this; + + self.settings = App.getPage('Settings') + self.settings.addEvent('create', function(){ + var tab = self.settings.createSubTab('category', { + 'label': 'Categories', + 'name': 'category', + 'subtab_label': 'Category & filtering' + }, self.settings.tabs.searcher ,'searcher'); + + self.tab = tab.tab; + self.content = tab.content; + + self.createList(); + self.createOrdering(); + + }) + + // Add categories in renamer + self.settings.addEvent('create', function(){ + var renamer_group = self.settings.tabs.renamer.groups.renamer; + + self.categories.each(function(category){ + + var input = new Option.Directory('section_name', 'option.name', category.get('destination'), { + 'name': category.get('label') + }); + input.inject(renamer_group.getElement('.renamer_to')); + input.fireEvent('injected'); + + input.save = function(){ + category.data.destination = input.getValue(); + category.save(); + }; + + }); + + }) + + }, + + createList: function(){ + var self = this; + + var count = self.categories.length; + + self.settings.createGroup({ + 'label': 'Categories', + 'description': 'Create categories, each one extending global filters. (Needs refresh \'' +(App.isMac() ? 'CMD+R' : 'F5')+ '\' after editing)' + }).inject(self.content).adopt( + self.category_container = new Element('div.container'), + new Element('a.add_new_category', { + 'text': count > 0 ? 'Create another category' : 'Click here to create a category.', + 'events': { + 'click': function(){ + var category = self.createCategory(); + $(category).inject(self.category_container) + } + } + }) + ); + + // Add categories, that aren't part of the core (for editing) + Array.each(self.categories, function(category){ + $(category).inject(self.category_container) + }); + + }, + + getCategory: function(id){ + return this.categories.filter(function(category){ + return category.data.id == id + }).pick() + }, + + getAll: function(){ + return this.categories; + }, + + createCategory: function(data){ + var self = this; + + var data = data || {'id': randomString()} + var category = new Category(data) + self.categories.include(category) + + return category; + }, + + createOrdering: function(){ + var self = this; + + var category_list; + var group = self.settings.createGroup({ + 'label': 'Category ordering' + }).adopt( + new Element('.ctrlHolder#category_ordering').adopt( + new Element('label[text=Order]'), + category_list = new Element('ul'), + new Element('p.formHint', { + 'html': 'Change the order the categories are in the dropdown list.
First one will be default.' + }) + ) + ).inject(self.content) + + Array.each(self.categories, function(category){ + new Element('li', {'data-id': category.data.id}).adopt( + new Element('span.category_label', { + 'text': category.data.label + }), + new Element('span.handle') + ).inject(category_list); + + }); + + // Sortable + self.category_sortable = new Sortables(category_list, { + 'revert': true, + 'handle': '', + 'opacity': 0.5, + 'onComplete': self.saveOrdering.bind(self) + }); + + }, + + saveOrdering: function(){ + var self = this; + + var ids = []; + + self.category_sortable.list.getElements('li').each(function(el, nr){ + ids.include(el.get('data-id')); + }); + + Api.request('category.save_order', { + 'data': { + 'ids': ids + } + }); + + } + +}) + +window.CategoryList = new CategoryListBase(); + +var Category = new Class({ + + data: {}, + + initialize: function(data){ + var self = this; + + self.data = data; + + self.create(); + + self.el.addEvents({ + 'change:relay(select)': self.save.bind(self, 0), + 'keyup:relay(input[type=text])': self.save.bind(self, [300]) + }); + + }, + + create: function(){ + var self = this; + + var data = self.data; + + self.el = new Element('div.category').adopt( + self.delete_button = new Element('span.delete.icon2', { + 'events': { + 'click': self.del.bind(self) + } + }), + new Element('.category_label.ctrlHolder').adopt( + new Element('label', {'text':'Name'}), + new Element('input.inlay', { + 'type':'text', + 'value': data.label, + 'placeholder': 'Example: Kids, Horror or His' + }), + new Element('p.formHint', {'text': 'See global filters for explanation.'}) + ), + new Element('.category_preferred.ctrlHolder').adopt( + new Element('label', {'text':'Preferred'}), + new Element('input.inlay', { + 'type':'text', + 'value': data.preferred, + 'placeholder': 'Blu-ray, DTS' + }) + ), + new Element('.category_required.ctrlHolder').adopt( + new Element('label', {'text':'Required'}), + new Element('input.inlay', { + 'type':'text', + 'value': data.required, + 'placeholder': 'Example: DTS, AC3 & English' + }) + ), + new Element('.category_ignored.ctrlHolder').adopt( + new Element('label', {'text':'Ignored'}), + new Element('input.inlay', { + 'type':'text', + 'value': data.ignored, + 'placeholder': 'Example: dubbed, swesub, french' + }) + ) + ); + + self.makeSortable() + + }, + + save: function(delay){ + var self = this; + + if(self.save_timer) clearTimeout(self.save_timer); + self.save_timer = (function(){ + + var data = self.getData(); + + Api.request('category.save', { + 'data': self.getData(), + 'useSpinner': true, + 'spinnerOptions': { + 'target': self.el + }, + 'onComplete': function(json){ + if(json.success){ + self.data = json.category; + } + } + }); + + }).delay(delay || 0, self) + + }, + + getData: function(){ + var self = this; + + var data = { + 'id' : self.data.id, + 'label' : self.el.getElement('.category_label input').get('value'), + 'required' : self.el.getElement('.category_required input').get('value'), + 'preferred' : self.el.getElement('.category_preferred input').get('value'), + 'ignored' : self.el.getElement('.category_ignored input').get('value'), + 'destination': self.data.destination + } + + return data + }, + + del: function(){ + var self = this; + + if(self.data.label == undefined){ + self.el.destroy(); + return; + } + + var label = self.el.getElement('.category_label input').get('value'); + var qObj = new Question('Are you sure you want to delete "'+label+'"?', '', [{ + 'text': 'Delete "'+label+'"', + 'class': 'delete', + 'events': { + 'click': function(e){ + (e).preventDefault(); + Api.request('category.delete', { + 'data': { + 'id': self.data.id + }, + 'useSpinner': true, + 'spinnerOptions': { + 'target': self.el + }, + 'onComplete': function(json){ + if(json.success) { + qObj.close(); + self.el.destroy(); + } else { + alert(json.message); + } + } + }); + } + } + }, { + 'text': 'Cancel', + 'cancel': true + }]); + + }, + + makeSortable: function(){ + var self = this; + + self.sortable = new Sortables(self.category_container, { + 'revert': true, + 'handle': '.handle', + 'opacity': 0.5, + 'onComplete': self.save.bind(self, 300) + }); + }, + + get: function(attr){ + return this.data[attr] + }, + + toElement: function(){ + return this.el + } + +}); \ No newline at end of file diff --git a/couchpotato/core/plugins/category/static/handle.png b/couchpotato/core/plugins/category/static/handle.png new file mode 100644 index 00000000..adff5b29 Binary files /dev/null and b/couchpotato/core/plugins/category/static/handle.png differ diff --git a/couchpotato/core/plugins/dashboard/main.py b/couchpotato/core/plugins/dashboard/main.py index df21fefa..2da4d8cc 100644 --- a/couchpotato/core/plugins/dashboard/main.py +++ b/couchpotato/core/plugins/dashboard/main.py @@ -4,8 +4,9 @@ from couchpotato.core.event import fireEvent 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 couchpotato.core.settings.model import Movie, Library, LibraryTitle from sqlalchemy.orm import joinedload_all +from sqlalchemy.sql.expression import asc import random as rndm import time @@ -40,67 +41,81 @@ class Dashboard(Plugin): profile_pre[profile.get('id')] = contains - # Get all active movies - active_status, snatched_status, downloaded_status, available_status = fireEvent('status.get', ['active', 'snatched', 'downloaded', 'available'], 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 = 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() + # Get all active movies + active_status = fireEvent('status.get', ['active'], single = True) + q = db.query(Movie) \ + .join(Library) \ + .filter(Movie.status_id == active_status.get('id')) \ + .with_entities(Movie.id, Movie.profile_id, Library.info, Library.year) \ + .group_by(Movie.id) - if random: - rndm.shuffle(all_movies) + if not random: + q = q.join(LibraryTitle) \ + .filter(LibraryTitle.default == True) \ + .order_by(asc(LibraryTitle.simple_title)) + active = q.all() 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, movie.library.year, single = True): - coming_soon = True - if pp.get('dvd') and fireEvent('searcher.could_be_released', False, eta, movie.library.year, single = True): - coming_soon = True + if len(active) > 0: - # Skip if movie is snatched/downloaded/available - skip = False - for release in movie.releases: - if release.status_id in [snatched_status.get('id'), downloaded_status.get('id'), available_status.get('id')]: - skip = True - break - if skip: - continue + # Do the shuffle + if random: + rndm.shuffle(active) - if coming_soon: - temp = movie.to_dict({ - 'profile': {'types': {}}, - 'releases': {'files':{}, 'info': {}}, - 'library': {'titles': {}, 'files':{}}, - 'files': {}, - }) + movie_ids = [] + for movie in active: + movie_id, profile_id, info, year = movie - # Don't list older movies - if ((not late and ((not eta.get('dvd') and not eta.get('theater')) or (eta.get('dvd') and eta.get('dvd') > (now - 2419200)))) or \ - (late and (eta.get('dvd', 0) > 0 or eta.get('theater')) and eta.get('dvd') < (now - 2419200))): - movies.append(temp) + pp = profile_pre.get(profile_id) + if not pp: continue - if len(movies) >= limit: - break + eta = info.get('release_date', {}) or {} + coming_soon = False + + # Theater quality + if pp.get('theater') and fireEvent('movie.searcher.could_be_released', True, eta, year, single = True): + coming_soon = True + elif pp.get('dvd') and fireEvent('movie.searcher.could_be_released', False, eta, year, single = True): + coming_soon = True + + if coming_soon: + + # Don't list older movies + if ((not late and (not eta.get('dvd') and not eta.get('theater') or eta.get('dvd') and eta.get('dvd') > (now - 2419200))) or + (late and (eta.get('dvd', 0) > 0 or eta.get('theater')) and eta.get('dvd') < (now - 2419200))): + movie_ids.append(movie_id) + + if len(movie_ids) >= limit: + break + + if len(movie_ids) > 0: + + # Get all movie information + movies_raw = db.query(Movie) \ + .options(joinedload_all('library.titles')) \ + .options(joinedload_all('library.files')) \ + .options(joinedload_all('files')) \ + .filter(Movie.id.in_(movie_ids)) \ + .all() + + # Create dict by movie id + movie_dict = {} + for movie in movies_raw: + movie_dict[movie.id] = movie + + for movie_id in movie_ids: + movies.append(movie_dict[movie_id].to_dict({ + 'library': {'titles': {}, 'files':{}}, + 'files': {}, + })) - db.expire_all() return { 'success': True, 'empty': len(movies) == 0, diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py index cdd67f5c..2f458f72 100644 --- a/couchpotato/core/plugins/file/main.py +++ b/couchpotato/core/plugins/file/main.py @@ -83,7 +83,8 @@ class FileManager(Plugin): Env.get('app').add_handlers(".*$", [('%s%s' % (Env.get('api_base'), route), StaticFileHandler, {'path': Env.get('cache_dir')})]) - def download(self, url = '', dest = None, overwrite = False, urlopen_kwargs = {}): + def download(self, url = '', dest = None, overwrite = False, urlopen_kwargs = None): + if not urlopen_kwargs: urlopen_kwargs = {} if not dest: # to Cache dest = os.path.join(Env.get('cache_dir'), '%s.%s' % (md5(url), getExt(url))) @@ -100,7 +101,9 @@ class FileManager(Plugin): self.createFile(dest, filedata, binary = True) return dest - def add(self, path = '', part = 1, type_tuple = (), available = 1, properties = {}): + def add(self, path = '', part = 1, type_tuple = (), available = 1, properties = None): + if not properties: properties = {} + type_id = self.getType(type_tuple).get('id') db = get_session() diff --git a/couchpotato/core/plugins/library/__init__.py b/couchpotato/core/plugins/library/__init__.py deleted file mode 100644 index f5970329..00000000 --- a/couchpotato/core/plugins/library/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .main import LibraryPlugin - -def start(): - return LibraryPlugin() - -config = [] diff --git a/couchpotato/core/plugins/log/main.py b/couchpotato/core/plugins/log/main.py index ee88b8d4..dc8f740f 100644 --- a/couchpotato/core/plugins/log/main.py +++ b/couchpotato/core/plugins/log/main.py @@ -90,7 +90,6 @@ class Logging(Plugin): if not os.path.isfile(path): break - reversed_lines = [] f = open(path, 'r') reversed_lines = toUnicode(f.read()).split('[0m\n') reversed_lines.reverse() @@ -120,7 +119,7 @@ class Logging(Plugin): path = '%s%s' % (Env.get('log_path'), '.%s' % x if x > 0 else '') if not os.path.isfile(path): - break + continue try: diff --git a/couchpotato/core/plugins/manage/__init__.py b/couchpotato/core/plugins/manage/__init__.py index 46eee210..912296b4 100644 --- a/couchpotato/core/plugins/manage/__init__.py +++ b/couchpotato/core/plugins/manage/__init__.py @@ -28,6 +28,14 @@ config = [{ 'description': 'Remove movie from db if it can\'t be found after re-scan.', 'default': True, }, + { + 'label': 'Scan at startup', + 'name': 'startup_scan', + 'type': 'bool', + 'default': True, + 'advanced': True, + 'description': 'Do a quick scan on startup. On slow systems better disable this.', + }, ], }, ], diff --git a/couchpotato/core/plugins/manage/main.py b/couchpotato/core/plugins/manage/main.py index 454e765c..3a475b70 100644 --- a/couchpotato/core/plugins/manage/main.py +++ b/couchpotato/core/plugins/manage/main.py @@ -26,7 +26,8 @@ class Manage(Plugin): addEvent('manage.diskspace', self.getDiskSpace) # Add files after renaming - def after_rename(message = None, group = {}): + def after_rename(message = None, group = None): + if not group: group = {} return self.scanFilesToLibrary(folder = group['destination_dir'], files = group['renamed_files']) addEvent('renamer.after', after_rename, priority = 110) @@ -44,7 +45,7 @@ class Manage(Plugin): }"""}, }) - if not Env.get('dev'): + if not Env.get('dev') and self.conf('startup_scan'): addEvent('app.load', self.updateLibraryQuick) def getProgress(self, **kwargs): @@ -168,7 +169,9 @@ class Manage(Plugin): fireEvent('notify.frontend', type = 'manage.updating', data = False) self.in_progress = False - def createAddToLibrary(self, folder, added_identifiers = []): + def createAddToLibrary(self, folder, added_identifiers = None): + if not added_identifiers: added_identifiers = [] + def addToLibrary(group, total_found, to_go): if self.in_progress[folder]['total'] is None: self.in_progress[folder] = { @@ -182,9 +185,9 @@ class Manage(Plugin): # Add it to release and update the info fireEvent('release.add', group = group) - fireEventAsync('library.update', identifier = identifier, on_complete = self.createAfterUpdate(folder, identifier)) + fireEventAsync('library.update.movie', identifier = identifier, on_complete = self.createAfterUpdate(folder, identifier)) else: - self.in_progress[folder]['to_go'] = self.in_progress[folder]['to_go'] - 1 + self.in_progress[folder]['to_go'] -= 1 return addToLibrary @@ -192,7 +195,10 @@ class Manage(Plugin): # Notify frontend def afterUpdate(): - self.in_progress[folder]['to_go'] = self.in_progress[folder]['to_go'] - 1 + if not self.in_progress or self.shuttingDown(): + return + + self.in_progress[folder]['to_go'] -= 1 total = self.in_progress[folder]['total'] movie_dict = fireEvent('movie.get', identifier, single = True) diff --git a/couchpotato/core/plugins/movie/__init__.py b/couchpotato/core/plugins/movie/__init__.py deleted file mode 100644 index 4df29ad8..00000000 --- a/couchpotato/core/plugins/movie/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .main import MoviePlugin - -def start(): - return MoviePlugin() - -config = [] diff --git a/couchpotato/core/plugins/profile/main.py b/couchpotato/core/plugins/profile/main.py index c70d7c99..68ab9360 100644 --- a/couchpotato/core/plugins/profile/main.py +++ b/couchpotato/core/plugins/profile/main.py @@ -5,6 +5,7 @@ from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.core.settings.model import Profile, ProfileType, Movie +from sqlalchemy.orm import joinedload_all log = CPLog(__name__) @@ -55,7 +56,9 @@ class ProfilePlugin(Plugin): def all(self): db = get_session() - profiles = db.query(Profile).all() + profiles = db.query(Profile) \ + .options(joinedload_all('types')) \ + .all() temp = [] for profile in profiles: @@ -104,7 +107,9 @@ class ProfilePlugin(Plugin): def default(self): db = get_session() - default = db.query(Profile).first() + default = db.query(Profile) \ + .options(joinedload_all('types')) \ + .first() default_dict = default.to_dict(self.to_dict) db.expire_all() @@ -155,7 +160,7 @@ class ProfilePlugin(Plugin): def fill(self): - db = get_session(); + db = get_session() profiles = [{ 'label': 'Best', diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py index 6ac1c6b6..1149c036 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -19,10 +19,10 @@ class QualityPlugin(Plugin): {'identifier': 'bd50', 'hd': True, 'size': (15000, 60000), 'label': 'BR-Disk', 'alternative': ['bd25'], 'allow': ['1080p'], 'ext':[], 'tags': ['bdmv', 'certificate', ('complete', 'bluray')]}, {'identifier': '1080p', 'hd': True, 'size': (4000, 20000), 'label': '1080p', 'width': 1920, 'height': 1080, 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['m2ts']}, {'identifier': '720p', 'hd': True, 'size': (3000, 10000), 'label': '720p', 'width': 1280, 'height': 720, 'alternative': [], 'allow': [], 'ext':['mkv', 'ts']}, - {'identifier': 'brrip', 'hd': True, 'size': (700, 7000), 'label': 'BR-Rip', 'alternative': ['bdrip'], 'allow': ['720p', '1080p'], 'ext':['avi']}, + {'identifier': 'brrip', 'hd': True, 'size': (700, 7000), 'label': 'BR-Rip', 'alternative': ['bdrip'], 'allow': ['720p', '1080p'], 'ext':['avi'], 'tags': ['hdtv', 'hdrip', 'webdl', ('web', 'dl')]}, {'identifier': 'dvdr', 'size': (3000, 10000), 'label': 'DVD-R', 'alternative': [], 'allow': [], 'ext':['iso', 'img'], 'tags': ['pal', 'ntsc', 'video_ts', 'audio_ts']}, - {'identifier': 'dvdrip', 'size': (600, 2400), 'label': 'DVD-Rip', 'width': 720, 'alternative': ['dvdrip'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg'], 'tags': [('dvd', 'rip'), ('dvd', 'xvid'), ('dvd', 'divx')]}, - {'identifier': 'scr', 'size': (600, 1600), 'label': 'Screener', 'alternative': ['screener', 'dvdscr', 'ppvrip', 'dvdscreener'], 'allow': ['dvdr', 'dvd'], 'ext':['avi', 'mpg', 'mpeg']}, + {'identifier': 'dvdrip', 'size': (600, 2400), 'label': 'DVD-Rip', 'width': 720, 'alternative': [], 'allow': [], 'ext':['avi', 'mpg', 'mpeg'], 'tags': [('dvd', 'rip'), ('dvd', 'xvid'), ('dvd', 'divx')]}, + {'identifier': 'scr', 'size': (600, 1600), 'label': 'Screener', 'alternative': ['screener', 'dvdscr', 'ppvrip', 'dvdscreener', 'hdscr'], 'allow': ['dvdr', 'dvd'], 'ext':['avi', 'mpg', 'mpeg'], 'tags': ['webrip', ('web', 'rip')]}, {'identifier': 'r5', 'size': (600, 1000), 'label': 'R5', 'alternative': ['r6'], 'allow': ['dvdr'], 'ext':['avi', 'mpg', 'mpeg']}, {'identifier': 'tc', 'size': (600, 1000), 'label': 'TeleCine', 'alternative': ['telecine'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, {'identifier': 'ts', 'size': (600, 1000), 'label': 'TeleSync', 'alternative': ['telesync', 'hdts'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, @@ -102,7 +102,7 @@ class QualityPlugin(Plugin): def fill(self): - db = get_session(); + db = get_session() order = 0 for q in self.qualities: @@ -152,45 +152,61 @@ class QualityPlugin(Plugin): return True - def guess(self, files, extra = {}): + def guess(self, files, extra = None): + if not extra: extra = {} # Create hash for cache - hash = md5(str([f.replace('.' + getExt(f), '') for f in files])) - cached = self.getCache(hash) - if cached and extra is {}: return cached + cache_key = md5(str([f.replace('.' + getExt(f), '') for f in files])) + cached = self.getCache(cache_key) + if cached and len(extra) == 0: return cached + qualities = self.all() for cur_file in files: words = re.split('\W+', cur_file.lower()) - for quality in self.all(): + found = {} + for quality in qualities: + contains = self.containsTag(quality, words, cur_file) + if contains: + found[quality['identifier']] = True - # Check tags + for quality in qualities: + + # Check identifier if quality['identifier'] in words: - log.debug('Found via identifier "%s" in %s', (quality['identifier'], cur_file)) - return self.setCache(hash, quality) + if len(found) == 0 or len(found) == 1 and found.get(quality['identifier']): + log.debug('Found via identifier "%s" in %s', (quality['identifier'], cur_file)) + return self.setCache(cache_key, quality) - if list(set(quality.get('alternative', [])) & set(words)): - log.debug('Found %s via alt %s in %s', (quality['identifier'], quality.get('alternative'), cur_file)) - return self.setCache(hash, quality) - - for tag in quality.get('tags', []): - if isinstance(tag, tuple) and '.'.join(tag) in '.'.join(words): - log.debug('Found %s via tag %s in %s', (quality['identifier'], quality.get('tags'), cur_file)) - return self.setCache(hash, quality) - - if list(set(quality.get('tags', [])) & set(words)): - log.debug('Found %s via tag %s in %s', (quality['identifier'], quality.get('tags'), cur_file)) - return self.setCache(hash, quality) + # Check alt and tags + contains = self.containsTag(quality, words, cur_file) + if contains: + return self.setCache(cache_key, quality) # Try again with loose testing - quality = self.guessLoose(hash, files = files, extra = extra) + quality = self.guessLoose(cache_key, files = files, extra = extra) if quality: - return self.setCache(hash, quality) + return self.setCache(cache_key, quality) log.debug('Could not identify quality for: %s', files) return None - def guessLoose(self, hash, files = None, extra = None): + def containsTag(self, quality, words, cur_file = ''): + + # Check alt and tags + for tag_type in ['alternative', 'tags']: + for alt in quality.get(tag_type, []): + if isinstance(alt, tuple) and '.'.join(alt) in '.'.join(words): + log.debug('Found %s via %s %s in %s', (quality['identifier'], tag_type, quality.get(tag_type), cur_file)) + return True + + if list(set(quality.get(tag_type, [])) & set(words)): + log.debug('Found %s via %s %s in %s', (quality['identifier'], tag_type, quality.get(tag_type), cur_file)) + return True + + return + + def guessLoose(self, cache_key, files = None, extra = None): if extra: for quality in self.all(): @@ -198,15 +214,15 @@ class QualityPlugin(Plugin): # Check width resolution, range 20 if quality.get('width') and (quality.get('width') - 20) <= extra.get('resolution_width', 0) <= (quality.get('width') + 20): log.debug('Found %s via resolution_width: %s == %s', (quality['identifier'], quality.get('width'), extra.get('resolution_width', 0))) - return self.setCache(hash, quality) + return self.setCache(cache_key, quality) # Check height resolution, range 20 if quality.get('height') and (quality.get('height') - 20) <= extra.get('resolution_height', 0) <= (quality.get('height') + 20): log.debug('Found %s via resolution_height: %s == %s', (quality['identifier'], quality.get('height'), extra.get('resolution_height', 0))) - return self.setCache(hash, quality) + return self.setCache(cache_key, quality) if 480 <= extra.get('resolution_width', 0) <= 720: log.debug('Found as dvdrip') - return self.setCache(hash, self.single('dvdrip')) + return self.setCache(cache_key, self.single('dvdrip')) return None diff --git a/couchpotato/core/plugins/quality/static/quality.js b/couchpotato/core/plugins/quality/static/quality.js index bd2ff2ac..ead9a904 100644 --- a/couchpotato/core/plugins/quality/static/quality.js +++ b/couchpotato/core/plugins/quality/static/quality.js @@ -41,7 +41,8 @@ var QualityBase = new Class({ self.settings.addEvent('create', function(){ var tab = self.settings.createSubTab('profile', { 'label': 'Quality', - 'name': 'profile' + 'name': 'profile', + 'subtab_label': 'Qualities' }, self.settings.tabs.searcher ,'searcher'); self.tab = tab.tab; @@ -102,7 +103,8 @@ var QualityBase = new Class({ var profile_list; var group = self.settings.createGroup({ - 'label': 'Profile Defaults' + 'label': 'Profile Defaults', + 'description': '(Needs refresh \'' +(App.isMac() ? 'CMD+R' : 'F5')+ '\' after editing)' }).adopt( new Element('.ctrlHolder#profile_ordering').adopt( new Element('label[text=Order]'), diff --git a/couchpotato/core/plugins/release/main.py b/couchpotato/core/plugins/release/main.py index bd66b1aa..6916b960 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -6,8 +6,10 @@ from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.core.plugins.scanner.main import Scanner from couchpotato.core.settings.model import File, Release as Relea, Movie +from sqlalchemy.orm import joinedload_all from sqlalchemy.sql.expression import and_, or_ import os +import traceback log = CPLog(__name__) @@ -35,6 +37,12 @@ class Release(Plugin): 'id': {'type': 'id', 'desc': 'ID of the release object in release-table'} } }) + addApiView('release.for_movie', self.forMovie, docs = { + 'desc': 'Returns all releases for a movie. Ordered by score(desc)', + 'params': { + 'id': {'type': 'id', 'desc': 'ID of the movie'} + } + }) addEvent('release.delete', self.delete) addEvent('release.clean', self.clean) @@ -88,8 +96,8 @@ class Release(Plugin): added_files = db.query(File).filter(or_(*[File.id == x for x in added_files])).all() rel.files.extend(added_files) db.commit() - except Exception, e: - log.debug('Failed to attach "%s" to release: %s', (cur_file, e)) + except: + log.debug('Failed to attach "%s" to release: %s', (added_files, traceback.format_exc())) fireEvent('movie.restatus', movie.id) @@ -174,7 +182,7 @@ class Release(Plugin): # Get matching provider provider = fireEvent('provider.belongs_to', item['url'], provider = item.get('provider'), single = True) - if item['type'] != 'torrent_magnet': + if item.get('protocol', item.get('type')) != 'torrent_magnet': item['download'] = provider.loginDownload if provider.urls.get('login') else provider.download success = fireEvent('searcher.download', data = item, movie = rel.movie.to_dict({ @@ -203,3 +211,22 @@ class Release(Plugin): return { 'success': False } + + def forMovie(self, id = None, **kwargs): + + db = get_session() + + releases_raw = db.query(Relea) \ + .options(joinedload_all('info')) \ + .options(joinedload_all('files')) \ + .filter(Relea.movie_id == id) \ + .all() + + releases = [r.to_dict({'info':{}, 'files':{}}) for r in releases_raw] + releases = sorted(releases, key = lambda k: k['info'].get('score', 0), reverse = True) + + return { + 'releases': releases, + 'success': True + } + diff --git a/couchpotato/core/plugins/renamer/__init__.py b/couchpotato/core/plugins/renamer/__init__.py old mode 100644 new mode 100755 index 155a939b..6472a2df --- a/couchpotato/core/plugins/renamer/__init__.py +++ b/couchpotato/core/plugins/renamer/__init__.py @@ -27,6 +27,7 @@ rename_options = { 'imdb_id': 'IMDB id (tt0123456)', 'cd': 'CD number (cd1)', 'cd_nr': 'Just the cd nr. (1)', + 'mpaa': 'MPAA Rating', }, } @@ -54,7 +55,7 @@ config = [{ { 'name': 'to', 'type': 'directory', - 'description': 'Folder where the movies should be moved to.', + 'description': 'Default folder where the movies are moved to.', }, { 'name': 'folder_name', @@ -72,6 +73,12 @@ config = [{ 'type': 'choice', 'options': rename_options }, + { + 'name': 'unrar', + 'type': 'bool', + 'description': 'Extract rar files if found.', + 'default': False, + }, { 'name': 'cleanup', 'type': 'bool', @@ -119,10 +126,10 @@ config = [{ { 'name': 'file_action', 'label': 'Torrent File Action', - 'default': 'move', + 'default': 'link', 'type': 'dropdown', - 'values': [('Move', 'move'), ('Copy', 'copy'), ('Hard link', 'hardlink'), ('Sym link', 'symlink'), ('Move & Sym link', 'move_symlink')], - 'description': 'Define which kind of file operation you want to use for torrents. Before you start using hard links or sym links, PLEASE read about their possible drawbacks.', + 'values': [('Link', 'link'), ('Copy', 'copy'), ('Move', 'move')], + 'description': 'Link or Copy after downloading completed (and allow for seeding), or Move after seeding completed. Link first tries hard link, then sym link and falls back to Copy.', 'advanced': True, }, { diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index a2fb5dc5..4bbf351a 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -9,7 +9,9 @@ from couchpotato.core.plugins.base import Plugin from couchpotato.core.settings.model import Library, File, Profile, Release, \ ReleaseInfo from couchpotato.environment import Env +from unrar2 import RarFile import errno +import fnmatch import os import re import shutil @@ -38,7 +40,6 @@ class Renamer(Plugin): addEvent('renamer.check_snatched', self.checkSnatched) addEvent('app.load', self.scan) - addEvent('app.load', self.checkSnatched) addEvent('app.load', self.setCrons) # Enable / disable interval @@ -60,23 +61,24 @@ class Renamer(Plugin): def scanView(self, **kwargs): - async = tryInt(kwargs.get('async', None)) - movie_folder = kwargs.get('movie_folder', None) - downloader = kwargs.get('downloader', None) - download_id = kwargs.get('download_id', None) + async = tryInt(kwargs.get('async', 0)) + movie_folder = kwargs.get('movie_folder') + downloader = kwargs.get('downloader') + download_id = kwargs.get('download_id') + + download_info = {'folder': movie_folder} if movie_folder else None + if download_info: + download_info.update({'id': download_id, 'downloader': downloader} if download_id else {}) fire_handle = fireEvent if not async else fireEventAsync - fire_handle('renamer.scan', - movie_folder = movie_folder, - download_info = {'id': download_id, 'downloader': downloader} if download_id else None - ) + fire_handle('renamer.scan', download_info) return { 'success': True } - def scan(self, movie_folder = None, download_info = None): + def scan(self, download_info = None): if self.isDisabled(): return @@ -85,6 +87,8 @@ class Renamer(Plugin): log.info('Renamer is already running, if you see this often, check the logs above for errors.') return + movie_folder = download_info and download_info.get('folder') + # Check to see if the "to" folder is inside the "from" folder. if movie_folder and not os.path.isdir(movie_folder) or not os.path.isdir(self.conf('from')) or not os.path.isdir(self.conf('to')): l = log.debug if movie_folder else log.error @@ -93,10 +97,14 @@ class Renamer(Plugin): elif self.conf('from') in self.conf('to'): log.error('The "to" can\'t be inside of the "from" folder. You\'ll get an infinite loop.') return - elif (movie_folder and movie_folder in [self.conf('to'), self.conf('from')]): + elif movie_folder and movie_folder in [self.conf('to'), self.conf('from')]: log.error('The "to" and "from" folders can\'t be inside of or the same as the provided movie folder.') return + # Make sure a checkSnatched marked all downloads/seeds as such + if not download_info and self.conf('run_every') > 0: + fireEvent('renamer.check_snatched') + self.renaming_started = True # make sure the movie folder name is included in the search @@ -119,10 +127,15 @@ class Renamer(Plugin): # Extend the download info with info stored in the downloaded release download_info = self.extendDownloadInfo(download_info) + # Unpack any archives + extr_files = None + if self.conf('unrar'): + folder, movie_folder, files, extr_files = self.extractFiles(folder = folder, movie_folder = movie_folder, files = files, + cleanup = self.conf('cleanup') and not self.downloadIsTorrent(download_info)) + groups = fireEvent('scanner.scan', folder = folder if folder else self.conf('from'), files = files, download_info = download_info, return_ignored = False, single = True) - destination = self.conf('to') folder_name = self.conf('folder_name') file_name = self.conf('file_name') trailer_name = self.conf('trailer_name') @@ -148,17 +161,35 @@ class Renamer(Plugin): continue # Rename the files using the library data else: - group['library'] = fireEvent('library.update', identifier = group['library']['identifier'], single = True) + group['library'] = fireEvent('library.update.movie', identifier = group['library']['identifier'], single = True) if not group['library']: log.error('Could not rename, no library item to work with: %s', group_identifier) continue library = group['library'] + library_ent = db.query(Library).filter_by(identifier = group['library']['identifier']).first() + movie_title = getTitle(library) + # Overwrite destination when set in category + destination = self.conf('to') + for movie in library_ent.movies: + if movie.category and movie.category.destination and len(movie.category.destination) > 0: + destination = movie.category.destination + log.debug('Setting category destination for "%s": %s' % (movie_title, destination)) + else: + log.debug('No category destination found for "%s"' % movie_title) + + break + # Find subtitle for renaming + group['before_rename'] = [] fireEvent('renamer.before', group) + # Add extracted files to the before_rename list + if extr_files: + group['before_rename'].extend(extr_files) + # Remove weird chars from moviename movie_name = re.sub(r"[\x00\/\\:\*\?\"<>\|]", '', movie_title) @@ -185,6 +216,7 @@ class Renamer(Plugin): 'imdb_id': library['identifier'], 'cd': '', 'cd_nr': '', + 'mpaa': library['info'].get('mpaa', ''), } for file_type in group['files']: @@ -192,8 +224,8 @@ class Renamer(Plugin): # Move nfo depending on settings if file_type is 'nfo' and not self.conf('rename_nfo'): log.debug('Skipping, renaming of %s disabled', file_type) - if self.conf('cleanup'): - for current_file in group['files'][file_type]: + for current_file in group['files'][file_type]: + if self.conf('cleanup') and (not self.downloadIsTorrent(download_info) or self.fileIsAdded(current_file, group)): remove_files.append(current_file) continue @@ -307,19 +339,18 @@ class Renamer(Plugin): cd += 1 # Before renaming, remove the lower quality files - library = db.query(Library).filter_by(identifier = group['library']['identifier']).first() remove_leftovers = True # Add it to the wanted list before we continue - if len(library.movies) == 0: + if len(library_ent.movies) == 0: profile = db.query(Profile).filter_by(core = True, label = group['meta_data']['quality']['label']).first() fireEvent('movie.add', params = {'identifier': group['library']['identifier'], 'profile_id': profile.id}, search_after = False) db.expire_all() - library = db.query(Library).filter_by(identifier = group['library']['identifier']).first() + library_ent = db.query(Library).filter_by(identifier = group['library']['identifier']).first() - for movie in library.movies: + for movie in library_ent.movies: - # Mark movie "done" onces it found the quality with the finish check + # Mark movie "done" once it's found the quality with the finish check try: if movie.status_id == active_status.get('id') and movie.profile: for profile_type in movie.profile.types: @@ -357,7 +388,7 @@ class Renamer(Plugin): self.tagDir(group, 'exists') # Notify on rename fail - download_message = 'Renaming of %s (%s) canceled, exists in %s already.' % (movie.library.titles[0].title, group['meta_data']['quality']['label'], release.quality.label) + download_message = 'Renaming of %s (%s) cancelled, exists in %s already.' % (movie.library.titles[0].title, group['meta_data']['quality']['label'], release.quality.label) fireEvent('movie.renaming.canceled', message = download_message, data = group) remove_leftovers = False @@ -374,14 +405,15 @@ class Renamer(Plugin): db.commit() # Remove leftover files - if self.conf('cleanup') and not self.conf('move_leftover') and remove_leftovers and \ - not (self.conf('file_action') != 'move' and self.downloadIsTorrent(download_info)): - log.debug('Removing leftover files') - for current_file in group['files']['leftover']: - remove_files.append(current_file) - elif not remove_leftovers: # Don't remove anything + if not remove_leftovers: # Don't remove anything break + log.debug('Removing leftover files') + for current_file in group['files']['leftover']: + if self.conf('cleanup') and not self.conf('move_leftover') and \ + (not self.downloadIsTorrent(download_info) or self.fileIsAdded(current_file, group)): + remove_files.append(current_file) + # Remove files delete_folders = [] for src in remove_files: @@ -425,14 +457,15 @@ class Renamer(Plugin): self.makeDir(os.path.dirname(dst)) try: - self.moveFile(src, dst, forcemove = not self.downloadIsTorrent(download_info)) + self.moveFile(src, dst, forcemove = not self.downloadIsTorrent(download_info) or self.fileIsAdded(src, group)) group['renamed_files'].append(dst) except: log.error('Failed moving the file "%s" : %s', (os.path.basename(src), traceback.format_exc())) self.tagDir(group, 'failed_rename') - if self.conf('file_action') != 'move' and self.downloadIsTorrent(download_info): - self.tagDir(group, 'renamed already') + # Tag folder if it is in the 'from' folder and it will not be removed because it is a torrent + if self.movieInFromFolder(movie_folder) and self.downloadIsTorrent(download_info): + self.tagDir(group, 'renamed_already') # Remove matching releases for release in remove_releases: @@ -442,7 +475,7 @@ class Renamer(Plugin): except: log.error('Failed removing %s: %s', (release.identifier, traceback.format_exc())) - if group['dirname'] and group['parentdir']: + if group['dirname'] and group['parentdir'] and not self.downloadIsTorrent(download_info): try: log.info('Deleting folder: %s', group['parentdir']) self.deleteEmptyFolder(group['parentdir']) @@ -462,7 +495,9 @@ class Renamer(Plugin): self.renaming_started = False - def getRenameExtras(self, extra_type = '', replacements = {}, folder_name = '', file_name = '', destination = '', group = {}, current_file = '', remove_multiple = False): + def getRenameExtras(self, extra_type = '', replacements = None, folder_name = '', file_name = '', destination = '', group = None, current_file = '', remove_multiple = False): + if not group: group = {} + if not replacements: replacements = {} replacements = replacements.copy() rename_files = {} @@ -483,9 +518,15 @@ class Renamer(Plugin): def tagDir(self, group, tag): ignore_file = None - for movie_file in sorted(list(group['files']['movie'])): - ignore_file = '%s.ignore' % os.path.splitext(movie_file)[0] - break + if isinstance(group, dict): + for movie_file in sorted(list(group['files']['movie'])): + ignore_file = '%s.%s.ignore' % (os.path.splitext(movie_file)[0], tag) + break + else: + if not os.path.isdir(group) or not tag: + return + ignore_file = os.path.join(group, '%s.ignore' % tag) + text = """This file is from CouchPotato It has marked this release as "%s" @@ -496,21 +537,48 @@ Remove it if you want it to be renamed (again, or at least let it try again) if ignore_file: self.createFile(ignore_file, text) + def untagDir(self, folder, tag = ''): + if not os.path.isdir(folder): + return + + # Remove any .ignore files + for root, dirnames, filenames in os.walk(folder): + for filename in fnmatch.filter(filenames, '*%s.ignore' % tag): + os.remove((os.path.join(root, filename))) + + def hastagDir(self, folder, tag = ''): + if not os.path.isdir(folder): + return False + + # Find any .ignore files + for root, dirnames, filenames in os.walk(folder): + if fnmatch.filter(filenames, '*%s.ignore' % tag): + return True + + return False def moveFile(self, old, dest, forcemove = False): dest = ss(dest) try: if forcemove: shutil.move(old, dest) - elif self.conf('file_action') == 'hardlink': - link(old, dest) - elif self.conf('file_action') == 'symlink': - symlink(old, dest) elif self.conf('file_action') == 'copy': shutil.copy(old, dest) - elif self.conf('file_action') == 'move_symlink': - shutil.move(old, dest) - symlink(dest, old) + elif self.conf('file_action') == 'link': + # First try to hardlink + try: + log.debug('Hardlinking file "%s" to "%s"...', (old, dest)) + link(old, dest) + except: + # Try to simlink next + log.debug('Couldn\'t hardlink file "%s" to "%s". Simlinking instead. Error: %s. ', (old, dest, traceback.format_exc())) + shutil.copy(old, dest) + try: + symlink(dest, old + '.link') + os.unlink(old) + os.rename(old + '.link', old) + except: + log.error('Couldn\'t symlink file "%s" to "%s". Copied instead. Error: %s. ', (old, dest, traceback.format_exc())) else: shutil.move(old, dest) @@ -536,9 +604,9 @@ Remove it if you want it to be renamed (again, or at least let it try again) return True def doReplace(self, string, replacements, remove_multiple = False): - ''' + """ replace confignames with the real thing - ''' + """ replacements = replacements.copy() if remove_multiple: @@ -584,19 +652,21 @@ Remove it if you want it to be renamed (again, or at least let it try again) if self.checking_snatched: log.debug('Already checking snatched') + return False self.checking_snatched = True - snatched_status, ignored_status, failed_status, done_status = \ - fireEvent('status.get', ['snatched', 'ignored', 'failed', 'done'], single = True) + snatched_status, ignored_status, failed_status, done_status, seeding_status, downloaded_status = \ + fireEvent('status.get', ['snatched', 'ignored', 'failed', 'done', 'seeding', 'downloaded'], single = True) db = get_session() rels = db.query(Release).filter_by(status_id = snatched_status.get('id')).all() + rels.extend(db.query(Release).filter_by(status_id = seeding_status.get('id')).all()) + scan_items = [] scan_required = False if rels: - self.checking_snatched = True log.debug('Checking status snatched releases...') statuses = fireEvent('download.status', merge = True) @@ -608,17 +678,6 @@ Remove it if you want it to be renamed (again, or at least let it try again) for rel in rels: rel_dict = rel.to_dict({'info': {}}) - # Get current selected title - default_title = getTitle(rel.movie.library) - - # Check if movie has already completed and is manage tab (legacy db correction) - 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 - movie_dict = fireEvent('movie.get', rel.movie_id, single = True) # check status @@ -640,7 +699,34 @@ Remove it if you want it to be renamed (again, or at least let it try again) log.debug('Found %s: %s, time to go: %s', (item['name'], item['status'].upper(), timeleft)) if item['status'] == 'busy': - pass + # Tag folder if it is in the 'from' folder and it will not be processed because it is still downloading + if item['folder'] and self.conf('from') in item['folder']: + self.tagDir(item['folder'], 'downloading') + + elif item['status'] == 'seeding': + + #If linking setting is enabled, process release + if self.conf('file_action') != 'move' and not rel.movie.status_id == done_status.get('id') and self.statusInfoComplete(item): + log.info('Download of %s completed! It is now being processed while leaving the original files alone for seeding. Current ratio: %s.', (item['name'], item['seed_ratio'])) + + # Remove the downloading tag + self.untagDir(item['folder'], 'downloading') + + rel.status_id = seeding_status.get('id') + rel.last_edit = int(time.time()) + db.commit() + + # Scan and set the torrent to paused if required + item.update({'pause': True, 'scan': True, 'process_complete': False}) + scan_items.append(item) + else: + if rel.status_id != seeding_status.get('id'): + rel.status_id = seeding_status.get('id') + rel.last_edit = int(time.time()) + db.commit() + + #let it seed + log.debug('%s is seeding with ratio: %s', (item['name'], item['seed_ratio'])) elif item['status'] == 'failed': fireEvent('download.remove_failed', item, single = True) rel.status_id = failed_status.get('id') @@ -648,11 +734,39 @@ Remove it if you want it to be renamed (again, or at least let it try again) db.commit() if self.conf('next_on_failed'): - fireEvent('searcher.try_next_release', movie_id = rel.movie_id) + fireEvent('movie.searcher.try_next_release', movie_id = rel.movie_id) elif item['status'] == 'completed': log.info('Download of %s completed!', item['name']) - if item['id'] and item['downloader'] and item['folder']: - fireEventAsync('renamer.scan', movie_folder = item['folder'], download_info = item) + if self.statusInfoComplete(item): + + # If the release has been seeding, process now the seeding is done + if rel.status_id == seeding_status.get('id'): + if rel.movie.status_id == done_status.get('id'): + # Set the release to done as the movie has already been renamed + rel.status_id = downloaded_status.get('id') + rel.last_edit = int(time.time()) + db.commit() + + # Allow the downloader to clean-up + item.update({'pause': False, 'scan': False, 'process_complete': True}) + scan_items.append(item) + else: + # Set the release to snatched so that the renamer can process the release as if it was never seeding + rel.status_id = snatched_status.get('id') + rel.last_edit = int(time.time()) + db.commit() + + # Scan and Allow the downloader to clean-up + item.update({'pause': False, 'scan': True, 'process_complete': True}) + scan_items.append(item) + + else: + # Remove the downloading tag + self.untagDir(item['folder'], 'downloading') + + # Scan and Allow the downloader to clean-up + item.update({'pause': False, 'scan': True, 'process_complete': True}) + scan_items.append(item) else: scan_required = True @@ -665,6 +779,23 @@ Remove it if you want it to be renamed (again, or at least let it try again) except: log.error('Failed checking for release in downloader: %s', traceback.format_exc()) + # The following can either be done here, or inside the scanner if we pass it scan_items in one go + for item in scan_items: + # Ask the renamer to scan the item + if item['scan']: + if item['pause'] and self.conf('file_action') == 'link': + fireEvent('download.pause', item = item, pause = True, single = True) + fireEvent('renamer.scan', download_info = item) + if item['pause'] and self.conf('file_action') == 'link': + fireEvent('download.pause', item = item, pause = False, single = True) + if item['process_complete']: + #First make sure the files were succesfully processed + if not self.hastagDir(item['folder'], 'failed_rename'): + # Remove the seeding tag if it exists + self.untagDir(item['folder'], 'renamed_already') + # Ask the downloader to process the item + fireEvent('download.process_complete', item = item, single = True) + if scan_required: fireEvent('renamer.scan') @@ -699,10 +830,146 @@ Remove it if you want it to be renamed (again, or at least let it try again) download_info.update({ 'imdb_id': rls.movie.library.identifier, 'quality': rls.quality.identifier, - 'type': rls_dict.get('info', {}).get('type') + 'protocol': rls_dict.get('info', {}).get('protocol') or rls_dict.get('info', {}).get('type'), }) return download_info def downloadIsTorrent(self, download_info): - return download_info and download_info.get('type') in ['torrent', 'torrent_magnet'] + return download_info and download_info.get('protocol') in ['torrent', 'torrent_magnet'] + + def fileIsAdded(self, src, group): + if not group or not group.get('before_rename'): + return False + return src in group['before_rename'] + + def statusInfoComplete(self, item): + return item['id'] and item['downloader'] and item['folder'] + + def movieInFromFolder(self, movie_folder): + return movie_folder and self.conf('from') in movie_folder or not movie_folder + + def extractFiles(self, folder = None, movie_folder = None, files = None, cleanup = False): + if not files: files = [] + + # RegEx for finding rar files + archive_regex = '(?P^(?P(?:(?!\.part\d+\.rar$).)*)\.(?:(?:part0*1\.)?rar)$)' + restfile_regex = '(^%s\.(?:part(?!0*1\.rar$)\d+\.rar$|[rstuvw]\d+$))' + extr_files = [] + + # Check input variables + if not folder: + folder = self.conf('from') + + check_file_date = True + if movie_folder: + check_file_date = False + + if not files: + for root, folders, names in os.walk(folder): + files.extend([os.path.join(root, name) for name in names]) + + # Find all archive files + archives = [re.search(archive_regex, name).groupdict() for name in files if re.search(archive_regex, name)] + + #Extract all found archives + for archive in archives: + # Check if it has already been processed by CPS + if self.hastagDir(os.path.dirname(archive['file'])): + continue + + # Find all related archive files + archive['files'] = [name for name in files if re.search(restfile_regex % re.escape(archive['base']), name)] + archive['files'].append(archive['file']) + + # Check if archive is fresh and maybe still copying/moving/downloading, ignore files newer than 1 minute + if check_file_date: + file_too_new = False + for cur_file in archive['files']: + if not os.path.isfile(cur_file): + file_too_new = time.time() + break + file_time = [os.path.getmtime(cur_file), os.path.getctime(cur_file)] + for t in file_time: + if t > time.time() - 60: + file_too_new = tryInt(time.time() - t) + break + + if file_too_new: + break + + if file_too_new: + try: + time_string = time.ctime(file_time[0]) + except: + try: + time_string = time.ctime(file_time[1]) + except: + time_string = 'unknown' + + log.info('Archive seems to be still copying/moving/downloading or just copied/moved/downloaded (created on %s), ignoring for now: %s', (time_string, os.path.basename(archive['file']))) + continue + + log.info('Archive %s found. Extracting...', os.path.basename(archive['file'])) + try: + rar_handle = RarFile(archive['file']) + extr_path = os.path.join(self.conf('from'), os.path.relpath(os.path.dirname(archive['file']), folder)) + self.makeDir(extr_path) + for packedinfo in rar_handle.infolist(): + if not packedinfo.isdir and not os.path.isfile(os.path.join(extr_path, os.path.basename(packedinfo.filename))): + log.debug('Extracting %s...', packedinfo.filename) + rar_handle.extract(condition = [packedinfo.index], path = extr_path, withSubpath = False, overwrite = False) + extr_files.append(os.path.join(extr_path, os.path.basename(packedinfo.filename))) + del rar_handle + except Exception, e: + log.error('Failed to extract %s: %s %s', (archive['file'], e, traceback.format_exc())) + continue + + # Delete the archive files + for filename in archive['files']: + if cleanup: + try: + os.remove(filename) + except Exception, e: + log.error('Failed to remove %s: %s %s', (filename, e, traceback.format_exc())) + continue + files.remove(filename) + + # Move the rest of the files and folders if any files are extracted to the from folder (only if folder was provided) + if extr_files and os.path.normpath(os.path.normcase(folder)) != os.path.normpath(os.path.normcase(self.conf('from'))): + for leftoverfile in list(files): + move_to = os.path.join(self.conf('from'), os.path.relpath(leftoverfile, folder)) + + try: + self.makeDir(os.path.dirname(move_to)) + self.moveFile(leftoverfile, move_to, cleanup) + except Exception, e: + log.error('Failed moving left over file %s to %s: %s %s', (leftoverfile, move_to, e, traceback.format_exc())) + # As we probably tried to overwrite the nfo file, check if it exists and then remove the original + if os.path.isfile(move_to): + if cleanup: + log.info('Deleting left over file %s instead...', leftoverfile) + os.unlink(leftoverfile) + else: + continue + + files.remove(leftoverfile) + extr_files.append(move_to) + + if cleanup: + # Remove all left over folders + log.debug('Removing old movie folder %s...', movie_folder) + self.deleteEmptyFolder(movie_folder) + + movie_folder = os.path.join(self.conf('from'), os.path.relpath(movie_folder, folder)) + folder = self.conf('from') + + if extr_files: + files.extend(extr_files) + + # Cleanup files and folder if movie_folder was not provided + if not movie_folder: + files = [] + folder = None + + return folder, movie_folder, files, extr_files diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index a400b159..188924a2 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -120,13 +120,17 @@ class Scanner(Plugin): files = [] for root, dirs, walk_files in os.walk(folder): files.extend(os.path.join(root, filename) for filename in walk_files) + + # Break if CP wants to shut down + if self.shuttingDown(): + break + except: log.error('Failed getting files from %s: %s', (folder, traceback.format_exc())) else: check_file_date = False files = [ss(x) for x in files] - db = get_session() for file_path in files: @@ -225,6 +229,10 @@ class Scanner(Plugin): # Remove the found files from the leftover stack leftovers = leftovers - set(found_files) + exts = [getExt(ff) for ff in found_files] + if 'ignore' in exts: + ignored_identifiers.append(identifier) + # Break if CP wants to shut down if self.shuttingDown(): break @@ -251,6 +259,10 @@ class Scanner(Plugin): # Remove the found files from the leftover stack leftovers = leftovers - set([ff]) + ext = getExt(ff) + if ext == 'ignore': + ignored_identifiers.append(new_identifier) + # Break if CP wants to shut down if self.shuttingDown(): break @@ -269,7 +281,7 @@ class Scanner(Plugin): except: break - # Check if movie is fresh and maybe still unpacking, ignore files new then 1 minute + # Check if movie is fresh and maybe still unpacking, ignore files newer than 1 minute file_too_new = False for cur_file in group['unsorted_files']: if not os.path.isfile(cur_file): @@ -321,14 +333,18 @@ class Scanner(Plugin): del movie_files + total_found = len(valid_files) + # Make sure only one movie was found if a download ID is provided - if download_info and not len(valid_files) == 1: + if download_info and total_found == 0: + log.info('Download ID provided (%s), but no groups found! Make sure the download contains valid media files (fully extracted).', download_info.get('imdb_id')) + elif download_info and total_found > 1: log.info('Download ID provided (%s), but more than one group found (%s). Ignoring Download ID...', (download_info.get('imdb_id'), len(valid_files))) download_info = None # Determine file types + db = get_session() processed_movies = {} - total_found = len(valid_files) while True and not self.shuttingDown(): try: identifier, group = valid_files.popitem() @@ -413,7 +429,7 @@ class Scanner(Plugin): if len(processed_movies) > 0: log.info('Found %s movies in the folder %s', (len(processed_movies), folder)) else: - log.debug('Found no movies in the folder %s', (folder)) + log.debug('Found no movies in the folder %s', folder) return processed_movies @@ -492,6 +508,7 @@ class Scanner(Plugin): detected_languages = {} # Subliminal scanner + paths = None try: paths = group['files']['movie'] scan_result = [] @@ -544,12 +561,14 @@ class Scanner(Plugin): break # Check and see if nfo contains the imdb-id + nfo_file = None if not imdb_id: try: - for nfo_file in files['nfo']: + for nf in files['nfo']: imdb_id = getImdb(nfo_file) if imdb_id: - log.debug('Found movie via nfo file: %s', nfo_file) + log.debug('Found movie via nfo file: %s', nf) + nfo_file = nf break except: pass @@ -569,26 +588,16 @@ class Scanner(Plugin): # Check if path is already in db if not imdb_id: db = get_session() - for cur_file in files['movie']: - f = db.query(File).filter_by(path = toUnicode(cur_file)).first() + for cf in files['movie']: + f = db.query(File).filter_by(path = toUnicode(cf)).first() try: imdb_id = f.library[0].identifier - log.debug('Found movie via database: %s', cur_file) + log.debug('Found movie via database: %s', cf) + cur_file = cf break except: pass - # Search based on OpenSubtitleHash - if not imdb_id and not group['is_dvd']: - for cur_file in files['movie']: - movie = fireEvent('movie.by_hash', file = cur_file, merge = True) - - if len(movie) > 0: - imdb_id = movie[0].get('imdb') - if imdb_id: - log.debug('Found movie via OpenSubtitleHash: %s', cur_file) - break - # Search based on identifiers if not imdb_id: for identifier in group['identifiers']: @@ -609,7 +618,7 @@ class Scanner(Plugin): log.debug('Identifier to short to use for search: %s', identifier) if imdb_id: - return fireEvent('library.add', attrs = { + return fireEvent('library.add.movie', attrs = { 'identifier': imdb_id }, update_after = False, single = True) @@ -675,10 +684,9 @@ class Scanner(Plugin): return getExt(s.lower()) in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tbn'] files = set(filter(test, files)) - images = {} - - # Fanart - images['backdrop'] = set(filter(lambda s: re.search('(^|[\W_])fanart|backdrop\d*[\W_]', s.lower()) and self.filesizeBetween(s, 0, 5), files)) + images = { + 'backdrop': set(filter(lambda s: re.search('(^|[\W_])fanart|backdrop\d*[\W_]', s.lower()) and self.filesizeBetween(s, 0, 5), files)) + } # Rest images['rest'] = files - images['backdrop'] @@ -750,7 +758,7 @@ class Scanner(Plugin): # Year year = self.findYear(identifier) - if year: + if year and identifier[:4] != year: identifier = '%s %s' % (identifier.split(year)[0].strip(), year) else: identifier = identifier.split('::')[0] diff --git a/couchpotato/core/plugins/score/main.py b/couchpotato/core/plugins/score/main.py index 4f16539d..5f9da1a1 100644 --- a/couchpotato/core/plugins/score/main.py +++ b/couchpotato/core/plugins/score/main.py @@ -1,11 +1,12 @@ from couchpotato.core.event import addEvent from couchpotato.core.helpers.encoding import toUnicode -from couchpotato.core.helpers.variable import getTitle +from couchpotato.core.helpers.variable import getTitle, splitString from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.core.plugins.score.scores import nameScore, nameRatioScore, \ sizeScore, providerScore, duplicateScore, partialIgnoredScore, namePositionScore, \ halfMultipartScore +from couchpotato.environment import Env log = CPLog(__name__) @@ -16,9 +17,14 @@ class Score(Plugin): addEvent('score.calculate', self.calculate) def calculate(self, nzb, movie): - ''' Calculate the score of a NZB, used for sorting later ''' + """ Calculate the score of a NZB, used for sorting later """ - score = nameScore(toUnicode(nzb['name'] + ' ' + nzb.get('name_extra', '')), movie['library']['year']) + # Merge global and category + preferred_words = splitString(Env.setting('preferred_words', section = 'searcher').lower()) + try: preferred_words = list(set(preferred_words + splitString(movie['category']['preferred'].lower()))) + except: pass + + score = nameScore(toUnicode(nzb['name']), movie['library']['year'], preferred_words) for movie_title in movie['library']['titles']: score += nameRatioScore(toUnicode(nzb['name']), toUnicode(movie_title['title'])) @@ -40,8 +46,13 @@ class Score(Plugin): # Duplicates in name score += duplicateScore(nzb['name'], getTitle(movie['library'])) + # Merge global and category + ignored_words = splitString(Env.setting('ignored_words', section = 'searcher').lower()) + try: ignored_words = list(set(ignored_words + splitString(movie['category']['ignored'].lower()))) + except: pass + # Partial ignored words - score += partialIgnoredScore(nzb['name'], getTitle(movie['library'])) + score += partialIgnoredScore(nzb['name'], getTitle(movie['library']), ignored_words) # Ignore single downloads from multipart score += halfMultipartScore(nzb['name']) diff --git a/couchpotato/core/plugins/score/scores.py b/couchpotato/core/plugins/score/scores.py index 95ef9b97..6aa0b465 100644 --- a/couchpotato/core/plugins/score/scores.py +++ b/couchpotato/core/plugins/score/scores.py @@ -1,6 +1,6 @@ from couchpotato.core.event import fireEvent from couchpotato.core.helpers.encoding import simplifyString -from couchpotato.core.helpers.variable import tryInt, splitString +from couchpotato.core.helpers.variable import tryInt from couchpotato.environment import Env import re @@ -23,8 +23,8 @@ name_scores = [ ] -def nameScore(name, year): - ''' Calculate score for words in the NZB name ''' +def nameScore(name, year, preferred_words): + """ Calculate score for words in the NZB name """ score = 0 name = name.lower() @@ -34,20 +34,18 @@ def nameScore(name, year): v = value.split(':') add = int(v.pop()) if v.pop() in name: - score = score + add + score += add # points if the year is correct if str(year) in name: - score = score + 5 + score += 5 # Contains preferred word nzb_words = re.split('\W+', simplifyString(name)) - preferred_words = splitString(Env.setting('preferred_words', section = 'searcher')) score += 100 * len(list(set(nzb_words) & set(preferred_words))) return score - def nameRatioScore(nzb_name, movie_name): nzb_words = re.split('\W+', fireEvent('scanner.create_file_identifier', nzb_name, single = True)) movie_words = re.split('\W+', simplifyString(movie_name)) @@ -70,9 +68,12 @@ def namePositionScore(nzb_name, movie_name): name_year = fireEvent('scanner.name_year', nzb_name, single = True) # Give points for movies beginning with the correct name - name_split = simplifyString(nzb_name).split(simplifyString(movie_name)) - if name_split[0].strip() == '': - score += 10 + split_by = simplifyString(movie_name) + name_split = [] + if len(split_by) > 0: + name_split = simplifyString(nzb_name).split(split_by) + if name_split[0].strip() == '': + score += 10 # If year is second in line, give more points if len(name_split) > 1 and name_year: @@ -134,13 +135,11 @@ def duplicateScore(nzb_name, movie_name): return len(list(set(duplicates) - set(movie_words))) * -4 -def partialIgnoredScore(nzb_name, movie_name): +def partialIgnoredScore(nzb_name, movie_name, ignored_words): nzb_name = nzb_name.lower() movie_name = movie_name.lower() - ignored_words = [x.strip().lower() for x in Env.setting('ignored_words', section = 'searcher').split(',')] - score = 0 for ignored_word in ignored_words: if ignored_word in nzb_name and ignored_word not in movie_name: @@ -148,6 +147,7 @@ def partialIgnoredScore(nzb_name, movie_name): return score + def halfMultipartScore(nzb_name): wrong_found = 0 diff --git a/couchpotato/core/plugins/searcher/__init__.py b/couchpotato/core/plugins/searcher/__init__.py deleted file mode 100644 index bed90eb2..00000000 --- a/couchpotato/core/plugins/searcher/__init__.py +++ /dev/null @@ -1,113 +0,0 @@ -from .main import Searcher -import random - -def start(): - return Searcher() - -config = [{ - 'name': 'searcher', - 'order': 20, - 'groups': [ - { - 'tab': 'searcher', - 'name': 'searcher', - 'label': 'Search', - 'description': 'Options for the searchers', - 'options': [ - { - 'name': 'preferred_words', - 'label': 'Preferred words', - 'default': '', - 'description': 'These words will give the releases a higher score.' - }, - { - 'name': 'required_words', - 'label': 'Required words', - 'default': '', - 'placeholder': 'Example: DTS, AC3 & English', - 'description': 'A release should contain at least one set of words. Sets are separated by "," and each word within a set must be separated with "&"' - }, - { - 'name': 'ignored_words', - 'label': 'Ignored words', - 'default': 'german, dutch, french, truefrench, danish, swedish, spanish, italian, korean, dubbed, swesub, korsub, dksubs', - 'description': 'Ignores releases that match any of these sets. (Works like explained above)' - }, - { - 'name': 'preferred_method', - 'label': 'First search', - 'description': 'Which of the methods do you prefer', - 'default': 'both', - 'type': 'dropdown', - 'values': [('usenet & torrents', 'both'), ('usenet', 'nzb'), ('torrents', 'torrent')], - }, - { - 'name': 'always_search', - 'default': False, - 'advanced': True, - 'type': 'bool', - 'label': 'Always search', - 'description': 'Search for movies even before there is a ETA. Enabling this will probably get you a lot of fakes.', - }, - ], - }, { - 'tab': 'searcher', - 'name': 'cronjob', - 'label': 'Cronjob', - 'advanced': True, - 'description': 'Cron settings for the searcher see: APScheduler for details.', - 'options': [ - { - 'name': 'run_on_launch', - 'label': 'Run on launch', - 'advanced': True, - 'default': 0, - 'type': 'bool', - 'description': 'Force run the searcher after (re)start.', - }, - { - 'name': 'cron_day', - 'label': 'Day', - 'advanced': True, - 'default': '*', - 'type': 'string', - 'description': '*: Every day, */2: Every 2 days, 1: Every first of the month.', - }, - { - 'name': 'cron_hour', - 'label': 'Hour', - 'advanced': True, - 'default': random.randint(0, 23), - 'type': 'string', - 'description': '*: Every hour, */8: Every 8 hours, 3: At 3, midnight.', - }, - { - 'name': 'cron_minute', - 'label': 'Minute', - 'advanced': True, - 'default': random.randint(0, 59), - 'type': 'string', - 'description': "Just keep it random, so the providers don't get DDOSed by every CP user on a 'full' hour." - }, - ], - }, - ], -}, { - 'name': 'nzb', - 'groups': [ - { - 'tab': 'searcher', - 'name': 'nzb', - 'label': 'NZB', - 'wizard': True, - 'options': [ - { - 'name': 'retention', - 'default': 1000, - 'type': 'int', - 'unit': 'days' - }, - ], - }, - ], -}] diff --git a/couchpotato/core/plugins/status/main.py b/couchpotato/core/plugins/status/main.py index c8c8f666..7546c651 100644 --- a/couchpotato/core/plugins/status/main.py +++ b/couchpotato/core/plugins/status/main.py @@ -23,6 +23,7 @@ class StatusPlugin(Plugin): 'ignored': 'Ignored', 'available': 'Available', 'suggest': 'Suggest', + 'seeding': 'Seeding', } status_cached = {} @@ -74,7 +75,7 @@ class StatusPlugin(Plugin): def get(self, identifiers): - if not isinstance(identifiers, (list)): + if not isinstance(identifiers, list): identifiers = [identifiers] db = get_session() diff --git a/couchpotato/core/plugins/subtitle/main.py b/couchpotato/core/plugins/subtitle/main.py index c6bef6ae..e447dacd 100644 --- a/couchpotato/core/plugins/subtitle/main.py +++ b/couchpotato/core/plugins/subtitle/main.py @@ -36,13 +36,12 @@ class Subtitle(Plugin): files = [] for file in release.files.filter(FileType.status.has(identifier = 'movie')).all(): - files.append(file.path); + files.append(file.path) # get subtitles for those files subliminal.list_subtitles(files, cache_dir = Env.get('cache_dir'), multi = True, languages = self.getLanguages(), services = self.services) def searchSingle(self, group): - if self.isDisabled(): return try: @@ -60,6 +59,7 @@ class Subtitle(Plugin): for d_sub in downloaded: log.info('Found subtitle (%s): %s', (d_sub.language.alpha2, files)) group['files']['subtitle'].append(d_sub.path) + group['before_rename'].append(d_sub.path) group['subtitle_language'][d_sub.path] = [d_sub.language.alpha2] return True diff --git a/couchpotato/core/plugins/suggestion/main.py b/couchpotato/core/plugins/suggestion/main.py index 0e7d701a..d6fdeb42 100644 --- a/couchpotato/core/plugins/suggestion/main.py +++ b/couchpotato/core/plugins/suggestion/main.py @@ -1,13 +1,14 @@ from couchpotato import get_session from couchpotato.api import addApiView from couchpotato.core.event import fireEvent -from couchpotato.core.helpers.encoding import ss -from couchpotato.core.helpers.variable import splitString, md5 +from couchpotato.core.helpers.variable import splitString from couchpotato.core.plugins.base import Plugin from couchpotato.core.settings.model import Movie from couchpotato.environment import Env +from sqlalchemy.orm import joinedload_all from sqlalchemy.sql.expression import or_ + class Suggestion(Plugin): def __init__(self): @@ -15,38 +16,40 @@ class Suggestion(Plugin): addApiView('suggestion.view', self.suggestView) addApiView('suggestion.ignore', self.ignoreView) - def suggestView(self, **kwargs): + def suggestView(self, limit = 6, **kwargs): movies = splitString(kwargs.get('movies', '')) ignored = splitString(kwargs.get('ignored', '')) - limit = kwargs.get('limit', 6) - - if not movies or len(movies) == 0: - db = get_session() - active_movies = db.query(Movie) \ - .filter(or_(*[Movie.status.has(identifier = s) for s in ['active', 'done']])).all() - movies = [x.library.identifier for x in active_movies] - - if not ignored or len(ignored) == 0: - ignored = splitString(Env.prop('suggest_ignore', default = '')) cached_suggestion = self.getCache('suggestion_cached') if cached_suggestion: suggestions = cached_suggestion else: + + if not movies or len(movies) == 0: + db = get_session() + active_movies = db.query(Movie) \ + .options(joinedload_all('library')) \ + .filter(or_(*[Movie.status.has(identifier = s) for s in ['active', 'done']])).all() + movies = [x.library.identifier for x in active_movies] + + if not ignored or len(ignored) == 0: + ignored = splitString(Env.prop('suggest_ignore', default = '')) + suggestions = fireEvent('movie.suggest', movies = movies, ignore = ignored, single = True) - self.setCache(md5(ss('suggestion_cached')), suggestions, timeout = 6048000) # Cache for 10 weeks + self.setCache('suggestion_cached', suggestions, timeout = 6048000) # Cache for 10 weeks return { 'success': True, 'count': len(suggestions), - 'suggestions': suggestions[:limit] + 'suggestions': suggestions[:int(limit)] } def ignoreView(self, imdb = None, limit = 6, remove_only = False, **kwargs): ignored = splitString(Env.prop('suggest_ignore', default = '')) + new_suggestions = [] if imdb: if not remove_only: ignored.append(imdb) @@ -86,6 +89,6 @@ class Suggestion(Plugin): if suggestions: new_suggestions.extend(suggestions) - self.setCache(md5(ss('suggestion_cached')), new_suggestions, timeout = 6048000) + self.setCache('suggestion_cached', new_suggestions, timeout = 6048000) return new_suggestions diff --git a/couchpotato/core/plugins/suggestion/static/suggest.css b/couchpotato/core/plugins/suggestion/static/suggest.css index 95e12b9e..2b05abf9 100644 --- a/couchpotato/core/plugins/suggestion/static/suggest.css +++ b/couchpotato/core/plugins/suggestion/static/suggest.css @@ -16,52 +16,90 @@ width: 50%; } } - + @media all and (max-width: 600px) { .suggestions .movie_result { width: 100%; } } - + .suggestions .movie_result .data { left: 100px; background: #4e5969; border: none; } - + .suggestions .movie_result .data .info { top: 15px; left: 15px; right: 15px; + bottom: 15px; + overflow: hidden; } - + .suggestions .movie_result .data .info h2 { white-space: normal; max-height: 120px; font-size: 18px; line-height: 18px; } - + + .suggestions .movie_result .data .info .rating, + .suggestions .movie_result .data .info .genres, .suggestions .movie_result .data .info .year { position: static; display: block; - margin: 5px 0 0; padding: 0; opacity: .6; } - + + .suggestions .movie_result .data .info .year { + margin: 10px 0 0; + } + + .suggestions .movie_result .data .info .rating { + font-size: 20px; + float: right; + margin-top: -20px; + } + .suggestions .movie_result .data .info .rating:before { + content: "\e031"; + font-family: 'Elusive-Icons'; + font-size: 14px; + margin: 0 5px 0 0; + vertical-align: bottom; + } + + .suggestions .movie_result .data .info .genres { + font-size: 11px; + font-style: italic; + text-align: right; + + } + .suggestions .movie_result .data { - cursor: default; + cursor: default; } - + .suggestions .movie_result .options { left: 100px; } - + .suggestions .movie_result .options select[name=title] { width: 100%; } + .suggestions .movie_result .options select[name=profile] { width: 100%; } + .suggestions .movie_result .options select[name=category] { width: 100%; } + + .suggestions .movie_result .button { + position: absolute; + margin: 2px 0 0 0; + right: 15px; + bottom: 15px; + } + + .suggestions .movie_result .thumbnail { width: 100px; } - + .suggestions .movie_result .actions { position: absolute; bottom: 10px; @@ -75,10 +113,9 @@ .suggestions .movie_result .data.open .actions { display: none; } - + .suggestions .movie_result .actions a { margin-left: 10px; vertical-align: middle; } - - \ No newline at end of file + diff --git a/couchpotato/core/plugins/suggestion/static/suggest.js b/couchpotato/core/plugins/suggestion/static/suggest.js index 5be7d139..817d965f 100644 --- a/couchpotato/core/plugins/suggestion/static/suggest.js +++ b/couchpotato/core/plugins/suggestion/static/suggest.js @@ -43,6 +43,8 @@ var SuggestList = new Class({ fill: function(json){ var self = this; + + if(!json) return; Object.each(json.suggestions, function(movie){ @@ -71,9 +73,23 @@ var SuggestList = new Class({ ) ); m.data_container.removeEvents('click'); + + // Add rating + m.info_container.adopt( + m.rating = m.info.rating && m.info.rating.imdb.length == 2 && parseFloat(m.info.rating.imdb[0]) > 0 ? new Element('span.rating', { + 'text': parseFloat(m.info.rating.imdb[0]), + 'title': parseInt(m.info.rating.imdb[1]) + ' votes' + }) : null, + m.genre = m.info.genres && m.info.genres.length > 0 ? new Element('span.genres', { + 'text': m.info.genres.slice(0, 3).join(', ') + }) : null + ) + $(m).inject(self.el); }); + + self.fireEvent('loaded'); }, diff --git a/couchpotato/core/plugins/trailer/main.py b/couchpotato/core/plugins/trailer/main.py index 1a8955fb..e27e3f9f 100644 --- a/couchpotato/core/plugins/trailer/main.py +++ b/couchpotato/core/plugins/trailer/main.py @@ -12,8 +12,8 @@ class Trailer(Plugin): def __init__(self): addEvent('renamer.after', self.searchSingle) - def searchSingle(self, message = None, group = {}): - + def searchSingle(self, message = None, group = None): + if not group: group = {} if self.isDisabled() or len(group['files']['trailer']) > 0: return trailers = fireEvent('trailer.search', group = group, merge = True) @@ -40,4 +40,3 @@ class Trailer(Plugin): break return True - diff --git a/couchpotato/core/plugins/userscript/main.py b/couchpotato/core/plugins/userscript/main.py index a76cf58c..1b3cfe3d 100644 --- a/couchpotato/core/plugins/userscript/main.py +++ b/couchpotato/core/plugins/userscript/main.py @@ -55,7 +55,7 @@ class Userscript(Plugin): 'excludes': fireEvent('userscript.get_excludes', merge = True), 'version': klass.getVersion(), 'api': '%suserscript/' % Env.get('api_base'), - 'host': '%s://%s' % (self.request.protocol, self.request.host), + 'host': '%s://%s' % (self.request.protocol, self.request.headers.get('X-Forwarded-Host') or self.request.headers.get('host')), } script = klass.renderTemplate(__file__, 'template.js', **params) @@ -81,8 +81,6 @@ class Userscript(Plugin): def getViaUrl(self, url = None, **kwargs): - print url - params = { 'url': url, 'movie': fireEvent('userscript.get_movie_via_url', url = url, single = True) diff --git a/couchpotato/core/providers/automation/bluray/__init__.py b/couchpotato/core/providers/automation/bluray/__init__.py index b916b0af..e0675247 100644 --- a/couchpotato/core/providers/automation/bluray/__init__.py +++ b/couchpotato/core/providers/automation/bluray/__init__.py @@ -11,7 +11,7 @@ config = [{ 'list': 'automation_providers', 'name': 'bluray_automation', 'label': 'Blu-ray.com', - 'description': 'Imports movies from blu-ray.com. (uses minimal requirements)', + 'description': 'Imports movies from blu-ray.com.', 'options': [ { 'name': 'automation_enabled', diff --git a/couchpotato/core/providers/automation/imdb/__init__.py b/couchpotato/core/providers/automation/imdb/__init__.py index a0013c4a..546cba97 100644 --- a/couchpotato/core/providers/automation/imdb/__init__.py +++ b/couchpotato/core/providers/automation/imdb/__init__.py @@ -9,7 +9,7 @@ config = [{ { 'tab': 'automation', 'list': 'watchlist_providers', - 'name': 'imdb_automation', + 'name': 'imdb_automation_watchlist', 'label': 'IMDB', 'description': 'From any public IMDB watchlists. Url should be the CSV link.', 'options': [ @@ -30,5 +30,33 @@ config = [{ }, ], }, + { + 'tab': 'automation', + 'list': 'automation_providers', + 'name': 'imdb_automation_charts', + 'label': 'IMDB', + 'description': 'Import movies from IMDB Charts', + 'options': [ + { + 'name': 'automation_providers_enabled', + 'default': False, + 'type': 'enabler', + }, + { + 'name': 'automation_charts_theater', + 'type': 'bool', + 'label': 'In Theaters', + 'description': 'New Movies In-Theaters chart', + 'default': True, + }, + { + 'name': 'automation_charts_top250', + 'type': 'bool', + 'label': 'TOP 250', + 'description': 'IMDB TOP 250 chart', + 'default': True, + }, + ], + }, ], }] diff --git a/couchpotato/core/providers/automation/imdb/main.py b/couchpotato/core/providers/automation/imdb/main.py index 75a2d75c..e9d14b5a 100644 --- a/couchpotato/core/providers/automation/imdb/main.py +++ b/couchpotato/core/providers/automation/imdb/main.py @@ -1,38 +1,100 @@ +import traceback + +from bs4 import BeautifulSoup +from couchpotato import fireEvent from couchpotato.core.helpers.rss import RSS from couchpotato.core.helpers.variable import getImdb, splitString, tryInt + from couchpotato.core.logger import CPLog from couchpotato.core.providers.automation.base import Automation -import traceback + +from couchpotato.core.providers.base import MultiProvider + log = CPLog(__name__) -class IMDB(Automation, RSS): +class IMDB(MultiProvider): + + def getTypes(self): + return [IMDBWatchlist, IMDBAutomation] + + +class IMDBBase(Automation, RSS): interval = 1800 + def getInfo(self, imdb_id): + return fireEvent('movie.info', identifier = imdb_id, merge = True) + + +class IMDBWatchlist(IMDBBase): + + enabled_option = 'automation_enabled' + def getIMDBids(self): movies = [] - enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] - urls = splitString(self.conf('automation_urls')) + watchlist_enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] + watchlist_urls = splitString(self.conf('automation_urls')) index = -1 - for url in urls: + for watchlist_url in watchlist_urls: index += 1 - if not enablers[index]: + if not watchlist_enablers[index]: continue try: - rss_data = self.getHTMLData(url) + log.debug('Started IMDB watchlists: %s', watchlist_url) + rss_data = self.getHTMLData(watchlist_url) imdbs = getImdb(rss_data, multiple = True) if rss_data else [] for imdb in imdbs: movies.append(imdb) + if self.shuttingDown(): + break + except: - log.error('Failed loading IMDB watchlist: %s %s', (url, traceback.format_exc())) + log.error('Failed loading IMDB watchlist: %s %s', (watchlist_url, traceback.format_exc())) + + return movies + + +class IMDBAutomation(IMDBBase): + + enabled_option = 'automation_providers_enabled' + + chart_urls = { + 'theater': 'http://www.imdb.com/movies-in-theaters/', + 'top250': 'http://www.imdb.com/chart/top', + } + + def getIMDBids(self): + + movies = [] + + for url in self.chart_urls: + if self.conf('automation_charts_%s' % url): + data = self.getHTMLData(self.chart_urls[url]) + if data: + html = BeautifulSoup(data) + + try: + result_div = html.find('div', attrs = {'id': 'main'}) + imdb_ids = getImdb(str(result_div), multiple = True) + + for imdb_id in imdb_ids: + info = self.getInfo(imdb_id) + if info and self.isMinimalMovie(info): + movies.append(imdb_id) + + if self.shuttingDown(): + break + + except: + log.error('Failed loading IMDB chart results from %s: %s', (url, traceback.format_exc())) return movies diff --git a/couchpotato/core/providers/automation/itunes/__init__.py b/couchpotato/core/providers/automation/itunes/__init__.py index b5c565f6..cc5dddc7 100644 --- a/couchpotato/core/providers/automation/itunes/__init__.py +++ b/couchpotato/core/providers/automation/itunes/__init__.py @@ -11,7 +11,7 @@ config = [{ 'list': 'automation_providers', 'name': 'itunes_automation', 'label': 'iTunes', - 'description': 'From any iTunes Store feed. Url should be the RSS link. (uses minimal requirements)', + 'description': 'From any iTunes Store feed. Url should be the RSS link.', 'options': [ { 'name': 'automation_enabled', diff --git a/couchpotato/core/providers/automation/itunes/main.py b/couchpotato/core/providers/automation/itunes/main.py index 14ca2a82..8e352370 100644 --- a/couchpotato/core/providers/automation/itunes/main.py +++ b/couchpotato/core/providers/automation/itunes/main.py @@ -31,7 +31,7 @@ class ITunes(Automation, RSS): for url in urls: index += 1 - if not enablers[index]: + if len(enablers) == 0 or len(enablers) < index or not enablers[index]: continue try: diff --git a/couchpotato/core/providers/automation/kinepolis/__init__.py b/couchpotato/core/providers/automation/kinepolis/__init__.py index d3b8e898..24bd4ebb 100644 --- a/couchpotato/core/providers/automation/kinepolis/__init__.py +++ b/couchpotato/core/providers/automation/kinepolis/__init__.py @@ -11,7 +11,7 @@ config = [{ 'list': 'automation_providers', 'name': 'kinepolis_automation', 'label': 'Kinepolis', - 'description': 'Imports movies from the current top 10 of kinepolis. (uses minimal requirements)', + 'description': 'Imports movies from the current top 10 of kinepolis.', 'options': [ { 'name': 'automation_enabled', diff --git a/couchpotato/core/providers/automation/moviemeter/__init__.py b/couchpotato/core/providers/automation/moviemeter/__init__.py index 773bed45..aff5d09d 100644 --- a/couchpotato/core/providers/automation/moviemeter/__init__.py +++ b/couchpotato/core/providers/automation/moviemeter/__init__.py @@ -11,7 +11,7 @@ config = [{ 'list': 'automation_providers', 'name': 'moviemeter_automation', 'label': 'Moviemeter', - 'description': 'Imports movies from the current top 10 of moviemeter.nl. (uses minimal requirements)', + 'description': 'Imports movies from the current top 10 of moviemeter.nl.', 'options': [ { 'name': 'automation_enabled', diff --git a/couchpotato/core/providers/automation/rottentomatoes/__init__.py b/couchpotato/core/providers/automation/rottentomatoes/__init__.py index dd96fe45..4675fac2 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/__init__.py +++ b/couchpotato/core/providers/automation/rottentomatoes/__init__.py @@ -11,18 +11,31 @@ config = [{ 'list': 'automation_providers', 'name': 'rottentomatoes_automation', 'label': 'Rottentomatoes', - 'description': 'Imports movies from the rottentomatoes "in theaters"-feed.', + 'description': 'Imports movies from rottentomatoes rss feeds specified below.', 'options': [ { 'name': 'automation_enabled', 'default': False, 'type': 'enabler', }, + { + 'name': 'automation_urls_use', + 'label': 'Use', + 'default': '1', + }, + { + 'name': 'automation_urls', + 'label': 'url', + 'type': 'combined', + 'combine': ['automation_urls_use', 'automation_urls'], + 'default': 'http://www.rottentomatoes.com/syndication/rss/in_theaters.xml', + }, { 'name': 'tomatometer_percent', 'default': '80', - 'label': 'Tomatometer' - } + 'label': 'Tomatometer', + 'description': 'Use as extra scoring requirement', + }, ], }, ], diff --git a/couchpotato/core/providers/automation/rottentomatoes/main.py b/couchpotato/core/providers/automation/rottentomatoes/main.py index b4482023..69611705 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/main.py +++ b/couchpotato/core/providers/automation/rottentomatoes/main.py @@ -1,5 +1,5 @@ from couchpotato.core.helpers.rss import RSS -from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.helpers.variable import tryInt, splitString from couchpotato.core.logger import CPLog from couchpotato.core.providers.automation.base import Automation from xml.etree.ElementTree import QName @@ -11,38 +11,42 @@ log = CPLog(__name__) class Rottentomatoes(Automation, RSS): interval = 1800 - urls = { - 'namespace': 'http://www.rottentomatoes.com/xmlns/rtmovie/', - 'theater': 'http://www.rottentomatoes.com/syndication/rss/in_theaters.xml', - } def getIMDBids(self): movies = [] - rss_movies = self.getRSSData(self.urls['theater']) - rating_tag = str(QName(self.urls['namespace'], 'tomatometer_percent')) + rotten_tomatoes_namespace = 'http://www.rottentomatoes.com/xmlns/rtmovie/' + urls = dict(zip(splitString(self.conf('automation_urls')), [tryInt(x) for x in splitString(self.conf('automation_urls_use'))])) - for movie in rss_movies: + for url in urls: - value = self.getTextElement(movie, "title") - result = re.search('(?<=%\s).*', value) + if not urls[url]: + continue - if result: + rss_movies = self.getRSSData(url) + rating_tag = str(QName(rotten_tomatoes_namespace, 'tomatometer_percent')) - log.info2('Something smells...') - rating = tryInt(self.getTextElement(movie, rating_tag)) - name = result.group(0) + for movie in rss_movies: - if rating < tryInt(self.conf('tomatometer_percent')): - log.info2('%s seems to be rotten...', name) - else: + value = self.getTextElement(movie, "title") + result = re.search('(?<=%\s).*', value) - log.info2('Found %s fresh enough movies, enqueuing: %s', (rating, name)) - year = datetime.datetime.now().strftime("%Y") - imdb = self.search(name, year) + if result: - if imdb: - movies.append(imdb['imdb']) + log.info2('Something smells...') + rating = tryInt(self.getTextElement(movie, rating_tag)) + name = result.group(0) + + if rating < tryInt(self.conf('tomatometer_percent')): + log.info2('%s seems to be rotten...', name) + else: + + log.info2('Found %s fresh enough movies, enqueuing: %s', (rating, name)) + year = datetime.datetime.now().strftime("%Y") + imdb = self.search(name, year) + + if imdb and self.isMinimalMovie(imdb): + movies.append(imdb['imdb']) return movies diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index 182a0310..e6a9cb00 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -13,13 +13,32 @@ import traceback import urllib2 import xml.etree.ElementTree as XMLTree - log = CPLog(__name__) +class MultiProvider(Plugin): + + def __init__(self): + self._classes = [] + + for Type in self.getTypes(): + klass = Type() + + # Overwrite name so logger knows what we're talking about + klass.setName('%s:%s' % (self.getName(), klass.getName())) + + self._classes.append(klass) + + def getTypes(self): + return [] + + def getClasses(self): + return self._classes + + class Provider(Plugin): - type = None # movie, nzb, torrent, subtitle, trailer + type = None # movie, show, subtitle, trailer, ... http_time_between_calls = 10 # Default timeout for url requests last_available_check = {} @@ -79,7 +98,11 @@ class Provider(Plugin): class YarrProvider(Provider): - cat_ids = [] + protocol = None # nzb, torrent, torrent_magnet + type = 'movie' + + cat_ids = {} + cat_backup_id = None sizeGb = ['gb', 'gib'] sizeMb = ['mb', 'mib'] @@ -89,14 +112,13 @@ class YarrProvider(Provider): last_login_check = 0 def __init__(self): - addEvent('provider.enabled_types', self.getEnabledProviderType) + addEvent('provider.enabled_protocols', self.getEnabledProtocol) addEvent('provider.belongs_to', self.belongsTo) - addEvent('yarr.search', self.search) - addEvent('%s.search' % self.type, self.search) + addEvent('provider.search.%s.%s' % (self.protocol, self.type), self.search) - def getEnabledProviderType(self): + def getEnabledProtocol(self): if self.isEnabled(): - return self.type + return self.protocol else: return [] @@ -257,7 +279,7 @@ class ResultList(list): new_result = self.fillResult(result) - is_correct_movie = fireEvent('searcher.correct_movie', + is_correct_movie = fireEvent('movie.searcher.correct_movie', nzb = new_result, movie = self.movie, quality = self.quality, imdb_results = self.kwargs.get('imdb_results', False), single = True) @@ -273,9 +295,12 @@ class ResultList(list): defaults = { 'id': 0, + 'protocol': self.provider.protocol, 'type': self.provider.type, 'provider': self.provider.getName(), 'download': self.provider.loginDownload if self.provider.urls.get('login') else self.provider.download, + 'seed_ratio': Env.setting('seed_ratio', section = self.provider.getName().lower(), default = ''), + 'seed_time': Env.setting('seed_time', section = self.provider.getName().lower(), default = ''), 'url': '', 'name': '', 'age': 0, diff --git a/couchpotato/core/providers/info/__init__.py b/couchpotato/core/providers/info/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/couchpotato/core/providers/movie/_modifier/__init__.py b/couchpotato/core/providers/info/_modifier/__init__.py similarity index 100% rename from couchpotato/core/providers/movie/_modifier/__init__.py rename to couchpotato/core/providers/info/_modifier/__init__.py diff --git a/couchpotato/core/providers/movie/_modifier/main.py b/couchpotato/core/providers/info/_modifier/main.py similarity index 99% rename from couchpotato/core/providers/movie/_modifier/main.py rename to couchpotato/core/providers/info/_modifier/main.py index e4d70221..835cce04 100644 --- a/couchpotato/core/providers/movie/_modifier/main.py +++ b/couchpotato/core/providers/info/_modifier/main.py @@ -28,6 +28,7 @@ class MovieResultModifier(Plugin): 'tagline': '', 'imdb': '', 'genres': [], + 'mpaa': None } def __init__(self): diff --git a/couchpotato/core/providers/movie/base.py b/couchpotato/core/providers/info/base.py similarity index 100% rename from couchpotato/core/providers/movie/base.py rename to couchpotato/core/providers/info/base.py diff --git a/couchpotato/core/providers/movie/couchpotatoapi/__init__.py b/couchpotato/core/providers/info/couchpotatoapi/__init__.py similarity index 100% rename from couchpotato/core/providers/movie/couchpotatoapi/__init__.py rename to couchpotato/core/providers/info/couchpotatoapi/__init__.py diff --git a/couchpotato/core/providers/movie/couchpotatoapi/main.py b/couchpotato/core/providers/info/couchpotatoapi/main.py similarity index 94% rename from couchpotato/core/providers/movie/couchpotatoapi/main.py rename to couchpotato/core/providers/info/couchpotatoapi/main.py index 9f76381a..ef7db1f9 100644 --- a/couchpotato/core/providers/movie/couchpotatoapi/main.py +++ b/couchpotato/core/providers/info/couchpotatoapi/main.py @@ -1,7 +1,7 @@ from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.encoding import tryUrlencode from couchpotato.core.logger import CPLog -from couchpotato.core.providers.movie.base import MovieProvider +from couchpotato.core.providers.info.base import MovieProvider from couchpotato.environment import Env import time @@ -80,7 +80,10 @@ class CouchPotatoApi(MovieProvider): return dates - def getSuggestions(self, movies = [], ignore = []): + def getSuggestions(self, movies = None, ignore = None): + if not ignore: ignore = [] + if not movies: movies = [] + suggestions = self.getJsonData(self.urls['suggest'], params = { 'movies': ','.join(movies), 'ignore': ','.join(ignore), diff --git a/couchpotato/core/providers/movie/omdbapi/__init__.py b/couchpotato/core/providers/info/omdbapi/__init__.py similarity index 100% rename from couchpotato/core/providers/movie/omdbapi/__init__.py rename to couchpotato/core/providers/info/omdbapi/__init__.py diff --git a/couchpotato/core/providers/movie/omdbapi/main.py b/couchpotato/core/providers/info/omdbapi/main.py old mode 100644 new mode 100755 similarity index 95% rename from couchpotato/core/providers/movie/omdbapi/main.py rename to couchpotato/core/providers/info/omdbapi/main.py index 89990747..87bb0a73 --- a/couchpotato/core/providers/movie/omdbapi/main.py +++ b/couchpotato/core/providers/info/omdbapi/main.py @@ -2,7 +2,7 @@ from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.encoding import tryUrlencode from couchpotato.core.helpers.variable import tryInt, tryFloat, splitString from couchpotato.core.logger import CPLog -from couchpotato.core.providers.movie.base import MovieProvider +from couchpotato.core.providers.info.base import MovieProvider import json import re import traceback @@ -95,9 +95,10 @@ class OMDBAPI(MovieProvider): #'rotten': (tryFloat(movie.get('tomatoRating', 0)), tryInt(movie.get('tomatoReviews', '').replace(',', ''))), }, 'imdb': str(movie.get('imdbID', '')), + 'mpaa': str(movie.get('Rated', '')), 'runtime': self.runtimeToMinutes(movie.get('Runtime', '')), 'released': movie.get('Released'), - 'year': year if isinstance(year, (int)) else None, + 'year': year if isinstance(year, int) else None, 'plot': movie.get('Plot'), 'genres': splitString(movie.get('Genre', '')), 'directors': splitString(movie.get('Director', '')), diff --git a/couchpotato/core/providers/movie/themoviedb/__init__.py b/couchpotato/core/providers/info/themoviedb/__init__.py similarity index 100% rename from couchpotato/core/providers/movie/themoviedb/__init__.py rename to couchpotato/core/providers/info/themoviedb/__init__.py diff --git a/couchpotato/core/providers/info/themoviedb/main.py b/couchpotato/core/providers/info/themoviedb/main.py new file mode 100644 index 00000000..387355f8 --- /dev/null +++ b/couchpotato/core/providers/info/themoviedb/main.py @@ -0,0 +1,156 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import simplifyString, toUnicode, ss +from couchpotato.core.helpers.variable import md5 +from couchpotato.core.logger import CPLog +from couchpotato.core.providers.info.base import MovieProvider +import tmdb3 +import traceback + +log = CPLog(__name__) + + +class TheMovieDb(MovieProvider): + + def __init__(self): + addEvent('movie.search', self.search, priority = 2) + addEvent('movie.info', self.getInfo, priority = 2) + addEvent('movie.info_by_tmdb', self.getInfo) + + # Configure TMDB settings + tmdb3.set_key(self.conf('api_key')) + tmdb3.set_cache('null') + + def search(self, q, limit = 12): + """ Find movie by name """ + + if self.isDisabled(): + return False + + search_string = simplifyString(q) + cache_key = 'tmdb.cache.%s.%s' % (search_string, limit) + results = self.getCache(cache_key) + + if not results: + log.debug('Searching for movie: %s', q) + + raw = None + try: + raw = tmdb3.searchMovie(search_string) + except: + log.error('Failed searching TMDB for "%s": %s', (search_string, traceback.format_exc())) + + results = [] + if raw: + try: + nr = 0 + + for movie in raw: + results.append(self.parseMovie(movie, with_titles = False)) + + nr += 1 + if nr == limit: + break + + log.info('Found: %s', [result['titles'][0] + ' (' + str(result.get('year', 0)) + ')' for result in results]) + + self.setCache(cache_key, results) + return results + except SyntaxError, e: + log.error('Failed to parse XML response: %s', e) + return False + + return results + + def getInfo(self, identifier = None): + + if not identifier: + return {} + + cache_key = 'tmdb.cache.%s' % identifier + result = self.getCache(cache_key) + + if not result: + try: + log.debug('Getting info: %s', cache_key) + movie = tmdb3.Movie(identifier) + result = self.parseMovie(movie) + self.setCache(cache_key, result) + except: + pass + + return result + + def parseMovie(self, movie, with_titles = True): + + cache_key = 'tmdb.cache.%s' % movie.id + movie_data = self.getCache(cache_key) + + if not movie_data: + + # Images + poster = self.getImage(movie, type = 'poster', size = 'poster') + poster_original = self.getImage(movie, type = 'poster', size = 'original') + backdrop_original = self.getImage(movie, type = 'backdrop', size = 'original') + + # Genres + try: + genres = [genre.name for genre in movie.genres] + except: + genres = [] + + # 1900 is the same as None + year = str(movie.releasedate or '')[:4] + if not movie.releasedate or year == '1900' or year.lower() == 'none': + year = None + + movie_data = { + 'via_tmdb': True, + 'tmdb_id': movie.id, + 'titles': [toUnicode(movie.title)], + 'original_title': movie.originaltitle, + 'images': { + 'poster': [poster] if poster else [], + #'backdrop': [backdrop] if backdrop else [], + 'poster_original': [poster_original] if poster_original else [], + 'backdrop_original': [backdrop_original] if backdrop_original else [], + }, + 'imdb': movie.imdb, + 'runtime': movie.runtime, + 'released': str(movie.releasedate), + 'year': year, + 'plot': movie.overview, + 'genres': genres, + } + + movie_data = dict((k, v) for k, v in movie_data.iteritems() if v) + + # Add alternative names + if with_titles: + movie_data['titles'].append(movie.originaltitle) + for alt in movie.alternate_titles: + alt_name = alt.title + if alt_name and not alt_name in movie_data['titles'] and alt_name.lower() != 'none' and alt_name is not None: + movie_data['titles'].append(alt_name) + + movie_data['titles'] = list(set(movie_data['titles'])) + + # Cache movie parsed + self.setCache(cache_key, movie_data) + + return movie_data + + def getImage(self, movie, type = 'poster', size = 'poster'): + + image_url = '' + try: + image_url = getattr(movie, type).geturl(size='original') + except: + log.debug('Failed getting %s.%s for "%s"', (type, size, movie.title)) + + return image_url + + def isDisabled(self): + if self.conf('api_key') == '': + log.error('No API key provided.') + return True + return False diff --git a/couchpotato/core/providers/metadata/base.py b/couchpotato/core/providers/metadata/base.py index b41960a0..f5610030 100644 --- a/couchpotato/core/providers/metadata/base.py +++ b/couchpotato/core/providers/metadata/base.py @@ -17,14 +17,15 @@ class MetaDataBase(Plugin): def __init__(self): addEvent('renamer.after', self.create) - def create(self, message = None, group = {}): + def create(self, message = None, group = None): if self.isDisabled(): return + if not group: group = {} log.info('Creating %s metadata.', self.getName()) # Update library to get latest info try: - updated_library = fireEvent('library.update', group['library']['identifier'], force = True, single = True) + updated_library = fireEvent('library.update.movie', group['library']['identifier'], force = True, single = True) group['library'] = mergeDicts(group['library'], updated_library) except: log.error('Failed to update movie, before creating metadata: %s', traceback.format_exc()) @@ -40,7 +41,7 @@ class MetaDataBase(Plugin): # Get file path name = getattr(self, 'get' + file_type.capitalize() + 'Name')(meta_name, root) - if name and self.conf('meta_' + file_type): + if name and (self.conf('meta_' + file_type) or self.conf('meta_' + file_type) is None): # Get file content content = getattr(self, 'get' + file_type.capitalize())(movie_info = movie_info, data = group) @@ -48,6 +49,11 @@ class MetaDataBase(Plugin): log.debug('Creating %s file: %s', (file_type, name)) if os.path.isfile(content): shutil.copy2(content, name) + shutil.copyfile(content, name) + + # Try and copy stats seperately + try: shutil.copystat(content, name) + except: pass else: self.createFile(name, content) group['renamed_files'].append(name) @@ -60,8 +66,9 @@ class MetaDataBase(Plugin): except: log.error('Unable to create %s file: %s', (file_type, traceback.format_exc())) - def getRootName(self, data): - return + def getRootName(self, data = None): + if not data: data = {} + return os.path.join(data['destination_dir'], data['filename']) def getFanartName(self, name, root): return @@ -72,13 +79,19 @@ class MetaDataBase(Plugin): def getNfoName(self, name, root): return - def getNfo(self, movie_info = {}, data = {}): - return + def getNfo(self, movie_info = None, data = None): + if not data: data = {} + if not movie_info: movie_info = {} - def getThumbnail(self, movie_info = {}, data = {}, wanted_file_type = 'poster_original'): + def getThumbnail(self, movie_info = None, data = None, wanted_file_type = 'poster_original'): + if not data: data = {} + if not movie_info: movie_info = {} file_types = fireEvent('file.types', single = True) - for file_type in file_types: - if file_type.get('identifier') == wanted_file_type: + file_type = {} + + for ft in file_types: + if ft.get('identifier') == wanted_file_type: + file_type = ft break # See if it is in current files @@ -94,5 +107,7 @@ class MetaDataBase(Plugin): except: pass - def getFanart(self, movie_info = {}, data = {}): + def getFanart(self, movie_info = None, data = None): + if not data: data = {} + if not movie_info: movie_info = {} return self.getThumbnail(movie_info = movie_info, data = data, wanted_file_type = 'backdrop_original') diff --git a/couchpotato/core/providers/metadata/wmc/__init__.py b/couchpotato/core/providers/metadata/wmc/__init__.py new file mode 100644 index 00000000..290436c6 --- /dev/null +++ b/couchpotato/core/providers/metadata/wmc/__init__.py @@ -0,0 +1,24 @@ +from .main import WindowsMediaCenter + +def start(): + return WindowsMediaCenter() + +config = [{ + 'name': 'windowsmediacenter', + 'groups': [ + { + 'tab': 'renamer', + 'subtab': 'metadata', + 'name': 'windowsmediacenter_metadata', + 'label': 'Windows Explorer / Media Center', + 'description': 'Generate folder.jpg', + 'options': [ + { + 'name': 'meta_enabled', + 'default': False, + 'type': 'enabler', + }, + ], + }, + ], +}] diff --git a/couchpotato/core/providers/metadata/wmc/main.py b/couchpotato/core/providers/metadata/wmc/main.py new file mode 100644 index 00000000..89258918 --- /dev/null +++ b/couchpotato/core/providers/metadata/wmc/main.py @@ -0,0 +1,7 @@ +from couchpotato.core.providers.metadata.base import MetaDataBase +import os + +class WindowsMediaCenter(MetaDataBase): + + def getThumbnailName(self, name, root): + return os.path.join(root, 'folder.jpg') diff --git a/couchpotato/core/providers/metadata/xbmc/main.py b/couchpotato/core/providers/metadata/xbmc/main.py index 1fd95846..e865e2d4 100644 --- a/couchpotato/core/providers/metadata/xbmc/main.py +++ b/couchpotato/core/providers/metadata/xbmc/main.py @@ -12,9 +12,6 @@ log = CPLog(__name__) class XBMC(MetaDataBase): - def getRootName(self, data = {}): - return os.path.join(data['destination_dir'], data['filename']) - def getFanartName(self, name, root): return self.createMetaName(self.conf('meta_fanart_name'), name, root) @@ -27,7 +24,9 @@ class XBMC(MetaDataBase): def createMetaName(self, basename, name, root): return os.path.join(root, basename.replace('%s', name)) - def getNfo(self, movie_info = {}, data = {}): + def getNfo(self, movie_info = None, data = None): + if not data: data = {} + if not movie_info: movie_info = {} # return imdb url only if self.conf('meta_url_only'): diff --git a/couchpotato/core/providers/movie/themoviedb/main.py b/couchpotato/core/providers/movie/themoviedb/main.py deleted file mode 100644 index 735419c3..00000000 --- a/couchpotato/core/providers/movie/themoviedb/main.py +++ /dev/null @@ -1,215 +0,0 @@ -from couchpotato.core.event import addEvent -from couchpotato.core.helpers.encoding import simplifyString, toUnicode -from couchpotato.core.logger import CPLog -from couchpotato.core.providers.movie.base import MovieProvider -from themoviedb import tmdb -import traceback - -log = CPLog(__name__) - - -class TheMovieDb(MovieProvider): - - def __init__(self): - addEvent('movie.by_hash', self.byHash) - addEvent('movie.search', self.search, priority = 2) - addEvent('movie.info', self.getInfo, priority = 2) - addEvent('movie.info_by_tmdb', self.getInfoByTMDBId) - - # Use base wrapper - tmdb.configure(self.conf('api_key')) - - def byHash(self, file): - ''' Find movie by hash ''' - - if self.isDisabled(): - return False - - cache_key = 'tmdb.cache.%s' % simplifyString(file) - results = self.getCache(cache_key) - - if not results: - log.debug('Searching for movie by hash: %s', file) - try: - raw = tmdb.searchByHashingFile(file) - - results = [] - if raw: - try: - results = self.parseMovie(raw) - log.info('Found: %s', results['titles'][0] + ' (' + str(results.get('year', 0)) + ')') - - self.setCache(cache_key, results) - return results - except SyntaxError, e: - log.error('Failed to parse XML response: %s', e) - return False - except: - log.debug('No movies known by hash for: %s', file) - pass - - return results - - def search(self, q, limit = 12): - ''' Find movie by name ''' - - if self.isDisabled(): - return False - - search_string = simplifyString(q) - cache_key = 'tmdb.cache.%s.%s' % (search_string, limit) - results = self.getCache(cache_key) - - if not results: - log.debug('Searching for movie: %s', q) - - raw = None - try: - raw = tmdb.search(search_string) - except: - log.error('Failed searching TMDB for "%s": %s', (search_string, traceback.format_exc())) - - results = [] - if raw: - try: - nr = 0 - - for movie in raw: - results.append(self.parseMovie(movie)) - - nr += 1 - if nr == limit: - break - - log.info('Found: %s', [result['titles'][0] + ' (' + str(result.get('year', 0)) + ')' for result in results]) - - self.setCache(cache_key, results) - return results - except SyntaxError, e: - log.error('Failed to parse XML response: %s', e) - return False - - return results - - def getInfo(self, identifier = None): - - if not identifier: - return {} - - cache_key = 'tmdb.cache.%s' % identifier - result = self.getCache(cache_key) - - if not result: - result = {} - movie = None - - try: - log.debug('Getting info: %s', cache_key) - movie = tmdb.imdbLookup(id = identifier) - except: - pass - - if movie: - result = self.parseMovie(movie[0]) - self.setCache(cache_key, result) - - return result - - def getInfoByTMDBId(self, id = None): - - cache_key = 'tmdb.cache.%s' % id - result = self.getCache(cache_key) - - if not result: - result = {} - movie = None - - try: - log.debug('Getting info: %s', cache_key) - movie = tmdb.getMovieInfo(id = id) - except: - pass - - if movie: - result = self.parseMovie(movie) - self.setCache(cache_key, result) - - return result - - def parseMovie(self, movie): - - # Images - poster = self.getImage(movie, type = 'poster', size = 'cover') - #backdrop = self.getImage(movie, type = 'backdrop', size = 'w1280') - poster_original = self.getImage(movie, type = 'poster', size = 'original') - backdrop_original = self.getImage(movie, type = 'backdrop', size = 'original') - - # Genres - try: - genres = self.getCategory(movie, 'genre') - except: - genres = [] - - # 1900 is the same as None - year = str(movie.get('released', 'none'))[:4] - if year == '1900' or year.lower() == 'none': - year = None - - movie_data = { - 'via_tmdb': True, - 'tmdb_id': int(movie.get('id', 0)), - 'titles': [toUnicode(movie.get('name'))], - 'original_title': movie.get('original_name'), - 'images': { - 'poster': [poster] if poster else [], - #'backdrop': [backdrop] if backdrop else [], - 'poster_original': [poster_original] if poster_original else [], - 'backdrop_original': [backdrop_original] if backdrop_original else [], - }, - 'imdb': movie.get('imdb_id'), - 'runtime': movie.get('runtime'), - 'released': movie.get('released'), - 'year': year, - 'plot': movie.get('overview'), - 'genres': genres, - } - - movie_data = dict((k, v) for k, v in movie_data.iteritems() if v) - - # Add alternative names - for alt in ['original_name', 'alternative_name']: - alt_name = toUnicode(movie.get(alt)) - if alt_name and not alt_name in movie_data['titles'] and alt_name.lower() != 'none' and alt_name != None: - movie_data['titles'].append(alt_name) - - return movie_data - - def getImage(self, movie, type = 'poster', size = 'cover'): - - image_url = '' - for image in movie.get('images', []): - if(image.get('type') == type) and image.get(size): - image_url = image.get(size) - break - - return image_url - - def getCategory(self, movie, type = 'genre'): - - cats = movie.get('categories', {}).get(type) - - categories = [] - for category in cats: - try: - categories.append(category) - except: - pass - - return categories - - def isDisabled(self): - if self.conf('api_key') == '': - log.error('No API key provided.') - True - else: - False diff --git a/couchpotato/core/providers/nzb/__init__.py b/couchpotato/core/providers/nzb/__init__.py index 651ae8b9..36098bb3 100644 --- a/couchpotato/core/providers/nzb/__init__.py +++ b/couchpotato/core/providers/nzb/__init__.py @@ -2,13 +2,12 @@ config = { 'name': 'nzb_providers', 'groups': [ { - 'label': 'Usenet', + 'label': 'Usenet Providers', 'description': 'Providers searching usenet for new releases', 'wizard': True, 'type': 'list', 'name': 'nzb_providers', 'tab': 'searcher', - 'subtab': 'providers', 'options': [], }, ], diff --git a/couchpotato/core/providers/nzb/base.py b/couchpotato/core/providers/nzb/base.py index f11382ba..53c73af0 100644 --- a/couchpotato/core/providers/nzb/base.py +++ b/couchpotato/core/providers/nzb/base.py @@ -3,7 +3,8 @@ import time class NZBProvider(YarrProvider): - type = 'nzb' + + protocol = 'nzb' def calculateAge(self, unix): return int(time.time() - unix) / 24 / 60 / 60 diff --git a/couchpotato/core/providers/nzb/binsearch/__init__.py b/couchpotato/core/providers/nzb/binsearch/__init__.py index d3288604..1cfb0b73 100644 --- a/couchpotato/core/providers/nzb/binsearch/__init__.py +++ b/couchpotato/core/providers/nzb/binsearch/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'nzb_providers', 'name': 'binsearch', 'description': 'Free provider, less accurate. See BinSearch', diff --git a/couchpotato/core/providers/nzb/binsearch/main.py b/couchpotato/core/providers/nzb/binsearch/main.py index 1d863002..dee5fc78 100644 --- a/couchpotato/core/providers/nzb/binsearch/main.py +++ b/couchpotato/core/providers/nzb/binsearch/main.py @@ -86,8 +86,10 @@ class BinSearch(NZBProvider): def download(self, url = '', nzb_id = ''): - params = {'action': 'nzb'} - params[nzb_id] = 'on' + params = { + 'action': 'nzb', + nzb_id: 'on' + } try: return self.urlopen(url, params = params, show_error = False) diff --git a/couchpotato/core/providers/nzb/ftdworld/__init__.py b/couchpotato/core/providers/nzb/ftdworld/__init__.py index 37aedb2a..5a004a70 100644 --- a/couchpotato/core/providers/nzb/ftdworld/__init__.py +++ b/couchpotato/core/providers/nzb/ftdworld/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'nzb_providers', 'name': 'FTDWorld', 'description': 'Free provider, less accurate. See FTDWorld', diff --git a/couchpotato/core/providers/nzb/newznab/__init__.py b/couchpotato/core/providers/nzb/newznab/__init__.py index 90f81cfc..3902ab13 100644 --- a/couchpotato/core/providers/nzb/newznab/__init__.py +++ b/couchpotato/core/providers/nzb/newznab/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'nzb_providers', 'name': 'newznab', 'order': 10, diff --git a/couchpotato/core/providers/nzb/newznab/main.py b/couchpotato/core/providers/nzb/newznab/main.py index 8eb3e84e..02ffcfdc 100644 --- a/couchpotato/core/providers/nzb/newznab/main.py +++ b/couchpotato/core/providers/nzb/newznab/main.py @@ -118,7 +118,7 @@ class Newznab(NZBProvider, RSS): return list - def belongsTo(self, url, provider = None): + def belongsTo(self, url, provider = None, host = None): hosts = self.getHosts() diff --git a/couchpotato/core/providers/nzb/nzbclub/__init__.py b/couchpotato/core/providers/nzb/nzbclub/__init__.py index 9955462c..95eeea13 100644 --- a/couchpotato/core/providers/nzb/nzbclub/__init__.py +++ b/couchpotato/core/providers/nzb/nzbclub/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'nzb_providers', 'name': 'NZBClub', 'description': 'Free provider, less accurate. See NZBClub', diff --git a/couchpotato/core/providers/nzb/nzbindex/__init__.py b/couchpotato/core/providers/nzb/nzbindex/__init__.py index aa8de4dd..47461e63 100644 --- a/couchpotato/core/providers/nzb/nzbindex/__init__.py +++ b/couchpotato/core/providers/nzb/nzbindex/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'nzb_providers', 'name': 'nzbindex', 'description': 'Free provider, less accurate. See NZBIndex', diff --git a/couchpotato/core/providers/nzb/nzbsrus/__init__.py b/couchpotato/core/providers/nzb/nzbsrus/__init__.py deleted file mode 100644 index 863e6a37..00000000 --- a/couchpotato/core/providers/nzb/nzbsrus/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -from .main import Nzbsrus - -def start(): - return Nzbsrus() - -config = [{ - 'name': 'nzbsrus', - 'groups': [ - { - 'tab': 'searcher', - 'subtab': 'providers', - 'list': 'nzb_providers', - 'name': 'nzbsrus', - 'label': 'Nzbsrus', - 'description': 'See NZBsRus. You need a VIP account!', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'type': 'enabler', - }, - { - 'name': 'userid', - 'label': 'User ID', - }, - { - 'name': 'api_key', - 'default': '', - 'label': 'Api Key', - }, - { - 'name': 'english_only', - 'default': 1, - 'type': 'bool', - 'label': 'English only', - 'description': 'Only search for English spoken movies on Nzbsrus', - }, - { - 'name': 'extra_score', - 'advanced': True, - 'label': 'Extra Score', - 'type': 'int', - 'default': 0, - 'description': 'Starting score for each release found via this provider.', - } - ], - }, - ], -}] diff --git a/couchpotato/core/providers/nzb/nzbsrus/main.py b/couchpotato/core/providers/nzb/nzbsrus/main.py deleted file mode 100644 index d52212a7..00000000 --- a/couchpotato/core/providers/nzb/nzbsrus/main.py +++ /dev/null @@ -1,62 +0,0 @@ -from couchpotato.core.helpers.encoding import tryUrlencode -from couchpotato.core.helpers.rss import RSS -from couchpotato.core.logger import CPLog -from couchpotato.core.providers.nzb.base import NZBProvider -from couchpotato.environment import Env -import time - -log = CPLog(__name__) - -class Nzbsrus(NZBProvider, RSS): - - urls = { - 'download': 'https://www.nzbsrus.com/nzbdownload_rss.php/%s', - 'detail': 'https://www.nzbsrus.com/nzbdetails.php?id=%s', - 'search': 'https://www.nzbsrus.com/api.php?extended=1&xml=1&listname={date,grabs}', - } - - cat_ids = [ - ([90, 45, 51], ['720p', '1080p', 'brrip', 'bd50', 'dvdr']), - ([48, 51], ['cam', 'ts', 'dvdrip', 'tc', 'r5', 'scr']), - ] - cat_backup_id = 240 - - def _search(self, movie, quality, results): - - cat_id_string = '&'.join(['c%s=1' % x for x in self.getCatId(quality.get('identifier'))]) - arguments = tryUrlencode({ - 'searchtext': 'imdb:' + movie['library']['identifier'][2:], - 'uid': self.conf('userid'), - 'key': self.conf('api_key'), - 'age': Env.setting('retention', section = 'nzb'), - - }) - - # check for english_only - if self.conf('english_only'): - arguments += '&lang0=1&lang3=1&lang1=1' - - url = '%s&%s&%s' % (self.urls['search'], arguments , cat_id_string) - nzbs = self.getRSSData(url, item_path = 'results/result', cache_timeout = 1800, headers = {'User-Agent': Env.getIdentifier()}) - - for nzb in nzbs: - - title = self.getTextElement(nzb, 'name') - if 'error' in title.lower(): continue - - nzb_id = self.getTextElement(nzb, 'id') - size = int(round(int(self.getTextElement(nzb, 'size')) / 1048576)) - age = int(round((time.time() - int(self.getTextElement(nzb, 'postdate'))) / 86400)) - - results.append({ - 'id': nzb_id, - 'name': title, - 'age': age, - 'size': size, - 'url': self.urls['download'] % nzb_id + self.getApiExt() + self.getTextElement(nzb, 'key'), - 'detail_url': self.urls['detail'] % nzb_id, - 'description': self.getTextElement(nzb, 'addtext'), - }) - - def getApiExt(self): - return '/%s/' % (self.conf('userid')) diff --git a/couchpotato/core/providers/nzb/nzbx/__init__.py b/couchpotato/core/providers/nzb/nzbx/__init__.py deleted file mode 100644 index 9cf80638..00000000 --- a/couchpotato/core/providers/nzb/nzbx/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from .main import Nzbx - -def start(): - return Nzbx() - -config = [{ - 'name': 'nzbx', - 'groups': [ - { - 'tab': 'searcher', - 'subtab': 'providers', - 'list': 'nzb_providers', - 'name': 'nzbX', - 'description': 'Free provider. See nzbX', - 'wizard': True, - 'options': [ - { - 'name': 'enabled', - 'type': 'enabler', - 'default': True, - }, - { - 'name': 'extra_score', - 'advanced': True, - 'label': 'Extra Score', - 'type': 'int', - 'default': 0, - 'description': 'Starting score for each release found via this provider.', - } - ], - }, - ], -}] diff --git a/couchpotato/core/providers/nzb/nzbx/main.py b/couchpotato/core/providers/nzb/nzbx/main.py deleted file mode 100644 index ec7fbfe2..00000000 --- a/couchpotato/core/providers/nzb/nzbx/main.py +++ /dev/null @@ -1,38 +0,0 @@ -from couchpotato.core.helpers.encoding import tryUrlencode -from couchpotato.core.helpers.variable import tryInt -from couchpotato.core.logger import CPLog -from couchpotato.core.providers.nzb.base import NZBProvider -from couchpotato.environment import Env - -log = CPLog(__name__) - - -class Nzbx(NZBProvider): - - urls = { - 'search': 'https://nzbx.co/api/search?%s', - 'details': 'https://nzbx.co/api/details?guid=%s', - } - - http_time_between_calls = 1 # Seconds - - def _search(self, movie, quality, results): - - # Get nbzs - arguments = tryUrlencode({ - 'q': movie['library']['identifier'].replace('tt', ''), - 'sf': quality.get('size_min'), - }) - nzbs = self.getJsonData(self.urls['search'] % arguments, headers = {'User-Agent': Env.getIdentifier()}) - - for nzb in nzbs: - - results.append({ - 'id': nzb['guid'], - 'url': nzb['nzb'], - 'detail_url': self.urls['details'] % nzb['guid'], - 'name': nzb['name'], - 'age': self.calculateAge(int(nzb['postdate'])), - 'size': tryInt(nzb['size']) / 1024 / 1024, - 'score': 5 if nzb['votes']['upvotes'] > nzb['votes']['downvotes'] else 0 - }) diff --git a/couchpotato/core/providers/nzb/omgwtfnzbs/__init__.py b/couchpotato/core/providers/nzb/omgwtfnzbs/__init__.py index fe80518c..933aff3e 100644 --- a/couchpotato/core/providers/nzb/omgwtfnzbs/__init__.py +++ b/couchpotato/core/providers/nzb/omgwtfnzbs/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'nzb_providers', 'name': 'OMGWTFNZBs', 'description': 'See OMGWTFNZBs', diff --git a/couchpotato/core/providers/torrent/__init__.py b/couchpotato/core/providers/torrent/__init__.py index 191e132e..250bcead 100644 --- a/couchpotato/core/providers/torrent/__init__.py +++ b/couchpotato/core/providers/torrent/__init__.py @@ -2,13 +2,12 @@ config = { 'name': 'torrent_providers', 'groups': [ { - 'label': 'Torrent', + 'label': 'Torrent Providers', 'description': 'Providers searching torrent sites for new releases', 'wizard': True, 'type': 'list', 'name': 'torrent_providers', 'tab': 'searcher', - 'subtab': 'providers', 'options': [], }, ], diff --git a/couchpotato/core/providers/torrent/awesomehd/__init__.py b/couchpotato/core/providers/torrent/awesomehd/__init__.py index 5c8c9794..de6a2144 100644 --- a/couchpotato/core/providers/torrent/awesomehd/__init__.py +++ b/couchpotato/core/providers/torrent/awesomehd/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'Awesome-HD', 'description': 'See AHD', @@ -23,6 +22,20 @@ config = [{ 'name': 'passkey', 'default': '', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'only_internal', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/awesomehd/main.py b/couchpotato/core/providers/torrent/awesomehd/main.py index fed12519..79482f2a 100644 --- a/couchpotato/core/providers/torrent/awesomehd/main.py +++ b/couchpotato/core/providers/torrent/awesomehd/main.py @@ -25,6 +25,11 @@ class AwesomeHD(TorrentProvider): if data: try: soup = BeautifulSoup(data) + + if soup.find('error'): + log.error(soup.find('error').get_text()) + return + authkey = soup.find('authkey').get_text() entries = soup.find_all('torrent') diff --git a/couchpotato/core/providers/torrent/base.py b/couchpotato/core/providers/torrent/base.py index 453954c9..3e7ddde8 100644 --- a/couchpotato/core/providers/torrent/base.py +++ b/couchpotato/core/providers/torrent/base.py @@ -7,7 +7,7 @@ log = CPLog(__name__) class TorrentProvider(YarrProvider): - type = 'torrent' + protocol = 'torrent' def imdbMatch(self, url, imdbId): if getImdb(url) == imdbId: @@ -27,6 +27,6 @@ class TorrentProvider(YarrProvider): class TorrentMagnetProvider(TorrentProvider): - type = 'torrent_magnet' + protocol = 'torrent_magnet' download = None diff --git a/couchpotato/core/providers/torrent/bitsoup/__init__.py b/couchpotato/core/providers/torrent/bitsoup/__init__.py new file mode 100644 index 00000000..a36ab08f --- /dev/null +++ b/couchpotato/core/providers/torrent/bitsoup/__init__.py @@ -0,0 +1,55 @@ +from .main import Bitsoup + +def start(): + return Bitsoup() + +config = [{ + 'name': 'bitsoup', + 'groups': [ + { + 'tab': 'searcher', + 'list': 'torrent_providers', + 'name': 'Bitsoup', + 'description': 'See Bitsoup', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'type': 'enabler', + 'default': False, + }, + { + 'name': 'username', + 'default': '', + }, + { + 'name': 'password', + 'default': '', + 'type': 'password', + }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, + { + 'name': 'extra_score', + 'advanced': True, + 'label': 'Extra Score', + 'type': 'int', + 'default': 20, + 'description': 'Starting score for each release found via this provider.', + } + ], + }, + ], +}] diff --git a/couchpotato/core/providers/torrent/bitsoup/main.py b/couchpotato/core/providers/torrent/bitsoup/main.py new file mode 100644 index 00000000..539ba43d --- /dev/null +++ b/couchpotato/core/providers/torrent/bitsoup/main.py @@ -0,0 +1,84 @@ +from bs4 import BeautifulSoup +from couchpotato.core.helpers.encoding import simplifyString, 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 Bitsoup(TorrentProvider): + + urls = { + 'test': 'https://www.bitsoup.me/', + 'login' : 'https://www.bitsoup.me/takelogin.php', + 'login_check': 'https://www.bitsoup.me/my.php', + 'search': 'https://www.bitsoup.me/browse.php?', + 'baseurl': 'https://www.bitsoup.me/%s', + } + + http_time_between_calls = 1 #seconds + + def _searchOnTitle(self, title, movie, quality, results): + + q = '"%s" %s' % (simplifyString(title), movie['library']['year']) + arguments = tryUrlencode({ + 'search': q, + }) + url = "%s&%s" % (self.urls['search'], arguments) + + data = self.getHTMLData(url, opener = self.login_opener) + + if data: + html = BeautifulSoup(data) + + try: + result_table = html.find('table', attrs = {'class': 'koptekst'}) + entries = result_table.find_all('tr') + for result in entries[1:]: + + all_cells = result.find_all('td') + + torrent = all_cells[1].find('a') + download = all_cells[3].find('a') + + torrent_id = torrent['href'] + torrent_id = torrent_id.replace('details.php?id=', '') + torrent_id = torrent_id.replace('&hit=1', '') + + torrent_name = torrent.getText() + + torrent_size = self.parseSize(all_cells[7].getText()) + torrent_seeders = tryInt(all_cells[9].getText()) + torrent_leechers = tryInt(all_cells[10].getText()) + torrent_url = self.urls['baseurl'] % download['href'] + torrent_detail_url = self.urls['baseurl'] % torrent['href'] + + results.append({ + 'id': torrent_id, + 'name': torrent_name, + 'size': torrent_size, + 'seeders': torrent_seeders, + 'leechers': torrent_leechers, + 'url': torrent_url, + 'detail_url': torrent_detail_url, + }) + + except: + log.error('Failed getting results from %s: %s', (self.getName(), traceback.format_exc())) + + + def getLoginParams(self): + return tryUrlencode({ + 'username': self.conf('username'), + 'password': self.conf('password'), + 'ssl': 'yes', + }) + + + def loginSuccess(self, output): + return 'logout.php' in output.lower() + + loginCheckSuccess = loginSuccess + diff --git a/couchpotato/core/providers/torrent/hdbits/__init__.py b/couchpotato/core/providers/torrent/hdbits/__init__.py index 8a9fc80e..07ea95d6 100644 --- a/couchpotato/core/providers/torrent/hdbits/__init__.py +++ b/couchpotato/core/providers/torrent/hdbits/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'HDBits', 'description': 'See HDBits', @@ -31,6 +30,20 @@ config = [{ 'name': 'passkey', 'default': '', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/iptorrents/__init__.py b/couchpotato/core/providers/torrent/iptorrents/__init__.py index 24f9772b..6cb2dead 100644 --- a/couchpotato/core/providers/torrent/iptorrents/__init__.py +++ b/couchpotato/core/providers/torrent/iptorrents/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'IPTorrents', 'description': 'See IPTorrents', @@ -34,6 +33,20 @@ config = [{ 'type': 'bool', 'description': 'Only search for [FreeLeech] torrents.', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/kickasstorrents/__init__.py b/couchpotato/core/providers/torrent/kickasstorrents/__init__.py index 999dbb1b..b095a97d 100644 --- a/couchpotato/core/providers/torrent/kickasstorrents/__init__.py +++ b/couchpotato/core/providers/torrent/kickasstorrents/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'KickAssTorrents', 'description': 'See KickAssTorrents', @@ -19,6 +18,20 @@ config = [{ 'type': 'enabler', 'default': True, }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/passthepopcorn/__init__.py b/couchpotato/core/providers/torrent/passthepopcorn/__init__.py index a535034a..66b3ea76 100644 --- a/couchpotato/core/providers/torrent/passthepopcorn/__init__.py +++ b/couchpotato/core/providers/torrent/passthepopcorn/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'PassThePopcorn', 'description': 'See PassThePopcorn.me', @@ -62,6 +61,20 @@ config = [{ 'default': 0, 'description': 'Require staff-approval for releases to be accepted.' }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, @@ -71,6 +84,6 @@ config = [{ 'description': 'Starting score for each release found via this provider.', } ], -} + } ] }] diff --git a/couchpotato/core/providers/torrent/publichd/__init__.py b/couchpotato/core/providers/torrent/publichd/__init__.py index 3c27cf48..ace12880 100644 --- a/couchpotato/core/providers/torrent/publichd/__init__.py +++ b/couchpotato/core/providers/torrent/publichd/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'PublicHD', 'description': 'Public Torrent site with only HD content. See PublicHD', @@ -19,6 +18,20 @@ config = [{ 'type': 'enabler', 'default': True, }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/publichd/main.py b/couchpotato/core/providers/torrent/publichd/main.py index 2043f8c4..7b497fd9 100644 --- a/couchpotato/core/providers/torrent/publichd/main.py +++ b/couchpotato/core/providers/torrent/publichd/main.py @@ -67,10 +67,22 @@ class PublicHD(TorrentMagnetProvider): log.error('Failed getting results from %s: %s', (self.getName(), traceback.format_exc())) def getMoreInfo(self, item): - full_description = self.getCache('publichd.%s' % item['id'], item['detail_url'], cache_timeout = 25920000) - html = BeautifulSoup(full_description) - nfo_pre = html.find('div', attrs = {'id':'torrmain'}) - description = toUnicode(nfo_pre.text) if nfo_pre else '' + + cache_key = 'publichd.%s' % item['id'] + description = self.getCache(cache_key) + + if not description: + + try: + full_description = self.urlopen(item['detail_url']) + html = BeautifulSoup(full_description) + nfo_pre = html.find('div', attrs = {'id':'torrmain'}) + description = toUnicode(nfo_pre.text) if nfo_pre else '' + except: + log.error('Failed getting more info for %s', item['name']) + description = '' + + self.setCache(cache_key, description, timeout = 25920000) item['description'] = description return item diff --git a/couchpotato/core/providers/torrent/sceneaccess/__init__.py b/couchpotato/core/providers/torrent/sceneaccess/__init__.py index baad57f6..4b675573 100644 --- a/couchpotato/core/providers/torrent/sceneaccess/__init__.py +++ b/couchpotato/core/providers/torrent/sceneaccess/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'SceneAccess', 'description': 'See SceneAccess', @@ -28,6 +27,20 @@ config = [{ 'default': '', 'type': 'password', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/scenehd/__init__.py b/couchpotato/core/providers/torrent/scenehd/__init__.py index 3cd2132e..c0a82ae7 100644 --- a/couchpotato/core/providers/torrent/scenehd/__init__.py +++ b/couchpotato/core/providers/torrent/scenehd/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'SceneHD', 'description': 'See SceneHD', @@ -28,6 +27,20 @@ config = [{ 'default': '', 'type': 'password', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/scenehd/main.py b/couchpotato/core/providers/torrent/scenehd/main.py index f471ec0c..2b76e43d 100644 --- a/couchpotato/core/providers/torrent/scenehd/main.py +++ b/couchpotato/core/providers/torrent/scenehd/main.py @@ -65,7 +65,7 @@ class SceneHD(TorrentProvider): log.error('Failed getting results from %s: %s', (self.getName(), traceback.format_exc())) - def getLoginParams(self, params): + def getLoginParams(self): return tryUrlencode({ 'username': self.conf('username'), 'password': self.conf('password'), diff --git a/couchpotato/core/providers/torrent/thepiratebay/__init__.py b/couchpotato/core/providers/torrent/thepiratebay/__init__.py index f2394dd6..83de7a94 100644 --- a/couchpotato/core/providers/torrent/thepiratebay/__init__.py +++ b/couchpotato/core/providers/torrent/thepiratebay/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'ThePirateBay', 'description': 'The world\'s largest bittorrent tracker. See ThePirateBay', @@ -25,6 +24,20 @@ config = [{ 'label': 'Proxy server', 'description': 'Domain for requests, keep empty to let CouchPotato pick.', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/thepiratebay/main.py b/couchpotato/core/providers/torrent/thepiratebay/main.py index 10608157..6aa22167 100644 --- a/couchpotato/core/providers/torrent/thepiratebay/main.py +++ b/couchpotato/core/providers/torrent/thepiratebay/main.py @@ -15,12 +15,13 @@ class ThePirateBay(TorrentMagnetProvider): urls = { 'detail': '%s/torrent/%s', - 'search': '%s/search/%s/%s/7/%d' + 'search': '%s/search/%s/%s/7/%s' } cat_ids = [ ([207], ['720p', '1080p']), - ([201], ['cam', 'ts', 'dvdrip', 'tc', 'r5', 'scr', 'brrip']), + ([201], ['cam', 'ts', 'dvdrip', 'tc', 'r5', 'scr']), + ([201, 207], ['brrip']), ([202], ['dvdr']) ] @@ -50,10 +51,11 @@ class ThePirateBay(TorrentMagnetProvider): page = 0 total_pages = 1 + cats = self.getCatId(quality['identifier']) while page < total_pages: - search_url = self.urls['search'] % (self.getDomain(), tryUrlencode('"%s" %s' % (title, movie['library']['year'])), page, self.getCatId(quality['identifier'])[0]) + search_url = self.urls['search'] % (self.getDomain(), tryUrlencode('"%s" %s' % (title, movie['library']['year'])), page, ','.join(str(x) for x in cats)) page += 1 data = self.getHTMLData(search_url) @@ -84,10 +86,10 @@ class ThePirateBay(TorrentMagnetProvider): if link and download: def extra_score(item): - trusted = (0, 10)[result.find('img', alt = re.compile('Trusted')) != None] - vip = (0, 20)[result.find('img', alt = re.compile('VIP')) != None] - confirmed = (0, 30)[result.find('img', alt = re.compile('Helpers')) != None] - moderated = (0, 50)[result.find('img', alt = re.compile('Moderator')) != None] + trusted = (0, 10)[result.find('img', alt = re.compile('Trusted')) is not None] + vip = (0, 20)[result.find('img', alt = re.compile('VIP')) is not None] + confirmed = (0, 30)[result.find('img', alt = re.compile('Helpers')) is not None] + moderated = (0, 50)[result.find('img', alt = re.compile('Moderator')) is not None] return confirmed + trusted + vip + moderated diff --git a/couchpotato/core/providers/torrent/torrentbytes/__init__.py b/couchpotato/core/providers/torrent/torrentbytes/__init__.py index 10e581a6..712eac85 100644 --- a/couchpotato/core/providers/torrent/torrentbytes/__init__.py +++ b/couchpotato/core/providers/torrent/torrentbytes/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'TorrentBytes', 'description': 'See TorrentBytes', @@ -28,6 +27,20 @@ config = [{ 'default': '', 'type': 'password', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/torrentday/__init__.py b/couchpotato/core/providers/torrent/torrentday/__init__.py index de715b53..d98bb917 100644 --- a/couchpotato/core/providers/torrent/torrentday/__init__.py +++ b/couchpotato/core/providers/torrent/torrentday/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'TorrentDay', 'description': 'See TorrentDay', @@ -28,6 +27,20 @@ config = [{ 'default': '', 'type': 'password', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/torrentleech/__init__.py b/couchpotato/core/providers/torrent/torrentleech/__init__.py index fa048d50..c788477f 100644 --- a/couchpotato/core/providers/torrent/torrentleech/__init__.py +++ b/couchpotato/core/providers/torrent/torrentleech/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'TorrentLeech', 'description': 'See TorrentLeech', @@ -28,6 +27,20 @@ config = [{ 'default': '', 'type': 'password', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/torrent/torrentshack/__init__.py b/couchpotato/core/providers/torrent/torrentshack/__init__.py index 203e0996..4171fc49 100644 --- a/couchpotato/core/providers/torrent/torrentshack/__init__.py +++ b/couchpotato/core/providers/torrent/torrentshack/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'TorrentShack', 'description': 'See TorrentShack', @@ -27,6 +26,20 @@ config = [{ 'default': '', 'type': 'password', }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'scene_only', 'type': 'bool', diff --git a/couchpotato/core/providers/torrent/torrentshack/main.py b/couchpotato/core/providers/torrent/torrentshack/main.py index 1b9ee197..b9d12c71 100644 --- a/couchpotato/core/providers/torrent/torrentshack/main.py +++ b/couchpotato/core/providers/torrent/torrentshack/main.py @@ -27,7 +27,7 @@ class TorrentShack(TorrentProvider): ] http_time_between_calls = 1 #seconds - cat_backup_id = None + cat_backup_id = 400 def _searchOnTitle(self, title, movie, quality, results): diff --git a/couchpotato/core/providers/torrent/yify/__init__.py b/couchpotato/core/providers/torrent/yify/__init__.py index 70d65687..775ecdbe 100644 --- a/couchpotato/core/providers/torrent/yify/__init__.py +++ b/couchpotato/core/providers/torrent/yify/__init__.py @@ -8,7 +8,6 @@ config = [{ 'groups': [ { 'tab': 'searcher', - 'subtab': 'providers', 'list': 'torrent_providers', 'name': 'Yify', 'description': 'Free provider, less accurate. Small HD movies, encoded by Yify.', @@ -19,6 +18,20 @@ config = [{ 'type': 'enabler', 'default': 0 }, + { + 'name': 'seed_ratio', + 'label': 'Seed ratio', + 'type': 'float', + 'default': 1, + 'description': 'Will not be (re)moved until this seed ratio is met.', + }, + { + 'name': 'seed_time', + 'label': 'Seed time', + 'type': 'int', + 'default': 40, + 'description': 'Will not be (re)moved until this seed time (in hours) is met.', + }, { 'name': 'extra_score', 'advanced': True, diff --git a/couchpotato/core/providers/userscript/allocine/main.py b/couchpotato/core/providers/userscript/allocine/main.py index 8cc889ee..f8ca630d 100644 --- a/couchpotato/core/providers/userscript/allocine/main.py +++ b/couchpotato/core/providers/userscript/allocine/main.py @@ -19,9 +19,6 @@ class AlloCine(UserscriptBase): except: return - name = None - year = None - try: start = data.find('') end = data.find('', start) diff --git a/couchpotato/core/providers/userscript/tmdb/main.py b/couchpotato/core/providers/userscript/tmdb/main.py index 6205851e..cab38fc6 100644 --- a/couchpotato/core/providers/userscript/tmdb/main.py +++ b/couchpotato/core/providers/userscript/tmdb/main.py @@ -9,7 +9,7 @@ class TMDB(UserscriptBase): def getMovie(self, url): match = re.search('(?P\d+)', url) - movie = fireEvent('movie.info_by_tmdb', id = match.group('id'), merge = True) + movie = fireEvent('movie.info_by_tmdb', identifier = match.group('id'), merge = True) if movie['imdb']: return self.getInfo(movie['imdb']) diff --git a/couchpotato/core/settings/__init__.py b/couchpotato/core/settings/__init__.py index cdf58aa2..61d982f2 100644 --- a/couchpotato/core/settings/__init__.py +++ b/couchpotato/core/settings/__init__.py @@ -1,13 +1,10 @@ from __future__ import with_statement from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent -from couchpotato.core.helpers.encoding import isInt, toUnicode -from couchpotato.core.helpers.variable import mergeDicts, tryInt +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.helpers.variable import mergeDicts, tryInt, tryFloat from couchpotato.core.settings.model import Properties import ConfigParser -import os.path -import time -import traceback class Settings(object): @@ -75,16 +72,26 @@ class Settings(object): addEvent('settings.register', self.registerDefaults) addEvent('settings.save', self.save) - def registerDefaults(self, section_name, options = {}, save = True): + def registerDefaults(self, section_name, options = None, save = True): + if not options: options = {} + self.addSection(section_name) + for option_name, option in options.iteritems(): self.setDefault(section_name, option_name, option.get('default', '')) + # Migrate old settings from old location to the new location + if option.get('migrate_from'): + if self.p.has_option(option.get('migrate_from'), option_name): + previous_value = self.p.get(option.get('migrate_from'), option_name) + self.p.set(section_name, option_name, previous_value) + self.p.remove_option(option.get('migrate_from'), option_name) + if option.get('type'): self.setType(section_name, option_name, option.get('type')) if save: - self.save(self) + self.save() def set(self, section, option, value): return self.p.set(section, option, value) @@ -122,7 +129,7 @@ class Settings(object): try: return self.p.getfloat(section, option) except: - return tryInt(self.p.get(section, option)) + return tryFloat(self.p.get(section, option)) def getUnicode(self, section, option): value = self.p.get(section, option).decode('unicode_escape') diff --git a/couchpotato/core/settings/model.py b/couchpotato/core/settings/model.py index 00ac34e5..f39544bc 100644 --- a/couchpotato/core/settings/model.py +++ b/couchpotato/core/settings/model.py @@ -82,6 +82,7 @@ class Movie(Entity): library = ManyToOne('Library', cascade = 'delete, delete-orphan', single_parent = True) status = ManyToOne('Status') profile = ManyToOne('Profile') + category = ManyToOne('Category') releases = OneToMany('Release', cascade = 'all, delete-orphan') files = ManyToMany('File', cascade = 'all, delete-orphan', single_parent = True) @@ -136,7 +137,10 @@ class Release(Entity): files = ManyToMany('File') info = OneToMany('ReleaseInfo', cascade = 'all, delete-orphan') - def to_dict(self, deep = {}, exclude = []): + def to_dict(self, deep = None, exclude = None): + if not exclude: exclude = [] + if not deep: deep = {} + orig_dict = super(Release, self).to_dict(deep = deep, exclude = exclude) new_info = {} @@ -199,13 +203,30 @@ class Profile(Entity): movie = OneToMany('Movie') types = OneToMany('ProfileType', cascade = 'all, delete-orphan') - def to_dict(self, deep = {}, exclude = []): + def to_dict(self, deep = None, exclude = None): + if not exclude: exclude = [] + if not deep: deep = {} + orig_dict = super(Profile, self).to_dict(deep = deep, exclude = exclude) orig_dict['core'] = orig_dict.get('core') or False orig_dict['hide'] = orig_dict.get('hide') or False return orig_dict +class Category(Entity): + """""" + using_options(order_by = 'order') + + label = Field(Unicode(50)) + order = Field(Integer, default = 0, index = True) + required = Field(Unicode(255)) + preferred = Field(Unicode(255)) + ignored = Field(Unicode(255)) + destination = Field(Unicode(255)) + + movie = OneToMany('Movie') + + class ProfileType(Entity): """""" using_options(order_by = 'order') @@ -271,13 +292,6 @@ class Notification(Entity): data = Field(JsonType) -class Folder(Entity): - """Renamer destination folders.""" - - path = Field(Unicode(255)) - label = Field(Unicode(255)) - - class Properties(Entity): identifier = Field(String(50), index = True) diff --git a/couchpotato/environment.py b/couchpotato/environment.py index ac0f729e..0f04d838 100644 --- a/couchpotato/environment.py +++ b/couchpotato/environment.py @@ -74,7 +74,7 @@ class Env(object): s = Env.get('settings') # Return setting - if value == None: + if value is None: return s.get(attr, default = default, section = section, type = type) # Set setting @@ -86,7 +86,7 @@ class Env(object): @staticmethod def prop(identifier, value = None, default = None): s = Env.get('settings') - if value == None: + if value is None: v = s.getProperty(identifier) return v if v else default diff --git a/couchpotato/runner.py b/couchpotato/runner.py index 0c0127fa..ab929192 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -89,7 +89,12 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En src_files = [options.config_file, db_path, db_path + '-shm', db_path + '-wal'] for src_file in src_files: if os.path.isfile(src_file): - shutil.copy2(src_file, toUnicode(os.path.join(new_backup, os.path.basename(src_file)))) + dst_file = toUnicode(os.path.join(new_backup, os.path.basename(src_file))) + shutil.copyfile(src_file, dst_file) + + # Try and copy stats seperately + try: shutil.copystat(src_file, dst_file) + except: pass # Remove older backups, keep backups 3 days or at least 3 backups = [] @@ -102,10 +107,14 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En for backup in backups: if total_backups > 3: if tryInt(os.path.basename(backup)) < time.time() - 259200: - for src_file in src_files: - b_file = toUnicode(os.path.join(backup, os.path.basename(src_file))) - if os.path.isfile(b_file): - os.remove(b_file) + for the_file in os.listdir(backup): + file_path = os.path.join(backup, the_file) + try: + if os.path.isfile(file_path): + os.remove(file_path) + except: + raise + os.rmdir(backup) total_backups -= 1 @@ -212,7 +221,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En # app.debug = development config = { 'use_reloader': reloader, - 'port': tryInt(Env.setting('port', default = 5000)), + 'port': tryInt(Env.setting('port', default = 5050)), '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), @@ -248,7 +257,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En application.add_handlers(".*$", [ ('%s%s/(.*)' % (static_path, dir_name), StaticFileHandler, {'path': toUnicode(os.path.join(base_path, 'couchpotato', 'static', dir_name))}) ]) - Env.set('static_path', static_path); + Env.set('static_path', static_path) # Load configs & plugins diff --git a/couchpotato/static/scripts/api.js b/couchpotato/static/scripts/api.js index 5e507bc1..38d18740 100644 --- a/couchpotato/static/scripts/api.js +++ b/couchpotato/static/scripts/api.js @@ -1,7 +1,7 @@ var ApiClass = new Class({ setup: function(options){ - var self = this + var self = this; self.options = options; }, @@ -13,7 +13,7 @@ var ApiClass = new Class({ return new Request[r_type](Object.merge({ 'callbackKey': 'callback_func', 'method': 'get', - 'url': self.createUrl(type, {'t': randomString()}), + 'url': self.createUrl(type, {'t': randomString()}) }, options)).send() }, @@ -26,4 +26,4 @@ var ApiClass = new Class({ } }); -window.Api = new ApiClass() \ No newline at end of file +window.Api = new ApiClass(); \ No newline at end of file diff --git a/couchpotato/static/scripts/block.js b/couchpotato/static/scripts/block.js index 82193ca5..7407b7fe 100644 --- a/couchpotato/static/scripts/block.js +++ b/couchpotato/static/scripts/block.js @@ -36,4 +36,4 @@ var BlockBase = new Class({ }); -var Block = BlockBase \ No newline at end of file +var Block = BlockBase; \ No newline at end of file diff --git a/couchpotato/static/scripts/block/menu.js b/couchpotato/static/scripts/block/menu.js index 8d315f59..91e29a23 100644 --- a/couchpotato/static/scripts/block/menu.js +++ b/couchpotato/static/scripts/block/menu.js @@ -18,11 +18,11 @@ Block.Menu = new Class({ self.button = new Element('a.button' + (self.options.button_class ? '.' + self.options.button_class : ''), { 'events': { 'click': function(){ - self.el.toggleClass('show') - self.fireEvent(self.el.hasClass('show') ? 'open' : 'close') + self.el.toggleClass('show'); + self.fireEvent(self.el.hasClass('show') ? 'open' : 'close'); if(self.el.hasClass('show')){ - self.el.addEvent('outerClick', self.removeOuterClick.bind(self)) + self.el.addEvent('outerClick', self.removeOuterClick.bind(self)); this.addEvent('outerClick', function(e){ if(e.target.get('tag') != 'input') self.removeOuterClick() @@ -41,7 +41,7 @@ Block.Menu = new Class({ removeOuterClick: function(){ var self = this; - self.el.removeClass('show') + self.el.removeClass('show'); self.el.removeEvents('outerClick'); self.button.removeEvents('outerClick'); @@ -49,8 +49,7 @@ Block.Menu = new Class({ addLink: function(tab, position){ var self = this; - var el = new Element('li').adopt(tab).inject(self.more_option_ul, position || 'bottom'); - return el; + return new Element('li').adopt(tab).inject(self.more_option_ul, position || 'bottom'); } }); \ No newline at end of file diff --git a/couchpotato/static/scripts/block/navigation.js b/couchpotato/static/scripts/block/navigation.js index 8389ff9f..f5642df2 100644 --- a/couchpotato/static/scripts/block/navigation.js +++ b/couchpotato/static/scripts/block/navigation.js @@ -5,7 +5,6 @@ Block.Navigation = new Class({ create: function(){ var self = this; - var settings_added = false; self.el = new Element('div.navigation').adopt( self.foldout = new Element('a.foldout.icon2.menu', { 'events': { @@ -28,7 +27,7 @@ Block.Navigation = new Class({ 'duration': 100 } }) - ) + ); new ScrollSpy({ min: 400, @@ -58,7 +57,7 @@ Block.Navigation = new Class({ }, - toggleMenu: function(e){ + toggleMenu: function(){ var self = this, body = $(document.body), html = body.getParent(); diff --git a/couchpotato/static/scripts/couchpotato.js b/couchpotato/static/scripts/couchpotato.js index 8e6ccede..dcd0f7bd 100644 --- a/couchpotato/static/scripts/couchpotato.js +++ b/couchpotato/static/scripts/couchpotato.js @@ -1,4 +1,4 @@ -var CouchPotato = new Class({ +var CouchPotato = new Class({ Implements: [Events, Options], @@ -15,7 +15,7 @@ var CouchPotato = new Class({ var self = this; self.setOptions(options); - self.c = $(document.body) + self.c = $(document.body); self.route = new Route(self.defaults); @@ -48,7 +48,6 @@ var CouchPotato = new Class({ }, pushState: function(e){ - var self = this; if((!e.meta && Browser.Platform.mac) || (!e.control && !Browser.Platform.mac)){ (e).preventDefault(); var url = e.target.get('href'); @@ -56,6 +55,10 @@ var CouchPotato = new Class({ History.push(url); } }, + + isMac: function(){ + return Browser.Platform.mac + }, createLayout: function(){ var self = this; @@ -107,11 +110,11 @@ var CouchPotato = new Class({ 'click': self.shutdownQA.bind(self) } }) - ] + ]; setting_links.each(function(a){ self.block.more.addLink(a) - }) + }); new ScrollSpy({ @@ -129,7 +132,7 @@ var CouchPotato = new Class({ var self = this; Object.each(Page, function(page_class, class_name){ - pg = new Page[class_name](self, {}); + var pg = new Page[class_name](self, {}); self.pages[class_name] = pg; $(pg).inject(self.content); @@ -152,7 +155,7 @@ var CouchPotato = new Class({ return; if(self.current_page) - self.current_page.hide() + self.current_page.hide(); try { var page = self.pages[page_name] || self.pages.Home; @@ -179,14 +182,14 @@ var CouchPotato = new Class({ shutdown: function(){ var self = this; - self.blockPage('You have shutdown. This is what suppose to happen ;)'); + self.blockPage('You have shutdown. This is what is supposed to happen ;)'); Api.request('app.shutdown', { 'onComplete': self.blockPage.bind(self) }); self.checkAvailable(1000); }, - shutdownQA: function(e){ + shutdownQA: function(){ var self = this; var q = new Question('Are you sure you want to shutdown CouchPotato?', '', [{ @@ -235,7 +238,7 @@ var CouchPotato = new Class({ checkForUpdate: function(onComplete){ var self = this; - Updater.check(onComplete) + Updater.check(onComplete); self.blockPage('Please wait. If this takes too long, something must have gone wrong.', 'Checking for updates'); self.checkAvailable(3000); @@ -253,7 +256,7 @@ var CouchPotato = new Class({ }, 'onSuccess': function(){ if(onAvailable) - onAvailable() + onAvailable(); self.unBlockPage(); self.fireEvent('reload'); } @@ -267,7 +270,6 @@ var CouchPotato = new Class({ self.unBlockPage(); - var body = $(document.body); self.mask = new Element('div.mask').adopt( new Element('div').adopt( new Element('h1', {'text': title || 'Unavailable'}), @@ -324,7 +326,7 @@ var CouchPotato = new Class({ 'target': '', 'events': { 'click': function(e){ - (e).stop() + (e).stop(); alert('Drag it to your bookmark ;)') } } @@ -347,35 +349,35 @@ var Route = new Class({ params: {}, initialize: function(defaults){ - var self = this + var self = this; self.defaults = defaults }, parse: function(){ var self = this; - var rep = function(pa){ + var rep = function (pa) { return pa.replace(Api.getOption('url'), '/').replace(App.getOption('base_url'), '/') - } + }; - var path = rep(History.getPath()) + var path = rep(History.getPath()); if(path == '/' && location.hash){ path = rep(location.hash.replace('#', '/')) } - self.current = path.replace(/^\/+|\/+$/g, '') - var url = self.current.split('/') + self.current = path.replace(/^\/+|\/+$/g, ''); + var url = self.current.split('/'); - self.page = (url.length > 0) ? url.shift() : self.defaults.page - self.action = (url.length > 0) ? url.shift() : self.defaults.action + self.page = (url.length > 0) ? url.shift() : self.defaults.page; + self.action = (url.length > 0) ? url.shift() : self.defaults.action; self.params = Object.merge({}, self.defaults.params); if(url.length > 1){ - var key + var key; url.each(function(el, nr){ if(nr%2 == 0) - key = el + key = el; else if(key) { - self.params[key] = el + self.params[key] = el; key = null } }) @@ -483,8 +485,8 @@ function randomString(length, extra) { var comparer = function(a, b) { for (var i = 0, l = keyPaths.length; i < l; i++) { - aVal = valueOf(a, keyPaths[i].path); - bVal = valueOf(b, keyPaths[i].path); + var aVal = valueOf(a, keyPaths[i].path), + bVal = valueOf(b, keyPaths[i].path); if (aVal > bVal) return keyPaths[i].sign; if (aVal < bVal) return -keyPaths[i].sign; } @@ -525,4 +527,4 @@ var createSpinner = function(target, options){ }, options); return new Spinner(opts).spin(target); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/couchpotato/static/scripts/page.js b/couchpotato/static/scripts/page.js index 1af800e8..58ba5acd 100644 --- a/couchpotato/static/scripts/page.js +++ b/couchpotato/static/scripts/page.js @@ -12,7 +12,7 @@ var PageBase = new Class({ initialize: function(options) { var self = this; - self.setOptions(options) + self.setOptions(options); // Create main page container self.el = new Element('div.page.'+self.name); @@ -74,4 +74,4 @@ var PageBase = new Class({ } }); -var Page = {} +var Page = {}; diff --git a/couchpotato/static/scripts/page/about.js b/couchpotato/static/scripts/page/about.js index ba451c84..f9313353 100644 --- a/couchpotato/static/scripts/page/about.js +++ b/couchpotato/static/scripts/page/about.js @@ -13,7 +13,7 @@ var AboutSettingTab = new Class({ addSettings: function(){ var self = this; - self.settings = App.getPage('Settings') + self.settings = App.getPage('Settings'); self.settings.addEvent('create', function(){ var tab = self.settings.createTab('about', { 'label': 'About', @@ -72,7 +72,7 @@ var AboutSettingTab = new Class({ ); if(!self.fillVersion(Updater.getInfo())) - Updater.addEvent('loaded', self.fillVersion.bind(self)) + Updater.addEvent('loaded', self.fillVersion.bind(self)); self.settings.createGroup({ 'name': 'Help Support CouchPotato' diff --git a/couchpotato/static/scripts/page/home.js b/couchpotato/static/scripts/page/home.js index 01344ad8..b93db5bd 100644 --- a/couchpotato/static/scripts/page/home.js +++ b/couchpotato/static/scripts/page/home.js @@ -5,7 +5,7 @@ Page.Home = new Class({ name: 'home', title: 'Manage new stuff for things and such', - indexAction: function(param){ + indexAction: function () { var self = this; if(self.soon_list){ @@ -14,10 +14,24 @@ Page.Home = new Class({ self.available_list.update(); self.late_list.update(); - return + return; } - // Snatched + self.chain = new Chain(); + self.chain.chain( + self.createAvailable.bind(self), + self.createSoon.bind(self), + self.createSuggestions.bind(self), + self.createLate.bind(self) + ); + + self.chain.callChain(); + + }, + + createAvailable: function(){ + var self = this; + self.available_list = new MovieList({ 'navigation': false, 'identifier': 'snatched', @@ -40,9 +54,19 @@ Page.Home = new Class({ 'filter': { 'release_status': 'snatched,available' }, - 'limit': null + 'limit': null, + 'onLoaded': function(){ + self.chain.callChain(); + } }); + $(self.available_list).inject(self.el); + + }, + + createSoon: function(){ + var self = this; + // Coming Soon self.soon_list = new MovieList({ 'navigation': false, @@ -50,10 +74,6 @@ Page.Home = new Class({ 'limit': 12, 'title': 'Available soon', 'description': 'These are being searched 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('h2', {'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 }, @@ -61,7 +81,10 @@ Page.Home = new Class({ 'load_more': false, 'view': 'thumbs', 'force_view': true, - 'api_call': 'dashboard.soon' + 'api_call': 'dashboard.soon', + 'onLoaded': function(){ + self.chain.callChain(); + } }); // Make all thumbnails the same size @@ -99,10 +122,30 @@ Page.Home = new Class({ images.setStyle('height', highest); }).delay(300); }); + }); + $(self.soon_list).inject(self.el); + + }, + + createSuggestions: function(){ + var self = this; + // Suggest - self.suggestion_list = new SuggestList(); + self.suggestion_list = new SuggestList({ + 'onLoaded': function(){ + self.chain.callChain(); + } + }); + + $(self.suggestion_list).inject(self.el); + + + }, + + createLate: function(){ + var self = this; // Still not available self.late_list = new MovieList({ @@ -110,7 +153,7 @@ Page.Home = new Class({ 'identifier': 'late', 'limit': 50, 'title': 'Still not available', - 'description': 'Try another quality profile or maybe add more providers in Settings.', + 'description': 'Try another quality profile or maybe add more providers in Settings.', 'filter': { 'late': true }, @@ -118,25 +161,14 @@ Page.Home = new Class({ 'load_more': false, 'view': 'list', 'actions': [MA.IMDB, MA.Trailer, MA.Edit, MA.Refresh, MA.Delete], - 'api_call': 'dashboard.soon' + 'api_call': 'dashboard.soon', + 'onLoaded': function(){ + self.chain.callChain(); + } }); - self.el.adopt( - $(self.available_list), - $(self.soon_list), - $(self.suggestion_list), - $(self.late_list) - ); - - // Recent - // Snatched - // Renamed - // Added - - // Free space - - // Shortcuts + $(self.late_list).inject(self.el); } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/couchpotato/static/scripts/page/manage.js b/couchpotato/static/scripts/page/manage.js index aef1f3c7..4827f51d 100644 --- a/couchpotato/static/scripts/page/manage.js +++ b/couchpotato/static/scripts/page/manage.js @@ -5,7 +5,7 @@ Page.Manage = new Class({ name: 'manage', title: 'Do stuff to your existing movies!', - indexAction: function(param){ + indexAction: function(){ var self = this; if(!self.list){ @@ -73,7 +73,7 @@ Page.Manage = new Class({ 'data': { 'full': +full } - }) + }); self.startProgressInterval(); @@ -86,9 +86,12 @@ Page.Manage = new Class({ self.progress_interval = setInterval(function(){ - Api.request('manage.progress', { + if(self.progress_request && self.progress_request.running) + return; + + self.update_in_progress = true; + self.progress_request = Api.request('manage.progress', { 'onComplete': function(json){ - self.update_in_progress = true; if(!json || !json.progress){ clearInterval(self.progress_interval); @@ -99,8 +102,13 @@ Page.Manage = new Class({ } } else { + + // Don't add loader when page is loading still + if(!self.list.navigation) + return; + if(!self.progress_container) - self.progress_container = new Element('div.progress').inject(self.list.navigation, 'after') + self.progress_container = new Element('div.progress').inject(self.list.navigation, 'after'); self.progress_container.empty(); diff --git a/couchpotato/static/scripts/page/settings.js b/couchpotato/static/scripts/page/settings.js index 0d7ebe7f..68b41d0a 100644 --- a/couchpotato/static/scripts/page/settings.js +++ b/couchpotato/static/scripts/page/settings.js @@ -46,16 +46,16 @@ Page.Settings = new Class({ var t = self.tabs[tab_name] || self.tabs[self.action] || self.tabs.general; // Subtab - var subtab = null + var subtab = null; Object.each(self.params, function(param, subtab_name){ subtab = subtab_name; - }) + }); self.el.getElements('li.'+c+' , .tab_content.'+c).each(function(active){ active.removeClass(c); }); - if (t.subtabs[subtab]){ + if(t.subtabs[subtab]){ t.tab[a](c); t.subtabs[subtab].tab[a](c); t.subtabs[subtab].content[a](c); @@ -87,7 +87,7 @@ Page.Settings = new Class({ self.data = json; onComplete(json); } - }) + }); return self.data; }, @@ -139,7 +139,7 @@ Page.Settings = new Class({ Object.each(json.options, function(section, section_name){ section['section_name'] = section_name; options.include(section); - }) + }); options.sort(function(a, b){ return (a.order || 100) - (b.order || 100) @@ -156,13 +156,13 @@ Page.Settings = new Class({ // Create tab if(!self.tabs[group.tab] || !self.tabs[group.tab].groups) self.createTab(group.tab, {}); - var content_container = self.tabs[group.tab].content + var content_container = self.tabs[group.tab].content; // Create subtab if(group.subtab){ - if (!self.tabs[group.tab].subtabs[group.subtab]) - self.createSubTab(group.subtab, {}, self.tabs[group.tab], group.tab); - var content_container = self.tabs[group.tab].subtabs[group.subtab].content + if(!self.tabs[group.tab].subtabs[group.subtab]) + self.createSubTab(group.subtab, group, self.tabs[group.tab], group.tab); + content_container = self.tabs[group.tab].subtabs[group.subtab].content } if(group.list && !self.lists[group.list]){ @@ -170,12 +170,10 @@ Page.Settings = new Class({ } // Create the group - if(!self.tabs[group.tab].groups[group.name]){ - var group_el = self.createGroup(group) + if(!self.tabs[group.tab].groups[group.name]) + self.tabs[group.tab].groups[group.name] = self.createGroup(group) .inject(group.list ? self.lists[group.list] : content_container) .addClass('section_'+section_name); - self.tabs[group.tab].groups[group.name] = group_el; - } // Create list if needed if(group.type && group.type == 'list'){ @@ -208,9 +206,9 @@ Page.Settings = new Class({ var self = this; if(self.tabs[tab_name] && self.tabs[tab_name].tab) - return self.tabs[tab_name].tab + return self.tabs[tab_name].tab; - var label = tab.label || (tab.name || tab_name).capitalize() + var label = tab.label || (tab.name || tab_name).capitalize(); var tab_el = new Element('li.t_'+tab_name).adopt( new Element('a', { 'href': App.createUrl(self.name+'/'+tab_name), @@ -221,14 +219,14 @@ Page.Settings = new Class({ if(!self.tabs[tab_name]) self.tabs[tab_name] = { 'label': label - } + }; self.tabs[tab_name] = Object.merge(self.tabs[tab_name], { 'tab': tab_el, 'subtabs': {}, - 'content': new Element('div.tab_content.tab_'+tab_name).inject(self.containers), + 'content': new Element('div.tab_content.tab_' + tab_name).inject(self.containers), 'groups': {} - }) + }); return self.tabs[tab_name] @@ -238,12 +236,12 @@ Page.Settings = new Class({ var self = this; if(parent_tab.subtabs[tab_name]) - return parent_tab.subtabs[tab_name] + return parent_tab.subtabs[tab_name]; if(!parent_tab.subtabs_el) parent_tab.subtabs_el = new Element('ul.subtabs').inject(parent_tab.tab); - var label = tab.label || (tab.name || tab_name.replace('_', ' ')).capitalize() + var label = tab.subtab_label || tab_name.replace('_', ' ').capitalize(); var tab_el = new Element('li.t_'+tab_name).adopt( new Element('a', { 'href': App.createUrl(self.name+'/'+parent_tab_name+'/'+tab_name), @@ -254,7 +252,7 @@ Page.Settings = new Class({ if(!parent_tab.subtabs[tab_name]) parent_tab.subtabs[tab_name] = { 'label': label - } + }; parent_tab.subtabs[tab_name] = Object.merge(parent_tab.subtabs[tab_name], { 'tab': tab_el, @@ -267,21 +265,17 @@ Page.Settings = new Class({ }, createGroup: function(group){ - var self = this; - - var group_el = new Element('fieldset', { + return new Element('fieldset', { 'class': (group.advanced ? 'inlineLabels advanced' : 'inlineLabels') + ' group_' + (group.name || '') + ' subtab_' + (group.subtab || '') }).adopt( - new Element('h2', { - 'text': group.label || (group.name).capitalize() - }).adopt( - new Element('span.hint', { - 'html': group.description || '' - }) - ) - ) - - return group_el + new Element('h2', { + 'text': group.label || (group.name).capitalize() + }).adopt( + new Element('span.hint', { + 'html': group.description || '' + }) + ) + ); }, createList: function(content_container){ @@ -299,12 +293,12 @@ var OptionBase = new Class({ Implements: [Options, Events], klass: 'textInput', - focused_class : 'focused', + focused_class: 'focused', save_on_change: true, initialize: function(section, name, value, options){ - var self = this - self.setOptions(options) + var self = this; + self.setOptions(options); self.section = section; self.name = name; @@ -329,11 +323,12 @@ var OptionBase = new Class({ * Create the element */ createBase: function(){ - var self = this - self.el = new Element('div.ctrlHolder') + var self = this; + self.el = new Element('div.ctrlHolder.' + self.section + '_' + self.name) }, - create: function(){}, + create: function(){ + }, createLabel: function(){ var self = this; @@ -343,7 +338,7 @@ var OptionBase = new Class({ }, setAdvanced: function(){ - this.el.addClass(this.options.advanced ? 'advanced': '') + this.el.addClass(this.options.advanced ? 'advanced' : '') }, createHint: function(){ @@ -354,7 +349,8 @@ var OptionBase = new Class({ }).inject(self.el); }, - afterInject: function(){}, + afterInject: function(){ + }, // Element has changed, do something changed: function(){ @@ -407,7 +403,7 @@ var OptionBase = new Class({ postName: function(){ var self = this; - return self.section +'['+self.name+']'; + return self.section + '[' + self.name + ']'; }, getValue: function(){ @@ -427,16 +423,16 @@ var OptionBase = new Class({ toElement: function(){ return this.el; } -}) +}); -var Option = {} +var Option = {}; Option.String = new Class({ Extends: OptionBase, type: 'string', create: function(){ - var self = this + var self = this; self.el.adopt( self.createLabel(), @@ -458,21 +454,21 @@ Option.Dropdown = new Class({ Extends: OptionBase, create: function(){ - var self = this + var self = this; self.el.adopt( self.createLabel(), self.input = new Element('select', { 'name': self.postName() }) - ) + ); Object.each(self.options.values, function(value){ new Element('option', { 'text': value[0], 'value': value[1] }).inject(self.input) - }) + }); self.input.set('value', self.getSettingValue()); @@ -491,7 +487,7 @@ Option.Checkbox = new Class({ create: function(){ var self = this; - var randomId = 'r-'+randomString() + var randomId = 'r-' + randomString(); self.el.adopt( self.createLabel().set('for', randomId), @@ -520,8 +516,8 @@ Option.Password = new Class({ create: function(){ var self = this; - self.parent() - self.input.set('type', 'password') + self.parent(); + self.input.set('type', 'password'); self.input.addEvent('focus', function(){ self.input.set('value', '') @@ -570,9 +566,9 @@ Option.Enabler = new Class({ afterInject: function(){ var self = this; - self.parentFieldset = self.el.getParent('fieldset').addClass('enabler') + self.parentFieldset = self.el.getParent('fieldset').addClass('enabler'); self.parentList = self.parentFieldset.getParent('.option_list'); - self.el.inject(self.parentFieldset, 'top') + self.el.inject(self.parentFieldset, 'top'); self.checkState() } @@ -622,7 +618,7 @@ Option.Directory = new Class({ self.getDirs() }, - previousDirectory: function(e){ + previousDirectory: function(){ var self = this; self.selectDirectory(self.getParentDir()) @@ -697,8 +693,8 @@ Option.Directory = new Class({ self.initial_directory = self.input.get('text'); - self.getDirs() - self.browser.show() + self.getDirs(); + self.browser.show(); self.el.addEvent('outerClick', self.hideBrowser.bind(self)) }, @@ -707,11 +703,11 @@ Option.Directory = new Class({ (e).preventDefault(); if(save) - self.save() + self.save(); else self.input.set('text', self.initial_directory); - self.browser.hide() + self.browser.hide(); self.el.removeEvents('outerClick') }, @@ -732,11 +728,11 @@ Option.Directory = new Class({ var prev_dirname = self.getCurrentDirname(previous_dir); if(previous_dir == json.home) prev_dirname = 'Home'; - else if (previous_dir == '/' && json.platform == 'nt') + else if(previous_dir == '/' && json.platform == 'nt') prev_dirname = 'Computer'; - self.back_button.set('data-value', previous_dir) - self.back_button.set('html', '« '+prev_dirname) + self.back_button.set('data-value', previous_dir); + self.back_button.set('html', '« ' + prev_dirname); self.back_button.show() } else { @@ -798,8 +794,6 @@ Option.Directory = new Class({ }, getCurrentDirname: function(dir){ - var self = this; - var dir_split = dir.split(Api.getOption('path_sep')); return dir_split[dir_split.length-2] || Api.getOption('path_sep') @@ -848,7 +842,7 @@ Option.Directories = new Class({ var parent = self.el.getParent('fieldset'); var dirs = parent.getElements('.multi_directory'); if(dirs.length == 0) - $(dir).inject(parent) + $(dir).inject(parent); else $(dir).inject(dirs.getLast(), 'after'); @@ -885,7 +879,7 @@ Option.Directories = new Class({ saveItems: function(){ var self = this; - var dirs = [] + var dirs = []; self.directories.each(function(dir){ if(dir.getValue()){ $(dir).removeClass('is_empty'); @@ -957,7 +951,7 @@ Option.Choice = new Class({ }).inject(self.input, 'after'); self.el.addClass('tag_input'); - var mtches = [] + var mtches = []; if(matches) matches.each(function(match, mnr){ var pos = value.indexOf(match), @@ -1037,7 +1031,7 @@ Option.Choice = new Class({ var prev_index = self.tags.indexOf(from_tag)-1; if(prev_index >= 0) - self.tags[prev_index].selectFrom('right') + self.tags[prev_index].selectFrom('right'); else from_tag.focus(); @@ -1049,7 +1043,7 @@ Option.Choice = new Class({ var next_index = self.tags.indexOf(from_tag)+1; if(next_index < self.tags.length) - self.tags[next_index].selectFrom('left') + self.tags[next_index].selectFrom('left'); else from_tag.focus(); }, @@ -1139,7 +1133,7 @@ Option.Choice.Tag = new Class({ if(e.key == 'left' && current_caret_pos == self.last_caret_pos){ self.fireEvent('goLeft'); } - else if (e.key == 'right' && self.last_caret_pos === current_caret_pos){ + else if(e.key == 'right' && self.last_caret_pos === current_caret_pos){ self.fireEvent('goRight'); } self.last_caret_pos = self.input.getCaretPosition(); @@ -1195,11 +1189,11 @@ Option.Choice.Tag = new Class({ self.fireEvent('goRight'); this.destroy(); } - else if (e.key == 'left'){ + else if(e.key == 'left'){ self.fireEvent('goLeft'); this.destroy(); } - else if (e.key == 'backspace'){ + else if(e.key == 'backspace'){ self.del(); this.destroy(); self.fireEvent('goLeft'); @@ -1213,7 +1207,7 @@ Option.Choice.Tag = new Class({ 'top': -200 } }); - self.el.adopt(temp_input) + self.el.adopt(temp_input); temp_input.focus(); } }, @@ -1266,10 +1260,10 @@ Option.Combined = new Class({ self.fieldset = self.input.getParent('fieldset'); self.combined_list = new Element('div.combined_table').inject(self.fieldset.getElement('h2'), 'after'); - self.values = {} - self.inputs = {} - self.items = [] - self.labels = {} + self.values = {}; + self.inputs = {}; + self.items = []; + self.labels = {}; self.options.combine.each(function(name){ @@ -1277,7 +1271,7 @@ Option.Combined = new Class({ var values = self.inputs[name].get('value').split(','); values.each(function(value, nr){ - if (!self.values[nr]) self.values[nr] = {}; + if(!self.values[nr]) self.values[nr] = {}; self.values[nr][name] = value.trim(); }); @@ -1286,19 +1280,18 @@ Option.Combined = new Class({ }); - var head = new Element('div.head').inject(self.combined_list) + var head = new Element('div.head').inject(self.combined_list); Object.each(self.inputs, function(input, name){ - self.labels[name] = input.getPrevious().get('text') + self.labels[name] = input.getPrevious().get('text'); new Element('abbr', { 'class': name, - 'text': self.labels[name], - //'title': input.getNext().get('text') + 'text': self.labels[name] }).inject(head) - }) + }); - Object.each(self.values, function(item, nr){ + Object.each(self.values, function(item){ self.createItem(item); }); @@ -1316,7 +1309,7 @@ Option.Combined = new Class({ self.items.each(function(ctrl_holder){ var empty_count = 0; self.options.combine.each(function(name){ - var input = ctrl_holder.getElement('input.'+name) + var input = ctrl_holder.getElement('input.' + name); if(input.get('value') == '' || input.get('type') == 'checkbox') empty_count++ }); @@ -1338,7 +1331,7 @@ Option.Combined = new Class({ value_empty = 0; self.options.combine.each(function(name){ - var value = values[name] || '' + var value = values[name] || ''; if(name.indexOf('use') != -1){ var checkbox = new Element('input[type=checkbox].inlay.'+name, { @@ -1375,7 +1368,7 @@ Option.Combined = new Class({ 'events': { 'click': self.deleteCombinedItem.bind(self) } - }).inject(item) + }).inject(item); self.items.include(item); @@ -1386,7 +1379,7 @@ Option.Combined = new Class({ var self = this; - var temp = {} + var temp = {}; self.items.each(function(item, nr){ self.options.combine.each(function(name){ var input = item.getElement('input.'+name); diff --git a/couchpotato/static/scripts/page/wanted.js b/couchpotato/static/scripts/page/wanted.js index eabd1465..98a676c8 100644 --- a/couchpotato/static/scripts/page/wanted.js +++ b/couchpotato/static/scripts/page/wanted.js @@ -5,7 +5,7 @@ Page.Wanted = new Class({ name: 'wanted', title: 'Gimmy gimmy gimmy!', - indexAction: function(param){ + indexAction: function(){ var self = this; if(!self.wanted){ @@ -35,12 +35,12 @@ Page.Wanted = new Class({ }, - doFullSearch: function(full){ + doFullSearch: function(){ var self = this; if(!self.search_in_progress){ - Api.request('searcher.full_search'); + Api.request('movie.searcher.full_search'); self.startProgressInterval(); } @@ -53,16 +53,16 @@ Page.Wanted = new Class({ var start_text = self.manual_search.get('text'); self.progress_interval = setInterval(function(){ if(self.search_progress && self.search_progress.running) return; - self.search_progress = Api.request('searcher.progress', { + self.search_progress = Api.request('movie.searcher.progress', { 'onComplete': function(json){ self.search_in_progress = true; - if(!json.progress){ + if(!json.movie){ clearInterval(self.progress_interval); self.search_in_progress = false; self.manual_search.set('text', start_text); } else { - var progress = json.progress; + var progress = json.movie; self.manual_search.set('text', 'Searching.. (' + (((progress.total-progress.to_go)/progress.total)*100).round() + '%)'); } } diff --git a/couchpotato/static/style/api.css b/couchpotato/static/style/api.css index c6354098..0c9f0f08 100644 --- a/couchpotato/static/style/api.css +++ b/couchpotato/static/style/api.css @@ -1,6 +1,5 @@ html { - font-size: 12px; - line-height: 1.5; + line-height: 1.5; font-family: "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; font-size: 14px; } diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index ae461c43..e79dbea8 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -142,7 +142,7 @@ body > .spinner, .mask{ .icon.download { background-image: url('../images/icon.download.png'); } .icon.edit { background-image: url('../images/icon.edit.png'); } .icon.completed { background-image: url('../images/icon.check.png'); } -.icon.folder { background-image: url('../images/icon.folder.png'); } +.icon.folder { background-image: url('../images/icon.folder.gif'); } .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'); } @@ -260,8 +260,7 @@ body > .spinner, .mask{ font-size: 1.75em; padding: 15px 30px 0 15px; height: 100%; - vertical-align: middle; - border-right: 1px solid rgba(255,255,255,.07); + border-right: 1px solid rgba(255,255,255,.07); color: #FFF; font-weight: normal; vertical-align: top; @@ -489,7 +488,6 @@ body > .spinner, .mask{ display: block; font-size: .85em; color: #aaa; - text-align: ; } .header .notification_menu li .more { @@ -606,7 +604,7 @@ body > .spinner, .mask{ .onlay, .inlay .selected, .inlay:not(.reversed) > li:hover, .inlay > li.active, .inlay.reversed > li { border-radius:3px; border: 1px solid #252930; - box-shadow: inset 0 1px 0px rgba(255,255,255,0.20); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.20); background: rgb(55,62,74); background-image: linear-gradient( 0, @@ -729,7 +727,7 @@ body > .spinner, .mask{ .more_menu .wrapper li .separator { border-bottom: 1px solid rgba(0,0,0,.1); display: block; - height: 1; + height: 1px; margin: 5px 0; } diff --git a/couchpotato/static/style/settings.css b/couchpotato/static/style/settings.css index 132d9c53..61d5239f 100644 --- a/couchpotato/static/style/settings.css +++ b/couchpotato/static/style/settings.css @@ -90,7 +90,7 @@ padding: 0 9px 10px 30px; margin: 0; border-bottom: 1px solid #333; - box-shadow: 0 1px 0px rgba(255,255,255, 0.15); + box-shadow: 0 1px 0 rgba(255,255,255, 0.15); } .page fieldset h2 .hint { font-size: 12px; @@ -107,10 +107,8 @@ .page fieldset > .ctrlHolder:first-child { display: block; padding: 0; - width: auto; - margin: 0; position: relative; - margin-bottom: -23px; + margin: 0 0 -23px; border: none; width: 20px; } @@ -132,12 +130,11 @@ .page .ctrlHolder .formHint { width: 47%; margin: -18px 0; - padding: 0; - color: #fff !important; + color: #fff !important; display: inline-block; vertical-align: middle; - padding-left: 2%; - line-height: 14px; + padding: 0 0 0 2%; + line-height: 14px; } .page .check { @@ -219,7 +216,7 @@ font-weight: bold; border: none; border-top: 1px solid rgba(255,255,255, 0.15); - box-shadow: 0 -1px 0px #333; + box-shadow: 0 -1px 0 #333; margin: 0; padding: 10px 0 5px 25px; } @@ -308,7 +305,7 @@ border-bottom: 6px solid #5c697b; display: block; position: absolute; - width: 0px; + width: 0; margin: -6px 0 0 45%; } @@ -688,7 +685,6 @@ } .group_userscript .bookmarklet { - display: block; display: block; float: left; padding: 20px 15px 0 25px; diff --git a/couchpotato/static/style/uniform.generic.css b/couchpotato/static/style/uniform.generic.css index e70a9158..8ac41363 100644 --- a/couchpotato/static/style/uniform.generic.css +++ b/couchpotato/static/style/uniform.generic.css @@ -92,9 +92,8 @@ border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; - -o-border-radius: 4px; - -khtml-border-radius: 4px; - } + + } .uniForm #errorMsg h3{} /* Feel free to use a heading level suitable to your page structure */ .uniForm #errorMsg ol{ margin: 0 0 1.5em 0; padding: 0; } .uniForm #errorMsg ol li{ margin: 0 0 3px 1.5em; padding: 7px; background: #f6bec1; position: relative; font-size: .85em; @@ -102,9 +101,8 @@ border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; - -o-border-radius: 4px; - -khtml-border-radius: 4px; - } + + } .uniForm .ctrlHolder.error, .uniForm .ctrlHolder.focused.error{ background: #ffdfdf; border: 1px solid #f3afb5; @@ -112,9 +110,8 @@ border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; - -o-border-radius: 4px; - -khtml-border-radius: 4px; - } + + } .uniForm .ctrlHolder.error input.error, .uniForm .ctrlHolder.error select.error, .uniForm .ctrlHolder.error textarea.error{ color: #af4c4c; margin: 0 0 6px 0; padding: 4px; } @@ -125,9 +122,8 @@ border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; - -o-border-radius: 4px; - -khtml-border-radius: 4px; - } + + } .uniForm #OKMsg p{ margin: 0; } /* ----------------------------------------------------------------------------- */ diff --git a/couchpotato/templates/index.html b/couchpotato/templates/index.html index 5f16ef46..d45dcb9b 100644 --- a/couchpotato/templates/index.html +++ b/couchpotato/templates/index.html @@ -2,7 +2,7 @@ - + {% for url in fireEvent('clientscript.get_styles', as_html = True, location = 'front', single = True) %} @@ -22,17 +22,18 @@